feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View file

@ -0,0 +1,267 @@
import type { D3roSupabaseClient, DictionaryEntry } from '@d3ro/api-client'
export type DictionaryCategory = DictionaryEntry['category']
export type DictionaryFilter = DictionaryCategory | 'all'
export interface DictionaryCursor {
updatedAt: string
id: string
}
export interface DictionaryDraft {
word: string
pronunciation: string | null
category: DictionaryCategory
}
export interface DictionaryPageResult {
entries: DictionaryEntry[]
nextCursor: DictionaryCursor | null
total: number
}
export type DictionaryClientErrorCode =
| 'auth'
| 'conflict'
| 'duplicate'
| 'network'
| 'validation'
| 'server'
export class DictionaryClientError extends Error {
constructor(
public readonly code: DictionaryClientErrorCode,
message: string
) {
super(message)
this.name = 'DictionaryClientError'
}
}
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
const CATEGORIES: readonly DictionaryCategory[] = ['user', 'technical', 'auto']
const MAX_WORD_LENGTH = 120
const MAX_PRONUNCIATION_LENGTH = 200
const MAX_PAGE_SIZE = 50
function requireUuid(value: string, code: 'auth' | 'validation', message: string): void {
if (!UUID_PATTERN.test(value)) throw new DictionaryClientError(code, message)
}
function normalizeText(value: string): string {
return value.trim().replace(/\s+/g, ' ')
}
export function sanitizeDictionarySearch(value: string): string {
return value
.trim()
.slice(0, 100)
.replace(/[%,()._'"\\]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
export function normalizeDictionaryDraft(draft: DictionaryDraft): DictionaryDraft {
const word = normalizeText(draft.word)
const pronunciation = normalizeText(draft.pronunciation ?? '') || null
if (word.length === 0 || word.length > MAX_WORD_LENGTH) {
throw new DictionaryClientError('validation', `단어는 1-${MAX_WORD_LENGTH}자로 입력해 주세요.`)
}
if (pronunciation !== null && pronunciation.length > MAX_PRONUNCIATION_LENGTH) {
throw new DictionaryClientError('validation', `발음은 ${MAX_PRONUNCIATION_LENGTH}자 이하여야 합니다.`)
}
if (!CATEGORIES.includes(draft.category)) {
throw new DictionaryClientError('validation', '사전 분류가 올바르지 않습니다.')
}
return { word, pronunciation, category: draft.category }
}
function mapDictionaryError(error: unknown): DictionaryClientError {
if (error instanceof DictionaryClientError) return error
const candidate = error as { code?: unknown; message?: unknown }
const databaseCode = typeof candidate?.code === 'string' ? candidate.code : ''
const message = typeof candidate?.message === 'string' ? candidate.message : 'Dictionary request failed'
const normalized = message.toLowerCase()
if (databaseCode === '23505') return new DictionaryClientError('duplicate', message)
if (
error instanceof TypeError
|| normalized.includes('failed to fetch')
|| normalized.includes('network request failed')
|| normalized.includes('networkerror')
) {
return new DictionaryClientError('network', message)
}
if (databaseCode === 'PGRST301' || databaseCode === '42501' || normalized.includes('jwt')) {
return new DictionaryClientError('auth', message)
}
if (databaseCode.startsWith('22')) return new DictionaryClientError('validation', message)
return new DictionaryClientError('server', message)
}
function assertOwned(userId: string, entry: DictionaryEntry): void {
requireUuid(userId, 'auth', 'Authenticated user is invalid')
requireUuid(entry.id, 'validation', 'Dictionary entry id is invalid')
if (entry.user_id !== userId) {
throw new DictionaryClientError('auth', 'Dictionary ownership could not be verified')
}
}
function escapeLikePattern(value: string): string {
return value.replace(/[\\%_]/g, (character) => `\\${character}`)
}
async function findDuplicate(
client: D3roSupabaseClient,
userId: string,
draft: DictionaryDraft,
excludingId?: string
): Promise<boolean> {
let query = client
.from('dictionary')
.select('id')
.eq('user_id', userId)
.eq('category', draft.category)
.ilike('word', escapeLikePattern(draft.word))
.limit(1)
if (excludingId) query = query.neq('id', excludingId)
const { data, error } = await query.maybeSingle()
if (error) throw error
return data !== null
}
export async function listDictionaryPage(
client: D3roSupabaseClient,
options: {
userId: string
search: string
category: DictionaryFilter
pageSize: number
cursor: DictionaryCursor | null
}
): Promise<DictionaryPageResult> {
requireUuid(options.userId, 'auth', 'Authenticated user is invalid')
if (options.category !== 'all' && !CATEGORIES.includes(options.category)) {
throw new DictionaryClientError('validation', 'Dictionary category is invalid')
}
if (options.cursor) {
requireUuid(options.cursor.id, 'validation', 'Dictionary cursor id is invalid')
if (!Number.isFinite(Date.parse(options.cursor.updatedAt))) {
throw new DictionaryClientError('validation', 'Dictionary cursor date is invalid')
}
}
const pageSize = Math.max(1, Math.min(options.pageSize, MAX_PAGE_SIZE))
try {
let query = client
.from('dictionary')
.select('*', { count: 'exact' })
.eq('user_id', options.userId)
.order('updated_at', { ascending: false })
.order('id', { ascending: false })
.limit(pageSize + 1)
if (options.category !== 'all') query = query.eq('category', options.category)
const search = sanitizeDictionarySearch(options.search)
if (search) {
const pattern = `%${search}%`
query = query.or(`word.ilike.${pattern},pronunciation.ilike.${pattern}`)
}
if (options.cursor) {
query = query.or(
`updated_at.lt.${options.cursor.updatedAt},and(updated_at.eq.${options.cursor.updatedAt},id.lt.${options.cursor.id})`
)
}
const { data, error, count } = await query
if (error) throw error
const rows = data ?? []
for (const row of rows) assertOwned(options.userId, row)
const hasMore = rows.length > pageSize
const entries = hasMore ? rows.slice(0, pageSize) : rows
const last = entries.at(-1)
return {
entries,
nextCursor: hasMore && last ? { updatedAt: last.updated_at, id: last.id } : null,
total: count ?? entries.length
}
} catch (error) {
throw mapDictionaryError(error)
}
}
export async function createDictionaryEntry(
client: D3roSupabaseClient,
userId: string,
draft: DictionaryDraft
): Promise<DictionaryEntry> {
requireUuid(userId, 'auth', 'Authenticated user is invalid')
const normalized = normalizeDictionaryDraft(draft)
try {
if (await findDuplicate(client, userId, normalized)) {
throw new DictionaryClientError('duplicate', 'Duplicate dictionary entry')
}
const { data, error } = await client
.from('dictionary')
.insert({ user_id: userId, ...normalized })
.select('*')
.single()
if (error) throw error
assertOwned(userId, data)
return data
} catch (error) {
throw mapDictionaryError(error)
}
}
export async function updateDictionaryEntry(
client: D3roSupabaseClient,
userId: string,
current: DictionaryEntry,
draft: DictionaryDraft
): Promise<DictionaryEntry> {
assertOwned(userId, current)
const normalized = normalizeDictionaryDraft(draft)
try {
if (await findDuplicate(client, userId, normalized, current.id)) {
throw new DictionaryClientError('duplicate', 'Duplicate dictionary entry')
}
const { data, error } = await client
.from('dictionary')
.update({
word: normalized.word,
pronunciation: normalized.pronunciation,
category: normalized.category
})
.eq('user_id', userId)
.eq('id', current.id)
.eq('updated_at', current.updated_at)
.select('*')
.maybeSingle()
if (error) throw error
if (!data) throw new DictionaryClientError('conflict', 'Dictionary entry changed')
assertOwned(userId, data)
return data
} catch (error) {
throw mapDictionaryError(error)
}
}
export async function deleteDictionaryEntry(
client: D3roSupabaseClient,
userId: string,
current: DictionaryEntry
): Promise<void> {
assertOwned(userId, current)
try {
const { data, error } = await client
.from('dictionary')
.delete()
.eq('user_id', userId)
.eq('id', current.id)
.eq('updated_at', current.updated_at)
.select('id')
.maybeSingle()
if (error) throw error
if (!data) throw new DictionaryClientError('conflict', 'Dictionary entry changed')
} catch (error) {
throw mapDictionaryError(error)
}
}