DEV Community

HarmonyOS
HarmonyOS

Posted on

How to Manage RDB Migrations Safely in HarmonyOS NEXT

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

  1. Define baseline schema (v1): keep CREATE TABLE SQL in creationQueries.
  2. Create migration class: RDBMigration handles one-step upgrades with transactions.
  3. List migrations externally: export ordered MIGRATIONS (e.g., 1→2 adds location COLUMN).
  4. Use builder: call n*ew RDBBuilder(...).setMigrations(MIGRATIONS).build(context)*.
  5. Validate chain: ensure steps start at 1, are continuous, and end at target dbVersion.
  6. Handle fresh installs: if version === 0, run creation queries and bump to 1.
  7. Apply migrations: run until store.version matches target; rollback on failure.
  8. 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
);`;
Enter fullscreen mode Exit fullscreen mode

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}`);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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];
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}

Enter fullscreen mode Exit fullscreen mode

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

Written by Bilal Basboz

Top comments (0)