ConusAI vendor 81941888f

This commit is contained in:
ConusAI vendor 81941888f
2026-10-07 08:47:51 +00:00
commit c3c6526952
135 changed files with 13010 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
import { and, eq } from 'drizzle-orm'
import type { drizzle } from 'drizzle-orm/postgres-js'
import { files, type FileRow } from '@/entities/file/model/schema'
/** The handle the shared `db()` returns (`@/shared/api`). */
type Db = ReturnType<typeof drizzle>
export type NewFile = Omit<FileRow, 'id' | 'createdAt'>
/**
* Attachment persistence. Writes name their author, as the notes repository does:
* a visitor removes only the files they attached.
*/
export function fileRepository(db: Db) {
return {
async create(input: NewFile): Promise<FileRow> {
const [row] = await db.insert(files).values(input).returning()
if (!row) throw new Error('the insert returned no row')
return row
},
async listByNote(noteId: string): Promise<FileRow[]> {
return db.select().from(files).where(eq(files.noteId, noteId))
},
/** Every attachment the visitor made, across their notes. */
async listByAuthor(authorId: string): Promise<FileRow[]> {
return db.select().from(files).where(eq(files.authorId, authorId))
},
/** The removed file, or `undefined` when `authorId` did not attach it. */
async remove(id: string, authorId: string): Promise<FileRow | undefined> {
const [row] = await db
.delete(files)
.where(and(eq(files.id, id), eq(files.authorId, authorId)))
.returning()
return row
},
}
}
+3
View File
@@ -0,0 +1,3 @@
export { files, type FileRow } from './model/schema'
export { MAX_UPLOAD_BYTES, ALLOWED_CONTENT_TYPES, uploadLimits, type UploadCandidate } from './model/limits'
export { fileRepository, type NewFile } from './api/repository'
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { MAX_UPLOAD_BYTES, uploadLimits } from '@/entities/file'
describe('uploadLimits', () => {
const ok = { name: 'plan.pdf', size: 1024, contentType: 'application/pdf' }
it('accepts an allowed type within the size limit', () => {
expect(uploadLimits.parse(ok)).toEqual(ok)
})
it('refuses a file over the size limit', () => {
expect(() => uploadLimits.parse({ ...ok, size: MAX_UPLOAD_BYTES + 1 })).toThrow(/5 MiB/)
})
it('refuses an empty file', () => {
expect(() => uploadLimits.parse({ ...ok, size: 0 })).toThrow(/empty/)
})
it('refuses a content type that is not on the list', () => {
expect(() => uploadLimits.parse({ ...ok, contentType: 'application/x-msdownload' })).toThrow(/not accepted/)
})
})
+22
View File
@@ -0,0 +1,22 @@
import { z } from 'zod'
/** The largest attachment accepted, in bytes (5 MiB). */
export const MAX_UPLOAD_BYTES = 5 * 1024 * 1024
/** The content types an attachment may have. Anything else is refused before upload. */
export const ALLOWED_CONTENT_TYPES = [
'image/png',
'image/jpeg',
'image/gif',
'application/pdf',
'text/plain',
'text/markdown',
] as const
export const uploadLimits = z.object({
name: z.string().trim().min(1, 'A file needs a name').max(255),
size: z.number().int().min(1, 'The file is empty').max(MAX_UPLOAD_BYTES, 'The file is larger than 5 MiB'),
contentType: z.enum(ALLOWED_CONTENT_TYPES, { error: 'That file type is not accepted' }),
})
export type UploadCandidate = z.infer<typeof uploadLimits>
+19
View File
@@ -0,0 +1,19 @@
import { integer, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'
/**
* An attachment on a note, stored in Blob Storage. `note_id` is a foreign key to
* notes(id) with ON DELETE CASCADE, added by the migration: an entity cannot
* import another entity's table (FSD), so the schema here carries the column only.
*/
export const files = pgTable('files', {
id: uuid('id').primaryKey().defaultRandom(),
noteId: uuid('note_id').notNull(),
authorId: text('author_id').notNull(),
name: text('name').notNull(),
contentType: text('content_type').notNull(),
size: integer('size').notNull(),
url: text('url').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
export type FileRow = typeof files.$inferSelect
+68
View File
@@ -0,0 +1,68 @@
import { randomUUID } from 'node:crypto'
import { drizzle } from 'drizzle-orm/postgres-js'
import { migrate } from 'drizzle-orm/postgres-js/migrator'
import postgres from 'postgres'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { noteRepository, type NoteDb } from '@/entities/note'
// Runs against a real Postgres: POSTGRES_URL must name one. Each run migrates a
// schema of its own and drops it afterwards, so runs never share rows.
const url = process.env.POSTGRES_URL
const schema = `note_test_${randomUUID().replaceAll('-', '')}`
let admin: postgres.Sql
let client: postgres.Sql
let repo: ReturnType<typeof noteRepository>
const ALICE = 'visitor-alice'
const BOB = 'visitor-bob'
beforeAll(async () => {
expect(url, 'POSTGRES_URL must name a test Postgres').toBeTruthy()
admin = postgres(url as string, { max: 1 })
await admin.unsafe(`create schema "${schema}"`)
client = postgres(url as string, { max: 1, connection: { search_path: schema } })
const db = drizzle(client) as NoteDb
await migrate(db, { migrationsFolder: 'drizzle', migrationsSchema: schema })
repo = noteRepository(db)
})
afterAll(async () => {
await client?.end()
await admin?.unsafe(`drop schema if exists "${schema}" cascade`)
await admin?.end()
})
describe('noteRepository', () => {
it('creates a note and reads it back with its author', async () => {
const created = await repo.create({ title: 'Plan', body: 'draft', authorId: ALICE })
const read = await repo.get(created.id)
expect(read).toMatchObject({ title: 'Plan', body: 'draft', authorId: ALICE })
})
it('lists only the notes of the asked-for author', async () => {
await repo.create({ title: 'Bob note', body: '', authorId: BOB })
const aliceNotes = await repo.listByAuthor(ALICE)
expect(aliceNotes.length).toBeGreaterThan(0)
expect(aliceNotes.every((note) => note.authorId === ALICE)).toBe(true)
})
it('lets the author update their own note', async () => {
const created = await repo.create({ title: 'Old', body: '', authorId: ALICE })
const updated = await repo.update(created.id, ALICE, { title: 'New', body: 'edited' })
expect(updated).toMatchObject({ id: created.id, title: 'New', body: 'edited' })
})
it('does not let another visitor update the note', async () => {
const created = await repo.create({ title: 'Mine', body: '', authorId: ALICE })
const attempt = await repo.update(created.id, BOB, { title: 'Hijacked', body: '' })
expect(attempt).toBeUndefined()
expect((await repo.get(created.id))?.title).toBe('Mine')
})
it('removes only for the author', async () => {
const created = await repo.create({ title: 'Doomed', body: '', authorId: ALICE })
expect(await repo.remove(created.id, BOB)).toBe(false)
expect(await repo.remove(created.id, ALICE)).toBe(true)
expect(await repo.get(created.id)).toBeUndefined()
})
})
+59
View File
@@ -0,0 +1,59 @@
import { and, desc, eq } from 'drizzle-orm'
import type { drizzle } from 'drizzle-orm/postgres-js'
import { notes, type Note } from '@/entities/note/model/schema'
import type { NoteInput } from '@/entities/note/model/input'
/** The handle the shared `db()` returns (`@/shared/api/db/server`). */
export type NoteDb = ReturnType<typeof drizzle>
/**
* Note persistence over one database handle. The handle is a parameter so a test
* can run the same code against its own Postgres.
*
* Every write names its author in the `where`: a visitor can change only the
* notes they wrote (tenancy, D16).
*/
export function noteRepository(db: NoteDb) {
return {
async create(input: NoteInput & { authorId: string }): Promise<Note> {
const [row] = await db.insert(notes).values(input).returning()
if (!row) throw new Error('the insert returned no row')
return row
},
async get(id: string): Promise<Note | undefined> {
const [row] = await db.select().from(notes).where(eq(notes.id, id)).limit(1)
return row
},
async listByAuthor(authorId: string, limit = 50): Promise<Note[]> {
return db
.select()
.from(notes)
.where(eq(notes.authorId, authorId))
.orderBy(desc(notes.createdAt))
.limit(limit)
},
/** The updated note, or `undefined` when `authorId` did not write it. */
async update(id: string, authorId: string, input: NoteInput): Promise<Note | undefined> {
const [row] = await db
.update(notes)
.set({ ...input, updatedAt: new Date() })
.where(and(eq(notes.id, id), eq(notes.authorId, authorId)))
.returning()
return row
},
/** `true` when the note existed and `authorId` wrote it. */
async remove(id: string, authorId: string): Promise<boolean> {
const rows = await db
.delete(notes)
.where(and(eq(notes.id, id), eq(notes.authorId, authorId)))
.returning({ id: notes.id })
return rows.length > 0
},
}
}
export type NoteRepository = ReturnType<typeof noteRepository>
+3
View File
@@ -0,0 +1,3 @@
export { notes, type Note } from './model/schema'
export { noteInput, noteId, type NoteInput } from './model/input'
export { noteRepository, type NoteDb, type NoteRepository } from './api/repository'
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { noteId, noteInput } from '@/entities/note'
describe('noteInput', () => {
it('trims the title and defaults the body', () => {
expect(noteInput.parse({ title: ' Plan ' })).toEqual({ title: 'Plan', body: '' })
})
it('refuses a blank title', () => {
expect(() => noteInput.parse({ title: ' ' })).toThrow(/title/)
})
it('refuses a title past 200 characters', () => {
expect(() => noteInput.parse({ title: 'x'.repeat(201) })).toThrow()
})
})
describe('noteId', () => {
it('accepts a UUID and refuses anything else', () => {
expect(noteId.parse('00000000-0000-4000-8000-000000000001')).toBe('00000000-0000-4000-8000-000000000001')
expect(() => noteId.parse('not-a-uuid')).toThrow()
})
})
+12
View File
@@ -0,0 +1,12 @@
import { z } from 'zod'
/** The fields a visitor writes. Parsed at the boundary, before any write (plan §4.4). */
export const noteInput = z.object({
title: z.string().trim().min(1, 'A note needs a title').max(200),
body: z.string().max(20000).default(''),
})
export type NoteInput = z.infer<typeof noteInput>
/** A note id as it arrives in a form or a URL: a UUID, nothing else. */
export const noteId = z.uuid()
+13
View File
@@ -0,0 +1,13 @@
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'
/** A note. `author_id` is the visitor's id from the signed `sc_vid` cookie (D16). */
export const notes = pgTable('notes', {
id: uuid('id').primaryKey().defaultRandom(),
title: text('title').notNull(),
body: text('body').notNull().default(''),
authorId: text('author_id').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})
export type Note = typeof notes.$inferSelect