mirror of
https://github.com/trezor/trezor-suite.git
synced 2026-02-20 00:33:07 +01:00
chore(suite): add support for idb migration revisions
This commit is contained in:
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
```
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
47
suite/idb-migration-utils/src/parseIdbVersion.ts
Normal file
47
suite/idb-migration-utils/src/parseIdbVersion.ts
Normal 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 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 };
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user