만들려고 하는 앱에 data-fetching을 최소화하면 좋겠다는 막연한 생각에 local-first 앱으로 구현하고자 했다.
일단 이것저것 하려고 해봤다. 최대한 프로젝트를 간결하게 유지하기 위해 데이터베이스는 Cloudflare D1, 스토리지는 Cloudflare R2, 백엔드는 Cloudflare Worker에 올려서 관리하는 게 목표이다.
LiveStore: SQLite-based data layer for local-first apps
헉 너무 좋아보인다. 그런데 몇 시간의 삽질을 한 결과 구현하기 어렵다는 생각이 들었다.
Reasons when not to use LiveStore
- You have an existing database which is the source of truth of your data. (Better use Zero or ElectricSQL for this.)
그렇다고 Zero or ElectricSQL을 쓰기에는 추가 서비스가 필요한 상황이라 적절치 못한 것 같았다.
그렇다면 직접 만드는 수밖에 없지...

클라이언트 데이터베이스는 D1이 SQLite 기반이므로 expo-sqlite를 쓰는 게 좋겠다.
전체적인 구상은 대충 이렇다.

id: serial, createdAt/updatedAt/deletedAt: Date를 포함한다.lastQueriedAt: Date와 lastStatus: DataStatus를 추가로 가진다. DataStatus는 예를 들어 권한 부족인지, 데이터가 삭제됐는지, 서버 에러가 발생해서 못 가져온 건지 등을 저장하면 좋지 않을까 한다.queriedAt) 어디(minId)부터 어디(maxId)까지 불러왔는지를 저장해 놓고, 불러올 때마다 업데이트한다. Infinite queries를 염두에 두고 만든 테이블이다.SELECT * FROM tableId
WHERE tableId.id < minId -- 아직 불러오지 못한 행들 (ORDER BY id DESC의 경우)
OR tableId.id > maxId -- 마지막 쿼리 이후로 새로 추가된 행들
OR tableId.updatedAt > queriedAt -- 마지막 쿼리 이후로 추가/변경된 행들id와 deletedAt만 남기는 것이 좋겠다.양쪽 모두 SQLite이므로, 칼럼 정의를 한 곳에만 할 수 있으면 정말 좋을 것이다. 그런데 이렇게 하려면 서버와 클라이언트 테이블이 완전히 동일해져야 한다. 따라서 위 다이어그램에서 lastQueriedAt과 lastStatus를 별도의 테이블로 빼는 게 좋겠다.

import { sql } from "drizzle-orm";
import {
type AnySQLiteColumn,
int,
sqliteTable,
text,
} from "drizzle-orm/sqlite-core";
import type { WordConjugation, WordMeaning } from "../types/word";
export const words = sqliteTable("words", {
id: int().primaryKey({ autoIncrement: true }),
createdAt: int({ mode: "timestamp" }).default(sql`(strftime('%s', 'now'))`),
updatedAt: int({ mode: "timestamp" }).default(sql`(strftime('%s', 'now'))`),
deletedAt: int({ mode: "timestamp" }).default(sql`(strftime('%s', 'now'))`),
text: text().notNull(),
pronunciation: text().notNull(),
audioUrl: text(),
meanings: text({ mode: "json" })
.$type<WordMeaning[]>()
.notNull()
.default(sql`'[]'`),
conjugations: text({ mode: "json" })
.$type<WordConjugation[]>()
.notNull()
.default(sql`'[]'`),
});
export const verbs = sqliteTable("verbs", {
id: int().primaryKey({ autoIncrement: true }),
createdAt: int({ mode: "timestamp" }).default(sql`(strftime('%s', 'now'))`),
updatedAt: int({ mode: "timestamp" }).default(sql`(strftime('%s', 'now'))`),
deletedAt: int({ mode: "timestamp" }).default(sql`(strftime('%s', 'now'))`),
wordId: int()
.notNull()
.references((): AnySQLiteColumn => words.id, { onDelete: "cascade" })
.unique(),
});
백엔드 디자인을 해보자. 최대한 가볍게 하기 위해 API 서버로만 사용하기로 하고, 프레임워크는 Hono + TRPC를 사용할 것이다. (프레임워크 설정은 생략)
import { type Schema, verbs } from "@acme/database";
import { asc, gt, lt, or } from "drizzle-orm";
import { drizzle } from "drizzle-orm/d1";
import { ListInputSchema } from "../../validators";
import { publicProcedure, router } from "../trpc";
export const verbsRouter = router({
list: publicProcedure.input(ListInputSchema).query(async ({ ctx, input }) => {
const db = drizzle<Schema>(ctx.env.DB);
const [$count, rows] = await Promise.all([
db.$count(verbs),
db
.select()
.from(verbs)
.orderBy(asc(verbs.id))
.where(
or(
input.minId === undefined ? undefined : lt(verbs.id, input.minId),
input.maxId === undefined ? undefined : gt(verbs.id, input.maxId),
input.lastQueriedAt === undefined
? undefined
: gt(verbs.updatedAt, input.lastQueriedAt),
input.lastQueriedAt === undefined
? undefined
: gt(verbs.deletedAt, input.lastQueriedAt),
),
)
.limit(input.limit ?? 50),
]);
return { $count, rows };
}),
});
일단 이런 느낌으로 결정. 위 다이어그램대로 구현되었다.
Expo + TRPC + drizzle + expo-sqlite 설정부터 하기.
// @acme/mobile/drizzle.config.ts
import type { Config } from "drizzle-kit";
export default {
schema: ["./lib/schema.ts", "../../packages/database/src/schema/*"],
out: "./drizzle",
dialect: "sqlite",
driver: "expo",
} satisfies Config;
// @acme/mobile/lib/schema.ts
import { int, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
export const queryHistories = sqliteTable("query_histories", {
tableId: text().primaryKey(),
minId: int().notNull(),
maxId: int().notNull(),
queriedAt: int().notNull(),
});
export type QueryStatus =
| "success"
| "permission_denied"
| "server_error"
| "not_found";
export const localQueryInfos = sqliteTable(
"local_query_infos",
{
id: int().primaryKey({ autoIncrement: true }),
tableName: text().notNull(),
tid: int().notNull(),
lastQueriedAt: int().notNull(),
lastStatus: text().$type<QueryStatus>().notNull(),
},
(table) => [uniqueIndex("table_idx").on(table.tableName, table.tid)],
);
// @acme/mobile/lib/db.ts
import type { Schema } from "@acme/database";
import { drizzle } from "drizzle-orm/expo-sqlite";
import { openDatabaseSync } from "expo-sqlite";
import type * as LocalSchema from "./schema";
export const expoDb = openDatabaseSync("db.db", { enableChangeListener: true });
export const db = drizzle<Schema & typeof LocalSchema>(expoDb);
// @acme/mobile/App.tsx
import { QueryClientProvider } from "@tanstack/react-query";
import { useMigrations } from "drizzle-orm/expo-sqlite/migrator";
import { Text, View } from "react-native";
import { queryClient } from "~/lib/trpc";
import migrations from "./drizzle/migrations";
import { db } from "./lib/db";
export default function () {
return (
<Layout>
<App />
</Layout>
);
}
function Layout({ children }: { children: React.ReactNode }) {
const { success, error } = useMigrations(db, migrations);
console.log({ success, error });
if (error) {
return (
<View>
<Text>Migration error: {error.message}</Text>
</View>
);
}
if (!success) {
return (
<View>
<Text>Migration is in progress...</Text>
</View>
);
}
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
이제 크게 두 가지 use-case에 대해 간단한 훅을 만들어서 사용할 수 있게 했다.
순차적으로 필요할 때마다 받아올 수 있도록, infinite query 스타일의 함수 fetchNextData를 반환하도록 했다.
// hooks/useListQuery.ts
function useListQuery<T extends TableConfig>(
table: SQLiteTableWithColumns<T>,
opts?: { limit?: number; order?: "asc" | "desc" },
) {
const tableName = useMemo(
() => getTableName(table) as string & keyof typeof trpcClient,
[table],
);
const tableColumns = useMemo(() => getTableColumns(table), [table]);
const queryHistoriesQuery = useLiveQuery(
db
.select()
.from(queryHistories)
.where(eq(queryHistories.tableId, tableName)),
);
const queryHistory = queryHistoriesQuery.data.at(0);
const dataQuery = useLiveQuery(db.select().from(table));
const fetchNextData = useCallback(async () => {
if (!queryHistoriesQuery.updatedAt) return;
const response = (await trpcClient[tableName].list.query({
limit: opts?.limit ?? 100,
minId: queryHistory?.minId,
maxId: queryHistory?.maxId,
lastQueriedAt: queryHistory?.queriedAt,
order: opts?.order ?? "asc",
})) as unknown as {
$count: number;
$now: number;
rows: SQLiteTableWithColumns<T>["$inferSelect"][];
};
const newMinId = Math.min(
...response.rows.map((word) => word.id!),
queryHistory?.minId ?? Number.POSITIVE_INFINITY,
);
const newMaxId = Math.max(
...response.rows.map((word) => word.id!),
queryHistory?.maxId ?? Number.NEGATIVE_INFINITY,
);
const set = Object.entries(tableColumns).reduce(
(acc, [columnName, column]) => {
acc[columnName as keyof SQLiteTableWithColumns<T>["$inferInsert"]] =
sql.raw(`excluded.${column.name}`);
return acc;
},
{} as SQLiteUpdateSetSource<SQLiteTableWithColumns<T>>,
);
db.transaction(async (tx) => {
await Promise.allSettled([
// Update query history
tx
.insert(queryHistories)
.values({
tableId: tableName,
minId: newMinId,
maxId: newMaxId,
queriedAt: response.$now,
})
.onConflictDoUpdate({
target: queryHistories.tableId,
set: {
minId: newMinId,
maxId: newMaxId,
queriedAt: response.$now,
},
}),
// Upsert values to the table
response.rows.length
? tx
.insert(table)
.values(response.rows as any[])
.onConflictDoUpdate({
target: table.id,
set: set,
})
: undefined,
// Insert local query info
response.rows.length
? tx
.insert(localQueryInfos)
.values(
response.rows.map((row) => ({
tableName,
tid: row.id as number,
lastQueriedAt: response.$now,
lastStatus: "success" as QueryStatus,
})),
)
.onConflictDoUpdate({
target: [localQueryInfos.tableName, localQueryInfos.tid],
set: {
lastQueriedAt: sql.raw(
`excluded.${localQueryInfos.lastQueriedAt.name}`,
),
lastStatus: sql.raw(
`excluded.${localQueryInfos.lastStatus.name}`,
),
},
})
: undefined,
]);
});
}, [
opts?.limit,
opts?.order,
table.id,
tableName,
table,
tableColumns,
queryHistoriesQuery.updatedAt,
queryHistory?.minId,
queryHistory?.maxId,
queryHistory?.queriedAt,
]);
return { dataQuery, fetchNextData, queryHistory };
}
// App.tsx
function App() {
const {
dataQuery: wordsQuery,
fetchNextData: fetchWords,
queryHistory: wordsHistory,
} = useListQuery(words);
return (
<View
style={[
StyleSheet.absoluteFill,
{
justifyContent: "center",
alignItems: "center",
},
]}
>
<Text>minId: {wordsHistory?.minId ?? "no minId"}</Text>
<Text>maxId: {wordsHistory?.maxId ?? "no maxId"}</Text>
<Text>
wordsQuery:{" "}
{wordsQuery.data
.filter((x) => !x.deletedAt)
.map((x) => `${x.id}: ${x.text}`)
.join(", ")}
</Text>
<Button title="Load more" onPress={() => fetchWords()} />
<Button
title="Delete all"
onPress={() => {
expoDb.closeSync();
deleteDatabaseSync("db.db");
}}
/>
</View>
);
}
일부 행의 deletedAt을 설정했을 때 (160과 167이 삭제됨)