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,428 @@
import type { RealtimeChannel } from '@supabase/supabase-js'
import type { D3roSupabaseClient, DictionaryEntry } from '@d3ro/api-client'
import { supabase } from '../../lib/supabase'
export type DictionaryCategory = DictionaryEntry['category']
export interface DictionaryCursor {
updatedAt: string
id: string
}
export interface DictionaryPageResult {
entries: DictionaryEntry[]
nextCursor: DictionaryCursor | null
total: number
}
export interface DictionaryListOptions {
userId: string
search?: string
category?: DictionaryCategory | 'all'
pageSize?: number
cursor?: DictionaryCursor | null
}
export interface DictionaryDraft {
word: string
pronunciation?: string | null
category?: DictionaryCategory
}
export interface NormalizedDictionaryDraft {
word: string
pronunciation: string | null
category: DictionaryCategory
}
export type DictionaryServiceErrorCode =
| 'auth'
| 'conflict'
| 'duplicate'
| 'network'
| 'not-found'
| 'validation'
| 'server'
export class DictionaryServiceError extends Error {
constructor(
public readonly code: DictionaryServiceErrorCode,
message: string,
) {
super(message)
this.name = 'DictionaryServiceError'
}
}
const DEFAULT_PAGE_SIZE = 30
const MAX_PAGE_SIZE = 50
const MAX_WORD_LENGTH = 120
const MAX_PRONUNCIATION_LENGTH = 200
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']
function client(): D3roSupabaseClient {
return supabase as unknown as D3roSupabaseClient
}
function requireUuid(value: string, label: string): void {
if (!UUID_PATTERN.test(value)) {
throw new DictionaryServiceError('auth', `A valid ${label} is required`)
}
}
function assertOwnedEntry(userId: string, entry: DictionaryEntry): void {
requireUuid(userId, 'authenticated user')
requireUuid(entry.id, 'dictionary entry id')
if (entry.user_id !== userId) {
throw new DictionaryServiceError('auth', 'Dictionary ownership could not be verified')
}
}
export function sanitizeDictionarySearch(value: string): string {
return value
.trim()
.slice(0, 100)
.replace(/[%,()._'"\\]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
function normalizeText(value: string): string {
return value.trim().replace(/\s+/g, ' ')
}
export function normalizeDictionaryDraft(
draft: DictionaryDraft,
): NormalizedDictionaryDraft {
const word = normalizeText(draft.word)
const pronunciation = normalizeText(draft.pronunciation ?? '') || null
const category = draft.category ?? 'user'
if (word.length === 0 || word.length > MAX_WORD_LENGTH) {
throw new DictionaryServiceError(
'validation',
`Dictionary words must contain 1-${MAX_WORD_LENGTH} characters`,
)
}
if (pronunciation !== null && pronunciation.length > MAX_PRONUNCIATION_LENGTH) {
throw new DictionaryServiceError(
'validation',
`Pronunciation must not exceed ${MAX_PRONUNCIATION_LENGTH} characters`,
)
}
if (!CATEGORIES.includes(category)) {
throw new DictionaryServiceError('validation', 'Dictionary category is invalid')
}
return { word, pronunciation, category }
}
function dictionaryError(error: unknown): DictionaryServiceError {
if (error instanceof DictionaryServiceError) return error
const candidate = error as { code?: unknown; message?: unknown }
const code = typeof candidate?.code === 'string' ? candidate.code : ''
const message = typeof candidate?.message === 'string'
? candidate.message
: 'Dictionary request failed'
const lower = message.toLowerCase()
if (code === '23505') return new DictionaryServiceError('duplicate', message)
if (
error instanceof TypeError
|| lower.includes('network request failed')
|| lower.includes('failed to fetch')
|| lower.includes('networkerror')
) {
return new DictionaryServiceError('network', message)
}
if (code === 'PGRST301' || code === '42501' || lower.includes('jwt')) {
return new DictionaryServiceError('auth', message)
}
if (code.startsWith('22')) return new DictionaryServiceError('validation', message)
return new DictionaryServiceError('server', message)
}
function validateCursor(cursor: DictionaryCursor): void {
requireUuid(cursor.id, 'dictionary cursor id')
if (!Number.isFinite(Date.parse(cursor.updatedAt))) {
throw new DictionaryServiceError('validation', 'Dictionary cursor timestamp is invalid')
}
}
function escapeLikePattern(value: string): string {
return value.replace(/[\\%_]/g, (character) => `\\${character}`)
}
async function findDuplicate(
userId: string,
draft: NormalizedDictionaryDraft,
excludingId?: string,
): Promise<Pick<DictionaryEntry, 'id'> | null> {
let query = client()
.from('dictionary')
.select('id')
.eq('user_id', userId)
.eq('category', draft.category)
.ilike('word', escapeLikePattern(draft.word))
.limit(1)
if (excludingId !== undefined) query = query.neq('id', excludingId)
const { data, error } = await query.maybeSingle()
if (error !== null) throw error
return data
}
function assertReturnedOwnership(
userId: string,
entry: DictionaryEntry | null,
): DictionaryEntry {
if (entry === null) {
throw new DictionaryServiceError('server', 'Dictionary row was not returned')
}
if (entry.user_id !== userId) {
throw new DictionaryServiceError('auth', 'Server returned a dictionary row for another user')
}
return entry
}
export async function listDictionaryPage(
options: DictionaryListOptions,
): Promise<DictionaryPageResult> {
requireUuid(options.userId, 'authenticated user')
const pageSize = Math.max(1, Math.min(options.pageSize ?? DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE))
const category = options.category ?? 'all'
if (category !== 'all' && !CATEGORIES.includes(category)) {
throw new DictionaryServiceError('validation', 'Dictionary category is invalid')
}
if (options.cursor !== null && options.cursor !== undefined) validateCursor(options.cursor)
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)
if (category !== 'all') query = query.eq('category', category)
const search = sanitizeDictionarySearch(options.search ?? '')
if (search.length > 0) {
const pattern = `%${search}%`
query = query.or(`word.ilike.${pattern},pronunciation.ilike.${pattern}`)
}
if (options.cursor !== null && options.cursor !== undefined) {
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 !== null) throw error
const rows = (data ?? []).map((row) => assertReturnedOwnership(options.userId, row))
const last = rows.at(-1)
return {
entries: rows,
nextCursor: rows.length === pageSize && last !== undefined
? { updatedAt: last.updated_at, id: last.id }
: null,
total: count ?? rows.length,
}
} catch (error) {
throw dictionaryError(error)
}
}
export async function createDictionaryEntry(
userId: string,
draft: DictionaryDraft,
): Promise<DictionaryEntry> {
requireUuid(userId, 'authenticated user')
const normalized = normalizeDictionaryDraft(draft)
try {
if (await findDuplicate(userId, normalized) !== null) {
throw new DictionaryServiceError('duplicate', 'This word already exists in the category')
}
const { data, error } = await client()
.from('dictionary')
.insert({
user_id: userId,
word: normalized.word,
pronunciation: normalized.pronunciation,
category: normalized.category,
})
.select('*')
.single()
if (error !== null) throw error
return assertReturnedOwnership(userId, data)
} catch (error) {
throw dictionaryError(error)
}
}
export async function updateDictionaryEntry(
userId: string,
current: DictionaryEntry,
draft: DictionaryDraft,
): Promise<DictionaryEntry> {
assertOwnedEntry(userId, current)
const normalized = normalizeDictionaryDraft(draft)
try {
if (await findDuplicate(userId, normalized, current.id) !== null) {
throw new DictionaryServiceError('duplicate', 'This word already exists in the category')
}
const { data, error } = await client()
.from('dictionary')
.update({
word: normalized.word,
pronunciation: normalized.pronunciation,
category: normalized.category,
updated_at: new Date().toISOString(),
})
.eq('user_id', userId)
.eq('id', current.id)
.eq('updated_at', current.updated_at)
.select('*')
.maybeSingle()
if (error !== null) throw error
if (data === null) {
throw new DictionaryServiceError(
'conflict',
'This dictionary entry changed or was removed on another device',
)
}
return assertReturnedOwnership(userId, data)
} catch (error) {
throw dictionaryError(error)
}
}
export async function deleteDictionaryEntry(
userId: string,
current: DictionaryEntry,
): Promise<void> {
assertOwnedEntry(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 !== null) throw error
if (data === null) {
throw new DictionaryServiceError(
'conflict',
'This dictionary entry changed or was removed on another device',
)
}
} catch (error) {
throw dictionaryError(error)
}
}
export async function getDictionaryPromptHints(
userId: string,
requestedLimit = 50,
): Promise<string> {
requireUuid(userId, 'authenticated user')
if (!Number.isSafeInteger(requestedLimit) || requestedLimit < 1) {
throw new DictionaryServiceError('validation', 'Dictionary hint limit is invalid')
}
const limit = Math.min(requestedLimit, 50)
try {
const { data, error } = await client()
.from('dictionary')
.select('word')
.eq('user_id', userId)
.order('usage_count', { ascending: false })
.order('updated_at', { ascending: false })
.limit(limit)
if (error !== null) throw error
const hints: string[] = []
let length = 0
for (const row of data ?? []) {
if (typeof row.word !== 'string') {
throw new DictionaryServiceError('server', 'Dictionary hint row is invalid')
}
const word = normalizeText(row.word)
if (word.length === 0) continue
const nextLength = length + (hints.length === 0 ? 0 : 2) + word.length
if (nextLength > 2_000) break
hints.push(word)
length = nextLength
}
return hints.join(', ')
} catch (error) {
throw dictionaryError(error)
}
}
export function mergeDictionaryEntries(
current: DictionaryEntry[],
incoming: DictionaryEntry[],
): DictionaryEntry[] {
const rows = new Map(current.map((entry) => [entry.id, entry]))
for (const candidate of incoming) {
const existing = rows.get(candidate.id)
if (existing === undefined || candidate.updated_at >= existing.updated_at) {
rows.set(candidate.id, candidate)
}
}
return [...rows.values()].sort((left, right) => {
const byUpdatedAt = right.updated_at.localeCompare(left.updated_at)
return byUpdatedAt !== 0 ? byUpdatedAt : right.id.localeCompare(left.id)
})
}
export interface DictionarySubscription {
unsubscribe: () => Promise<void>
}
export function subscribeToDictionary(
userId: string,
onRemoteChange: () => void,
onStatus: (status: string) => void,
): DictionarySubscription {
requireUuid(userId, 'authenticated user')
let channel: RealtimeChannel | null = supabase
.channel(`mobile-dictionary-${userId}`)
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'dictionary', filter: `user_id=eq.${userId}` },
onRemoteChange,
)
.on(
'postgres_changes',
{ event: 'UPDATE', schema: 'public', table: 'dictionary', filter: `user_id=eq.${userId}` },
onRemoteChange,
)
.subscribe(onStatus)
return {
unsubscribe: async (): Promise<void> => {
if (channel === null) return
const activeChannel = channel
channel = null
await supabase.removeChannel(activeChannel)
},
}
}