{"id":50815621,"url":"https://github.com/alexmarqs/rollbackit","last_synced_at":"2026-06-13T09:05:55.105Z","repository":{"id":363850299,"uuid":"1261854666","full_name":"alexmarqs/rollbackit","owner":"alexmarqs","description":"Automatic rollback for multi-step operations. Register an undo next to each step; if a later one throws, they unwind in reverse. Zero-dependency, type-safe.","archived":false,"fork":false,"pushed_at":"2026-06-10T15:42:37.000Z","size":274,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-10T17:15:13.212Z","etag":null,"topics":["async","cleanup","commit","compensating-transaction","error-handing","error-handling","nodejs","pnpm","rollback","saga","saga-pattern","transaction","tsdown","typesafe","typescript","undo"],"latest_commit_sha":null,"homepage":"","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/alexmarqs.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null}},"created_at":"2026-06-07T08:36:57.000Z","updated_at":"2026-06-10T15:46:29.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/alexmarqs/rollbackit","commit_stats":null,"previous_names":["alexmarqs/rollbackit"],"tags_count":2,"template":false,"template_full_name":null,"purl":"pkg:github/alexmarqs/rollbackit","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/alexmarqs%2Frollbackit","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/alexmarqs%2Frollbackit/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/alexmarqs%2Frollbackit/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/alexmarqs%2Frollbackit/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/alexmarqs","download_url":"https://codeload.github.com/alexmarqs/rollbackit/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/alexmarqs%2Frollbackit/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34278221,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-13T02:00:06.617Z","response_time":62,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["async","cleanup","commit","compensating-transaction","error-handing","error-handling","nodejs","pnpm","rollback","saga","saga-pattern","transaction","tsdown","typesafe","typescript","undo"],"created_at":"2026-06-13T09:05:54.489Z","updated_at":"2026-06-13T09:05:55.097Z","avatar_url":"https://github.com/alexmarqs.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003cdiv align=\"center\"\u003e\n\n\u003cimg src=\"https://raw.githubusercontent.com/alexmarqs/rollbackit/main/.github/assets/rollbackit.png\" width=\"220px\" align=\"center\" alt=\"rollbackit logo\" /\u003e\n\n\u003ch1 align=\"center\"\u003erollbackit\u003c/h1\u003e\n\n\u003cp align=\"center\"\u003eType-safe, zero-dependency, framework-agnostic rollback for multi-step operations in TypeScript \u0026 JavaScript.\u003cbr/\u003eRegister an undo for each step; if anything fails, they run in reverse — automatically.\u003c/p\u003e\n\n\u003cp align=\"center\"\u003e\n  \u003ca href=\"https://github.com/alexmarqs/rollbackit/actions/workflows/ci.yml\"\u003e\u003cimg src=\"https://img.shields.io/github/actions/workflow/status/alexmarqs/rollbackit/ci.yml?branch=main\u0026label=CI\" alt=\"CI\" /\u003e\u003c/a\u003e\n  \u003ca href=\"https://opensource.org/licenses/MIT\" target=\"_blank\"\u003e\u003cimg height=20 src=\"https://img.shields.io/badge/License-MIT-yellow.svg\" /\u003e\u003c/a\u003e\n  \u003ca href=\"https://www.npmjs.com/package/rollbackit\"\u003e\u003cimg src=\"https://img.shields.io/npm/v/rollbackit.svg\" alt=\"npm version\" /\u003e\u003c/a\u003e\n  \u003ca href=\"https://www.npmjs.com/package/rollbackit\"\u003e\u003cimg src=\"https://img.shields.io/badge/dependencies-0-brightgreen\" alt=\"zero dependencies\" /\u003e\u003c/a\u003e\n\u003c/p\u003e\n\n\u003c/div\u003e\n\n## Features\n\n- 🪶 **Lightweight** — tiny footprint, **zero dependencies**.\n- 🔒 **Type safe** — written in TypeScript, ships with full types.\n- ↩️ **Reverse-order undo** — compensating operations run newest-first (LIFO), the right order to unwind dependent steps.\n- 🧩 **Two ergonomic APIs** — a `withRollback` scope that cleans up for you, or a `createRollback` instance you drive by hand.\n- 🛟 **Failure-aware** — collect every rollback failure, or stop at the first; left-over operations are handed back so you can log or retry.\n- 🪢 **Progressive commit** — `commit()` seals the current batch and stays open, so independent units of work can share one flow without sharing fate.\n- 🌐 **Framework agnostic** — plain functions, no runtime lock-in. Works with any stack: Express, Fastify, Next.js, NestJS, serverless, or no framework at all.\n- 📦 **ESM \u0026 CJS** — works in both module systems, Node 18+, and the browser.\n\n## Install\n\n```bash\nnpm install rollbackit\n```\n\n```bash\npnpm add rollbackit\n```\n\n```bash\nyarn add rollbackit\n```\n\n```bash\nbun add rollbackit\n```\n\n## Contents\n\n- [Features](#features)\n- [Install](#install)\n- [Quick start](#quick-start)\n- [When to use it](#when-to-use-it)\n- [Usage](#usage)\n  - [`withRollback` (recommended)](#withrollback-recommended)\n  - [`createRollback` (manual control)](#createrollback-manual-control)\n  - [Committing early (point of no return)](#committing-early-point-of-no-return)\n  - [Batches in one flow (progressive commit)](#batches-in-one-flow-progressive-commit)\n- [API](#api)\n- [Behavior notes](#behavior-notes)\n- [FAQ](#faq-for-humans-and-ai-agents)\n- [Tech Stack](#tech-stack)\n- [Contributing](#contributing)\n- [License](#license)\n\n\n## Quick start\n\n```ts\nimport { withRollback } from \"rollbackit\";\n\nconst result = await withRollback(async (rb) =\u003e {\n  const user = await db.createUser(data);\n  rb.add(\"delete user\", () =\u003e db.deleteUser(user.id)); // undo for the step above\n\n  await sendWelcomeEmail(user); // if this throws, \"delete user\" runs, then the error re-throws\n\n  return user; // success → nothing is rolled back\n});\n```\n\nThat's the whole idea: **register an undo right after each step**. On success, undos are discarded; on failure, they run newest-first and the original error propagates.\n\n## When to use it\n\n**Use rollbackit when:**\n\n- A sequence of side effects must be all-or-nothing, but they span systems a single database transaction can't cover (DB + object storage + search index + third-party APIs).\n- You're implementing the **saga pattern** / **compensating transactions** in application code and don't want a full workflow engine.\n- You want cleanup logic to live *next to* the step it reverses, instead of in a far-away `catch`.\n\n**Reach for something else when:**\n\n- Everything happens in **one database** — use a native DB transaction; it's atomic, this isn't.\n- You only need to release local resources (file handles, sockets) — `try/finally` or `using` / `AsyncDisposableStack` may be enough.\n- You need durable, crash-surviving orchestration with retries across restarts — use a real saga/workflow engine (Temporal, AWS Step Functions, etc.). rollbackit is in-memory and lives for the duration of one process.\n\n\n\n## Usage\n\n### `withRollback` (recommended)\n\nWraps your steps in a scope (see [Quick start](#quick-start) above). If the\ncallback succeeds, the scope is committed and nothing is rolled back. If it\nthrows, the registered operations run automatically in reverse order before the\n**original error is re-thrown**. Steps with no side effect to undo simply don't\nregister an `add`.\n\nBecause the original error propagates, `withRollback` does not return the\nrollback failures. Pass `onFailures` to observe them (log, alert, metrics):\n\n```ts\nawait withRollback(\n  async (rb) =\u003e {\n    /* ... */\n  },\n  {\n    onFailures: ({ failures, pending }) =\u003e\n      logger.warn(\"rollback incomplete\", { failures, pending }),\n  },\n);\n```\n\n### `createRollback` (manual control)\n\nWhen you need to drive the lifecycle yourself:\n\n```ts\nimport { createRollback } from \"rollbackit\";\n\nconst rb = createRollback();\n\ntry {\n  const created = await db.createUser(data);\n  rb.add(\"delete user\", () =\u003e db.deleteUser(created.id));\n\n  await storage.createBucket(created.id);\n  rb.add(\"delete bucket\", () =\u003e storage.deleteBucket(created.id));\n\n  rb.commit(); // all good — keep the changes\n} catch (error) {\n  const { failures } = await rb.rollback(); // undo in reverse order\n  if (failures.length) {\n    logger.warn(\"rollback incomplete\", failures); // operations that threw while undoing\n  }\n  throw error;\n}\n```\n\n### Committing early (point of no return)\n\n`commit()` doesn't have to run at the end. Call it mid-flow at the **pivot** —\nthe step after which undoing the earlier work would be wrong (money moved, an\nevent was published, an irreversible action happened). Everything registered so\nfar is sealed; a later failure rolls *forward* (retry, alert), never back.\n\n```ts\nconst rb = createRollback();\n\ntry {\n  const order = await db.createOrder(data);\n  rb.add(\"delete order\", () =\u003e db.deleteOrder(order.id));\n\n  await inventory.reserve(order);\n  rb.add(\"release stock\", () =\u003e inventory.release(order));\n\n  // Pivot: once the card is charged, we're committed to fulfilling —\n  // rolling back the order now would be worse than the inconsistency.\n  await payment.charge(order);\n  rb.commit(); // seal everything; do not roll back from here\n\n  // Post-pivot work. If this throws, rollback() is a no-op — handle it forward.\n  await email.sendReceipt(order);\n\n  return order;\n} catch (error) {\n  await rb.rollback(); // only undoes if we threw *before* commit (before charging)\n  throw error;\n}\n```\n\n`commit()` seals everything registered *so far* and drops those undos. Work you\nregister *after* it starts a fresh batch that's still reversible — see\n[Batches in one flow](#batches-in-one-flow-progressive-commit) below.\n\n### Batches in one flow (progressive commit)\n\n`commit()` doesn't finalize the instance — it **seals the current batch** and\nstays open. Each commit draws a line: a later `rollback()` only unwinds the\noperations registered *since the last commit*. This lets independent units of\nwork share one flow without sharing fate — no nesting required.\n\n```ts\nconst rb = createRollback();\n\n// stage one — two side effects, undone together if this batch fails\nasync function stageOne() {\n  const user = await db.createUser(data);\n  rb.add(\"delete user\", () =\u003e db.deleteUser(user.id));\n\n  const bucket = await storage.createBucket(user.id);\n  rb.add(\"delete bucket\", () =\u003e storage.deleteBucket(bucket.id));\n}\n\n// stage two — an independent batch\nasync function stageTwo() {\n  const sub = await billing.subscribe(plan);\n  rb.add(\"cancel subscription\", () =\u003e billing.cancel(sub.id));\n}\n\ntry {\n  await stageOne();\n  rb.commit(); // stage one succeeded — seal it; its undos are dropped\n\n  await stageTwo(); // throws here? only stage two rolls back — stage one stays\n  rb.commit();\n} catch (error) {\n  await rb.rollback(); // unwinds only the batch in progress\n  throw error;\n}\n```\n\nThis works inside `withRollback` too — the `rb` it hands your callback is the\nsame instance, so committing mid-callback seals a batch and a later throw\nunwinds only what came after it (on success `withRollback` commits the final\nbatch for you):\n\n```ts\nawait withRollback(async (rb) =\u003e {\n  await stageOne(rb);\n  rb.commit(); // seal stage one — survives even if stage two throws\n\n  await stageTwo(rb); // throws? only stage two rolls back, then re-throws\n});\n```\n\nReach for the manual `createRollback` form over nesting `withRollback` when the\nbatches are **sequential or data-driven** (a loop, a pipeline, N stages decided\nat runtime): it keeps the flow flat and lets your control flow set the\nboundaries. The trade-off is the point: once a batch is committed it's\npermanent — `rollback()` never reaches past a `commit` line.\n\n## API\n\n### `createRollback(): Rollback`\n\nCreates a rollback instance.\n\n| Member | Type | Description |\n| --- | --- | --- |\n| `add(description, rollback, options?)` | `(string, () =\u003e Promise\u003cvoid\u003e, options?: { stopOnFailure?: boolean }) =\u003e void` | Register a rollback operation. Pass `{ stopOnFailure: true }` to halt the unwind if *this* operation's rollback throws (see below). Throws `RolledBackError` if called after `rollback` (after `commit` is fine — see below). |\n| `commit()` | `() =\u003e void` | Seal the current batch: treat the work so far as permanent and drop its undos. The instance stays open for the next batch. Safe to call multiple times. |\n| `rollback(options?)` | `(options?: RollbackOptions) =\u003e Promise\u003cRollbackResult\u003e` | Run the operations registered since the last `commit`, in reverse order, and finalize the instance. Returns the failures and any `pending` (un-run) operations. Safe to call multiple times; subsequent calls are no-ops. |\n\n`RollbackOptions`:\n\n| Option | Type | Default | Description |\n| --- | --- | --- | --- |\n| `stopOnFailure` | `boolean` | `false` | Stop at the first rollback operation that throws instead of unwinding the rest. |\n\n`RollbackResult`:\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `failures` | `readonly RollbackFailure[]` | Operations that threw while rolling back (`{ description, error }`). |\n| `pending` | `readonly RollbackOperation[]` | Operations never run because `stopOnFailure` halted early (carries the `rollback` fns, so you can log or retry them). Empty unless an early stop occurred. |\n\n### `withRollback\u003cT\u003e(fn, options?): Promise\u003cT\u003e`\n\nRuns `fn(rollback)` within a scope: commits on success, rolls back in reverse\norder on failure (then re-throws the original error). `WithRollbackOptions`\nextends `RollbackOptions` with:\n\n| Option | Type | Description |\n| --- | --- | --- |\n| `onFailures` | `(result: RollbackResult) =\u003e void` | Called with the `RollbackResult` when `fn` throws and one or more rollback operations also throw while unwinding. Observation hook — it must not throw; any error it throws is ignored so it can't mask the original error. |\n\n## Behavior notes\n\n- **Reverse order** — rollbacks run newest-first (LIFO), the correct order to unwind dependent steps.\n- **Failures don't stop the sequence** — by default a throwing rollback operation is collected into `result.failures` and the remaining operations still run. Set `stopOnFailure: true` to halt at the first failure; the older, un-run operations are returned in `result.pending` (use this only when compensations are ordered dependencies). You can also set it per operation via `add(description, rollback, { stopOnFailure: true })` to halt only if that specific operation's rollback throws; the run-level flag, when `true`, halts on every failure regardless.\n- **Commit seals, rollback finalizes** — `commit()` seals the current batch and keeps the instance open, so you can register a new batch after it (see [Batches in one flow](#batches-in-one-flow-progressive-commit)). Only `rollback()` finalizes the instance; `add` after a rollback throws `RolledBackError`. Repeat `commit`/`rollback` calls are safe no-ops.\n- **The original error always wins** — `withRollback` re-throws whatever `fn` threw, never a rollback error. Observe rollback failures via `onFailures` (or the returned `RollbackResult` with `createRollback`).\n\n## FAQ (For humans and AI agents)\n\n**When should I use `withRollback` vs `createRollback`?**\nPrefer `withRollback` — it scopes the lifecycle for you (commit on success, roll back on throw) and is the right fit for ~90% of cases. Drop to `createRollback` when you need manual control over *when* to commit or roll back, or to inspect the `RollbackResult` directly.\n\n**What happens if a rollback operation itself throws?**\nIt's recorded in `result.failures` and the remaining operations still run, so one bad undo doesn't strand the rest. Set `stopOnFailure: true` to halt instead; whatever was left un-run comes back in `result.pending`.\n\n**Is this a replacement for database transactions?**\nNo. If all your work is in one database, use a native transaction — it's truly atomic. rollbackit is for *distributed* side effects across systems that have no shared transaction (DB + storage + search + external APIs), where the only way to \"undo\" is to run a compensating action.\n\n**Does rollback run in parallel?**\nNo — operations roll back sequentially, newest-first, which is the safe default for dependent steps. If you have independent cleanups you want concurrent, compose them inside a single rollback function: `rb.add(\"cleanup\", () =\u003e Promise.allSettled([a(), b()]))`.\n\n**What if a step has nothing to undo?**\nDon't call `add`. Only register a rollback for steps that created a side effect worth reversing (pure reads, validation, etc. register nothing).\n\n**Does it work with CommonJS / ESM / the browser?**\nYes to all — it ships both ESM and CJS builds with full type declarations, targets Node 18+, and has no Node-specific dependencies, so it runs in the browser too.\n\n**Is it safe to call `rollback()` or `commit()` more than once?**\nYes. `commit()` is repeatable — each call seals the current batch and leaves the instance open for more (see [Batches in one flow](#batches-in-one-flow-progressive-commit)). `rollback()` finalizes the instance and subsequent calls are no-ops (returning an empty result). Only `add()` after a `rollback()` throws — `RolledBackError`.\n\n## Tech Stack\n\nBuilt with tech/tools that I enjoy using:\n- [TypeScript](https://www.typescriptlang.org/) - for type safety and developer experience.\n- [Vitest](https://vitest.dev/) - for testing.\n- [Biome](https://biomejs.dev/) - for linting and formatting.\n- [Changesets](https://changesets.io/) - for versioning and publishing.\n- [pnpm](https://pnpm.io/) - for package management.\n- [GitHub Actions](https://github.com/features/actions) - for CI/CD.\n- [Tsdown](https://tsdown.dev/) -  library bundler powered by Rolldown.\n- [Lefthook](https://github.com/Arkweid/lefthook) - for pre-commit hooks.\n- [CodeRabbit](https://coderabbit.ai/) - for PR-level GitHub code review.\n\n## Contributing\n\nContributions are welcome! Please open an issue or pull request.\n\n## License\n\n[MIT](./LICENSE) © [Alexandre Marques](https://github.com/alexmarqs)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Falexmarqs%2Frollbackit","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Falexmarqs%2Frollbackit","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Falexmarqs%2Frollbackit/lists"}