Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/poor-parrots-jump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@platforma-sdk/npm-migrations': major
---

Initial release.
28 changes: 26 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ packages:
- tools/pl-bootstrap
- tools/eslint-config
- tools/blocks-deps-updater
- tools/npm-migrations

- sdk/model
- sdk/workflow-tengo
Expand Down
25 changes: 25 additions & 0 deletions tools/npm-migrations/README.md
Original file line number Diff line number Diff line change
@@ -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()
```
9 changes: 9 additions & 0 deletions tools/npm-migrations/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { node } from '@milaboratories/eslint-config';

/** @type {import('eslint').Linter.Config[]} */
export default [
{ ignores: ['**/*.test.ts', '**/__tests__/**', 'test/**'] },
...node,
];


34 changes: 34 additions & 0 deletions tools/npm-migrations/package.json
Original file line number Diff line number Diff line change
@@ -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:"
}
}


224 changes: 224 additions & 0 deletions tools/npm-migrations/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});


Loading