Drizzle ORM은 Capacitor 앱에서 다음을 통해 실행됩니다 sqlite-proxy 드라이버: 네이티브 SQLite 플러그인에서 SQL을 실행하고 행을 배열로 반환하는 비동기 함수 하나를 Drizzle에 전달하면, Drizzle이 타입화된 쿼리 빌더, 관계형 쿼리 및 트랜잭션을 처리합니다. 이 가이드는 해당 함수를 다음과 같이 구성하는 방법을 보여줍니다 @capacitor-community/sqlite, Drizzle Kit 마이그레이션을 앱 번들에 포함하는 방법, 그리고 놓치면 결과를 조용히 손상시키는 엣지 케이스를 나열합니다.
아래 모든 내용은 다음 버전으로 테스트되었습니다 drizzle-orm 0.45 및 drizzle-kit 0.31, Capacitor 8.
모바일 데이터베이스에서 Drizzle을 사용하는 이유
- TypeScript로 스키마 정의. 테이블은 단순한 TypeScript 객체입니다. select와 insert의 타입은 추론되며, code 생성 단계가 필요 없습니다.
- SQL 형태의 API.
db.select().from(users).where(eq(users.id, 1))SQL과 1:1로 매핑되므로 디바이스에서 실행되는 내용을 추론할 수 있습니다. - 스키마에서 생성된 마이그레이션. Drizzle Kit이 스키마를 비교해 SQL 마이그레이션 파일을 작성합니다.
- 데코레이터 없음. 추가 TypeScript 플래그 없이 Vite와 esbuild에서 작동합니다.
트레이드오프: Drizzle 패키지에는 Capacitor 드라이버가 없어서 브리지를 직접 작성해야 합니다. 약 30줄입니다.
SQLite 플러그인 고르기
Drizzle의 sqlite-proxy 드라이버는 결과 행을 위치 기준으로 매핑합니다: all 및 values 2차원 배열을 기대하는 경우도 있고, get 생성된 컬럼 순서와 같은 단일 배열을 기대하는 경우도 있습니다 SELECT. Capacitor SQLite 플러그인은 컬럼명을 키로 하는 객체를 반환하므로, 드라이버가 각 객체를 다시 배열로 변환해야 합니다. 이는 객체 키가 컬럼 순서를 유지할 때만 가능합니다.
| 플러그인 | 행 형식 | 컬럼 순서 유지 | Drizzle 프록시 적합 |
|---|---|---|---|
@capacitor-community/sqlite |
객체 | 네. iOS에서 플러그인이 ios_columns 리스트를 보내고 JS 레이어가 각 행을 컬럼 순서대로 재구성합니다 |
양호 |
@capgo/capacitor-fast-sql |
객체 | iOS에서는 보장되지 않으며, row는 Swift dictionary에서 직렬화됩니다 | 대신 Kysely를 사용하세요 |
이 가이드가 커뮤니티 플러그인을 사용하는 이유입니다. 이미 Fast SQL을 사용 중이거나 더 빠른 데이터 경로를 원한다면, Kysely 는 컬럼 이름으로 row를 읽고 이를 직접 다룹니다. 두 플러그인의 개요는 Capacitor 앱에서 SQLite를 사용하는 방법.
설치
bun add drizzle-orm @capacitor-community/sqlite
bun add -d drizzle-kit
bunx cap sync
@capacitor-community/sqlite iOS와 Android에 SQLCipher를 번들링하므로, 아무것도 암호화하지 않아도 앱에 암호화 code이 포함된 상태로 출시됩니다. App Store 수출 규정 준수 질문 시 이 점을 염두에 두세요.
스키마 정의하기
// src/db/schema.ts
import { relations } from 'drizzle-orm';
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
export const users = sqliteTable('users', {
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name').notNull(),
email: text('email').notNull().unique(),
createdAt: integer('created_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
});
export const posts = sqliteTable(
'posts',
{
id: integer('id').primaryKey({ autoIncrement: true }),
authorId: integer('author_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
published: integer('published', { mode: 'boolean' }).notNull().default(false),
},
(t) => [index('idx_posts_author').on(t.authorId)],
);
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, { fields: [posts.authorId], references: [users.id] }),
}));
모바일에서는 몇 가지 세부 사항이 중요합니다:
mode: 'timestamp'는 epoch 이후 초를 정수로 저장하고 제공합니다DateTypeScript의 객체입니다.$defaultFn(() => new Date())JavaScript에서 기본값을 설정합니다. SQL 기본값은 다음과 같습니다.unixepoch()SQLite 3.38 이상이 필요하며, 구형 Android 시스템의 SQLite에는 해당 버전이 없습니다.mode: 'boolean'저장합니다1/0. Drizzle은 양방향으로 변환합니다.relations()이것이 구동하는 것입니다db.query.*포함with: {...}. 외래 키만으로는 관계형 쿼리를 수행할 수 없습니다.
연결 열기
커뮤니티 플러그인은 연결 레지스트리를 유지합니다. 라이브 리로드 또는 핫 모듈 교체 시 code가 두 번 실행될 수 있으므로, 새 연결을 생성하기보다 기존 연결을 재사용하세요.
// src/db/connection.ts
import {
CapacitorSQLite,
SQLiteConnection,
type SQLiteDBConnection,
} from '@capacitor-community/sqlite';
const sqlite = new SQLiteConnection(CapacitorSQLite);
export async function openConnection(name: string): Promise<SQLiteDBConnection> {
const consistent = (await sqlite.checkConnectionsConsistency()).result;
const exists = (await sqlite.isConnection(name, false)).result;
const conn =
consistent && exists
? await sqlite.retrieveConnection(name, false)
: await sqlite.createConnection(name, false, 'no-encryption', 1, false);
await conn.open();
await conn.execute('PRAGMA foreign_keys = ON', false);
return conn;
}
플러그인은 파일 이름에 SQLite.db 을(를) 추가하므로 app 로 변경됩니다 appSQLite.db 디스크에 저장됩니다. 웹에서는 플러그인이 jeep-sqlite 커스텀 엘리먼트와 sqlite.initWebStore() 첫 번째 연결 전에 필요합니다. 브라우저 타겟이라면 플러그인의 웹 설정을 따르세요.
sqlite-proxy 드라이버
이것이 통합의 핵심입니다. Drizzle은 SQL, 매개변수, 그리고 method:
run: 행 불필요 (INSERT, UPDATE, DELETE, DDL, 트랜잭션 제어).all: 모든 행을 배열로 반환.get: 첫 번째 행을 배열로 반환하거나undefined.values: 모든 행을 배열로 반환 (rawvalues()콜 및 내부 처리에 사용).
// src/db/client.ts
import type { SQLiteDBConnection } from '@capacitor-community/sqlite';
import { drizzle } from 'drizzle-orm/sqlite-proxy';
import * as schema from './schema';
const READ = /^\s*(select|pragma|with|explain)\b/i;
const TX_CONTROL = /^\s*(begin|commit|rollback|savepoint|release)\b/i;
export function createDb(conn: SQLiteDBConnection) {
return drizzle(
async (sql, params, method) => {
// Transaction control: no params, no rows, no implicit wrapping
if (TX_CONTROL.test(sql)) {
await conn.execute(sql, false);
return { rows: [] };
}
let rows: Record<string, unknown>[];
if (READ.test(sql)) {
rows = (await conn.query(sql, params)).values ?? [];
} else {
// 'all' returns rows for INSERT/UPDATE/DELETE ... RETURNING
const res = await conn.run(sql, params, false, method === 'run' ? 'no' : 'all');
rows = res.changes?.values ?? [];
}
if (method === 'run') return { rows: [] };
// Drizzle maps by position. The plugin keeps column order in each object.
const arrays = rows.map((row) => Object.values(row));
return { rows: method === 'get' ? (arrays[0] as any) : arrays };
},
{ schema },
);
}
export type AppDb = ReturnType<typeof createDb>;
각 분기가 존재하는 이유:
TX_CONTROL: Drizzle은db.transaction()전송하는 방식으로begin,commit,rollback, 그리고savepoint/release중첩 트랜잭션을 구현합니다. 이들은execute(sql, false), TypeORM의 공식 Capacitor 드라이버가 사용하는 것과 동일한 경로를 거칩니다BEGIN/COMMIT.false의 세 번째 인자로run(): 플러그인 기본값은 각 statement를 자체 트랜잭션으로 감싸므로 Drizzle의 명시적 트랜잭션과 충돌합니다begin.returnMode 'all': ...이.returning()insert, update, delete에서 작동하도록 만듭니다.Object.values(row): 키가 지정된 행을 다시 위치 기반 배열로 변환합니다.
플러그인과 동일한 방식으로 행을 반환하는 인메모리 SQLite를 대상으로 이 함수를 직접 실행해 보았습니다. insert, .returning(), 필터링된 select, .get(), 관계형 쿼리, 트랜잭션, 에러 발생 시 롤백 모두 올바른 결과를 냅니다.
한 번만 초기화
// src/db/index.ts
import { openConnection } from './connection';
import { createDb, type AppDb } from './client';
import { runMigrations } from './migrate';
import bundle from '../../drizzle/migrations';
let dbPromise: Promise<AppDb> | null = null;
export function getDb(): Promise<AppDb> {
if (!dbPromise) {
dbPromise = (async () => {
const conn = await openConnection('app');
await runMigrations(conn, bundle);
return createDb(conn);
})();
}
return dbPromise;
}
Drizzle Kit으로 마이그레이션
Drizzle의 내장 sqlite-proxy migrator가 읽습니다 .sql Node의 기능으로 디스크에서 파일을 fs, WebView에는 없는 기능입니다. 해결 방법은 Drizzle Kit이 SQL을 JavaScript 모듈로 bundle하게 한 뒤 직접 적용하는 것입니다.
1. Drizzle Kit 설정
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'sqlite',
driver: 'expo',
});
driver: 'expo' 는 Expo가 필요하다는 뜻이 아닙니다. 이는 Drizzle Kit이 추가로 작성하도록 지시합니다. drizzle/migrations.js, 이는 journal과 각 .sql 파일을 import합니다. 이것이 Node가 아닌 모든 런타임에 필요한 형식입니다.
2. 생성
bunx drizzle-kit generate
다음을 얻게 됩니다. drizzle/0000_<name>.sql, drizzle/meta/_journal.json 및 drizzle/migrations.js. 세 가지 모두 commit하세요. 다음 명령을 generate 스키마를 변경할 때마다 다시 실행하세요. 이미 배포된 SQL 파일은 수정하지 마세요.
3. bundler가 .sql을 텍스트로 import하도록 설정
migrations.js 다음과 같은 줄이 있습니다: import m0000 from './0000_fearless_selene.sql'. Vite는 이를 인식하지 못하므로, .sql추가 의존성 대신 가벼운 플러그인을 사용하세요:
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
{
name: 'sql-as-text',
transform(code, id) {
if (id.endsWith('.sql')) {
return { code: `export default ${JSON.stringify(code)};`, map: null };
}
},
},
],
});
그리고 TypeScript가 import를 받아들이도록 타입 선언도 추가하세요:
// src/sql.d.ts
declare module '*.sql' {
const content: string;
export default content;
}
webpack(Angular 커스텀 빌더, 이전 구성)을 쓴다면, asset/source 대신 다음 규칙을 .sql 파일에 적용하세요.
4. 시작 시 마이그레이션 적용
// src/db/migrate.ts
import type { SQLiteDBConnection } from '@capacitor-community/sqlite';
interface MigrationBundle {
journal: { entries: { idx: number; when: number; tag: string }[] };
migrations: Record<string, string>;
}
export async function runMigrations(conn: SQLiteDBConnection, bundle: MigrationBundle) {
await conn.execute(
`CREATE TABLE IF NOT EXISTS __drizzle_migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hash TEXT NOT NULL,
created_at NUMERIC
)`,
false,
);
const res = await conn.query(
'SELECT created_at FROM __drizzle_migrations ORDER BY created_at DESC LIMIT 1',
);
const lastApplied = Number(res.values?.[0]?.created_at ?? 0);
for (const entry of bundle.journal.entries) {
if (entry.when <= lastApplied) continue;
const key = `m${entry.idx.toString().padStart(4, '0')}`;
const sqlText = bundle.migrations[key];
if (!sqlText) throw new Error(`Missing migration ${entry.tag}`);
const statements = sqlText
.split('--> statement-breakpoint')
.map((s) => s.trim())
.filter(Boolean);
await conn.beginTransaction();
try {
for (const statement of statements) {
await conn.execute(statement, false);
}
await conn.run(
'INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)',
[entry.tag, entry.when],
false,
);
await conn.commitTransaction();
} catch (error) {
await conn.rollbackTransaction();
throw error;
}
}
}
Drizzle 자체 migrator와 동일한 규칙을 따릅니다. 저널 타임스탬프가 기록된 최신 타임스탬프보다 최신이면 마이그레이션이 대기 중인 것으로 간주합니다. 각 마이그레이션은 자체 트랜잭션에서 실행되므로, 실패해도 이전 상태가 그대로 유지됩니다. 앱 실행 시마다 호출해도 부담이 없습니다.
JavaScript를 Capgo Live Update 사용하기스토어 출시 없이도 새 마이그레이션을 사용자에게 전달할 수 있습니다. 마이그레이션은 추가 전용(새 테이블, 새 nullable 컬럼)으로 유지하세요. 이전 번들로 롤백하면 최신 스키마에서 실행되기 때문입니다.
쿼리
import { and, desc, eq, sql } from 'drizzle-orm';
import { getDb } from './db';
import { posts, users } from './db/schema';
const db = await getDb();
// Insert and get the row back
const [ada] = await db
.insert(users)
.values({ name: 'Ada', email: 'ada@example.com' })
.returning();
// Bulk insert
await db.insert(posts).values([
{ authorId: ada.id, title: 'Draft' },
{ authorId: ada.id, title: 'Live', published: true },
]);
// Select with filters
const live = await db
.select()
.from(posts)
.where(and(eq(posts.authorId, ada.id), eq(posts.published, true)))
.orderBy(desc(posts.id));
// Single row or undefined
const user = await db.select().from(users).where(eq(users.email, 'ada@example.com')).get();
// Update and delete
await db.update(posts).set({ published: true }).where(eq(posts.id, live[0].id));
await db.delete(posts).where(eq(posts.published, false));
// Aggregate
const [{ count }] = await db
.select({ count: sql<number>`count(*)` })
.from(posts);
관계형 쿼리
const authors = await db.query.users.findMany({
with: { posts: { where: (p, { eq }) => eq(p.published, true) } },
});
const one = await db.query.users.findFirst({
where: (u, { eq }) => eq(u.id, ada.id),
with: { posts: true },
});
Drizzle은 SQLite의 JSON 함수로 이를 빌드한 뒤 결과를 파싱합니다. iOS와 최신 Android 버전에서 동작합니다. 만약 사용 중인 minSdkVersion 버전이 오래되었다면, SELECT json('{}') 가장 오래된 테스트 기기에서 확인해 보세요.
Join: 중복 컬럼명에 별칭 지정
이는 object row 기반 프록시 드라이버의 유일한 까다로운 부분입니다. Drizzle은 조인된 컬럼에 AS 별칭을 추가하지 않으므로, 이 쿼리는:
// Wrong: both columns are named "id" in the result set
await db
.select({ postId: posts.id, userId: users.id })
.from(posts)
.innerJoin(users, eq(posts.authorId, users.id));
라는 이름의 컬럼 두 개를 id생성합니다. 이들은 행 객체에서 하나의 키로 합쳐지고, 배열은 값이 하나 부족하며, Drizzle은 값을 잘못된 필드에 할당합니다. 제 테스트에서는 postId 사용자 ID와 userId 함께 반환되었습니다. undefined. 에러가 발생하지 않습니다.
명시적으로 별칭을 지정하세요:
await db
.select({
postId: sql<number>`${posts.id}`.as('post_id'),
userId: sql<number>`${users.id}`.as('user_id'),
title: posts.title,
author: users.name,
})
.from(posts)
.innerJoin(users, eq(posts.authorId, users.id));
이름이 다른 컬럼을 선택하는 것(posts.title, users.name)은 문제없습니다. 피해야 할 것은 db.select().from(a).innerJoin(b, ...) 두 테이블이 같은 이름의 컬럼을 공유할 때 필드 목록 없이 사용하는 것입니다 id 또는 created_at. 관계형 쿼리는 이 문제가 없습니다.
트랜잭션
await db.transaction(async (tx) => {
const [user] = await tx
.insert(users)
.values({ name: 'Grace', email: 'grace@example.com' })
.returning();
await tx.insert(posts).values({ authorId: user.id, title: 'Hello' });
// Nested transactions become savepoints
await tx.transaction(async (inner) => {
await inner.insert(posts).values({ authorId: user.id, title: 'Maybe' });
});
});
콜백에서 예외가 발생하면 Drizzle은 rollback. 다시 호출하면 tx.rollback() 예외를 던져 트랜잭션도 중단합니다.
데이터베이스당 연결이 하나뿐이므로 트랜잭션을 짧게 유지하고, ...하지 마세요 await 그 안에서 네트워크 호출이 발생합니다. 같은 연결의 다른 쓰기 작업은 열려 있는 트랜잭션 안에서 실행됩니다.
문제 해결
시작할 때 “Connection already exists” 에러가 발생합니다. 초기화가 두 번 실행됐습니다. 보통 hot reload 때문입니다. 위에 제시된 checkConnectionsConsistency 및 retrieveConnection 패턴을 사용하세요.
cannot start a transaction within a transaction. 어떤 run() 호출이 이루어졌습니다. false 를 세 번째 인자로 전달하지 않아, 플러그인이 Drizzle의 트랜잭션 내부에서 자체 트랜잭션을 열었습니다.
앱을 실행할 때마다 마이그레이션이 실행되고 다음 에러로 실패합니다. table already exists. 이 __drizzle_migrations insert가 누락되었거나 롤백되었습니다. journal 파일이 있는지 확인하세요. when bundle의 값이 테이블의 행과 일치합니다.
Build 오류 Failed to parse source for import analysis 켜짐 .sql. Vite transform plugin이 누락되었거나, 파일을 파싱하려는 다른 plugin보다 나중에 실행됩니다. 다음을 plugins 배열.
.returning() 빈 배열을 반환합니다. 쓰기 작업이 완료되었습니다 run return mode로 no. 드라이버가 다음을 전달하는지 확인하세요 'all' 할 때 method 이(가) 아닙니다 run.
날짜가 1000배 차이 납니다. mode: 'timestamp' 초 단위 저장, mode: 'timestamp_ms' 밀리초 단위 저장. 컬럼별로 하나를 선택하고 raw와 혼합하지 마세요. Date.now() inserts.
다른 대안을 고려해야 할 때
- Fast SQL로 가장 빠른 데이터 경로를 원하거나, 이름으로 키된 row 객체를 원할 때: Capacitor와 함께 사용하는 Kysely.
- decorators, entities, repositories를 선호하거나 TypeORM 백엔드 모델을 이식할 때: Capacitor를 사용하는 TypeORM.
- 데이터 암호화가 필요합니다: Capacitor에서 SQLite 데이터베이스 암호화.
네이티브 플러그인을 추가하면 iOS와 Android 앱을 다시 빌드해야 합니다. Mac이 없다면, Capgo 빌드 클라우드에서 빌드와 서명을 모두 처리합니다.