feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
212
apps/web/src/lib/history-client.ts
Normal file
212
apps/web/src/lib/history-client.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import type { D3roSupabaseClient, HistoryEntry } from '@d3ro/api-client'
|
||||
|
||||
export type HistoryListFilter = 'all' | 'favorites'
|
||||
|
||||
export interface HistoryCursor {
|
||||
createdAt: string
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface HistoryPageOptions {
|
||||
userId: string
|
||||
filter: HistoryListFilter
|
||||
search: string
|
||||
pageSize: number
|
||||
cursor: HistoryCursor | null
|
||||
}
|
||||
|
||||
export interface HistoryPageResult {
|
||||
entries: HistoryEntry[]
|
||||
nextCursor: HistoryCursor | null
|
||||
}
|
||||
|
||||
export type HistoryClientErrorCode = 'auth' | 'conflict' | 'not-found' | 'validation' | 'network' | 'server'
|
||||
|
||||
export class HistoryClientError extends Error {
|
||||
constructor(
|
||||
public readonly code: HistoryClientErrorCode,
|
||||
message: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'HistoryClientError'
|
||||
}
|
||||
}
|
||||
|
||||
export interface HistoryUpdate {
|
||||
title?: string | null
|
||||
original_text?: string
|
||||
polished_text?: string | null
|
||||
is_favorite?: boolean
|
||||
}
|
||||
|
||||
const MAX_PAGE_SIZE = 50
|
||||
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
|
||||
|
||||
function requireUuid(value: string, code: 'auth' | 'validation', message: string): void {
|
||||
if (!UUID_PATTERN.test(value)) throw new HistoryClientError(code, message)
|
||||
}
|
||||
|
||||
function mapError(error: unknown): HistoryClientError {
|
||||
if (error instanceof HistoryClientError) 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 : 'History request failed'
|
||||
const normalized = message.toLowerCase()
|
||||
|
||||
if (
|
||||
error instanceof TypeError
|
||||
|| normalized.includes('failed to fetch')
|
||||
|| normalized.includes('network request failed')
|
||||
|| normalized.includes('networkerror')
|
||||
) {
|
||||
return new HistoryClientError('network', message)
|
||||
}
|
||||
if (databaseCode === 'PGRST301' || databaseCode === '42501' || normalized.includes('jwt')) {
|
||||
return new HistoryClientError('auth', message)
|
||||
}
|
||||
return new HistoryClientError('server', message)
|
||||
}
|
||||
|
||||
export function sanitizeHistorySearch(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.slice(0, 100)
|
||||
.replace(/[%,()._'"\\]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export async function listHistoryPage(
|
||||
client: D3roSupabaseClient,
|
||||
options: HistoryPageOptions
|
||||
): Promise<HistoryPageResult> {
|
||||
requireUuid(options.userId, 'auth', 'A valid authenticated user is required')
|
||||
const pageSize = Math.max(1, Math.min(options.pageSize, MAX_PAGE_SIZE))
|
||||
|
||||
try {
|
||||
let query = client
|
||||
.from('history')
|
||||
.select('*')
|
||||
.eq('user_id', options.userId)
|
||||
.order('created_at', { ascending: false })
|
||||
.order('id', { ascending: false })
|
||||
.limit(pageSize + 1)
|
||||
|
||||
if (options.filter === 'favorites') query = query.eq('is_favorite', true)
|
||||
if (options.cursor !== null) {
|
||||
query = query.or(
|
||||
`created_at.lt.${options.cursor.createdAt},and(created_at.eq.${options.cursor.createdAt},id.lt.${options.cursor.id})`
|
||||
)
|
||||
}
|
||||
|
||||
const search = sanitizeHistorySearch(options.search)
|
||||
if (search.length > 0) {
|
||||
const pattern = `%${search}%`
|
||||
query = query.or(
|
||||
`title.ilike.${pattern},original_text.ilike.${pattern},polished_text.ilike.${pattern},summary_text.ilike.${pattern}`
|
||||
)
|
||||
}
|
||||
|
||||
const { data, error } = await query
|
||||
if (error) throw error
|
||||
|
||||
const rows = data ?? []
|
||||
const hasMore = rows.length > pageSize
|
||||
const entries = hasMore ? rows.slice(0, pageSize) : rows
|
||||
const last = entries.at(-1)
|
||||
return {
|
||||
entries,
|
||||
nextCursor: hasMore && last ? { createdAt: last.created_at, id: last.id } : null
|
||||
}
|
||||
} catch (error) {
|
||||
throw mapError(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getHistoryEntry(
|
||||
client: D3roSupabaseClient,
|
||||
userId: string,
|
||||
entryId: string
|
||||
): Promise<HistoryEntry> {
|
||||
requireUuid(userId, 'auth', 'A valid authenticated user is required')
|
||||
requireUuid(entryId, 'validation', 'A valid history id is required')
|
||||
|
||||
try {
|
||||
const { data, error } = await client
|
||||
.from('history')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.eq('id', entryId)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) throw error
|
||||
if (!data) throw new HistoryClientError('not-found', 'History entry was not found')
|
||||
return data
|
||||
} catch (error) {
|
||||
throw mapError(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateHistoryEntryRevisionSafe(
|
||||
client: D3roSupabaseClient,
|
||||
userId: string,
|
||||
entryId: string,
|
||||
expectedRevision: number,
|
||||
patch: HistoryUpdate
|
||||
): Promise<HistoryEntry> {
|
||||
requireUuid(userId, 'auth', 'A valid authenticated user is required')
|
||||
requireUuid(entryId, 'validation', 'A valid history id is required')
|
||||
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1) {
|
||||
throw new HistoryClientError('validation', 'Expected revision is invalid')
|
||||
}
|
||||
if (Object.keys(patch).length === 0) {
|
||||
throw new HistoryClientError('validation', 'At least one field is required')
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, error } = await client
|
||||
.from('history')
|
||||
.update({ ...patch, revision: expectedRevision + 1 })
|
||||
.eq('user_id', userId)
|
||||
.eq('id', entryId)
|
||||
.eq('revision', expectedRevision)
|
||||
.select('*')
|
||||
.maybeSingle()
|
||||
|
||||
if (error) throw error
|
||||
if (!data) throw new HistoryClientError('conflict', 'History revision changed')
|
||||
return data
|
||||
} catch (error) {
|
||||
throw mapError(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteHistoryEntryRevisionSafe(
|
||||
client: D3roSupabaseClient,
|
||||
userId: string,
|
||||
entryId: string,
|
||||
expectedRevision: number
|
||||
): Promise<void> {
|
||||
requireUuid(userId, 'auth', 'A valid authenticated user is required')
|
||||
requireUuid(entryId, 'validation', 'A valid history id is required')
|
||||
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1) {
|
||||
throw new HistoryClientError('validation', 'Expected revision is invalid')
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, error } = await client
|
||||
.from('history')
|
||||
.delete()
|
||||
.eq('user_id', userId)
|
||||
.eq('id', entryId)
|
||||
.eq('revision', expectedRevision)
|
||||
.select('id')
|
||||
.maybeSingle()
|
||||
|
||||
if (error) throw error
|
||||
if (!data) throw new HistoryClientError('conflict', 'History revision changed')
|
||||
} catch (error) {
|
||||
throw mapError(error)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue