feat: persist text memo notes in work server

This commit is contained in:
2026-04-26 17:36:46 +09:00
parent 20a6333ed2
commit 42ae640470
7 changed files with 926 additions and 56 deletions

View File

@@ -12,6 +12,7 @@ import { registerNotificationRoutes } from './routes/notification.js';
import { registerPlanRoutes } from './routes/plan.js';
import { registerServerCommandRoutes } from './routes/server-command.js';
import { registerSchemaRoutes } from './routes/schema.js';
import { registerTextMemoRoutes } from './routes/text-memo.js';
import { registerVisitorHistoryRoutes } from './routes/visitor-history.js';
import { shouldPersistNotFoundErrorLog } from './not-found.js';
import { createErrorLog } from './services/error-log-service.js';
@@ -37,6 +38,7 @@ export function createApp() {
app.register(registerNotificationRoutes);
app.register(registerPlanRoutes);
app.register(registerServerCommandRoutes);
app.register(registerTextMemoRoutes);
app.register(registerVisitorHistoryRoutes);
app.setNotFoundHandler(async (request, reply) => {

View File

@@ -0,0 +1,78 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
createTextMemoNote,
deleteTextMemoNote,
importTextMemoNotes,
listTextMemoNotes,
textMemoNoteCreateSchema,
textMemoNoteImportSchema,
textMemoNoteUpdateSchema,
updateTextMemoNote,
} from '../services/text-memo-service.js';
function resolveClientId(headers: Record<string, unknown>) {
return String(headers['x-client-id'] ?? '').trim();
}
export async function registerTextMemoRoutes(app: FastifyInstance) {
app.get('/api/text-memo/notes', async (request) => {
const items = await listTextMemoNotes(resolveClientId(request.headers));
return {
ok: true,
items,
};
});
app.post('/api/text-memo/notes', async (request) => {
const payload = textMemoNoteCreateSchema.parse(request.body ?? {});
const item = await createTextMemoNote(resolveClientId(request.headers), payload);
return {
ok: true,
item,
};
});
app.post('/api/text-memo/notes/import', async (request) => {
const payload = textMemoNoteImportSchema.parse(request.body ?? {});
const items = await importTextMemoNotes(resolveClientId(request.headers), payload);
return {
ok: true,
items,
};
});
app.put('/api/text-memo/notes/:noteId', async (request, reply) => {
const noteId = z.string().trim().min(1).parse((request.params as { noteId: string }).noteId);
const payload = textMemoNoteUpdateSchema.parse(request.body ?? {});
const item = await updateTextMemoNote(resolveClientId(request.headers), noteId, payload);
if (!item) {
return reply.code(404).send({
message: '수정할 메모를 찾을 수 없습니다.',
});
}
return {
ok: true,
item,
};
});
app.delete('/api/text-memo/notes/:noteId', async (request, reply) => {
const noteId = z.string().trim().min(1).parse((request.params as { noteId: string }).noteId);
const deleted = await deleteTextMemoNote(resolveClientId(request.headers), noteId);
if (!deleted) {
return reply.code(404).send({
message: '삭제할 메모를 찾을 수 없습니다.',
});
}
return {
ok: true,
};
});
}

View File

@@ -0,0 +1,286 @@
import { z } from 'zod';
import { db } from '../db/client.js';
const TEXT_MEMO_TABLE = 'text_memo_notes';
const MAX_NOTE_COUNT = 12;
const MAX_BODY_LENGTH = 1200;
const CLIENT_ID_MAX_LENGTH = 120;
const NOTE_ID_MAX_LENGTH = 120;
const memoBodySchema = z.string().trim().min(1).max(MAX_BODY_LENGTH);
const memoTimestampSchema = z.string().datetime({ offset: true }).optional();
export const textMemoNoteCreateSchema = z.object({
id: z.string().trim().min(1).max(NOTE_ID_MAX_LENGTH).optional(),
body: memoBodySchema,
createdAt: memoTimestampSchema,
updatedAt: memoTimestampSchema,
});
export const textMemoNoteImportSchema = z.object({
notes: z.array(
z.object({
id: z.string().trim().min(1).max(NOTE_ID_MAX_LENGTH),
body: memoBodySchema,
createdAt: z.string().datetime({ offset: true }),
}),
).max(MAX_NOTE_COUNT),
});
export const textMemoNoteUpdateSchema = z.object({
body: memoBodySchema,
});
export type TextMemoNoteItem = {
id: string;
body: string;
createdAt: string;
updatedAt: string;
};
function normalizeClientId(value: unknown) {
return String(value ?? '').trim().slice(0, CLIENT_ID_MAX_LENGTH);
}
function normalizeIsoDate(value: string | undefined, fallback: string) {
if (!value) {
return fallback;
}
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? fallback : new Date(parsed).toISOString();
}
function generateNoteId() {
return `memo-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
function mapTextMemoRow(row: Record<string, unknown>): TextMemoNoteItem {
return {
id: String(row.note_id ?? ''),
body: String(row.body ?? ''),
createdAt: new Date(String(row.created_at ?? '')).toISOString(),
updatedAt: new Date(String(row.updated_at ?? '')).toISOString(),
};
}
async function trimClientNotes(clientId: string) {
const rows = await db(TEXT_MEMO_TABLE)
.select('note_id')
.where({ client_id: clientId })
.orderBy('updated_at', 'desc')
.orderBy('id', 'desc')
.offset(MAX_NOTE_COUNT);
const deleteIds = rows
.map((row) => String(row.note_id ?? '').trim())
.filter(Boolean);
if (deleteIds.length === 0) {
return;
}
await db(TEXT_MEMO_TABLE)
.where({ client_id: clientId })
.whereIn('note_id', deleteIds)
.delete();
}
export async function ensureTextMemoTable() {
const hasTable = await db.schema.hasTable(TEXT_MEMO_TABLE);
if (!hasTable) {
await db.schema.createTable(TEXT_MEMO_TABLE, (table) => {
table.increments('id').primary();
table.string('client_id', CLIENT_ID_MAX_LENGTH).notNullable().index();
table.string('note_id', NOTE_ID_MAX_LENGTH).notNullable();
table.text('body').notNullable();
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
table.unique(['client_id', 'note_id']);
});
return;
}
const requiredColumns: Array<[string, (table: any) => void]> = [
['client_id', (table) => table.string('client_id', CLIENT_ID_MAX_LENGTH).notNullable().index()],
['note_id', (table) => table.string('note_id', NOTE_ID_MAX_LENGTH).notNullable()],
['body', (table) => table.text('body').notNullable().defaultTo('')],
['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
];
for (const [columnName, createColumn] of requiredColumns) {
const hasColumn = await db.schema.hasColumn(TEXT_MEMO_TABLE, columnName);
if (!hasColumn) {
await db.schema.alterTable(TEXT_MEMO_TABLE, (table) => {
createColumn(table);
});
}
}
}
export async function listTextMemoNotes(rawClientId: unknown) {
const clientId = normalizeClientId(rawClientId);
if (!clientId) {
throw new Error('메모를 조회할 clientId가 비어 있습니다.');
}
await ensureTextMemoTable();
const rows = await db(TEXT_MEMO_TABLE)
.select('*')
.where({ client_id: clientId })
.orderBy('updated_at', 'desc')
.orderBy('id', 'desc')
.limit(MAX_NOTE_COUNT);
return rows.map((row) => mapTextMemoRow(row));
}
export async function createTextMemoNote(rawClientId: unknown, payload: z.infer<typeof textMemoNoteCreateSchema>) {
const clientId = normalizeClientId(rawClientId);
if (!clientId) {
throw new Error('메모를 저장할 clientId가 비어 있습니다.');
}
await ensureTextMemoTable();
const now = new Date().toISOString();
const createdAt = normalizeIsoDate(payload.createdAt, now);
const updatedAt = normalizeIsoDate(payload.updatedAt, createdAt);
const noteId = payload.id?.trim() || generateNoteId();
await db(TEXT_MEMO_TABLE)
.insert({
client_id: clientId,
note_id: noteId,
body: payload.body,
created_at: createdAt,
updated_at: updatedAt,
});
await trimClientNotes(clientId);
const row = await db(TEXT_MEMO_TABLE)
.select('*')
.where({
client_id: clientId,
note_id: noteId,
})
.first();
if (!row) {
throw new Error('저장된 메모를 다시 불러오지 못했습니다.');
}
return mapTextMemoRow(row);
}
export async function updateTextMemoNote(
rawClientId: unknown,
rawNoteId: unknown,
payload: z.infer<typeof textMemoNoteUpdateSchema>,
) {
const clientId = normalizeClientId(rawClientId);
const noteId = String(rawNoteId ?? '').trim().slice(0, NOTE_ID_MAX_LENGTH);
if (!clientId || !noteId) {
throw new Error('수정할 메모 식별자가 올바르지 않습니다.');
}
await ensureTextMemoTable();
const affected = await db(TEXT_MEMO_TABLE)
.where({
client_id: clientId,
note_id: noteId,
})
.update({
body: payload.body,
updated_at: new Date().toISOString(),
});
if (!affected) {
return null;
}
const row = await db(TEXT_MEMO_TABLE)
.select('*')
.where({
client_id: clientId,
note_id: noteId,
})
.first();
return row ? mapTextMemoRow(row) : null;
}
export async function deleteTextMemoNote(rawClientId: unknown, rawNoteId: unknown) {
const clientId = normalizeClientId(rawClientId);
const noteId = String(rawNoteId ?? '').trim().slice(0, NOTE_ID_MAX_LENGTH);
if (!clientId || !noteId) {
throw new Error('삭제할 메모 식별자가 올바르지 않습니다.');
}
await ensureTextMemoTable();
const affected = await db(TEXT_MEMO_TABLE)
.where({
client_id: clientId,
note_id: noteId,
})
.delete();
return affected > 0;
}
export async function importTextMemoNotes(
rawClientId: unknown,
payload: z.infer<typeof textMemoNoteImportSchema>,
) {
const clientId = normalizeClientId(rawClientId);
if (!clientId) {
throw new Error('메모를 가져올 clientId가 비어 있습니다.');
}
await ensureTextMemoTable();
const existingCountRow = await db(TEXT_MEMO_TABLE)
.where({ client_id: clientId })
.count<{ count: number | string }[]>({ count: '*' })
.first();
const existingCount = Number(existingCountRow?.count ?? 0);
if (existingCount > 0) {
return listTextMemoNotes(clientId);
}
const sortedNotes = [...payload.notes]
.sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt))
.slice(0, MAX_NOTE_COUNT);
if (sortedNotes.length === 0) {
return [];
}
await db.transaction(async (trx) => {
for (const note of sortedNotes) {
await trx(TEXT_MEMO_TABLE).insert({
client_id: clientId,
note_id: note.id,
body: note.body,
created_at: note.createdAt,
updated_at: note.createdAt,
});
}
});
return listTextMemoNotes(clientId);
}

View File

@@ -26,7 +26,7 @@ export const registeredWidgets: WidgetRegistryItem[] = [
id: 'text-memo-widget',
title: 'Text Memo Widget',
description:
'텍스트 메모를 작성하고 최근 저장본을 브라우저 Local Storage에 유지한 뒤 다시 불러올 수 있는 위젯입니다.',
'텍스트 메모를 작성하고 최근 저장본을 work-server DB에 보관해 다시 불러올 수 있는 위젯입니다.',
features: ['component-sample', 'feature-registry', 'imperative-handle'],
},
];

View File

@@ -4,6 +4,14 @@ import { forwardRef, useEffect, useMemo, useState } from 'react';
import { renderModalWithEnterConfirm } from '../../app/main/modalKeyboard';
import { WidgetShell } from '../core';
import type { WidgetHandle } from '../core';
import {
createTextMemoNote,
deleteTextMemoNote as deleteTextMemoNoteFromServer,
fetchTextMemoNotes,
importTextMemoNotes,
updateTextMemoNote as updateTextMemoNoteFromServer,
type TextMemoNoteRecord,
} from './textMemoApi';
import './TextMemoWidget.css';
const STORAGE_KEY = 'ai-code-app:text-memo-widget';
@@ -14,6 +22,7 @@ type MemoNote = {
id: string;
body: string;
createdAt: string;
updatedAt: string;
};
function readStoredNotes() {
@@ -40,12 +49,32 @@ function readStoredNotes() {
typeof note.body === 'string' &&
typeof note.createdAt === 'string'
);
});
}).map((note) => ({
...note,
updatedAt: typeof note.updatedAt === 'string' ? note.updatedAt : note.createdAt,
}));
} catch {
return [];
}
}
function clearStoredNotes() {
if (typeof window === 'undefined') {
return;
}
window.localStorage.removeItem(STORAGE_KEY);
}
function toMemoNote(note: TextMemoNoteRecord): MemoNote {
return {
id: note.id,
body: note.body,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
};
}
function formatMemoTimestamp(value: string) {
return new Intl.DateTimeFormat('ko-KR', {
month: 'short',
@@ -75,26 +104,69 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
const [selectedId, setSelectedId] = useState<string | null>(null);
const [isListOpen, setIsListOpen] = useState(false);
const [isEditing, setIsEditing] = useState(true);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
const storedNotes = readStoredNotes();
setNotes(storedNotes);
let cancelled = false;
if (storedNotes[0]) {
setSelectedId(storedNotes[0].id);
setBody(storedNotes[0].body);
setIsEditing(false);
}
void (async () => {
setIsLoading(true);
try {
const serverNotes = await fetchTextMemoNotes();
let nextNotes = serverNotes.map(toMemoNote);
if (nextNotes.length === 0) {
const storedNotes = readStoredNotes();
if (storedNotes.length > 0) {
const imported = await importTextMemoNotes(
storedNotes.map((note) => ({
id: note.id,
body: note.body,
createdAt: note.updatedAt || note.createdAt,
})),
);
nextNotes = imported.map(toMemoNote);
clearStoredNotes();
}
}
if (cancelled) {
return;
}
setNotes(nextNotes);
if (nextNotes[0]) {
setSelectedId(nextNotes[0].id);
setBody(nextNotes[0].body);
setIsEditing(false);
} else {
setSelectedId(null);
setBody('');
setIsEditing(true);
}
} catch (error) {
if (cancelled) {
return;
}
const nextMessage = error instanceof Error ? error.message : '메모를 불러오지 못했습니다.';
void messageApi.error(nextMessage);
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(notes));
}, [notes]);
const selectedIndex = useMemo(() => {
if (!selectedId) {
return -1;
@@ -107,50 +179,49 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
const selectedNote = selectedIndex >= 0 ? notes[selectedIndex] : null;
const isDirty = selectedNote ? selectedNote.body !== body : hasDraft;
const saveNote = () => {
const saveNote = async () => {
const trimmedBody = body.trim();
if (!trimmedBody) {
if (!trimmedBody || isSaving) {
return;
}
const timestamp = new Date().toISOString();
setIsSaving(true);
if (selectedNote) {
const updatedNote: MemoNote = {
...selectedNote,
body: trimmedBody,
createdAt: timestamp,
};
const nextNotes = [updatedNote, ...notes.filter((note) => note.id !== selectedNote.id)].slice(
0,
MAX_NOTE_COUNT,
);
try {
if (selectedNote) {
const updatedNote = toMemoNote(await updateTextMemoNoteFromServer(selectedNote.id, { body: trimmedBody }));
const nextNotes = [updatedNote, ...notes.filter((note) => note.id !== selectedNote.id)].slice(
0,
MAX_NOTE_COUNT,
);
setNotes(nextNotes);
setSelectedId(updatedNote.id);
setBody(updatedNote.body);
setIsEditing(false);
void messageApi.success('저장됨');
return;
}
const nextNote = toMemoNote(await createTextMemoNote({ body: trimmedBody }));
const nextNotes = [nextNote, ...notes].slice(0, MAX_NOTE_COUNT);
setNotes(nextNotes);
setSelectedId(updatedNote.id);
setBody(updatedNote.body);
setSelectedId(nextNote.id);
setBody(nextNote.body);
setIsEditing(false);
void messageApi.success('저장됨');
return;
} catch (error) {
const nextMessage = error instanceof Error ? error.message : '메모 저장에 실패했습니다.';
void messageApi.error(nextMessage);
} finally {
setIsSaving(false);
}
const nextNote: MemoNote = {
id: `${Date.now()}`,
body: trimmedBody,
createdAt: timestamp,
};
const nextNotes = [nextNote, ...notes].slice(0, MAX_NOTE_COUNT);
setNotes(nextNotes);
setSelectedId(nextNote.id);
setBody(nextNote.body);
setIsEditing(false);
void messageApi.success('저장됨');
};
const handlePrimaryAction = () => {
if (isEditing || isDirty || !selectedNote) {
saveNote();
void saveNote();
return;
}
@@ -180,7 +251,7 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
autoFocusButton: 'ok',
modalRender: renderModalWithEnterConfirm,
okButtonProps: { danger: true },
onOk: () => {
onOk: async () => {
if (isDraftOnly) {
setBody('');
setIsEditing(true);
@@ -188,6 +259,14 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
return;
}
try {
await deleteTextMemoNoteFromServer(selectedNote.id);
} catch (error) {
const nextMessage = error instanceof Error ? error.message : '메모 삭제에 실패했습니다.';
void messageApi.error(nextMessage);
return;
}
const nextNotes = notes.filter((note) => note.id !== selectedNote.id);
const nextSelectedNote = nextNotes[0] ?? null;
@@ -245,7 +324,7 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
shape="circle"
icon={<DeleteOutlined />}
aria-label="메모 삭제"
disabled={!selectedNote && !hasDraft}
disabled={isLoading || isSaving || (!selectedNote && !hasDraft)}
onClick={handleDelete}
/>
<Button
@@ -253,6 +332,7 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
shape="circle"
icon={<UnorderedListOutlined />}
aria-label="메모 목록"
disabled={isLoading}
onClick={() => {
setIsListOpen((previous) => !previous);
}}
@@ -265,7 +345,7 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
shape="circle"
icon={<LeftOutlined />}
aria-label="이전 메모"
disabled={selectedIndex <= 0}
disabled={isLoading || selectedIndex <= 0}
onClick={() => {
moveSelection(-1);
}}
@@ -275,7 +355,7 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
shape="circle"
icon={<RightOutlined />}
aria-label="다음 메모"
disabled={selectedIndex < 0 || selectedIndex >= notes.length - 1}
disabled={isLoading || selectedIndex < 0 || selectedIndex >= notes.length - 1}
onClick={() => {
moveSelection(1);
}}
@@ -285,14 +365,18 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
shape="circle"
icon={isEditing || isDirty || !selectedNote ? <CheckOutlined /> : <EditOutlined />}
aria-label={isEditing || isDirty || !selectedNote ? '저장' : '편집'}
disabled={!hasDraft && !selectedNote}
disabled={isLoading || isSaving || (!hasDraft && !selectedNote)}
onClick={handlePrimaryAction}
/>
</div>
</div>
<div className={`text-memo-widget__body${isListOpen ? ' text-memo-widget__body--list-open' : ''}`}>
{isListOpen ? (
{isLoading ? (
<div className="text-memo-widget__empty">
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="메모를 불러오는 중입니다." />
</div>
) : isListOpen ? (
<div className="text-memo-widget__sheet">
{notes.length === 0 ? (
<div className="text-memo-widget__empty">
@@ -321,7 +405,7 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
) : (
<div className="text-memo-widget__editor">
<div className="text-memo-widget__meta">
<span>{selectedNote ? formatMemoTimestamp(selectedNote.createdAt) : ''}</span>
<span>{selectedNote ? formatMemoTimestamp(selectedNote.updatedAt) : ''}</span>
<span>{body.length}/{MAX_BODY_LENGTH}</span>
</div>
@@ -331,7 +415,7 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
placeholder="메모 입력"
variant="borderless"
maxLength={MAX_BODY_LENGTH}
readOnly={!isEditing && !!selectedNote}
readOnly={isSaving || (!isEditing && !!selectedNote)}
onChange={(event) => {
setBody(event.target.value);
}}

View File

@@ -5,7 +5,7 @@ export const sampleMeta: SampleMeta = {
id: 'text-memo-widget',
componentId: 'text-memo-widget',
title: 'Text Memo Widget',
description: '짧은 텍스트 메모를 작성하고 최근 저장본을 Local Storage에 유지하는 위젯입니다.',
description: '짧은 텍스트 메모를 작성하고 최근 저장본을 DB에 유지하는 위젯입니다.',
category: 'Widgets',
kind: 'feature',
variantLabel: 'Memo / Notes',

View File

@@ -0,0 +1,420 @@
import { appendClientIdHeader, getOrCreateClientId } from '../../app/main/clientIdentity';
import { getRegisteredAccessToken } from '../../app/main/tokenAccess';
const TEXT_MEMO_API_BASE_URL = import.meta.env.VITE_WORK_SERVER_URL || '/api';
const TEXT_MEMO_API_PATH = '/text-memo/notes';
const TEXT_MEMO_TABLE = 'text_memo_notes';
const TEXT_MEMO_REQUEST_TIMEOUT_MS = 8000;
export type TextMemoNoteRecord = {
id: string;
body: string;
createdAt: string;
updatedAt: string;
};
type TextMemoApiListResponse = {
ok: boolean;
items: TextMemoNoteRecord[];
};
type TextMemoApiItemResponse = {
ok: boolean;
item: TextMemoNoteRecord;
};
type CrudResponse<Row> = {
ok: boolean;
rows: Row[];
};
type SchemaTableRow = {
table_name: string;
};
type RawTextMemoRow = {
note_id: string;
body: string;
created_at_text: string;
updated_at_text: string;
};
class TextMemoApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'TextMemoApiError';
this.status = status;
}
}
async function requestTextMemoApi<T>(path: string, init?: RequestInit): Promise<T> {
const headers = appendClientIdHeader(init?.headers);
const method = init?.method?.toUpperCase() ?? 'GET';
const hasBody = init?.body !== undefined && init.body !== null;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TEXT_MEMO_REQUEST_TIMEOUT_MS);
if (hasBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
const token = getRegisteredAccessToken();
if (token && !headers.has('X-Access-Token')) {
headers.set('X-Access-Token', token);
}
let response: Response;
try {
response = await fetch(`${TEXT_MEMO_API_BASE_URL}${path}`, {
...init,
headers,
signal: controller.signal,
cache: init?.cache ?? (method === 'GET' ? 'no-store' : undefined),
});
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof DOMException && error.name === 'AbortError') {
throw new TextMemoApiError('메모 서버 응답이 지연됩니다. 잠시 후 다시 시도해 주세요.', 408);
}
throw error;
}
clearTimeout(timeoutId);
if (!response.ok) {
const text = await response.text();
try {
const payload = JSON.parse(text) as { message?: string };
throw new TextMemoApiError(payload.message || '메모 요청에 실패했습니다.', response.status);
} catch {
throw new TextMemoApiError(text || '메모 요청에 실패했습니다.', response.status);
}
}
const contentType = response.headers.get('content-type') ?? '';
if (!contentType.toLowerCase().includes('application/json')) {
throw new TextMemoApiError('메모 서버 응답 형식이 올바르지 않습니다.', 502);
}
return response.json() as Promise<T>;
}
function getRequiredClientId() {
const clientId = getOrCreateClientId().trim();
if (!clientId) {
throw new TextMemoApiError('메모를 저장할 클라이언트 식별자를 만들지 못했습니다.', 500);
}
return clientId;
}
function isNotFoundError(error: unknown) {
return error instanceof TextMemoApiError && error.status === 404;
}
function mapRow(row: RawTextMemoRow): TextMemoNoteRecord {
return {
id: row.note_id,
body: row.body,
createdAt: row.created_at_text,
updatedAt: row.updated_at_text,
};
}
let fallbackSetupPromise: Promise<void> | null = null;
async function ensureFallbackTable() {
if (!fallbackSetupPromise) {
fallbackSetupPromise = (async () => {
const schema = await requestTextMemoApi<{ items: SchemaTableRow[] }>('/schema/tables');
const hasTable = schema.items.some((item) => item.table_name === TEXT_MEMO_TABLE);
if (!hasTable) {
await requestTextMemoApi('/ddl/raw', {
method: 'POST',
body: JSON.stringify({
sql: `
create table if not exists ${TEXT_MEMO_TABLE} (
id bigserial primary key,
client_id varchar(120) not null,
note_id varchar(120) not null,
body text not null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
created_at_text text null,
updated_at_text text null
);
create unique index if not exists ${TEXT_MEMO_TABLE}_client_note_uidx on ${TEXT_MEMO_TABLE} (client_id, note_id);
create index if not exists ${TEXT_MEMO_TABLE}_client_updated_idx on ${TEXT_MEMO_TABLE} (client_id, updated_at desc);
`,
}),
});
}
await requestTextMemoApi('/ddl/raw', {
method: 'POST',
body: JSON.stringify({
sql: `
alter table ${TEXT_MEMO_TABLE} add column if not exists created_at_text text null;
alter table ${TEXT_MEMO_TABLE} add column if not exists updated_at_text text null;
`,
}),
});
})().catch((error) => {
fallbackSetupPromise = null;
throw error;
});
}
await fallbackSetupPromise;
}
async function selectFallbackRows() {
await ensureFallbackTable();
const clientId = getRequiredClientId();
const response = await requestTextMemoApi<CrudResponse<RawTextMemoRow>>(`/crud/${TEXT_MEMO_TABLE}/select`, {
method: 'POST',
body: JSON.stringify({
columns: ['note_id', 'body', 'created_at_text', 'updated_at_text'],
where: [{ field: 'client_id', operator: 'eq', value: clientId }],
orderBy: [
{ field: 'updated_at', direction: 'desc' },
{ field: 'id', direction: 'desc' },
],
limit: 12,
}),
});
return response.rows.map(mapRow);
}
async function trimFallbackRows() {
const clientId = getRequiredClientId();
const response = await requestTextMemoApi<CrudResponse<{ note_id: string }>>(`/crud/${TEXT_MEMO_TABLE}/select`, {
method: 'POST',
body: JSON.stringify({
columns: ['note_id'],
where: [{ field: 'client_id', operator: 'eq', value: clientId }],
orderBy: [
{ field: 'updated_at', direction: 'desc' },
{ field: 'id', direction: 'desc' },
],
limit: 500,
offset: 12,
}),
});
const extraIds = response.rows.map((row) => row.note_id).filter(Boolean);
if (extraIds.length === 0) {
return;
}
await requestTextMemoApi(`/crud/${TEXT_MEMO_TABLE}/delete`, {
method: 'DELETE',
body: JSON.stringify({
where: [
{ field: 'client_id', operator: 'eq', value: clientId },
{ field: 'note_id', operator: 'in', value: extraIds },
],
}),
});
}
async function fetchTextMemoNotesFallback() {
return selectFallbackRows();
}
async function createTextMemoNoteFallback(payload: {
id?: string;
body: string;
createdAt?: string;
updatedAt?: string;
}) {
await ensureFallbackTable();
const clientId = getRequiredClientId();
const noteId = payload.id?.trim() || `memo-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
const now = new Date().toISOString();
const createdAt = payload.createdAt?.trim() || now;
const updatedAt = payload.updatedAt?.trim() || createdAt;
const response = await requestTextMemoApi<CrudResponse<RawTextMemoRow>>(`/crud/${TEXT_MEMO_TABLE}/insert`, {
method: 'POST',
body: JSON.stringify({
data: {
client_id: clientId,
note_id: noteId,
body: payload.body,
created_at: createdAt,
updated_at: updatedAt,
created_at_text: createdAt,
updated_at_text: updatedAt,
},
}),
});
await trimFallbackRows();
return mapRow(response.rows[0]);
}
async function updateTextMemoNoteFallback(noteId: string, payload: { body: string }) {
await ensureFallbackTable();
const clientId = getRequiredClientId();
const response = await requestTextMemoApi<CrudResponse<RawTextMemoRow>>(`/crud/${TEXT_MEMO_TABLE}/update`, {
method: 'PATCH',
body: JSON.stringify({
data: {
body: payload.body,
updated_at: new Date().toISOString(),
updated_at_text: new Date().toISOString(),
},
where: [
{ field: 'client_id', operator: 'eq', value: clientId },
{ field: 'note_id', operator: 'eq', value: noteId },
],
}),
});
if (!response.rows[0]) {
throw new TextMemoApiError('수정할 메모를 찾을 수 없습니다.', 404);
}
return mapRow(response.rows[0]);
}
async function deleteTextMemoNoteFallback(noteId: string) {
await ensureFallbackTable();
const clientId = getRequiredClientId();
const response = await requestTextMemoApi<CrudResponse<RawTextMemoRow>>(`/crud/${TEXT_MEMO_TABLE}/delete`, {
method: 'DELETE',
body: JSON.stringify({
where: [
{ field: 'client_id', operator: 'eq', value: clientId },
{ field: 'note_id', operator: 'eq', value: noteId },
],
}),
});
if ((response.rows?.length ?? 0) === 0) {
throw new TextMemoApiError('삭제할 메모를 찾을 수 없습니다.', 404);
}
}
async function importTextMemoNotesFallback(notes: Array<{ id: string; body: string; createdAt: string }>) {
const existingNotes = await fetchTextMemoNotesFallback();
if (existingNotes.length > 0) {
return existingNotes;
}
const sortedNotes = [...notes]
.sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt))
.slice(0, 12);
for (const note of sortedNotes) {
await createTextMemoNoteFallback({
id: note.id,
body: note.body,
createdAt: note.createdAt,
updatedAt: note.createdAt,
});
}
return fetchTextMemoNotesFallback();
}
export async function fetchTextMemoNotes() {
try {
const response = await requestTextMemoApi<TextMemoApiListResponse>(TEXT_MEMO_API_PATH);
return response.items;
} catch (error) {
if (!isNotFoundError(error)) {
throw error;
}
return fetchTextMemoNotesFallback();
}
}
export async function createTextMemoNote(payload: {
id?: string;
body: string;
createdAt?: string;
updatedAt?: string;
}) {
try {
const response = await requestTextMemoApi<TextMemoApiItemResponse>(TEXT_MEMO_API_PATH, {
method: 'POST',
body: JSON.stringify(payload),
});
return response.item;
} catch (error) {
if (!isNotFoundError(error)) {
throw error;
}
return createTextMemoNoteFallback(payload);
}
}
export async function updateTextMemoNote(noteId: string, payload: { body: string }) {
try {
const response = await requestTextMemoApi<TextMemoApiItemResponse>(
`${TEXT_MEMO_API_PATH}/${encodeURIComponent(noteId)}`,
{
method: 'PUT',
body: JSON.stringify(payload),
},
);
return response.item;
} catch (error) {
if (!isNotFoundError(error)) {
throw error;
}
return updateTextMemoNoteFallback(noteId, payload);
}
}
export async function deleteTextMemoNote(noteId: string) {
try {
await requestTextMemoApi<{ ok: boolean }>(`${TEXT_MEMO_API_PATH}/${encodeURIComponent(noteId)}`, {
method: 'DELETE',
});
} catch (error) {
if (!isNotFoundError(error)) {
throw error;
}
await deleteTextMemoNoteFallback(noteId);
}
}
export async function importTextMemoNotes(notes: Array<{ id: string; body: string; createdAt: string }>) {
try {
const response = await requestTextMemoApi<TextMemoApiListResponse>(`${TEXT_MEMO_API_PATH}/import`, {
method: 'POST',
body: JSON.stringify({ notes }),
});
return response.items;
} catch (error) {
if (!isNotFoundError(error)) {
throw error;
}
return importTextMemoNotesFallback(notes);
}
}