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,2 @@
export * from './types'
export * from './memo-service'

View file

@ -0,0 +1,264 @@
import type { RealtimeChannel } from '@supabase/supabase-js'
import type { HistoryEntry, MemoTag } from '@d3ro/api-client'
import { supabase } from '../../lib/supabase'
import type {
MemoHistoryItem,
MemoSearchResult,
MemoTagSummary,
} from './types'
export type MemoServiceErrorCode =
| 'auth'
| 'conflict'
| 'network'
| 'not-found'
| 'server'
| 'validation'
export class MemoServiceError extends Error {
constructor(
public readonly code: MemoServiceErrorCode,
message: string,
public readonly retryable = false,
) {
super(message)
this.name = 'MemoServiceError'
}
}
export interface MemoSubscription {
unsubscribe: () => Promise<void>
}
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 isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function requireUuid(value: string, label: string): void {
if (!UUID_PATTERN.test(value)) throw new MemoServiceError('auth', `A valid ${label} is required`)
}
export function normalizeMemoTag(value: string): { tag: string; normalizedTag: string } {
const tag = value.trim().replace(/\s+/g, ' ')
if (tag.length < 1 || tag.length > 80) {
throw new MemoServiceError('validation', 'Memo tags must contain 1-80 characters')
}
return { tag, normalizedTag: tag.toLocaleLowerCase('en-US') }
}
export function sanitizeMemoSearch(value: string): string {
return value.trim().slice(0, 100).replace(/[\u0000-\u001f\u007f]/g, ' ')
.replace(/\s+/g, ' ').trim()
}
function toMemoError(error: unknown): MemoServiceError {
if (error instanceof MemoServiceError) 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 : 'Memo request failed'
const lower = message.toLowerCase()
if (
error instanceof TypeError
|| lower.includes('network request failed')
|| lower.includes('failed to fetch')
|| lower.includes('networkerror')
) return new MemoServiceError('network', message, true)
if (code === '40001' || lower.includes('conflict')) {
return new MemoServiceError('conflict', message, true)
}
if (code === 'P0002') return new MemoServiceError('not-found', message)
if (code === '42501' || code === 'PGRST301' || lower.includes('jwt')) {
return new MemoServiceError('auth', message)
}
if (code.startsWith('22')) return new MemoServiceError('validation', message)
return new MemoServiceError('server', message, true)
}
async function rpc<T>(name: string, params: Record<string, unknown> = {}): Promise<T> {
const { data, error } = await supabase.rpc(name as never, params as never)
if (error !== null) throw toMemoError(error)
return data as T
}
function assertHistory(value: unknown, userId: string): HistoryEntry {
if (
!isRecord(value)
|| typeof value.id !== 'string'
|| !UUID_PATTERN.test(value.id)
|| value.user_id !== userId
|| typeof value.original_text !== 'string'
|| typeof value.created_at !== 'string'
) throw new MemoServiceError('server', 'Server returned an invalid memo history row')
return value as HistoryEntry
}
function parseSearchRows(value: unknown, userId: string): MemoHistoryItem[] {
if (!Array.isArray(value)) throw new MemoServiceError('server', 'Server returned an invalid memo result')
return value.map((row) => {
if (!isRecord(row) || !Array.isArray(row.tags) || !row.tags.every((tag) => typeof tag === 'string')) {
throw new MemoServiceError('server', 'Server returned invalid memo tags')
}
const seen = new Set<string>()
const tags = row.tags.filter((tag) => {
const normalized = normalizeMemoTag(tag).normalizedTag
if (seen.has(normalized)) return false
seen.add(normalized)
return true
})
return { history: assertHistory(row.history_row, userId), tags }
})
}
function parseTagSummaries(value: unknown): MemoTagSummary[] {
if (!Array.isArray(value)) throw new MemoServiceError('server', 'Server returned invalid tag summaries')
return value.map((row) => {
if (
!isRecord(row)
|| typeof row.tag !== 'string'
|| typeof row.normalized_tag !== 'string'
|| !Number.isSafeInteger(Number(row.history_count))
|| Number(row.history_count) < 0
) throw new MemoServiceError('server', 'Server returned an invalid tag summary')
const normalized = normalizeMemoTag(row.tag)
if (normalized.normalizedTag !== row.normalized_tag) {
throw new MemoServiceError('server', 'Server returned a non-canonical tag summary')
}
return {
tag: normalized.tag,
normalizedTag: normalized.normalizedTag,
historyCount: Number(row.history_count),
}
})
}
export async function searchMemos(
userId: string,
queryValue = '',
tagValue: string | null = null,
limit = 50,
offset = 0,
): Promise<MemoSearchResult> {
requireUuid(userId, 'authenticated user')
const query = sanitizeMemoSearch(queryValue)
const tag = tagValue === null ? null : normalizeMemoTag(tagValue).tag
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100 || !Number.isSafeInteger(offset) || offset < 0) {
throw new MemoServiceError('validation', 'Memo page bounds are invalid')
}
try {
const [rows, summaries] = await Promise.all([
rpc<unknown>('mobile_search_memos_v1', {
p_query: query,
p_tag: tag,
p_limit: limit,
p_offset: offset,
}),
rpc<unknown>('mobile_list_memo_tags_v1'),
])
return {
items: parseSearchRows(rows, userId),
tagSummaries: parseTagSummaries(summaries),
}
} catch (error) {
throw toMemoError(error)
}
}
export async function addMemoTag(
userId: string,
historyId: string,
tagValue: string,
): Promise<MemoTag> {
requireUuid(userId, 'authenticated user')
requireUuid(historyId, 'history id')
const tag = normalizeMemoTag(tagValue).tag
try {
const saved = await rpc<MemoTag>('mobile_add_memo_tag_v1', {
p_history_id: historyId,
p_tag: tag,
})
if (
saved.user_id !== userId
|| saved.history_id !== historyId
|| normalizeMemoTag(saved.tag).normalizedTag !== normalizeMemoTag(tag).normalizedTag
) throw new MemoServiceError('server', 'Server returned an invalid memo tag')
return saved
} catch (error) {
throw toMemoError(error)
}
}
export async function removeMemoTag(
userId: string,
historyId: string,
tagValue: string,
): Promise<void> {
requireUuid(userId, 'authenticated user')
requireUuid(historyId, 'history id')
const tag = normalizeMemoTag(tagValue).tag
try {
const removed = await rpc<boolean>('mobile_remove_memo_tag_v1', {
p_history_id: historyId,
p_tag: tag,
})
if (removed !== true) throw new MemoServiceError('not-found', 'Memo tag was not found')
} catch (error) {
throw toMemoError(error)
}
}
export async function renameMemoTag(
userId: string,
oldTagValue: string,
newTagValue: string,
): Promise<number> {
requireUuid(userId, 'authenticated user')
const oldTag = normalizeMemoTag(oldTagValue).tag
const newTag = normalizeMemoTag(newTagValue).tag
try {
const changed = await rpc<number>('mobile_rename_memo_tag_v1', {
p_old_tag: oldTag,
p_new_tag: newTag,
})
if (!Number.isSafeInteger(changed) || changed < 0) {
throw new MemoServiceError('server', 'Server returned an invalid rename count')
}
if (changed === 0) throw new MemoServiceError('not-found', 'Memo tag was not found')
return changed
} catch (error) {
throw toMemoError(error)
}
}
export function buildMemoShareText(item: MemoHistoryItem): string {
const title = item.history.title?.trim() || 'D3RO Voice memo'
const body = item.history.polished_text?.trim()
|| item.history.summary_text?.trim()
|| item.history.original_text.trim()
const tags = item.tags.length > 0 ? `\n\n${item.tags.map((tag) => `#${tag}`).join(' ')}` : ''
return `${title}\n\n${body}${tags}`.trim()
}
export function subscribeToMemoTags(
userId: string,
onRemoteChange: () => void,
onStatus: (status: string) => void,
): MemoSubscription {
requireUuid(userId, 'authenticated user')
let channel: RealtimeChannel | null = supabase
.channel(`mobile-memo-tags-${userId}`)
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'memo_tags', filter: `user_id=eq.${userId}` },
onRemoteChange,
)
.subscribe(onStatus)
return {
unsubscribe: async (): Promise<void> => {
if (channel === null) return
const active = channel
channel = null
await supabase.removeChannel(active)
},
}
}

View file

@ -0,0 +1,19 @@
import type { HistoryEntry, MemoTag } from '@d3ro/api-client'
export type { HistoryEntry, MemoTag }
export interface MemoTagSummary {
tag: string
normalizedTag: string
historyCount: number
}
export interface MemoHistoryItem {
history: HistoryEntry
tags: string[]
}
export interface MemoSearchResult {
items: MemoHistoryItem[]
tagSummaries: MemoTagSummary[]
}