Read the original article:How to Manage RDB Migrations Safely in HarmonyOS NEXT
Requirement Description
Build a small, production-ready migration flow for a single-table RDB in HarmonyOS NEXT (ArkTS) that:
-
Uses a reusable RDBBuilder to own:
- DB name, target DB Version, and creation Queries for first install.
- A validated, ordered list of RDBMigration steps (single-step n→n+1).
Guarantees transactional execution for every migration; bumps store.version only on success.
Handles fresh installs (version === 0) by running creationQueries once and setting version = 1.
Applies migrations sequentially until the target version (e.g., 1→2).
Keeps migrations externalized (separate file) for clarity and testability.
Emits clear logs/errors for CI and runtime diagnostics.
Background Knowledge
- RDB versioning: relationalStore.RdbStore.version mirrors SQLite’s user_version. Fresh DBs start at 0. After running creationQueries, set version = 1.
- Migrations: Always single-step (n→n+1). This keeps chains simple and easy to test.
- Transactions: Wrap each migration in beginTransaction / commit / rollBack. Bump version only after success.
- Validation: Ensure migrations start at 1, are continuous, and end at the target version.
- Idempotency: Use guards like IF NOT EXISTS for safe re-runs.
- Caveats: Some SQLite builds lack DROP COLUMN; fallback is copy-table strategy.
- Testing: Verify upgrade paths with seed DBs from older versions.
Implementation Steps
- Define baseline schema (v1): keep CREATE TABLE SQL in creationQueries.
- Create migration class: RDBMigration handles one-step upgrades with transactions.
- List migrations externally: export ordered MIGRATIONS (e.g., 1→2 adds location COLUMN).
- Use builder: call n*ew RDBBuilder(...).setMigrations(MIGRATIONS).build(context)*.
- Validate chain: ensure steps start at 1, are continuous, and end at target dbVersion.
- Handle fresh installs: if version === 0, run creation queries and bump to 1.
- Apply migrations: run until store.version matches target; rollback on failure.
- Consume store: use the upgraded RdbStore in queries (include new columns).
Code Snippet / Configuration
1.Baseline schema (v1)
TripsTable.ets file:
export const SQL_CREATE_TRIPS_V1 = `
CREATE TABLE IF NOT EXISTS Trips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
template TEXT,
start_date INTEGER,
days INTEGER
);`;
2.Migration class (single-step, transactional)
RDBMigration.ets file:
import { relationalStore } from '@kit.ArkData';
export class RDBMigration {
readonly fromVersion: number;
readonly toVersion: number;
private readonly migrateAction: (store: relationalStore.RdbStore) => Promise<void>;
constructor(
fromVersion: number,
toVersion: number,
migrateAction: (store: relationalStore.RdbStore) => Promise<void>
) {
if (!Number.isInteger(fromVersion) || !Number.isInteger(toVersion)) {
throw new Error('Migration versions must be integers.');
}
if (toVersion !== fromVersion + 1) {
throw new Error('Each migration must be single step: n -> n+1.');
}
this.fromVersion = fromVersion;
this.toVersion = toVersion;
this.migrateAction = migrateAction;
}
async run(store: relationalStore.RdbStore): Promise<void> {
if (store.version !== this.fromVersion) {
return;
} // not my turn
store.beginTransaction();
try {
await this.migrateAction(store);
store.version = this.toVersion; // bump only on success
store.commit();
} catch (e) {
store.rollBack();
throw new Error(`Migration ${this.fromVersion}→${this.toVersion} failed: ${e}`);
}
}
}
3.Migrations list (externalized)
Migrations.ets file:
import { RDBMigration } from './RDBMigration';
import { relationalStore } from '@kit.ArkData';
// v1 → v2: add 'location' + index on start_date
const MIGRATION_1_2 = new RDBMigration(1, 2, async (store: relationalStore.RdbStore) => {
await store.executeSql(`ALTER TABLE Trips ADD COLUMN location TEXT NOT NULL DEFAULT ''`);
await store.executeSql(`CREATE INDEX IF NOT EXISTS idx_trips_start_date ON Trips(start_date)`);
});
// Add next steps later (2→3, 3→4, …)
export const MIGRATIONS: RDBMigration[] = [MIGRATION_1_2];
4.Builder that owns creation + migrations
RDBBuilder.ets file:
import { relationalStore } from '@kit.ArkData';
import { Context } from '@kit.AbilityKit';
import { RDBMigration } from './RDBMigration';
export interface RDBBuilderParams {
dbName: string;
dbVersion?: number; // target version (default 1)
creationQueries: string[];
}
export class RDBBuilder {
private rdbName: string;
private version: number;
private creationQueries: string[];
private migrations: RDBMigration[] = [];
constructor(options: RDBBuilderParams) {
if (options.dbVersion && !Number.isInteger(options.dbVersion)) {
throw new Error('RDB version must be integer.');
}
this.version = options.dbVersion ?? 1;
this.rdbName = options.dbName;
this.creationQueries = options.creationQueries;
}
/**
* Runs creation queries when the RDB version is 0 (initial version).
* @param { relationalStore.RdbStore } store
*/
private async runCreationQueries(store: relationalStore.RdbStore) {
if (store.version === 0) {
for (const q of this.creationQueries) {
await store.executeSql(q);
}
store.version = 1;
}
}
/**
* Validates passed migrations.
*/
private validateMigrations() {
const target = this.version;
if (target <= 1) {
return;
}
if (!this.migrations.length) {
throw new Error('No migrations provided.');
}
// Check first migration starts at 1 (or 0 if including initial)
if (this.migrations[0].fromVersion !== 1) {
throw new Error(`First migration must start at version 1, got ${this.migrations[0].fromVersion}`);
}
// Check chain continuity
for (let i = 0; i < this.migrations.length; i++) {
const m = this.migrations[i];
if (m.toVersion !== m.fromVersion + 1) {
throw new Error(`Migration ${m.fromVersion}→${m.toVersion} is invalid. Must be +1 step.`);
}
if (i > 0 && m.fromVersion !== this.migrations[i - 1].toVersion) {
throw new Error(`Missing migration ${this.migrations[i - 1].toVersion}→${m.fromVersion}`);
}
}
// Check last migration ends at targetVersion
const last = this.migrations[this.migrations.length - 1];
if (last.toVersion !== target) {
throw new Error(`Last migration ends at ${last.toVersion}, expected ${target}`);
}
}
/**
* Set App RDB migrations list
* @param { RDBMigration[] } migrations
* @returns { RDBBuilder }
*/
public setMigrations(migrations: RDBMigration[]): RDBBuilder {
this.migrations = [...migrations].sort((a, b) => a.fromVersion - b.fromVersion);
this.validateMigrations();
return this;
}
/**
* Initialize RDB with migration handling (versions).
* @param { Context } the application context.
* @returns { Promise<relationalStore.RdbStore> } The RDB store {@link relationalStore.RdbStore}.
*/
public async build(context: Context): Promise<relationalStore.RdbStore> {
const store = await relationalStore.getRdbStore(context, {
name: this.rdbName,
securityLevel: relationalStore.SecurityLevel.S1,
});
// Fresh DB? Run creation queries once (version 0)
if (store.version === 0) {
await this.runCreationQueries(store);
}
const target = this.version;
// Loop until we reach targetVersion, or detect no progress (missing step).
while (store.version < target) {
for (const m of this.migrations) {
await m.run(store);
if (store.version >= target) {
break;
}
}
}
return store;
}
}
5.Wire it up for the Trips table
AppDatabase.ets file:
import { Context } from '@kit.AbilityKit';
import { relationalStore } from '@kit.ArkData';
import { BusinessError } from '@kit.BasicServicesKit';
import { RDBBuilder } from './RDBBuilder';
import { MIGRATIONS } from './Migrations';
import { SQL_CREATE_TRIPS_V1 } from './TripsTable';
const TARGET_DB_VERSION = 2;
export interface TripModel {
id: number;
template: string;
startDate: string;
days: number;
location: string; // v2
}
export class AppDatabase {
private store?: relationalStore.RdbStore;
constructor(context: Context) {
new RDBBuilder({
dbName: 'trips_database.db',
dbVersion: TARGET_DB_VERSION,
creationQueries: [SQL_CREATE_TRIPS_V1],
})
.setMigrations(MIGRATIONS)
.build(context)
.then(s => {
this.store = s;
console.info(`RdbStore ready, version: ${s.version}`);
})
.catch((err: BusinessError) => {
console.error(`Failed to init RdbStore: ${err?.message ?? err}`);
// Optionally: throw err; // to crash on fatal DB init errors
});
}
async getTrips(): Promise<TripModel[]> {
const db = this.store;
if (!db) {
return [];
}
const rs = await db.query(new relationalStore.RdbPredicates('Trips'),
['id', 'template', 'start_date', 'days', 'location']);
const out: TripModel[] = [];
while (rs.goToNextRow()) {
const startMs = rs.getLong(rs.getColumnIndex('start_date'));
out.push({
id: rs.getLong(rs.getColumnIndex('id')),
template: rs.getString(rs.getColumnIndex('template')),
startDate: new Date(startMs).toLocaleDateString(),
days: rs.getLong(rs.getColumnIndex('days')),
location: rs.getString(rs.getColumnIndex('location'))
});
}
rs.close();
return out;
}
}
Test Results
- Fresh install (v0): runs creation SQL → sets version to 1 → applies 1→2 migration → ends at 2.
- Upgrade (v1→v2): skips creation, runs migration → ends at 2.
- Missing or broken chain: builder validation fails early with clear error.
- Migration failure: transaction rollback keeps DB at previous stable version.
- Already up-to-date (v2): no work performed, DB opens immediately.
Limitations or Considerations
- Single-step only: Each migration must be n → n+1. Avoid multi-version jumps.
- DDL caveats: Some SQLite builds don’t support DROP COLUMN. Use copy-table strategy if needed.
- Defaults required: When adding NOT NULL columns, always provide a default.
- Validation strictness: Builder enforces order and continuity; disable checks only if absolutely necessary.
- Testing: Seed DBs at versions 0..N-1 and upgrade them in CI to catch migration issues early.
- Error handling: Decide if DB init errors should crash the app or be logged gracefully.
Related Documents or Links
HarmonyOS NEXT – ArkData relationalStore (RDB Store) API Reference
Top comments (0)