@trackunit/migrations
v0.2.76
Published
Coordinator library that upgrades every `@trackunit/*` dependency to its latest published version and runs pending migrations.
Keywords
Readme
@trackunit/migrations
Coordinator library that upgrades every @trackunit/* dependency to its latest published version and runs pending migrations.
How it works
migrate does two independent things, in this order:
- Upgrade dependencies. Every
@trackunit/*entry in the consumer'sdependenciesanddevDependenciesis moved to its latest published version, resolved from the npm registry. This is unconditional -- a package that ships no codemods still gets bumped -- and the range operator the consumer chose is preserved, so^1.2.3becomes^2.0.0rather than a pin. - Collect migrations. Each published
@trackunit/*package can declare a"migrations"field in itspackage.jsonpointing to amigrations.jsonmanifest. The library recursively scansnode_modules(including nested/transitive dependencies) to find those packages and queues every migration in their manifests that has not already run.
A migration has an identity, not a version
Nothing in the run compares versions to decide whether a migration applies. A migration has run or it has not, and trackunit-migrations.json records the ones that have, by package:name:
{
"appliedMigrations": ["@trackunit/react-components:v2-0-0-tag-add-removetaglabel"],
"migrations": []
}The manifest that gets read ships inside the version that is installed, so what it declares applies to that version by construction — there is nothing left for a version comparison to decide. This is the same model Flyway and Alembic use, and it removes a whole class of failure that a hand-written version field could not avoid: a tag one release ahead of reality silently withholds a codemod forever, and a tag one release behind silently rewrites source against an API that does not exist yet. Both happened in this repo before the field was removed.
The obligation that replaces it sits with whoever publishes a codemod: a migration must ship in the same release as the API change it migrates, and never earlier. Publishing it early puts it in the manifest of a version it does not apply to, which is the one way this model can still go wrong.
Names are opaque identifiers. The v2-0-0- prefixes are a readability convention matching the directory layout, not something the tool parses. Renaming an entry makes it a different migration and it will run again, so don't.
A manifest entry does still carry a version, and it does exactly one job: it orders a package's codemods, oldest change first, so several codemods touching the same code apply in the sequence they were written for rather than in whatever order the JSON keys happen to sit. It is never a filter. An entry with no version, or one semver cannot read, keeps its manifest position — authoring order is then the best signal available, and a malformed field must not silently reorder anything.
The distinction matters because conflating the two is what the old model got wrong: one field answered both "has this run" and "does this apply", so a tag that was wrong in either direction broke the other question too.
The version bump reads the registry rather than node_modules on purpose. Migrations only exist inside published packages, so gating the bump on discovering a migrations field means a workspace far enough behind can never upgrade: its installed packages predate the field, nothing is discovered, and nothing is bumped.
"Latest published version" means the latest your Node can actually run. Resolution applies the engines.node filter, so a package whose newest release requires a Node you do not have resolves to the newest release you can run instead. That is correct, but silently stopping short reads as the tool failing to upgrade, so the run says when it happened:
@trackunit/react-components: stopped at 1.10.61; 2.10.21 requires Node >=24.x and this run is on Node 22.14.0The version named is the one the latest dist-tag points at, because that is what resolution would have picked. A higher release the tag does not point at -- a hotfix from a maintenance branch, or anything deliberately untagged -- was never a candidate on any Node version, so blaming it for the cap would be untrue.
Only a version held back by an unsatisfied engines.node is reported. A newer version passed over for any other reason is not an engine cap, a malformed engines range is treated as "cannot tell" rather than "blocked", and prereleases are never named as the version you are missing. The cap is reported, never worked around: which version gets picked is npm-pick-manifest's decision and it is already the right one.
A package that cannot be resolved is reported and left alone, and the summary says how many were skipped so a partial upgrade is never mistaken for a complete one. If no package resolves, migrate fails instead of reporting success -- that is an environment problem (no network, a proxying or private registry, an expired token), and treating it as "already up to date" would recreate the silent no-op this generator exists to remove.
A package declared in both dependencies and devDependencies is evaluated once per field against that field's own specifier, so a workspace:* devDependency survives an upgrade of the dependencies entry beside it. The registry is still queried once per package name.
One run does the whole job. Migrations live inside published packages, so they are only discoverable once the new versions are on disk — which means discovery has to happen after the install, not before it. When versions change, migrate upgrades package.json, installs, and only then discovers and queues; when nothing needs upgrading there is nothing to install and it all happens in one pass. Either way migrate stops at queueing, so you still review trackunit-migrations.json before run-migrations touches any source.
Two consequences of writing the file after Nx has flushed and locked its file tree. Nx reports UPDATE package.json but not the migrations file, and --dry-run reports the package.json change without discovering migrations, because the install it would need never runs. Nx already warns that a dry run changed nothing.
If the install fails, the run fails. package.json keeps the upgraded versions, because that part succeeded, and no migrations file is written — exiting cleanly there would report a workspace as migrated when discovery never ran. The error names the install command and the re-run.
--skipInstall is the one case that cannot finish in a single run: you have taken responsibility for the install, so the migrations shipped in the new versions are not visible yet. That run says so, writes no migrations file, and names both remaining steps.
Inside the manager monorepo the registry step is skipped -- @trackunit/* libs are workspace projects whose versions are owned by CI -- and migrations are discovered from libs/ and apps/ instead. Collection is otherwise identical; an author can run the codemod they just wrote without waiting for a release.
Then the second generator applies them:
run-migrations-- executes the pending migrations fromtrackunit-migrations.json, records each one that succeeded inappliedMigrations, and clears the list
Only run-migrations records anything. migrate proposes work and carries the applied set through untouched, so a codemod that failed, or that you never ran, is offered again next time.
run-migrations also enforces the record it keeps: an entry whose package:name is already in appliedMigrations is skipped and dropped from the pending list, and an identity listed twice runs once. A queue can name the same codemod twice after a merge, a hand-edit, or the legacy-format fallback that re-queues everything, and re-running is not harmless in general -- a rename that also rewrites an import list, or anything that appends, corrupts source on the second pass, and migrate would not offer it again afterwards to say so.
Both generators treat the workspace root as the root of Nx's file tree, never the directory nx happened to be invoked from. That one root decides where node_modules is scanned, what implementation paths are stored relative to, and where trackunit-migrations.json is read and written. Splitting them would be worse than picking the wrong one, because the halves then disagree while the run still reports success: invoked from apps/foo, discovery would scan apps/foo/node_modules, queue nothing, and write an empty-looking file at the root.
Upgrading from the old format
An earlier version of this file tracked a migratedVersions high-water mark per package. That cannot be translated into identities — it records how far a package got, never which codemods got it there. A workspace still on the old format gets one warning and every migration queued again. Review the list before running it; a codemod whose change is already in place matches nothing and is a no-op.
flowchart TD
subgraph step1 [Step 1: nx g @trackunit/migrations:migrate]
scan["Recursively scan node_modules\nfor @trackunit/* packages"]
scan --> checkField{"package.json\nhas migrations field?"}
checkField -->|No| skipPkg[Skip package]
checkField -->|Yes| loadManifest["Load migrations.json"]
loadManifest --> readState["Read trackunit-migrations.json\nfor appliedMigrations"]
readState --> filterApplied{"package:name already\nin appliedMigrations?"}
filterApplied -->|Yes| skipMigration[Skip migration]
filterApplied -->|No| collect["Add to pending list"]
end
subgraph updatePkg [Version Updates: every @trackunit/* dependency]
listDeps["List @trackunit/* in\nconsumer dependencies +\ndevDependencies"]
listDeps --> resolveLatest["Resolve latest version\nfrom npm registry"]
resolveLatest --> compareDeps{"Latest >\npackage.json version?"}
compareDeps -->|Yes| bumpDep["Rewrite version in\nconsumer package.json,\nkeeping the range operator"]
compareDeps -->|No| noop[No change]
bumpDep --> reinstall["Install, then discover\nin the same run --\nmigrations ship inside\nthe new versions"]
end
collect --> writeFile["Write trackunit-migrations.json\nwith pending migrations +\nunchanged appliedMigrations"]
subgraph step2 [Step 2: nx g @trackunit/migrations:run-migrations]
readFile["Read trackunit-migrations.json"] --> loop["For each pending migration"]
loop --> importImpl["Import migration implementation"]
importImpl --> execute["Execute migration against NX Tree\n(AST transforms, file rewrites)"]
execute --> recordApplied["Record package:name in\nappliedMigrations on success,\nkeep failures pending"]
end
writeFile --> review["Developer reviews\ntrackunit-migrations.json"]
review --> readFileflowchart LR
subgraph nodeModules ["node_modules (recursive scan, for migrations only)"]
rc["@trackunit/react-components\nv2.0.0\nmigrations: ./migrations.json"]
rcc["@trackunit/react-chart-components\nv1.20.0\nmigrations: ./migrations.json"]
su["@trackunit/shared-utils\nv1.13.0\n(no migrations field --\nstill version-bumped)"]
nested["some-lib/node_modules/\n@trackunit/react-components\nv1.21.8 (older, deduped out)"]
end
subgraph migrationsFiles [migrations.json per package]
rcMig["react-components/migrations.json\n- rename-button-kind\n- button-required-variant"]
rccMig["react-chart-components/migrations.json\n- chart-api-v2"]
end
subgraph output [trackunit-migrations.json]
pending["migrations:\n- rename-button-kind\n- button-required-variant\n- chart-api-v2\n\nappliedMigrations:\n (filled in by run-migrations)"]
end
rc --> rcMig
rcc --> rccMig
su -.->|skipped| nodeModules
nested -.->|deduped| rc
rcMig --> pending
rccMig --> pendingWriting classname-aware migrations
If a migration needs to detect or rewrite Tailwind/classname strings in source files (e.g. renaming or removing a utility class), don't reimplement detection logic — import @trackunit/css-classname-utils (libs/css/classname-utils) directly. It has no eslint/@typescript-eslint/utils runtime dependency, so it's safe to use outside an ESLint rule context, and it's already the basis for the equivalent ESLint rules in plugin-trackunit. See the "Shared classname utilities" section of the Tailwind architecture doc for what it exposes, notably replaceClassToken() for word-boundary-safe token replace/remove.
Usage
Step 1: Discover and collect migrations
nx g @trackunit/migrations:migrateThis moves every @trackunit/* dependency in your package.json to its latest published version and writes a trackunit-migrations.json file listing any pending migrations. Review the file before proceeding.
If any version changed, the generator installs the new packages and discovers migrations in the same run -- migrations ship inside the packages themselves, so they are only inspectable once installed. Add --skipInstall to run the install yourself, and --verbose to see the per-package decisions, including the dependencies that were left alone.
Step 2: Run pending migrations
nx g @trackunit/migrations:run-migrationsThis executes each migration listed in trackunit-migrations.json, records the ones that succeeded, and clears the pending list. Commit the file: it is what stops those migrations being offered again. Deleting it means every migration runs again on the next migrate.
Internal monorepo
yarn nx g @trackunit/migrations:migrate
yarn nx g @trackunit/migrations:run-migrationsFor more info and a full guide on Iris App SDK Development, please visit our Developer Hub.
Trackunit
This package was developed by Trackunit ApS.
Trackunit is the leading SaaS-based IoT solution for the construction industry, offering an ecosystem of hardware, fleet management software & telematics.
