npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@pasblin/versioned-json

v0.1.0

Published

Framework-agnostic lifecycle toolkit for versioned JSON: migrations, deprecations, retirement and pluggable validators.

Readme

@pasblin/versioned-json

CI npm version License: MIT

Framework-agnostic lifecycle toolkit for versioned JSON: migrations, deprecations, retirement and pluggable validators.

Works in any JavaScript / TypeScript project: Angular, React, Vue, Svelte, Node.js, Deno, Bun, etc.

What problem does it solve?

Long-lived JSON documents change shape over time. Once a doc is in production you cannot just rename a field, drop a column or tighten a type without breaking every document already stored somewhere. versioned-json gives you a small, opinionated toolkit to manage that lifecycle:

  • Per-version schemas: describe what each historical version looks like.
  • Pure forward migrations: v(n) → v(n+1), no skipping, enforced at registry build time.
  • One typed result: the public API always returns the latest version type, regardless of how old the input was.
  • Errors vs warnings: validation produces structured ValidationIssues, not exceptions, so consumers can render them in UIs.
  • Deprecations as data: mark a field deprecated on any schema; the registry walks both the source document (using the source schema's deprecations) and the migrated latest document (using the latest schema's deprecations), so warnings fire even for fields that the migration chain removes or renames before reaching the latest shape.
  • Lifecycle hooks for legacy retirement: bump minSupportedVersion to soft-retire old versions before deleting their migrations.

Project policy

This library enforces (or makes it easy to enforce) the following rules:

  1. Never remove fields directly — deprecate them first.
  2. Never change the meaning of an existing field — add a new one and deprecate the old.
  3. New fields always ship with an explicit default, applied by a migration.
  4. Every new version requires a migration from the previous one — missing steps fail at registry build time.
  5. The internal model is always the latest version — callers never see intermediate shapes.
  6. Validators distinguish error from warningseverity: 'error' blocks the result, severity: 'warning' does not.
  7. Tests include real fixtures of old versions — see src/__tests__/recipe/fixtures/.

Install

npm install @pasblin/versioned-json
# To use the optional Zod adapter:
npm install @pasblin/versioned-json zod

Quick start

import { z } from 'zod';
import { createRegistry, defineMigration, defineSchema } from '@pasblin/versioned-json';
import { zodAdapter } from '@pasblin/versioned-json/zod';

// 1. Describe each historical shape.
const DocV1 = z.object({ version: z.literal(1), title: z.string() });
const DocV2 = DocV1.extend({ version: z.literal(2), tags: z.array(z.string()).default([]) });

const schemaV1 = defineSchema({ version: 1, validator: zodAdapter(DocV1) });
const schemaV2 = defineSchema({
  version: 2,
  validator: zodAdapter(DocV2),
  deprecated: [{ path: 'title', sinceVersion: 2, replacement: 'name' }],
});

// 2. Migrate v(n) → v(n+1) with explicit defaults.
const m1to2 = defineMigration({
  from: 1,
  to: 2,
  up: (doc) => ({ ...doc, version: 2 as const, tags: [] }),
});

// 3. Build the registry once.
const registry = createRegistry({
  schemas: [schemaV1, schemaV2],
  migrations: [m1to2],
  latest: schemaV2,
});

// 4. Use it on arbitrary input.
const result = registry.process({ version: 1, title: 'hello' });

if (result.ok) {
  result.data; // typed as DocV2
  result.warnings; // deprecation notices, etc.
  result.meta; // { detectedVersion, targetVersion, appliedMigrations }
} else {
  result.errors; // ValidationIssue[] with severity 'error'
}

Registry options

createRegistry accepts a small but important set of options. The defaults cover the simplest case ({ version: 1 } at the top of every document); the rest are essential for real adoption.

| Option | Type | Default | When to use | | --------------------- | ---------------------------------- | ------------------- | ---------------------------------------------------------------------------- | | schemas | Schema[] | — | Required. Every historical version, in any order. | | migrations | Migration[] | — | Required. One per v(n) → v(n+1) step. | | latest | Schema<V, TLatest> | — | Required. Pinpoints the latest schema and lets TS infer the output type. | | versionField | string | 'version' | Override when the version key is not the root version field. | | resolveVersion | (input) => V \| undefined | none | Pluggable detection strategy when the version is not at the root. | | comparator | VersionComparator<V> | integerVersion… | Use lexicographicVersionComparator for string versions like '1.0.2'. | | minSupportedVersion | V | smallest registered | Soft-retire old versions; documents below this bound are rejected. | | assumeVersion | V | none | Treat documents missing the version field as this version (legacy adoption). | | strictSource | boolean | true | When false, skip validation against the source schema before migrating. | | onMigration | (step: AppliedMigration) => void | none | Observability hook fired once per applied migration step. | | onDeprecation | (issue: ValidationIssue) => void | none | Observability hook fired once per deprecation warning emitted. |

Adopting on existing data (no version field yet)

When you bolt versioning onto a system that already has JSON in production, the legacy documents do not carry your new version field. Two flags make this bearable:

  • versionField lets you pick a name without colliding with existing domain keys (e.g. '_schemaVersion' to avoid clashing with a business 'version' string already in use).
  • assumeVersion tells the registry what to do when the field is missing. Set it to the current latest so legacy documents pass through unchanged; every new export writes the field explicitly going forward.
export const registry = createRegistry({
  schemas: [schemaV1, schemaV2, schemaV3, schemaV4],
  migrations: [m1to2, m2to3, m3to4],
  latest: schemaV4,
  versionField: '_schemaVersion', // dedicated key, not your business 'version'
  assumeVersion: 4, // pre-existing JSONs are treated as v4
  strictSource: true, // catch malformed legacy shapes early
});

Bump assumeVersion every time you raise latest. New exports must always write the field explicitly; assumeVersion is only a safety net for data that predates the field. The write side is a one-liner on top of the document:

// Whenever your code emits a new document, stamp the latest schema version.
const doc = {
  _schemaVersion: 4,
  // ...your domain payload
};

fs.writeFileSync('out.json', JSON.stringify(doc, null, 2));

Custom version detection

When the version is not at the root or follows a non-trivial encoding — nested under meta.schemaVersion, encoded in a $schema URL, derived from the document content, or supplied by an external source (filename, HTTP header) — pass a resolveVersion(input) function. It overrides versionField and gives you full control:

createRegistry({
  schemas: [schemaV1, schemaV2, schemaV3, schemaV4],
  migrations: [m1to2, m2to3, m3to4],
  latest: schemaV4,
  resolveVersion: (input) => {
    if (typeof input !== 'object' || input === null) return undefined;
    const obj = input as { meta?: { schemaVersion?: number } };
    return obj.meta?.schemaVersion;
  },
  assumeVersion: 4, // applies when resolveVersion returns undefined
});

Semantics:

  • Return any version value: the registry validates it through the comparator and rejects unsupported values with UNKNOWN_VERSION.
  • Return undefined: the registry falls back to assumeVersion if configured, otherwise emits MISSING_VERSION.
  • The function must be pure (same input ⇒ same output, no side effects); thrown errors propagate.

Observability hooks

Two optional hooks let you stream pipeline events into your logger, metrics, or telemetry without iterating result.warnings and result.meta.appliedMigrations yourself:

import type { AppliedMigration, ValidationIssue } from '@pasblin/versioned-json';

createRegistry({
  schemas: [schemaV1, schemaV2, schemaV3, schemaV4],
  migrations: [m1to2, m2to3, m3to4],
  latest: schemaV4,
  onMigration: (step: AppliedMigration) => {
    metrics.increment('versioned_json.migration', { from: step.from, to: step.to });
  },
  onDeprecation: (issue: ValidationIssue) => {
    logger.warn('[deprecation]', issue.code, issue.path, issue.message);
  },
});

Semantics:

  • onMigration fires once per applied step, in pipeline order, only after the full migration succeeds. It is not called when the source document is already at latest.
  • onDeprecation fires once per warning emitted by either the source schema (during the source-document walk) or the latest schema (during the post-migration walk).
  • Hooks must not throw; thrown errors propagate and abort process(...). Wrap with try/catch in user code if you need at-most-once-delivery semantics.

Validators without Zod

zodAdapter is convenient but optional. Any function that returns a ValidationResult works via fromValidateFn. Useful when you want zero runtime dependencies, custom error codes, or to wrap an existing JSON schema engine.

This is the Quick start, rewritten without Zod — same shape, same behaviour:

import {
  createRegistry,
  defineMigration,
  defineSchema,
  fromValidateFn,
} from '@pasblin/versioned-json';

interface DocV1 {
  version: 1;
  title: string;
}
interface DocV2 {
  version: 2;
  title: string;
  tags: string[];
}

// 1. Hand-rolled validators — each returns ValidationResult<T>.
const validateV1 = fromValidateFn<DocV1>((input) => {
  if (typeof input !== 'object' || input === null) {
    return {
      ok: false,
      errors: [{ severity: 'error', code: 'NOT_OBJECT', message: 'Expected object', path: '' }],
      warnings: [],
    };
  }
  const obj = input as Record<string, unknown>;
  if (obj.version !== 1 || typeof obj.title !== 'string') {
    return {
      ok: false,
      errors: [{ severity: 'error', code: 'INVALID_DOC_V1', message: 'invalid v1 doc', path: '' }],
      warnings: [],
    };
  }
  return { ok: true, data: { version: 1, title: obj.title }, warnings: [] };
});

const validateV2 = fromValidateFn<DocV2>((input) => {
  const obj = input as Record<string, unknown>;
  if (obj?.version !== 2 || typeof obj.title !== 'string' || !Array.isArray(obj.tags)) {
    return {
      ok: false,
      errors: [{ severity: 'error', code: 'INVALID_DOC_V2', message: 'invalid v2 doc', path: '' }],
      warnings: [],
    };
  }
  return {
    ok: true,
    data: { version: 2, title: obj.title, tags: obj.tags as string[] },
    warnings: [],
  };
});

// 2. Schemas + migration + registry — identical to the Zod Quick start.
const schemaV1 = defineSchema({ version: 1, validator: validateV1 });
const schemaV2 = defineSchema({
  version: 2,
  validator: validateV2,
  deprecated: [{ path: 'title', sinceVersion: 2, replacement: 'name' }],
});

const m1to2 = defineMigration({
  from: 1,
  to: 2,
  up: (doc) => ({ ...doc, version: 2 as const, tags: [] }),
});

const registry = createRegistry({
  schemas: [schemaV1, schemaV2],
  migrations: [m1to2],
  latest: schemaV2,
});

// 3. Use it.
const result = registry.process({ version: 1, title: 'hello' });
if (result.ok) {
  console.log(result.data); // { version: 2, title: 'hello', tags: [] }
}

Deprecation path syntax

Deprecation paths describe where in a document a deprecated value can live. The grammar is intentionally tiny:

| Pattern | Matches | | --------------------------- | ----------------------------------------------------------------- | | title | The title key at the root. | | meta.author.name | A nested object key, dot-separated. | | tags[0] | The first element of the tags array. | | items[*].sub.flag | The sub.flag key on every element of items. | | parents[*].children[*].id | A wildcard at every array level — most useful for legacy renames. |

No other operators are supported (no slices, no regex, no conditional selectors). If you need them, build the deprecation list dynamically before passing it to defineSchema.

What a deprecation warning looks like at runtime

Given this declaration on schemaV3:

const schemaV3 = defineSchema({
  version: 3,
  validator: validateV3,
  deprecated: [
    {
      path: 'parents[*].children[*].oldId',
      sinceVersion: 3,
      plannedRemovalVersion: 5,
      replacement: 'parents[*].children[*].id',
      reason: 'renamed for clarity',
    },
  ],
});

Processing an input that contains parents[0].children[1].oldId: 'x' yields one warning per concrete hit (wildcards are expanded against the actual data):

result.warnings;
// [
//   {
//     severity: 'warning',
//     code: 'DEPRECATED_FIELD',
//     path: 'parents[0].children[1].oldId',
//     message:
//       'Field "parents[0].children[1].oldId" is deprecated since version 3 ' +
//       'and is scheduled for removal in version 5; ' +
//       'use "parents[*].children[*].id" instead (renamed for clarity).',
//     meta: {
//       declaredPath: 'parents[*].children[*].oldId',
//       sinceVersion: 3,
//       plannedRemovalVersion: 5,
//       replacement: 'parents[*].children[*].id',
//       reason: 'renamed for clarity',
//     },
//   },
// ]

Deprecation warnings never block: result.ok stays true and result.data holds the migrated document. Use them to drive logging, telemetry or UI hints.

Real-world example: a recipe document family

The library's own integration test ( src/__tests__/recipe.integration.test.ts ) walks a realistic 4-version family. The shape below is condensed but real — it is the exact pipeline the test runs against fixtures v1.jsonv4.json.

import { readFileSync } from 'node:fs';
import { z } from 'zod';
import { createRegistry, defineMigration, defineSchema, ErrorCode } from '@pasblin/versioned-json';
import { zodAdapter } from '@pasblin/versioned-json/zod';

// --- Per-version Zod shapes (each version extends the previous one) -------
const StepV1 = z.object({
  timer: z.object({ active: z.boolean() }),
  tags: z.array(z.string()),
});
const RecipeV1 = z.object({
  version: z.literal(1),
  title: z.string(),
  cuisine: z.string(),
  year: z.number().int(),
  type: z.enum(['Main', 'Side']),
  steps: z.array(StepV1),
});
const RecipeV2 = RecipeV1.extend({ version: z.literal(2), notes: z.string().default('NONE') });
const StepV3 = StepV1.extend({
  timing: z.object({
    method: z.string(),
    minMinutes: z.number().int().positive().optional(),
    maxMinutes: z.number().int().positive().optional(),
  }),
});
const RecipeV3 = RecipeV2.extend({
  version: z.literal(3),
  cookbook: z.string().default('home'),
  steps: z.array(StepV3),
});
const StepV4 = StepV3.extend({ category: z.string() });
const RecipeV4 = RecipeV3.extend({
  version: z.literal(4),
  relatedDishes: z.array(z.string()).default([]),
  steps: z.array(StepV4),
});

// --- Schemas -------------------------------------------------------------
const schemaV1 = defineSchema({ version: 1, validator: zodAdapter(RecipeV1) });
const schemaV2 = defineSchema({ version: 2, validator: zodAdapter(RecipeV2) });
const schemaV3 = defineSchema({ version: 3, validator: zodAdapter(RecipeV3) });
const schemaV4 = defineSchema({
  version: 4,
  validator: zodAdapter(RecipeV4),
  deprecated: [
    {
      path: 'steps[*].timing.minMinutes',
      sinceVersion: 4,
      plannedRemovalVersion: 6,
      replacement: 'steps[*].timing.range.min',
      reason: 'flattened range fields will be grouped under a single object',
    },
    {
      path: 'steps[*].timing.maxMinutes',
      sinceVersion: 4,
      plannedRemovalVersion: 6,
    },
  ],
});

// --- Migrations: each one applies its own explicit defaults ---------------
const m1to2 = defineMigration({
  from: 1,
  to: 2,
  up: (doc) => ({ ...doc, version: 2 as const, notes: 'NONE' }),
});
const m2to3 = defineMigration({
  from: 2,
  to: 3,
  up: (doc) => ({
    ...doc,
    version: 3 as const,
    cookbook: 'home',
    steps: doc.steps.map((s) => ({ ...s, timing: { method: 'manual' } })),
  }),
});
const m3to4 = defineMigration({
  from: 3,
  to: 4,
  up: (doc) => ({
    ...doc,
    version: 4 as const,
    relatedDishes: [],
    steps: doc.steps.map((s) => ({ ...s, category: 'general' })),
  }),
});

export const recipeRegistry = createRegistry({
  schemas: [schemaV1, schemaV2, schemaV3, schemaV4],
  migrations: [m1to2, m2to3, m3to4],
  latest: schemaV4,
});

// --- Use it ---------------------------------------------------------------
const legacyV1 = JSON.parse(readFileSync('./legacy-v1.json', 'utf-8')) as unknown;
const result = recipeRegistry.process(legacyV1);

if (!result.ok) {
  if (result.errors[0]?.code === ErrorCode.UnsupportedLegacyVersion) {
    // The version was registered but soft-retired with `minSupportedVersion`.
    console.warn('Document refers to a retired schema version, refusing.');
  }
  throw new Error(`Cannot upgrade document: ${result.errors[0]?.code}`);
}

result.data; // typed as RecipeV4 — latest, fully validated
result.meta.appliedMigrations; // [{from:1,to:2}, {from:2,to:3}, {from:3,to:4}]
result.warnings; // any DEPRECATED_FIELD hits found in the source

The corresponding integration test asserts every step of this pipeline, including deprecation warnings on v3.json and rejection of legacy v1.json when minSupportedVersion: 3 is configured.

Lifecycle of a version

[introduced] → [active] → [field-deprecated] → [legacy, minSupported bumped] → [retired]
  • Field deprecation: declare deprecated: [{ path, sinceVersion, ... }] on the schema where the field becomes obsolete. The registry emits a DEPRECATED_FIELD warning every time it is present in an input.
  • Version retirement: when telemetry says no live documents are still on v(n), bump minSupportedVersion to n+1. Documents declaring v(n) will now be rejected with UNSUPPORTED_LEGACY_VERSION instead of being migrated. After a deprecation window, delete the schema, the migration and the fixture files; keep one regression test that asserts the new error.

API surface

  • defineSchema / Schema — per-version description (validator + optional deprecations).
  • defineMigration / Migration — pure forward transformation.
  • createMigrator — chain executor used internally; exposed for advanced use cases.
  • createRegistry / Registry.process / Registry.processOrThrow — top-level orchestrator. Configurable detection (versionField, resolveVersion), retirement (minSupportedVersion), legacy adoption (assumeVersion), and observability hooks (onMigration, onDeprecation).
  • ValidatorAdapter + fromValidateFn — plug your own validator.
  • zodAdapter (sub-export @pasblin/versioned-json/zod) — ready-made adapter for Zod schemas.
  • integerVersionComparator (default), lexicographicVersionComparator, VersionComparator — pluggable ordering for version identifiers.
  • VersionedJsonError, ErrorCode, and the typed subclasses for programmatic branching.

CLI

The package ships a small versioned-json binary for one-off upgrades from the shell. It loads a built JS module exposing your Registry, processes a JSON document and writes the migrated result to stdout or a file.

versioned-json upgrade --registry ./registry.js doc.json
versioned-json upgrade --registry ./registry.js --out upgraded.json --pretty doc.json
cat doc.json | versioned-json upgrade --registry ./registry.js -

Options:

  • --registry <path> (required) — path to a built JS module exporting a Registry (default export or a named registry export).
  • --out <path> — write the migrated document to a file; defaults to stdout.
  • --pretty — pretty-print the JSON output.
  • --quiet — suppress warnings on stderr.
  • --allow-failed — exit 0 even when the document fails.
  • -h, --help — show help.

Exit codes are stable: 0 success, 1 the document failed validation or migration, 2 misuse (bad arguments, registry not loadable, etc.).

The <input> positional is a file path, or - to read JSON from stdin.

TypeScript and module formats

The package ships both ESM and CJS builds with .d.ts declarations:

import { createRegistry } from '@pasblin/versioned-json'; // ESM
const { createRegistry } = require('@pasblin/versioned-json'); // CJS

zod is declared as an optional peer dependency: only install it if you import from @pasblin/versioned-json/zod.

Development

# Install dependencies
npm install

# Run tests in watch mode
npm run test:watch

# Build the library
npm run build

# Lint & format
npm run lint
npm run format

Scripts

| Script | Description | | ------------------------- | -------------------------------------------------------------- | | build | Build ESM + CJS + d.ts with tsup | | dev | Build in watch mode | | test | Run tests once with Vitest | | test:watch | Tests in watch mode | | test:coverage | Tests with coverage | | typecheck | Type-check without emitting | | lint / lint:fix | ESLint | | format / format:check | Prettier | | changeset | Create a changeset for the next release |

Releasing

This repo uses Changesets:

  1. Run npm run changeset and describe your change.
  2. Commit and push.
  3. The Release GitHub Action opens a "Version Packages" PR.
  4. Merging that PR publishes to npm automatically.

You need to set the secret NPM_TOKEN in the repository for publishing.

License

MIT © pasblin