ConusAI vendor 81941888f
This commit is contained in:
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user