diff --git a/.changeset/poor-parrots-jump.md b/.changeset/poor-parrots-jump.md new file mode 100644 index 0000000000..afded006d1 --- /dev/null +++ b/.changeset/poor-parrots-jump.md @@ -0,0 +1,5 @@ +--- +'@platforma-sdk/npm-migrations': major +--- + +Initial release. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e01e0605af..c6cdc54e8f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3054,6 +3054,30 @@ importers: specifier: 'catalog:' version: 8.17.0(eslint@9.25.1)(typescript@5.6.3) + tools/npm-migrations: + devDependencies: + '@milaboratories/build-configs': + specifier: workspace:* + version: link:../build-configs + '@milaboratories/eslint-config': + specifier: workspace:* + version: link:../eslint-config + '@milaboratories/ts-builder': + specifier: workspace:* + version: link:../ts-builder + '@milaboratories/ts-configs': + specifier: workspace:* + version: link:../ts-configs + '@types/node': + specifier: 'catalog:' + version: 24.5.2 + typescript: + specifier: 'catalog:' + version: 5.6.3 + vitest: + specifier: 'catalog:' + version: 2.1.9(@types/node@24.5.2)(happy-dom@15.11.7)(jsdom@25.0.1)(sass@1.83.4) + tools/oclif-index: dependencies: '@oclif/core': @@ -16839,12 +16863,12 @@ snapshots: '@types/fs-extra@8.1.5': dependencies: - '@types/node': 22.5.5 + '@types/node': 24.5.2 '@types/glob@7.2.0': dependencies: '@types/minimatch': 6.0.0 - '@types/node': 22.5.5 + '@types/node': 24.5.2 '@types/graceful-fs@4.1.9': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index bbbbc9f6ff..8a84d2f289 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -45,6 +45,7 @@ packages: - tools/pl-bootstrap - tools/eslint-config - tools/blocks-deps-updater + - tools/npm-migrations - sdk/model - sdk/workflow-tengo diff --git a/tools/npm-migrations/README.md b/tools/npm-migrations/README.md new file mode 100644 index 0000000000..2c46633a0a --- /dev/null +++ b/tools/npm-migrations/README.md @@ -0,0 +1,25 @@ +# @platforma-sdk/npm-migrations + +Minimal migration runner for npm postinstall using a `migrations` section in `package.json`. + +Behavior: +- If no migration record found for a package, no migrations are executed and the version is set to latest (migrations length). +- If a record exists, migrations are applied one-by-one from the recorded index to the latest, updating `migrations[packageName]` after each step. + +Usage: + +```ts +import { Migrator } from '@platforma-sdk/npm-migrations'; + +const migrator = new Migrator('your-package-name'); + +migrator.addMigration(() => { + // do stuff +}) + +migrator.addMigration(() => { + // do stuff +}) + +await migrator.applyMigrations() +``` diff --git a/tools/npm-migrations/eslint.config.mjs b/tools/npm-migrations/eslint.config.mjs new file mode 100644 index 0000000000..32dc2ed015 --- /dev/null +++ b/tools/npm-migrations/eslint.config.mjs @@ -0,0 +1,9 @@ +import { node } from '@milaboratories/eslint-config'; + +/** @type {import('eslint').Linter.Config[]} */ +export default [ + { ignores: ['**/*.test.ts', '**/__tests__/**', 'test/**'] }, + ...node, +]; + + diff --git a/tools/npm-migrations/package.json b/tools/npm-migrations/package.json new file mode 100644 index 0000000000..b4848caef5 --- /dev/null +++ b/tools/npm-migrations/package.json @@ -0,0 +1,34 @@ +{ + "name": "@platforma-sdk/npm-migrations", + "version": "0.1.0", + "description": "Lightweight npm postinstall migrations runner for package.json migrations section", + "type": "module", + "license": "UNLICENSED", + "scripts": { + "type-check": "ts-builder types --target node", + "build": "ts-builder build --target node", + "watch": "ts-builder build --target node --watch", + "test": "vitest", + "lint": "eslint .", + "do-pack": "rm -f *.tgz && pnpm pack && mv *.tgz package.tgz" + }, + "files": [ + "dist", + "README.md" + ], + "engines": { + "node": ">=20" + }, + "dependencies": {}, + "devDependencies": { + "@milaboratories/build-configs": "workspace:*", + "@milaboratories/eslint-config": "workspace:*", + "@milaboratories/ts-builder": "workspace:*", + "@milaboratories/ts-configs": "workspace:*", + "@types/node": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} + + diff --git a/tools/npm-migrations/src/index.test.ts b/tools/npm-migrations/src/index.test.ts new file mode 100644 index 0000000000..55c9df7026 --- /dev/null +++ b/tools/npm-migrations/src/index.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, beforeEach, afterEach, test } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { Migrator } from './index'; + +let tempDir: string; + +describe('Apply migrations', () => { + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'npm-migrations-')); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + test('zero migrations', async () => { + const pkgPath = path.join(tempDir, 'package.json'); + const pkgName = '@pkg/example'; + + await fs.writeFile(pkgPath, `{ + "name": "test", + "version": "1.0.0" +} +`); + + const migrator = new Migrator(pkgName, { projectRoot: tempDir }); + await migrator.applyMigrations(); + const updated = await fs.readFile(pkgPath, 'utf8'); + expect(updated).toBe(`{ + "name": "test", + "version": "1.0.0", + "migrations": { + "@pkg/example": 0 + } +} +`); + }) + + test('version rollback', async () => { + const pkgPath = path.join(tempDir, 'package.json'); + const pkgName = '@pkg/example'; + + const pkgText = `{ + "name": "test", + "version": "1.0.0", + "migrations": { + "@pkg/example": 10 + } +} +` + + await fs.writeFile(pkgPath, pkgText); + + const migrator = new Migrator(pkgName, { projectRoot: tempDir }); + migrator.addMigration(() => { + throw new Error('test'); + }); + await migrator.applyMigrations(); + const updated = await fs.readFile(pkgPath, 'utf8'); + expect(updated).toBe(pkgText); + }) + + test.for([ + { + name: 'no package migrations', + initialText: `{ + "name": "test", + "version": "1.0.0", + "migrations": { + "@pkg/example-1": 1 + } +} +`, + expectMigrations: [], + expectText: `{ + "name": "test", + "version": "1.0.0", + "migrations": { + "@pkg/example-1": 1, + "@pkg/example": 3 + } +} +`, + }, + { + name: 'no migrations entry', + initialText: `{ + "name": "test", + "version": "1.0.0" +}`, + expectMigrations: [], + expectText: `{ + "name": "test", + "version": "1.0.0", + "migrations": { + "@pkg/example": 3 + } +} +`, + } + ])('new package installation ($name)', async ({ initialText, expectText, expectMigrations }) => { + const pkgPath = path.join(tempDir, 'package.json'); + const pkgName = '@pkg/example'; + + await fs.writeFile(pkgPath, initialText); + + let called: number[] = []; + const migration = (i: number) => { return () => {called.push(i);} }; + + const migrator = new Migrator(pkgName, { projectRoot: tempDir }); + migrator.addMigration(migration(0), migration(1), migration(2)); // 3 migrations + await migrator.applyMigrations(); + + const updated = await fs.readFile(pkgPath, 'utf8'); + expect(updated).toBe(expectText); + expect(called).toStrictEqual(expectMigrations); + }); + + test('on first install apply all', async () => { + const pkgPath = path.join(tempDir, 'package.json'); + const pkgName = '@pkg/example'; + + await fs.writeFile(pkgPath, `{ + "name": "test", + "version": "1.0.0" +} +`); + + let called: number[] = []; + const migration = (i: number) => { return () => {called.push(i);} }; + + const migrator = new Migrator(pkgName, { projectRoot: tempDir, onFirstInstall: 'apply-all' }); + migrator.addMigration(migration(0), migration(1), migration(2)); + await migrator.applyMigrations(); + + const updated = await fs.readFile(pkgPath, 'utf8'); + expect(updated).toBe(`{ + "name": "test", + "version": "1.0.0", + "migrations": { + "@pkg/example": 3 + } +} +`); + expect(called).toStrictEqual([0,1,2]); + }) + + test.for([ + { + name: 'ends with bracket', + initialText: `{ + "name": "test", "version": "1.0.0", + "migrations": { + "@pkg/example" : 1 } +} +`, + expectMigrations: [1,2], + expectText: `{ + "name": "test", "version": "1.0.0", + "migrations": { + "@pkg/example" : 3 } +} +`, + }, + { + name: 'ends with newline', + initialText: `{ + "name": "test", "version": "1.0.0", + "migrations": { + "@pkg/example" : 0 + } +} +`, + expectMigrations: [0,1,2], + expectText: `{ + "name": "test", "version": "1.0.0", + "migrations": { + "@pkg/example" : 3 + } +} +`, + }, + { + name: 'ends with comma', + initialText: `{ + "name": "test", "version": "1.0.0", + "migrations": { + "@pkg/example" : 2, + "@pkg/example-2": 4 + } +} +`, + expectMigrations: [2], + expectText: `{ + "name": "test", "version": "1.0.0", + "migrations": { + "@pkg/example" : 3, + "@pkg/example-2": 4 + } +} +`, + }, + ])('preserve formatting ($name)', async ({ initialText, expectText, expectMigrations }) => { + const pkgPath = path.join(tempDir, 'package.json'); + const pkgName = '@pkg/example'; + + await fs.writeFile(pkgPath, initialText); + + let called: number[] = []; + const migration = (i: number) => { return () => {called.push(i);} }; + + const migrator = new Migrator(pkgName, { projectRoot: tempDir }); + migrator.addMigration(migration(0), migration(1), migration(2)); + await migrator.applyMigrations(); + + const updated = await fs.readFile(pkgPath, 'utf8'); + expect(updated).toBe(expectText); + expect(called).toStrictEqual(expectMigrations); + }); +}); + + diff --git a/tools/npm-migrations/src/index.ts b/tools/npm-migrations/src/index.ts new file mode 100644 index 0000000000..309654a993 --- /dev/null +++ b/tools/npm-migrations/src/index.ts @@ -0,0 +1,133 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export type Migration = () => void | Promise; + +export class Migrator { + private readonly packageName: string; + private readonly projectRoot: string; + private readonly onFirstInstall: 'apply-all' | 'skip-all'; + private migrations: Migration[] = []; + + constructor(packageName: string, opts?: { + projectRoot?: string; + onFirstInstall?: 'apply-all' | 'skip-all'; + }) { + this.packageName = packageName; + this.projectRoot = opts?.projectRoot ?? (process.env.INIT_CWD || process.cwd()); + this.onFirstInstall = opts?.onFirstInstall ?? 'skip-all'; + } + + public addMigration(...migrations: Migration[]): void { + this.migrations.push(...migrations); + } + + public async applyMigrations(): Promise { + const pkg = this.readPackageJsonObj(); + pkg.migrations = pkg.migrations ?? {}; + + const pkgMigrationValue = pkg.migrations[this.packageName]; + const nextToApply: number | null = Number.isInteger(pkgMigrationValue) ? pkgMigrationValue : null; + + // If no record: set to latest immediately, do NOT run migrations + if (nextToApply === null && this.onFirstInstall === 'skip-all') { + pkg.migrations[this.packageName] = this.migrations.length; + this.writePackageJsonObj(pkg); + return; + } + + // Apply pending migrations one-by-one + let toApply = nextToApply ?? 0; + while (toApply < this.migrations.length) { + const migration = this.migrations[toApply]; + await migration?.(); + + const oldID = toApply; + const newID = toApply + 1; + toApply++; + + pkg.migrations[this.packageName] = newID; + + // Update version preserving formatting if possible + if (this.updateMigrationVersion(oldID, newID)) { + continue; + } + this.writePackageJsonObj(pkg); + } + } + + private readPackageJson(): string { + const p = path.resolve(this.projectRoot, 'package.json'); + return fs.readFileSync(p, 'utf8'); + } + + private writePackageJson(text: string): void { + const p = path.resolve(this.projectRoot, 'package.json'); + fs.writeFileSync(p, text, 'utf8'); + } + + private readPackageJsonObj(): { migrations?: Record } { + const txt = this.readPackageJson(); + const obj: unknown = JSON.parse(txt); + return (obj && typeof obj === 'object' ? (obj as { migrations?: Record }) : { }) as { + migrations?: Record; + }; + } + + private writePackageJsonObj(obj: { migrations?: Record }): void { + const txt = JSON.stringify(obj, null, 2) + '\n'; + this.writePackageJson(txt); + } + + /** + * @param v - last applied migration + * @returns true if package migration string was found in package.json + */ + private updateMigrationVersion(from: number, to: number): boolean { + const text = this.readPackageJson(); + const newline = text.includes('\r\n') ? '\r\n' : '\n'; + + const migBlockRe = /("migrations"\s*:\s*\{)([\s\S]*?)(\n*\s*\})/s; + const m = text.match(migBlockRe); + if (!m || m.index == null) return false; + + const blockStart = m[1]; + const inner = m[2]; + const blockEnd = m[3]; + + const pkgKey = `"${this.packageName}"`; + + const innerLines = inner.split(newline); + + let found = false; + for (const [i, line] of innerLines.entries()) { + if (!line.includes(pkgKey)) { + continue; + } + + found = true; + + const keyPos = line.indexOf(pkgKey); + const colonPos = line.indexOf(':', keyPos + pkgKey.length); + if (colonPos === -1) { + return false; + } + + const keyPart = line.slice(0, colonPos + 1); + const valuePart = line.slice(colonPos + 1); + const newValuePart = valuePart.replace(new RegExp(`${from}(\\s*[},]|$)`), `${to}$1`); + + innerLines[i] = keyPart + newValuePart; + } + + const newInner = innerLines.join(newline); + if (newInner === inner) return found; + + const before = text.slice(0, m.index); + const after = text.slice(m.index + m[0].length); + const updated = before + blockStart + newInner + blockEnd + after; + this.writePackageJson(updated); + + return found; + } +} diff --git a/tools/npm-migrations/tsconfig.json b/tools/npm-migrations/tsconfig.json new file mode 100644 index 0000000000..595e4f165f --- /dev/null +++ b/tools/npm-migrations/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@milaboratories/ts-configs/tsconfig.node.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} + + diff --git a/tools/npm-migrations/vitest.config.mts b/tools/npm-migrations/vitest.config.mts new file mode 100644 index 0000000000..be88b5e41a --- /dev/null +++ b/tools/npm-migrations/vitest.config.mts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + watch: false, + testTimeout: 10000, + maxConcurrency: 1, + include: ['src/**/*.test.ts'], + } +}); + +