Files
life-echo/app-expo/src/features/voice/segment-outbox.ts

134 lines
4.2 KiB
TypeScript
Raw Normal View History

import { executeSql, querySql } from '@/core/storage/sqlite';
import type { SegmentOutboxEntry, SegmentOutboxStatus } from './types';
const CREATE_TABLE_SQL = `
CREATE TABLE IF NOT EXISTS segment_outbox (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT NOT NULL,
voice_session_id TEXT NOT NULL,
segment_index INTEGER NOT NULL,
file_uri TEXT NOT NULL,
duration_ms INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
retry_count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
`;
let initialized = false;
async function ensureTable(): Promise<void> {
if (initialized) return;
await executeSql(CREATE_TABLE_SQL);
initialized = true;
}
function mapRow(row: Record<string, unknown>): SegmentOutboxEntry {
return {
id: row.id as number,
conversationId: row.conversation_id as string,
voiceSessionId: row.voice_session_id as string,
segmentIndex: row.segment_index as number,
fileUri: row.file_uri as string,
durationMs: row.duration_ms as number,
status: row.status as SegmentOutboxStatus,
retryCount: row.retry_count as number,
createdAt: row.created_at as number,
};
}
/**
* SQLite-backed outbox for voice segments.
* Stores metadata + queue state only audio files live on the local filesystem.
*
* State machine: pending sending sent | failed
* Single writer pattern enforced by serializing all writes through this module.
*/
export const segmentOutbox = {
async enqueue(
entry: Omit<
SegmentOutboxEntry,
'id' | 'status' | 'retryCount' | 'createdAt'
>,
): Promise<number> {
await ensureTable();
const result = await executeSql(
`INSERT INTO segment_outbox (conversation_id, voice_session_id, segment_index, file_uri, duration_ms, status, retry_count, created_at)
VALUES (?, ?, ?, ?, ?, 'pending', 0, ?)`,
[
entry.conversationId,
entry.voiceSessionId,
entry.segmentIndex,
entry.fileUri,
entry.durationMs,
Date.now(),
],
);
return result.lastInsertRowId;
},
async getPending(conversationId?: string): Promise<SegmentOutboxEntry[]> {
await ensureTable();
const sql = conversationId
? `SELECT * FROM segment_outbox WHERE status = 'pending' AND conversation_id = ? ORDER BY created_at ASC`
: `SELECT * FROM segment_outbox WHERE status = 'pending' ORDER BY created_at ASC`;
const params = conversationId ? [conversationId] : [];
const rows = await querySql<Record<string, unknown>>(sql, params);
return rows.map(mapRow);
},
async markSending(id: number): Promise<void> {
await ensureTable();
await executeSql(
`UPDATE segment_outbox SET status = 'sending' WHERE id = ?`,
[id],
);
},
async markSent(id: number): Promise<void> {
await ensureTable();
await executeSql(`UPDATE segment_outbox SET status = 'sent' WHERE id = ?`, [
id,
]);
},
async markFailed(id: number): Promise<void> {
await ensureTable();
await executeSql(
`UPDATE segment_outbox SET status = 'failed', retry_count = retry_count + 1 WHERE id = ?`,
[id],
);
},
async resetFailed(conversationId?: string): Promise<void> {
await ensureTable();
const sql = conversationId
? `UPDATE segment_outbox SET status = 'pending' WHERE status = 'failed' AND conversation_id = ?`
: `UPDATE segment_outbox SET status = 'pending' WHERE status = 'failed'`;
const params = conversationId ? [conversationId] : [];
await executeSql(sql, params);
},
async clearSent(conversationId?: string): Promise<void> {
await ensureTable();
const sql = conversationId
? `DELETE FROM segment_outbox WHERE status = 'sent' AND conversation_id = ?`
: `DELETE FROM segment_outbox WHERE status = 'sent'`;
const params = conversationId ? [conversationId] : [];
await executeSql(sql, params);
},
async clearAll(conversationId: string): Promise<void> {
await ensureTable();
await executeSql(`DELETE FROM segment_outbox WHERE conversation_id = ?`, [
conversationId,
]);
},
/** For testing — reset the initialization flag. */
_resetForTest(): void {
initialized = false;
},
} as const;