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

@joelouf/lifecycle-manager

v1.0.0

Published

A zero-dependency, tenant-isolated record-lifecycle engine — trash, archive, legal hold, retention, and GDPR redaction enforced as auditable state transitions over any entity.

Readme

@joelouf/lifecycle-manager

A zero-dependency, tenant-isolated record-lifecycle engine that models trash, archive, legal hold, retention, and GDPR erasure as auditable, reversible state transitions over any entity.

"Delete" is three different acts that must never share a mechanism: voiding a fact, archiving an entity, and trashing a mistake. This engine encodes that trichotomy as first-class operations and enforces the guarantees a system of record is held to — retention windows, legal hold, right-to-erasure, immutable audit, multi-tenant isolation, and optimistic concurrency. One LifecycleService serves every entity kind by reading a registry, and every side effect — storage, time, ids, audit, authorization — is a port, so the core is pure and the same engine runs against memory or a database by swapping adapters.

Features

  • Zero runtime dependencies - pure JavaScript, no transitive packages, nothing to audit but the source
  • One trichotomy, never conflated - voiding a fact, archiving an entity, and trashing a mistake are distinct operations; the engine rejects the wrong pairing rather than guessing
  • A fact is never hard-deleted - fact-bearing records are un-purgeable by construction, so purge and the retention sweep can erase a mistake without ever destroying financial history
  • Cascade with an exact cohort - trashing a parent stamps its descendants with the cascade root, and restoring the root clears precisely that set — atomically, never a child alone
  • Legal hold overrides everything - a hold on a record, or anywhere in its cascade tree, suspends void, trash, purge, redaction, and the retention sweep
  • Right-to-erasure without losing the fact - redaction scrubs declared PII on the entity and on every referencing fact's snapshot while the monetary fields stay intact
  • Strict multi-tenant isolation - every read is tenant-scoped, cross-tenant access is refused before lookup, and the retention sweep authorizes per record
  • Optimistic concurrency - both writes and hard-deletes are version-conditional, so a hold or restore landing mid-purge aborts the purge instead of being overrun
  • Tamper-evident audit - one append-only event per mutation, hash-chained from its predecessor, with a verifier that pinpoints any break
  • Ports, not ambient authority - the core reads no global state and performs no I/O; storage, clock, ids, audit, and authorization are all injected

Architecture

core/
  types.js              # Domain shapes (ManagedRecord, Deletion, LegalHold, Redaction, AuditEvent) and enums
  errors.js             # LifecycleError and the typed error codes every block maps to
  ports.js              # Dependency-inverted seams: Clock, IdGenerator, RecordStore, AuditSink, Authorizer
  registry.js           # Per-kind config: factBearing? retentionDays? piiFields? states? transitions? guards?
  authz.js              # Role-based authorization over the lifecycle operations
  retention.js          # Purge eligibility from the trash timestamp and the kind's retention window
  transitions.js        # Legal status moves: which states are active, which archived, what may follow what
  lifecycle-service.js  # The orchestrator — the only thing that mutates
adapters/
  in-memory.js          # Reference Store/Audit/Clock/Id implementations of the ports

The engine is registry-driven: one LifecycleService serves every entity kind by reading its config, so deletion is cross-cutting infrastructure, not per-model code. Persistence, time, ids, audit, and authorization are ports, so the core is pure and the same service runs against in-memory adapters (tests) or your database (production) by swapping the implementations.

Install

npm install @joelouf/lifecycle-manager

Quick Start

Construct the Service

import {
    LifecycleService,
    InMemoryRecordStore,
    InMemoryAuditSink,
    SystemClock,
    SequentialIdGenerator,
    SequentialCorrelationSource,
    createRoleAuthorizer,
    createDefaultRegistry,
    Role
} from '@joelouf/lifecycle-manager';

const store = new InMemoryRecordStore();
const audit = new InMemoryAuditSink();
const service = new LifecycleService({
    store,
    audit,
    authz: createRoleAuthorizer(),
    registry: createDefaultRegistry(),
    clock: new SystemClock(),
    ids: new SequentialIdGenerator(),
    correlation: new SequentialCorrelationSource()
});

const owner = { id: 'u1', tenantId: 't1', roles: [Role.owner] };
const ctx = { principal: owner, reason: 'tenant moved out' };

Trash a Mistake, Then Restore It

// Trashing an entity cascades: entity children are trashed, fact children are voided,
// and every descendant is stamped with the cascade root.
await service.trash({ tenantId: 't1', id: 'p1' }, ctx);

// Restoring the root clears exactly that cohort — atomically, never a child on its own.
const result = await service.restore({ tenantId: 't1', id: 'p1' }, ctx);
result.affected.length; // the whole cohort the trash removed

Preview Before You Act

import { LifecycleOp } from '@joelouf/lifecycle-manager';

// previewImpact runs the same assessment the real op does — the dry-run can never disagree.
const preview = await service.previewImpact(
    { tenantId: 't1', id: 'p1', op: LifecycleOp.purge },
    { principal: owner, reason: 'audit', stepUp: true }
);
preview.allowed;  // false if retention has not elapsed, a hold exists, or a guard objects
preview.blocks;   // the typed reasons it would be refused
preview.affected; // the cohort it would touch

Erase PII, Keep the Fact

import { REDACTED } from '@joelouf/lifecycle-manager';

// Redaction scrubs declared PII on the contact and on every referencing fact's snapshot,
// while the monetary fields of those facts stay intact.
await service.redact(
    { tenantId: 't1', id: 'c1' },
    { principal: owner, reason: 'GDPR erasure', stepUp: true }
);
// contact.data.name === REDACTED, but the transaction's amountCents is untouched.

The Trichotomy

"Delete" is three acts that must never share a mechanism.

| Act | Applies to | Means | Effect on the books | Reversible | Purgeable | |---|---|---|---|---|---| | Void | a fact (e.g. a transaction) | "this event shouldn't count" | excluded from every fold | yes (restore) | never | | Archive | an entity (property, contact, lease) | "real, but the relationship ended" | unchanged | yes (unarchive) | never | | Trash | an entity created by mistake | "this shouldn't exist" | cascades out | yes (restore, within retention) | after retention |

Deletion.mode: 'void' | 'trash' is the single discriminator; archive is the orthogonal status axis and is not a deletion at all. A fact can only be voided; an entity can only be archived or trashed; the engine refuses the wrong pairing with WRONG_DELETION_MODE.

Operations

Every mutating call on LifecycleService follows the same spine — authorize, load, tenant-check, version-check, assess (guards / hold / retention / step-up), mutate atomically, audit, return the affected cohort — and takes an ActingContext (principal, optional reason, expectedVersion, correlationId, idempotencyKey, stepUp).

| Operation | Effect | |---|---| | archive(input, ctx) | Move an entity to an archived status; never a deletion | | unarchive(input, ctx) | Return an entity to its kind's default active state | | void(input, ctx) | Exclude a fact from every fold while keeping the record | | trash(input, ctx) | Soft-delete an entity and cascade to its live descendants | | restore(input, ctx) | Un-stamp exactly the cascade cohort of a trashed root | | purge(input, ctx) | Hard-delete a trashed cohort after retention; facts survive by their snapshots | | redact(input, ctx) | Scrub declared PII on the entity and on referencing fact snapshots | | placeHold(input, ctx) / releaseHold(input, ctx) | Apply or lift a legal hold that suspends deletion and erasure | | previewImpact(input, ctx) | Dry-run the assessment for any op — blocks and cohort, no mutation | | listTrash(tenantId) | The current trash, with per-record purge eligibility | | sweepRetention(ctx) | Purge every eligible root, skipping facts, archived, and held records | | trashMany / restoreMany / purgeMany | Bulk variants returning typed multi-status results |

previewImpact and every executor call the same private assessment, so a preview can never disagree with the effect it predicts.

Threat Model & Data Governance

This engine sits on the deletion path of a system of record, where the failure modes are compliance incidents — losing audited history, erasing evidence under litigation, leaking data across tenants, or silently overwriting a concurrent decision. Each guarantee below is a control against one of those, and each is pinned by a test.

  • A financial fact is never hard-deleted. Fact-bearing kinds have no retention window and short-circuit purge eligibility, so purge and sweepRetention remove only non-fact cohort members. A mistake can be erased; the audited event it touched cannot. This is enforced by construction, not by a policy flag that can be misconfigured.
  • Erasure honors litigation hold. redact refuses when the target or any referencing fact is under hold, so identity frozen for litigation is never altered — closing a spoliation hole where erasing a contact would have scrubbed a held transaction's snapshot.
  • Right-to-erasure preserves the books. Redaction scrubs only declared PII fields, on the entity and on the frozen snapshots of facts that reference it, while monetary fields remain. The identity is crypto-shreddable; the transaction stays auditable.
  • Tenant isolation with no existence oracle. Every read is tenant-scoped; a record belonging to another tenant is refused as CROSS_TENANT before lookup, while a genuine miss in your own tenant is NOT_FOUND — so the error never reveals whether another tenant's id exists.
  • Concurrency closes the load-to-commit window. Both put and hardDelete are version-conditional (validate-then-apply), so a hold or restore that lands between a purge's assessment and its commit aborts the purge with CONFLICT rather than being overrun.
  • Irreversible operations require step-up. purge, redact, and releaseHold demand an elevated stepUp context in addition to an owner/admin role; the retention sweep self-elevates only under its system principal.
  • Least authority by default. Authorization is role-based and checked first; a member may trash, archive, restore, and void, but not purge, redact, or hold; an auditor is read-only. Force-purge that bypasses retention is owner-only and still never touches a fact or a hold.
  • Tamper-evident history. Every mutation appends one audit event carrying actor, reason, correlation id, before/after, and cohort, hash-chained from its predecessor; verifyChain() recomputes the chain and reports the first broken link.

Because the core is pure and every effect is a port, none of this depends on ambient authority: the engine reads no global state, opens no connection, and executes no input as code. Its behavior is a deterministic function of the store and context you pass in, which makes every control above reproducible and testable in isolation.

Security

Run the suite and the type-check:

npm test            # node:test (38 cases across the trichotomy, cascade, retention, hold, redaction, authz)
npm run typecheck   # tsc --checkJs (strict JSDoc verification, dev-only; not shipped)

To report a vulnerability, see SECURITY.md.

Supply Chain

  • Zero runtime dependencies — installing this package adds nothing to your dependency tree.
  • No peer dependencies — it runs anywhere Node >= 18 does.
  • Dev toolchain is type-checking only — the sole devDependencies are typescript and @types/node (which pulls undici-types): three packages, all compiler/type tooling that is never shipped and never installed by consumers. pnpm audit reports no known vulnerabilities.
  • Deny-by-default packaging — the published tarball is an explicit files allowlist, so only vetted source, declarations, and SECURITY.md ship; tests, config, and CI never leave the repository.
  • Reproducible and pinned — a committed pnpm-lock.yaml locks the dev toolchain by integrity hash, the packageManager field pins pnpm itself, and CI runs pnpm install --frozen-lockfile, the test suite, tsc --checkJs, and pnpm audit on every push.
  • Verifiable provenance — releases are published from CI with pnpm publish --provenance, attaching a signed Sigstore / SLSA build attestation that ties each tarball to this repository and commit.

License

MIT