chore(suite): add support for idb migration revisions

This commit is contained in:
Matus Balascak
2025-10-14 09:42:14 +02:00
parent 2e3963997f
commit 4e4dd3af35
9 changed files with 366 additions and 112 deletions

View File

@@ -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<SuiteDBSchema>,
oldVersion: number,
currentVersion: number,
tx: IDBPTransaction<SuiteDBSchema, StoreNames<SuiteDBSchema>[], '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<SuiteDBSchema> = async (db, oldVersion, newVersio
await runMigrations(db, oldVersion, transaction);
};
export const db = new SuiteDB<SuiteDBSchema>('trezor-suite', VERSION, onUpgrade, reloadApp);
export const db = new SuiteDB<SuiteDBSchema>(
'trezor-suite',
LATEST_MIGRATION_VERSION,
onUpgrade,
reloadApp,
);

View File

@@ -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 <major.minor.patch>
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/<versionString>.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');
});
```

View File

@@ -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<SuiteDBSchema>("${semver.version}", (db, tx) => {
// ⚠️ TODO: implement migration logic for v${semver.version}
export default createMigration<SuiteDBSchema>("${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();

View File

@@ -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 = <Schema extends DBSchema>(
version: `${number}.${number}.${number}`,
version: IdbVersionString,
migrate: DBMigration<Schema>['migrate'],
): DBMigration<Schema> => {
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,
};
};

View File

@@ -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 00xFFFFFFFF)`);
}
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}`;
};

View File

@@ -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 0255`,
);
}
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 };
};

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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();
}
});
});