From c3367bc6111c18d3ec260567bfbbf012df0d3015 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 28 Sep 2026 02:16:27 +0900 Subject: [PATCH] refactor(api-client): publish only the shared contract from the root barrel --- .../__tests__/public-surface-r2-35.test.ts | 63 +++++ packages/api-client/src/history.ts | 60 ----- packages/api-client/src/index.ts | 25 +- packages/api-client/src/meetings.ts | 225 ------------------ packages/api-client/src/stt-contract.ts | 22 ++ packages/api-client/src/transcribe.ts | 25 +- packages/api-client/src/usage.ts | 54 ----- 7 files changed, 100 insertions(+), 374 deletions(-) create mode 100644 packages/api-client/__tests__/public-surface-r2-35.test.ts delete mode 100644 packages/api-client/src/history.ts delete mode 100644 packages/api-client/src/meetings.ts create mode 100644 packages/api-client/src/stt-contract.ts delete mode 100644 packages/api-client/src/usage.ts diff --git a/packages/api-client/__tests__/public-surface-r2-35.test.ts b/packages/api-client/__tests__/public-surface-r2-35.test.ts new file mode 100644 index 0000000..f33bd77 --- /dev/null +++ b/packages/api-client/__tests__/public-surface-r2-35.test.ts @@ -0,0 +1,63 @@ +// packages/api-client/__tests__/public-surface-r2-35.test.ts +// 루트 barrel은 공유 계약(타입 + 클라이언트 팩토리)만 공개해야 한다. +// 앱마다 따로 소유하는 저장소·쿼터·STT 호출 헬퍼가 '정본처럼' 다시 노출되지 않도록 고정한다. + +import { describe, expect, expectTypeOf, it, vi } from 'vitest' +import * as apiClient from '../src/index' +import type { TranscribeAudioResult } from '../src/index' + +describe('@d3ro/api-client root surface', () => { + it('exposes only the client factory runtime values', () => { + expect(Object.keys(apiClient).sort()).toEqual([ + 'SupabaseConfigurationError', + 'createD3roSupabaseClient', + 'isClientConfigured', + 'requireSupabasePublicConfig', + ]) + }) + + it('does not re-export repository, quota, auth or STT call helpers', () => { + const exported = apiClient as Record + for (const name of [ + 'listHistory', + 'createHistoryEntry', + 'deleteHistoryEntry', + 'getTodayUsage', + 'listUsageLastDays', + 'getSubscription', + 'listMeetings', + 'updateMeeting', + 'appendTranscript', + 'signInWithOAuth', + 'getSession', + 'transcribeAudio', + 'getSupabaseBrowserClient', + 'createSupabaseServerClient', + ]) { + expect(exported[name], name).toBeUndefined() + } + }) + + it('keeps the STT result contract available as a type', () => { + expectTypeOf().toHaveProperty('text').toEqualTypeOf() + expectTypeOf().toHaveProperty('durationSeconds').toEqualTypeOf() + }) +}) + +describe('transcribeAudio reference client', () => { + it('does not fall back to process.env for gateway configuration', async () => { + vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', 'https://env.supabase.co') + vi.stubEnv('NEXT_PUBLIC_SUPABASE_ANON_KEY', 'env-anon-key') + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + try { + const { transcribeAudio } = await import('../src/transcribe') + await expect(transcribeAudio({ token: 'user-token', audio: new Blob(['x']) })) + .rejects.toThrow('authenticated Supabase session') + expect(fetchMock).not.toHaveBeenCalled() + } finally { + vi.unstubAllEnvs() + vi.unstubAllGlobals() + } + }) +}) diff --git a/packages/api-client/src/history.ts b/packages/api-client/src/history.ts deleted file mode 100644 index 7b0b8bb..0000000 --- a/packages/api-client/src/history.ts +++ /dev/null @@ -1,60 +0,0 @@ -// packages/api-client/src/history.ts -// history 테이블 도메인 함수 (개인 음성 입력 이력) - -import type { D3roSupabaseClient } from './client' -import type { HistoryEntry, HistoryMode, HistoryStatus } from './types' - -export async function listHistory( - client: D3roSupabaseClient, - options?: { limit?: number; mode?: HistoryMode; status?: HistoryStatus } -): Promise { - let query = client - .from('history') - .select('*') - .order('created_at', { ascending: false }) - - if (options?.limit) query = query.limit(options.limit) - if (options?.mode) query = query.eq('mode', options.mode) - if (options?.status) query = query.eq('status', options.status) - - const { data, error } = await query - if (error) throw new Error(`listHistory failed: ${error.message}`) - return data ?? [] -} - -export async function createHistoryEntry( - client: D3roSupabaseClient, - input: { - userId: string - originalText: string - duration: number - mode?: HistoryMode - polishedText?: string | null - detectedLanguage?: string | null - } -): Promise { - const { data, error } = await client - .from('history') - .insert({ - user_id: input.userId, - original_text: input.originalText, - duration: input.duration, - mode: input.mode ?? 'dictation', - polished_text: input.polishedText ?? null, - detected_language: input.detectedLanguage ?? null, - word_count: input.originalText.split(/\s+/).filter(Boolean).length - }) - .select('*') - .single() - - if (error) throw new Error(`createHistoryEntry failed: ${error.message}`) - return data -} - -export async function deleteHistoryEntry( - client: D3roSupabaseClient, - entryId: string -): Promise { - const { error } = await client.from('history').delete().eq('id', entryId) - if (error) throw new Error(`deleteHistoryEntry failed: ${error.message}`) -} diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index 09ebee4..8ae9c6f 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -1,20 +1,13 @@ -// packages/api-client — barrel export -// Database 타입 + 모든 도메인 함수 재수출. -// @supabase/ssr 0.10 + supabase-js 2.103 정합으로 제네릭이 consumer에 전파됨. +// packages/api-client — root barrel +// 공유 계약만 공개한다: Database/도메인 타입 + Supabase 클라이언트 팩토리 + STT 계약 타입. +// 저장소(CRUD)·쿼터 조회·STT 호출 같은 런타임 정책은 각 앱(web/mobile)이 소유한다. +// @supabase/ssr 0.10 + supabase-js 2.103 정합으로 Database 제네릭이 consumer에 전파됨. // -// Sub-path도 지원: -// '@d3ro/api-client/client' — Supabase 클라이언트 팩토리 -// '@d3ro/api-client/auth' — 로그인/세션 -// '@d3ro/api-client/meetings' — 회의 CRUD -// '@d3ro/api-client/history' — 음성 입력 이력 -// '@d3ro/api-client/usage' — 쿼터/구독 +// Sub-path: +// '@d3ro/api-client/client' — Supabase 클라이언트 팩토리 +// '@d3ro/api-client/supabase-browser' — Next.js 브라우저 클라이언트 (@supabase/ssr) +// '@d3ro/api-client/supabase-server' — Next.js RSC/route handler 클라이언트 (@supabase/ssr) export * from './types' export * from './client' -export * from './auth' -export * from './meetings' -export * from './history' -export * from './usage' -export * from './transcribe' -export * from './supabase-browser' -export * from './supabase-server' +export type { TranscribeAudioParams, TranscribeAudioResult } from './stt-contract' diff --git a/packages/api-client/src/meetings.ts b/packages/api-client/src/meetings.ts deleted file mode 100644 index 3aeb925..0000000 --- a/packages/api-client/src/meetings.ts +++ /dev/null @@ -1,225 +0,0 @@ -// packages/api-client/src/meetings.ts -// meetings / meeting_memos / meeting_documents / transcripts 도메인 함수. - -import type { D3roSupabaseClient } from './client' -import type { - Meeting, - MeetingMemo, - MeetingDocument, - Transcript, - DocumentTemplateType -} from './types' - -// ── meetings ───────────────────────────────────────────────── - -export async function listMeetings( - client: D3roSupabaseClient, - options?: { limit?: number; teamId?: string | null } -): Promise { - let query = client - .from('meetings') - .select('*') - .order('started_at', { ascending: false }) - - if (options?.limit) { - query = query.limit(options.limit) - } - if (options?.teamId !== undefined) { - query = options.teamId === null ? query.is('team_id', null) : query.eq('team_id', options.teamId) - } - - const { data, error } = await query - if (error) throw new Error(`listMeetings failed: ${error.message}`) - return data ?? [] -} - -export async function getMeeting( - client: D3roSupabaseClient, - meetingId: string -): Promise { - const { data, error } = await client - .from('meetings') - .select('*') - .eq('id', meetingId) - .maybeSingle() - - if (error) throw new Error(`getMeeting failed: ${error.message}`) - return data -} - -export async function createMeeting( - client: D3roSupabaseClient, - input: { - userId: string - title?: string | null - teamId?: string | null - } -): Promise { - const { data, error } = await client - .from('meetings') - .insert({ - user_id: input.userId, - title: input.title ?? null, - team_id: input.teamId ?? null, - status: 'recording' - }) - .select('*') - .single() - - if (error) throw new Error(`createMeeting failed: ${error.message}`) - return data -} - -export async function updateMeeting( - client: D3roSupabaseClient, - meetingId: string, - patch: Partial -): Promise { - const { data, error } = await client - .from('meetings') - .update(patch) - .eq('id', meetingId) - .select('*') - .single() - - if (error) throw new Error(`updateMeeting failed: ${error.message}`) - return data -} - -export async function deleteMeeting( - client: D3roSupabaseClient, - meetingId: string -): Promise { - const { error } = await client.from('meetings').delete().eq('id', meetingId) - if (error) throw new Error(`deleteMeeting failed: ${error.message}`) -} - -// ── meeting_memos ──────────────────────────────────────────── - -export async function listMemos( - client: D3roSupabaseClient, - meetingId: string -): Promise { - const { data, error } = await client - .from('meeting_memos') - .select('*') - .eq('meeting_id', meetingId) - .order('timestamp_ms', { ascending: true }) - - if (error) throw new Error(`listMemos failed: ${error.message}`) - return data ?? [] -} - -export async function createMemo( - client: D3roSupabaseClient, - input: { - meetingId: string - userId: string - content: string - timestampMs: number - } -): Promise { - const { data, error } = await client - .from('meeting_memos') - .insert({ - meeting_id: input.meetingId, - user_id: input.userId, - content: input.content, - timestamp_ms: input.timestampMs - }) - .select('*') - .single() - - if (error) throw new Error(`createMemo failed: ${error.message}`) - return data -} - -// ── meeting_documents ──────────────────────────────────────── - -export async function listDocuments( - client: D3roSupabaseClient, - meetingId: string -): Promise { - const { data, error } = await client - .from('meeting_documents') - .select('*') - .eq('meeting_id', meetingId) - .order('created_at', { ascending: true }) - - if (error) throw new Error(`listDocuments failed: ${error.message}`) - return data ?? [] -} - -export async function createDocument( - client: D3roSupabaseClient, - input: { - meetingId: string - userId: string - templateType: DocumentTemplateType - title: string - content: string - promptUsed?: string | null - llmModel?: string | null - } -): Promise { - const { data, error } = await client - .from('meeting_documents') - .insert({ - meeting_id: input.meetingId, - user_id: input.userId, - template_type: input.templateType, - title: input.title, - content: input.content, - prompt_used: input.promptUsed ?? null, - llm_model: input.llmModel ?? null - }) - .select('*') - .single() - - if (error) throw new Error(`createDocument failed: ${error.message}`) - return data -} - -// ── transcripts ────────────────────────────────────────────── - -export async function listTranscripts( - client: D3roSupabaseClient, - meetingId: string -): Promise { - const { data, error } = await client - .from('transcripts') - .select('*') - .eq('meeting_id', meetingId) - .order('segment_index', { ascending: true }) - - if (error) throw new Error(`listTranscripts failed: ${error.message}`) - return data ?? [] -} - -export async function appendTranscript( - client: D3roSupabaseClient, - input: { - meetingId: string - segmentIndex: number - timestampMs: number - text: string - speaker?: string | null - durationMs?: number | null - } -): Promise { - const { data, error } = await client - .from('transcripts') - .insert({ - meeting_id: input.meetingId, - segment_index: input.segmentIndex, - timestamp_ms: input.timestampMs, - text: input.text, - speaker: input.speaker ?? null, - duration_ms: input.durationMs ?? null - }) - .select('*') - .single() - - if (error) throw new Error(`appendTranscript failed: ${error.message}`) - return data -} diff --git a/packages/api-client/src/stt-contract.ts b/packages/api-client/src/stt-contract.ts new file mode 100644 index 0000000..68b7f7c --- /dev/null +++ b/packages/api-client/src/stt-contract.ts @@ -0,0 +1,22 @@ +// packages/api-client/src/stt-contract.ts +// Cloud STT gateway(Supabase Edge `stt-proxy`) 요청/응답 계약 타입. +// 런타임 호출 코드는 각 앱이 소유하고, 이 모듈은 타입 계약만 공유한다. + +export interface TranscribeAudioParams { + audio: Blob | ArrayBuffer | Uint8Array | string + language?: string + supabaseUrl?: string + anonKey?: string + token?: string +} + +export interface TranscribeAudioResult { + text: string + confidence: number + language: string + durationSeconds: number + provider: string + latencyMs: number + modelId?: string + cost?: number +} diff --git a/packages/api-client/src/transcribe.ts b/packages/api-client/src/transcribe.ts index e866770..a28133c 100644 --- a/packages/api-client/src/transcribe.ts +++ b/packages/api-client/src/transcribe.ts @@ -1,24 +1,11 @@ // D3RO Cloud STT client. User-facing transcription always passes through the // Supabase Edge gateway that owns authentication, quota and usage accounting. +// Not part of the package's public surface (no root/sub-path export): the +// shared contract is ./stt-contract. Kept only as the reference for its test. -export interface TranscribeAudioParams { - audio: Blob | ArrayBuffer | Uint8Array | string - language?: string - supabaseUrl?: string - anonKey?: string - token?: string -} +import type { TranscribeAudioParams, TranscribeAudioResult } from './stt-contract' -export interface TranscribeAudioResult { - text: string - confidence: number - language: string - durationSeconds: number - provider: string - latencyMs: number - modelId?: string - cost?: number -} +export type { TranscribeAudioParams, TranscribeAudioResult } function decodeBase64(value: string): Uint8Array { const normalized = value.replace(/^data:[^,]*,/, '').replace(/\s+/g, '') @@ -83,8 +70,8 @@ function parseResult(value: unknown, latencyMs: number): TranscribeAudioResult { } export async function transcribeAudio(params: TranscribeAudioParams): Promise { - const supabaseUrl = params.supabaseUrl ?? process.env.NEXT_PUBLIC_SUPABASE_URL ?? '' - const anonKey = params.anonKey ?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? '' + const supabaseUrl = params.supabaseUrl ?? '' + const anonKey = params.anonKey ?? '' const token = params.token ?? '' if (!supabaseUrl || !anonKey || !token) { throw new Error('STT gateway requires an authenticated Supabase session.') diff --git a/packages/api-client/src/usage.ts b/packages/api-client/src/usage.ts deleted file mode 100644 index 4d46736..0000000 --- a/packages/api-client/src/usage.ts +++ /dev/null @@ -1,54 +0,0 @@ -// packages/api-client/src/usage.ts -// daily_usage 조회 (쿼터 UI 표시용) - -import type { D3roSupabaseClient } from './client' -import type { DailyUsage, Subscription } from './types' - -export async function getTodayUsage( - client: D3roSupabaseClient, - feature: string -): Promise { - const today = new Date().toISOString().slice(0, 10) - const { data, error } = await client - .from('daily_usage') - .select('count') - .eq('date', today) - .eq('feature', feature) - .maybeSingle() - - if (error) { - // RLS로 인해 빈 결과도 정상. 에러는 throw하지 않고 0 반환. - return 0 - } - return (data?.count as number | undefined) ?? 0 -} - -export async function listUsageLastDays( - client: D3roSupabaseClient, - days: number -): Promise { - const since = new Date() - since.setDate(since.getDate() - days) - const sinceStr = since.toISOString().slice(0, 10) - - const { data, error } = await client - .from('daily_usage') - .select('*') - .gte('date', sinceStr) - .order('date', { ascending: false }) - - if (error) throw new Error(`listUsageLastDays failed: ${error.message}`) - return data ?? [] -} - -export async function getSubscription( - client: D3roSupabaseClient -): Promise { - const { data, error } = await client - .from('subscriptions') - .select('*') - .maybeSingle() - - if (error) return null - return data -}