From 4e4dd3af356a9024207683151571ec77773bbdf9 Mon Sep 17 00:00:00 2001 From: Matus Balascak Date: Tue, 14 Oct 2025 09:42:14 +0200 Subject: [PATCH] chore(suite): add support for idb migration revisions --- packages/suite/src/storage/index.ts | 34 +++-- suite/idb-migration-utils/MIGRATION.md | 68 ++++++---- .../scripts/makeMigration.ts | 121 +++++++++++++----- .../src/createMigration.ts | 15 ++- suite/idb-migration-utils/src/encode.ts | 47 ++++--- .../src/parseIdbVersion.ts | 47 +++++++ .../src/tests/createMigration.test.ts | 29 ++++- .../src/tests/encode.test.ts | 57 ++++++--- .../src/tests/parseIdbVersionString.test.ts | 60 +++++++++ 9 files changed, 366 insertions(+), 112 deletions(-) create mode 100644 suite/idb-migration-utils/src/parseIdbVersion.ts create mode 100644 suite/idb-migration-utils/src/tests/parseIdbVersionString.test.ts diff --git a/packages/suite/src/storage/index.ts b/packages/suite/src/storage/index.ts index 4334dde6fc..f98611b312 100644 --- a/packages/suite/src/storage/index.ts +++ b/packages/suite/src/storage/index.ts @@ -1,6 +1,6 @@ import { IDBPDatabase, IDBPTransaction, StoreNames } from 'idb'; -import { idbVersionToSemver, semverToIDBVersion } from '@suite/idb-migration-utils'; +import { idbVersionToString } from '@suite/idb-migration-utils'; import SuiteDB, { OnUpgradeFunc } from '@trezor/suite-storage'; import { reloadApp } from 'src/utils/suite/reload'; @@ -9,23 +9,36 @@ import type { SuiteDBSchema } from './definitions'; import { runLegacyMigrations } from './migrations'; import * as migrations from './migrations/versions'; -const VERSION = semverToIDBVersion(process.env.VERSION); const LAST_LEGACY_VERSION = 57; -const MIGRATIONS = Object.values(migrations).sort((a, b) => a.threshold - b.threshold); +/** + * If number is only 24 bits (lower than lowest 25 bit number), append 8 empty bits at the end (making it a 32 bit number with zero revision number). + * This is backwards compatibility for versions before 25.11, see suite/idb-migration-utils/MIGRATION.md + */ +const normalizeVersion = (version: number) => (version < 0x01000000 ? version << 8 : version); + +const MIGRATIONS = Object.values(migrations) + .map(m => ({ ...m, threshold: normalizeVersion(m.threshold) })) + .sort((a, b) => a.threshold - b.threshold); + +const LATEST_MIGRATION_VERSION = MIGRATIONS.length + ? MIGRATIONS[MIGRATIONS.length - 1].threshold + : 0; const runMigrations = async ( db: IDBPDatabase, - oldVersion: number, + currentVersion: number, tx: IDBPTransaction[], 'versionchange'>, ) => { if (MIGRATIONS.length) { - console.log(`Current DB version: ${idbVersionToSemver(oldVersion)}`); + console.log(`Current DB version: ${idbVersionToString(currentVersion)}`); + + const normalizedCurrentVersion = normalizeVersion(currentVersion); for (const migration of MIGRATIONS) { - if (oldVersion < migration.threshold) { + if (normalizedCurrentVersion < migration.threshold) { console.log( - `Running migration for version ${idbVersionToSemver(migration.threshold)}`, + `Running migration for version ${idbVersionToString(migration.threshold)}`, ); await migration.migrate(db, tx); } @@ -55,4 +68,9 @@ const onUpgrade: OnUpgradeFunc = async (db, oldVersion, newVersio await runMigrations(db, oldVersion, transaction); }; -export const db = new SuiteDB('trezor-suite', VERSION, onUpgrade, reloadApp); +export const db = new SuiteDB( + 'trezor-suite', + LATEST_MIGRATION_VERSION, + onUpgrade, + reloadApp, +); diff --git a/suite/idb-migration-utils/MIGRATION.md b/suite/idb-migration-utils/MIGRATION.md index 60ebca8e63..28e0a58414 100644 --- a/suite/idb-migration-utils/MIGRATION.md +++ b/suite/idb-migration-utils/MIGRATION.md @@ -3,64 +3,80 @@ ## Version encoding IndexedDB requires its **version** to be a single integer, while Suite follows **SemVer** -(`major.minor.patch`). We pack every release into one 24-bit number so that integer +(`major.minor.patch`). We pack every release into one 32-bit number so that integer ordering == SemVer ordering. ### Layout | Bit range | Stored value | | --------- | ------------ | -| 23 – 16 | `major` | -| 15 – 8 | `minor` | -| 7 – 0 | `patch` | +| 31 – 24 | `major` | +| 23 – 16 | `minor` | +| 15 – 8 | `patch` | +| 7 – 0 | `revision` | ```typescript -encoded = (major << 16) | (minor << 8) | patch; +encoded = (major << 24) | (minor << 16) | (patch << 8) | revision; ``` +> The optional revision allows multiple internal database migrations within the same Suite release without changing the public app version. + ### Examples -| SemVer | Encoded (hex) | Encoded (dec) | -| ------------- | ------------- | ------------- | -| `1.0.0` | `0x010000` | 65 536 | -| `2.5.3` | `0x020503` | 132 611 | -| `25.7.0` | `0x190700` | 1 640 192 | -| `255.255.255` | `0xFFFFFF` | 16 777 215 | +| Version | Encoded (hex) | Encoded (dec) | +| ----------------- | ------------- | ------------- | +| `1.0.0` | `0x01000000` | 16 777 216 | +| `2.5.3` | `0x02050300` | 33 882 880 | +| `25.7.0` | `0x19070000` | 419 889 152 | +| `25.7.0.1` | `0x19070001` | 423 362 561 | +| `255.255.255.255` | `0xFFFFFFFF` | 4 294 967 295 | -> Each component must be 0-255. The max representable value is `0xFFFFFF` (16 777 215). +> Each component must be 0-255. The max representable value is `0xFFFFFFFF` (4 294 967 295). ### Backwards compatibility -The original schema used simple **integer** versions that incremented -with every change and stopped at **57**. -From release **25.7.0** onward we switch to the 24-bit SemVer encoding. +Old database versions used: -As can be seen in the examples section, the encoded decimal value for `25.7.0` is `1 640 192`. -Since **1 640 192 > 57**, every packed SemVer value is _guaranteed_ to be -higher than any legacy version. +- plain **integer** versions (0 - 57) +- **24-bit** SemVer versions up to `25.10.0` + +All **24-bit** values are ≤ `0xFFFFFF`, while the **32-bit** encoded versions start at `0x01000000`. +Therefore, every **32-bit** encoded version is higher than any earlier schema, and automatic upgrades work seamlessly. ## Creating a migration #### 1. Run the scaffold ```bash -yarn workspace @trezor/suite make:migration +yarn workspace @trezor/suite make:migration [version] +``` -# example -yarn workspace @trezor/suite make:migration 25.8.0 +There are several ways of how to call the command: + +```bash +// Create first migration for given version (fails if already exists) +yarn workspace @trezor/suite make:migration 25.11.0 + +// Create follow-up migration (revision = 1) +yarn workspace @trezor/suite make:migration 25.11.0.1 + +// Auto-detect current Suite version and create the next revision automatically (e.g. .1, .2, ...) +// This is the recommended way as you don't have to think about the version +yarn workspace @trezor/suite make:migration ``` The script will: -- create `src/storage/migrations/versions/25.8.0.ts` in `suite` package -- append `export { default as m25_8_0 } from './25.8.0'; to .../versions/index.ts` -- prepend `## 25.8.0` to `src/storage/CHANGELOG.md` +- create `src/storage/migrations/versions/.ts` in `suite` package (e.g. `25.11.0.ts` or `25.11.0.1.ts`) +- append `export { default as m25_11_0_1 } from './25.11.0.1';` to `.../versions/index.ts` +- for base migrations (rev = 0), prepend `## x.y.z` to `src/storage/CHANGELOG.md` +- for revisions (rev > 0), skip changelog updates #### 2. Implement the migration in the generated file ```typescript -// src/storage/migrations/versions/25.8.0.ts -export default createMigration('25.8.0', db => { +// src/storage/migrations/versions/25.11.0.1.ts +export default createMigration('25.11.0.1', db => { db.createObjectStore('example'); }); ``` diff --git a/suite/idb-migration-utils/scripts/makeMigration.ts b/suite/idb-migration-utils/scripts/makeMigration.ts index 487ab9e460..b45eaff3f6 100644 --- a/suite/idb-migration-utils/scripts/makeMigration.ts +++ b/suite/idb-migration-utils/scripts/makeMigration.ts @@ -4,6 +4,8 @@ import fs from 'fs'; import path from 'path'; import { SemVer } from 'semver'; +import { VersionInfo, parseIdbVersion } from '../src/parseIdbVersion'; + const SUITE_ROOT = path.resolve(__dirname, '..', '..', '..', 'packages', 'suite'); const MIGRATIONS_DIR = path.join(SUITE_ROOT, 'src', 'storage', 'migrations', 'versions'); const CHANGELOG_PATH = path.join(SUITE_ROOT, 'src', 'storage', 'CHANGELOG.md'); @@ -18,15 +20,75 @@ function ensureDirExists(dir: string) { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } -function createMigrationFile(semver: SemVer) { +function escapeRegExp(str: string) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function getSuiteVersion(): string { + const packageJsonPath = path.join(SUITE_ROOT, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + exit(`❌ package.json not found at ${packageJsonPath}`); + } + + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + + const { suiteVersion } = packageJson; + + if (typeof suiteVersion !== 'string' || !suiteVersion.trim()) { + exit('❌ suiteVersion not found in package.json or is not a valid string'); + } + + const version = suiteVersion.trim(); + + try { + new SemVer(version); + } catch { + exit(`❌ Invalid suiteVersion in package.json: "${version}"`); + } + + return version; +} + +function resolveAutoVersion() { + const suiteVersion = getSuiteVersion(); + const suiteSemVer = new SemVer(suiteVersion); + + const migrationFiles = fs.existsSync(MIGRATIONS_DIR) ? fs.readdirSync(MIGRATIONS_DIR) : []; + + const base = escapeRegExp(suiteSemVer.version); + const revisionsRegex = new RegExp(`^${base}(?:\\.(\\d+))?\\.ts$`); + + const revisions = migrationFiles + .map(name => { + const match = name.match(revisionsRegex); + if (!match) return null; + + return match[1] ? Number(match[1]) : 0; + }) + .filter((v): v is number => v !== null && Number.isInteger(v) && v >= 0 && v <= 255); + + const nextRevision = revisions.length ? Math.max(...revisions) + 1 : 0; + + if (nextRevision > 255) { + exit(`❌ Maximum number of revisions (255) reached for version ${suiteSemVer.version}`); + } + + const nextVersionString = + nextRevision === 0 ? suiteSemVer.version : `${suiteSemVer.version}.${nextRevision}`; + + return parseIdbVersion(nextVersionString); +} + +function createMigrationFile({ semver, revision, versionString }: VersionInfo) { const { major, minor, patch } = semver; - const fileName = `${semver.version}.ts`; + const fileName = `${versionString}.ts`; const filePath = path.join(MIGRATIONS_DIR, fileName); - const exportIdentifier = `m${major}_${minor}_${patch}`; + const exportIdentifier = + revision === 0 ? `m${major}_${minor}_${patch}` : `m${major}_${minor}_${patch}_${revision}`; if (fs.existsSync(filePath)) { - exit(`❌ Migration ${semver.version} already exists at ${filePath}`); + exit(`❌ Migration ${versionString} already exists at ${filePath}`); } ensureDirExists(MIGRATIONS_DIR); @@ -39,20 +101,32 @@ import { createMigration } from '@suite/idb-migration-utils'; import { SuiteDBSchema } from 'src/storage/definitions'; -export default createMigration("${semver.version}", (db, tx) => { - // ⚠️ TODO: implement migration logic for v${semver.version} +export default createMigration("${versionString}", (db, tx) => { + // ⚠️ TODO: implement migration logic for v${versionString} }); `; fs.writeFileSync(filePath, template, 'utf8'); console.log(`✅ Created ${filePath}`); - const exportLine = `export { default as ${exportIdentifier} } from './${semver.version}';\n`; - fs.appendFileSync(EXPORT_PATH, exportLine, 'utf8'); - console.log(`✅ Appended export to ${EXPORT_PATH}`); + const exportLine = `export { default as ${exportIdentifier} } from './${versionString}';\n`; + + const indexContent = fs.readFileSync(EXPORT_PATH, 'utf8'); + if (!indexContent.includes(exportLine)) { + fs.appendFileSync(EXPORT_PATH, exportLine, 'utf8'); + console.log(`✅ Appended export to ${EXPORT_PATH}`); + } else { + console.log(`ℹ️ Export already present in ${EXPORT_PATH}, skipping append`); + } } -function updateChangelog(semver: SemVer) { +function updateChangelog({ semver, revision }: VersionInfo) { + if (revision > 0) { + console.log('ℹ️ Revision migration; changelog not updated'); + + return; + } + if (!fs.existsSync(CHANGELOG_PATH)) { console.warn(`CHANGELOG_PATH not found at ${CHANGELOG_PATH}; heading not added`); @@ -72,32 +146,17 @@ function updateChangelog(semver: SemVer) { console.log(`✅ Updated ${CHANGELOG_PATH} with new version entry`); } -function getSuiteVersion(): string { - const packageJsonPath = path.join(SUITE_ROOT, 'package.json'); - if (!fs.existsSync(packageJsonPath)) { - exit(`❌ package.json not found at ${packageJsonPath}`); - } - - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - - return packageJson.suiteVersion; -} - function run() { const [, , argVersion] = process.argv; - const version = argVersion || getSuiteVersion(); + try { + const versionInfo = argVersion ? parseIdbVersion(argVersion) : resolveAutoVersion(); - if (!version) exit('Usage: yarn workspace @trezor/suite make:migration x.y.z'); - - const semver = new SemVer(version); - - if (semver.prerelease.length > 0) { - exit(`❌ Prerelease versions are not supported: ${semver.version}`); + createMigrationFile(versionInfo); + updateChangelog(versionInfo); + } catch (error) { + exit(`❌ ${error instanceof Error ? error.message : String(error)}`); } - - createMigrationFile(semver); - updateChangelog(semver); } run(); diff --git a/suite/idb-migration-utils/src/createMigration.ts b/suite/idb-migration-utils/src/createMigration.ts index cdba3019b4..96cd2a5b2d 100644 --- a/suite/idb-migration-utils/src/createMigration.ts +++ b/suite/idb-migration-utils/src/createMigration.ts @@ -1,19 +1,20 @@ import type { DBSchema } from 'idb'; -import * as semver from 'semver'; -import { semverToIDBVersion } from './encode'; +import { encodeIDBVersion } from './encode'; +import { parseIdbVersion } from './parseIdbVersion'; import { DBMigration } from './types'; +type BaseSemver = `${number}.${number}.${number}`; +type IdbVersionString = `${BaseSemver}` | `${BaseSemver}.${number}`; + export const createMigration = ( - version: `${number}.${number}.${number}`, + version: IdbVersionString, migrate: DBMigration['migrate'], ): DBMigration => { - if (!semver.valid(version)) { - throw new Error(`createMigration: Invalid version format: ${version}`); - } + const { versionString: threshold } = parseIdbVersion(version); return { - threshold: semverToIDBVersion(version), + threshold: encodeIDBVersion(threshold), migrate, }; }; diff --git a/suite/idb-migration-utils/src/encode.ts b/suite/idb-migration-utils/src/encode.ts index 48d6848af2..8b9fcb60e5 100644 --- a/suite/idb-migration-utils/src/encode.ts +++ b/suite/idb-migration-utils/src/encode.ts @@ -1,33 +1,32 @@ -import * as semver from 'semver'; +import { parseIdbVersion } from './parseIdbVersion'; -export const semverToIDBVersion = (rawVersion: string | undefined): number => { - const version = semver.parse(rawVersion); +export const encodeIDBVersion = (rawVersion: string): number => { + const { semver, revision } = parseIdbVersion(rawVersion); - if (!version) { - throw new Error(`Invalid version: ${rawVersion}`); - } + const { major, minor, patch } = semver; - if (version.prerelease.length > 0) { - throw new Error(`Prerelease versions are not supported: ${rawVersion}`); - } - - const { major, minor, patch } = version; - - if ([major, minor, patch].some(v => v > 255)) { - throw new Error(`Version ${rawVersion} is too large to be encoded`); - } - - return (major << 16) | (minor << 8) | patch; + return ((major << 24) | (minor << 16) | (patch << 8) | revision) >>> 0; }; -export const idbVersionToSemver = (version: number): semver.SemVer => { - if (version < 0 || version > 0xffffff) { - throw new RangeError(`Value is out of 24-bit range: ${version}`); +export const idbVersionToString = (version: number): string => { + if (!Number.isInteger(version) || version < 0 || version > 0xffffffff) { + throw new RangeError(`Invalid IDB version: ${version} (must be an integer 0–0xFFFFFFFF)`); } - const patch = version & 0xff; - const minor = (version >>> 8) & 0xff; - const major = (version >>> 16) & 0xff; + const unsignedIntVersion = version >>> 0; - return new semver.SemVer(`${major}.${minor}.${patch}`); + if (unsignedIntVersion < 0x01000000) { + const major = (unsignedIntVersion >>> 16) & 0xff; + const minor = (unsignedIntVersion >>> 8) & 0xff; + const patch = unsignedIntVersion & 0xff; + + return `${major}.${minor}.${patch}`; + } + + const major = (unsignedIntVersion >>> 24) & 0xff; + const minor = (unsignedIntVersion >>> 16) & 0xff; + const patch = (unsignedIntVersion >>> 8) & 0xff; + const rev = unsignedIntVersion & 0xff; + + return rev ? `${major}.${minor}.${patch}.${rev}` : `${major}.${minor}.${patch}`; }; diff --git a/suite/idb-migration-utils/src/parseIdbVersion.ts b/suite/idb-migration-utils/src/parseIdbVersion.ts new file mode 100644 index 0000000000..fc90f78d6b --- /dev/null +++ b/suite/idb-migration-utils/src/parseIdbVersion.ts @@ -0,0 +1,47 @@ +import * as semver from 'semver'; + +export interface VersionInfo { + semver: semver.SemVer; + revision: number; + versionString: string; +} + +const inByteRange = (raw: string) => /^(0|[1-9]\d{0,2})$/.test(raw) && Number(raw) <= 255; + +/** + * Parses and validates an IndexedDB migration version string. + * Accepts "x.y.z" or "x.y.z.r". + * Throws on invalid format or out-of-range values. + */ +export const parseIdbVersion = (raw: string): VersionInfo => { + const parts = raw + .trim() + .split('.') + .map(part => part.trim()); + + if ((parts.length !== 3 && parts.length !== 4) || parts.some(part => part === '')) { + throw new Error( + `Invalid IDB version: "${raw}". Expected "major.minor.patch" or "major.minor.patch.revision".`, + ); + } + + if (parts.some(part => !inByteRange(part))) { + throw new Error( + `Invalid IDB version: "${raw}". Each part must be a decimal integer in 0–255`, + ); + } + + const core = parts.slice(0, 3).join('.'); + const semverVersion = new semver.SemVer(core); + + if (semverVersion.prerelease.length > 0) { + throw new Error(`Invalid IDB version: "${raw}". Prerelease tags are not allowed`); + } + + const revision = parts.length === 4 ? Number(parts[3]) : 0; + + const versionString = + revision === 0 ? semverVersion.version : `${semverVersion.version}.${revision}`; + + return { semver: semverVersion, revision, versionString }; +}; diff --git a/suite/idb-migration-utils/src/tests/createMigration.test.ts b/suite/idb-migration-utils/src/tests/createMigration.test.ts index f315e1b1d0..56f7be9afc 100644 --- a/suite/idb-migration-utils/src/tests/createMigration.test.ts +++ b/suite/idb-migration-utils/src/tests/createMigration.test.ts @@ -4,16 +4,43 @@ describe('createMigration', () => { it('returns correct threshold', () => { const migrateFn = jest.fn(); expect(createMigration('0.0.1', migrateFn)).toEqual({ - threshold: 0x000001, + threshold: 0x00000100, migrate: migrateFn, }); }); + it('supports max encodable version', () => { + const migrateFn = jest.fn(); + expect(createMigration('255.255.255.255', migrateFn).threshold).toBe(0xffffffff); + }); + it('calls the callback with correct params', () => { const migrateFn = jest.fn(); const migration = createMigration('1.2.3', migrateFn); + expect(migrateFn).not.toHaveBeenCalledWith(); migration.migrate('db' as any, 'tx' as any); expect(migrateFn).toHaveBeenCalledWith('db', 'tx'); }); + + it('treats .0 revision as equivalent to 3-part', () => { + const migrateFn = jest.fn(); + const a = createMigration('1.2.3', migrateFn).threshold; + const b = createMigration('1.2.3.0', migrateFn).threshold; + expect(a).toBe(b); + }); + + it('throws on invalid version string', () => { + const migrateFn = jest.fn(); + expect(() => createMigration('1.2' as any, migrateFn)).toThrow(); + expect(() => createMigration('1.2.3-alpha' as any, migrateFn)).toThrow(); + }); + + it('throws on invalid revision', () => { + const migrateFn = jest.fn(); + expect(() => createMigration('1.2.3.256' as any, migrateFn)).toThrow(); + expect(() => createMigration('1.2.3.-1' as any, migrateFn)).toThrow(); + expect(() => createMigration('1.2.3.foo' as any, migrateFn)).toThrow(); + expect(() => createMigration('1.2.3.1.1' as any, migrateFn)).toThrow(); + }); }); diff --git a/suite/idb-migration-utils/src/tests/encode.test.ts b/suite/idb-migration-utils/src/tests/encode.test.ts index e534333490..a87554ef97 100644 --- a/suite/idb-migration-utils/src/tests/encode.test.ts +++ b/suite/idb-migration-utils/src/tests/encode.test.ts @@ -1,29 +1,44 @@ -import { idbVersionToSemver, semverToIDBVersion } from '../encode'; +import { encodeIDBVersion, idbVersionToString } from '../encode'; -const VERSIONS = ['25.9.0', '25.10.0', '25.11.0', '25.11.1', '25.11.2', '25.12.0', '26.1.0']; +const VERSIONS = [ + '1.2.3', + '1.2.3.1', + '1.2.3.2', + '1.2.4', + '25.10.0', + '25.10.0.1', + '25.10.1', + '25.11.0', + '25.11.2', + '26.0.0', +]; describe('IDB Version Encoding', () => { - it('semverToIDBVersion', () => { - expect(semverToIDBVersion('1.2.3')).toBe(0x010203); - expect(semverToIDBVersion('255.255.255')).toBe(0xffffff); + it(encodeIDBVersion.name, () => { + expect(encodeIDBVersion('1.2.3')).toBe(0x01020300); + expect(encodeIDBVersion('255.255.255')).toBe(0xffffff00); + expect(encodeIDBVersion('255.255.255.255')).toBe(0xffffffff); - expect(() => semverToIDBVersion('256.0.0')).toThrow(); - expect(() => semverToIDBVersion('1.2.3-alpha')).toThrow(); - expect(() => semverToIDBVersion('invalid')).toThrow(); + expect(() => encodeIDBVersion('256.0.0')).toThrow(); + expect(() => encodeIDBVersion('1.2.3-alpha')).toThrow(); + expect(() => encodeIDBVersion('invalid')).toThrow(); }); - it('idbVersionToSemver', () => { - expect(idbVersionToSemver(0x010203).version).toBe('1.2.3'); - expect(idbVersionToSemver(0xffffff).version).toBe('255.255.255'); + it(idbVersionToString.name, () => { + expect(idbVersionToString(0x01020300)).toBe('1.2.3'); + expect(idbVersionToString(0x01020304)).toBe('1.2.3.4'); + expect(idbVersionToString(0x00ffffff)).toBe('255.255.255'); + expect(idbVersionToString(0x00000000)).toBe('0.0.0'); + expect(idbVersionToString(0xffffffff)).toBe('255.255.255.255'); - expect(() => idbVersionToSemver(-1)).toThrow(); - expect(() => idbVersionToSemver(0x01020400)).toThrow(); + expect(() => idbVersionToString(-1)).toThrow(); + expect(() => idbVersionToString(0x0102040021)).toThrow(); }); it('each next version is greater than the previous one', () => { let last = 0; VERSIONS.forEach(v => { - const idbVersion = semverToIDBVersion(v); + const idbVersion = encodeIDBVersion(v); expect(idbVersion).toBeGreaterThanOrEqual(last); last = idbVersion; }); @@ -31,7 +46,19 @@ describe('IDB Version Encoding', () => { it('encode/decode returns initial value', () => { VERSIONS.forEach(v => { - expect(idbVersionToSemver(semverToIDBVersion(v)).version).toBe(v); + expect(idbVersionToString(encodeIDBVersion(v))).toBe(v); }); }); + + it('treats .0 revision as equivalent to 3-part', () => { + expect(encodeIDBVersion('1.2.3')).toBe(encodeIDBVersion('1.2.3.0')); + expect(idbVersionToString(encodeIDBVersion('1.2.3.0'))).toBe('1.2.3'); + }); + + it('rejects invalid revision values', () => { + expect(() => encodeIDBVersion('1.2.3.-1')).toThrow(); + expect(() => encodeIDBVersion('1.2.3.256')).toThrow(); + expect(() => encodeIDBVersion('1.2.3.foo' as any)).toThrow(); + expect(() => encodeIDBVersion('1.2.3.1.1' as any)).toThrow(); + }); }); diff --git a/suite/idb-migration-utils/src/tests/parseIdbVersionString.test.ts b/suite/idb-migration-utils/src/tests/parseIdbVersionString.test.ts new file mode 100644 index 0000000000..bfa5531e10 --- /dev/null +++ b/suite/idb-migration-utils/src/tests/parseIdbVersionString.test.ts @@ -0,0 +1,60 @@ +import { parseIdbVersion } from '../parseIdbVersion'; + +describe(parseIdbVersion.name, () => { + it('parses x.y.z with revision=0', () => { + const result = parseIdbVersion('1.2.3'); + expect(result.semver.version).toBe('1.2.3'); + expect(result.revision).toBe(0); + expect(result.versionString).toBe('1.2.3'); + }); + + it('parses x.y.z.r with revision', () => { + const result = parseIdbVersion('25.7.0.1'); + expect(result.semver.version).toBe('25.7.0'); + expect(result.revision).toBe(1); + expect(result.versionString).toBe('25.7.0.1'); + }); + + it('trims whitespace around the version', () => { + const cases = [' 2.5.3 ', ' 2 . 5 . 3 ']; + cases.forEach(raw => { + const result = parseIdbVersion(raw); + expect(result.semver.version).toBe('2.5.3'); + expect(result.revision).toBe(0); + }); + }); + + it('handles boundary values (0 and 255)', () => { + const min = parseIdbVersion('0.0.0'); + expect(min.semver.version).toBe('0.0.0'); + expect(min.revision).toBe(0); + + const max = parseIdbVersion('255.255.255.255'); + expect(max.semver.version).toBe('255.255.255'); + expect(max.revision).toBe(255); + }); + + it('rejects invalid shapes or empty segments', () => { + for (const raw of ['1.2', '1.2.3.4.5', '1.2.3.', '1..3', ' ']) { + expect(() => parseIdbVersion(raw)).toThrow(); + } + }); + + it('rejects non-decimal or malformed numeric parts', () => { + for (const raw of ['0x1.2.3', '1e2.2.3', '+1.2.3', '1.2.3.1.']) { + expect(() => parseIdbVersion(raw)).toThrow(); + } + }); + + it('rejects out-of-range parts', () => { + for (const raw of ['256.0.0', '1.256.3', '1.2.300', '1.2.3.256']) { + expect(() => parseIdbVersion(raw)).toThrow(); + } + }); + + it('rejects prerelease tags', () => { + for (const raw of ['1.2.3-alpha', '1.2.3-rc.1']) { + expect(() => parseIdbVersion(raw)).toThrow(); + } + }); +});