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

jira-sync-engine

v0.0.5

Published

Sync Jira Cloud issues, comments, and worklogs into PostgreSQL via webhooks and JQL backfill.

Readme

jira-sync-engine

Syncs Jira Cloud data (issues, comments, worklogs) into PostgreSQL. Supports real-time webhook ingestion and batch backfill via JQL.

Installation

npm install jira-sync-engine drizzle-orm

You also need a PostgreSQL driver that drizzle supports — e.g. pg, postgres, @neondatabase/serverless, etc.

Quick start

Pass your existing drizzle instance so the library shares your connection pool and credentials:

import { drizzle } from "drizzle-orm/node-postgres"; // or postgres-js, neon, etc.
import { JiraSync, runMigrations } from "jira-sync-engine";

const db = drizzle({ connection: process.env.DATABASE_URL });

// Run once to create the `jira` schema and tables
await runMigrations(db);

const sync = new JiraSync({
  jiraHost: "mycompany.atlassian.net",
  jiraEmail: "[email protected]",
  jiraApiToken: process.env.JIRA_API_TOKEN,
  db,
});

// Backfill issues updated in the last 7 days
const result = await sync.syncBackfill({
  jql: "updated >= -7d ORDER BY updated DESC",
});
console.log(result);
// { issues: { synced: 42, errors: 0 }, comments: { synced: 310, errors: 0 }, worklogs: { synced: 8, errors: 0 } }

await sync.close();

Configuration

type JiraSyncConfig = {
  jiraHost: string; // e.g. "mycompany.atlassian.net"
  jiraEmail: string;
  jiraApiToken: string;
  jiraWebhookSecret?: string; // required for webhook verification
  db?: PgAsyncDatabase; // your drizzle instance (any PostgreSQL driver)
  databaseUrl?: string; // fallback when `db` is not provided
  maxPostgresConnections?: number; // default: 10; ignored when `db` is provided
  backfillRelatedEntities?: boolean; // auto-upsert FK parents (users/projects), default: true
  customFieldMappings?: CustomFieldMapping[]; // see "Custom fields" below
  additionalJqlFields?: string[]; // extra Jira fields to fetch alongside mapped ones
  logger?: {
    debug: (obj: unknown, msg?: string) => void;
    error: (obj: unknown, msg?: string) => void;
  };
};

Webhooks

Register a webhook in Jira Cloud pointing to your endpoint, then call processWebhook with the raw request body and the X-Hub-Signature header value:

// Express example
app.post("/jira/webhook", express.raw({ type: "*/*" }), async (req, res) => {
  await sync.processWebhook(req.body, req.headers["x-hub-signature"] as string);
  res.sendStatus(200);
});

Supported events: jira:issue_created, jira:issue_updated, jira:issue_deleted, comment_created, comment_updated, comment_deleted, worklog_created, worklog_updated, worklog_deleted.

Backfill

// Sync all issues in a project
await sync.syncIssues({ jql: "project = MYPROJ ORDER BY updated DESC" });

// Sync comments and worklogs for specific issue IDs
await sync.syncComments(["10001", "10002"]);
await sync.syncWorklogs(["10001", "10002"]);

// Or sync everything at once (issues → then their comments and worklogs)
await sync.syncBackfill({ jql: "project = MYPROJ" });

Custom fields

Jira instances assign different numeric IDs to the same logical custom field (e.g. customfield_10031 on one instance might mean "urgency" and on another it means something else entirely). The customFieldMappings option lets each deployment declare its own mapping from Jira field IDs to stable column names in the issues table.

Built-in extractors

| Strategy | Extracts | | ------------- | ------------------------------------------------------ | | 'direct' | The raw field value as-is (strings, numbers, booleans) | | 'value' | .value from a Jira option object { value: "..." } | | 'accountId' | .accountId from a Jira user object |

For anything more complex, pass a function (raw: unknown) => unknown.

Example

import { JiraSync } from "jira-sync-engine";

const sync = new JiraSync({
  jiraHost: "mycompany.atlassian.net",
  jiraEmail: "[email protected]",
  jiraApiToken: process.env.JIRA_API_TOKEN,
  db,
  customFieldMappings: [
    // "impact" is a plain text field on this instance
    { jiraFieldId: "customfield_10108", column: "impact", extract: "direct" },
    // "urgency" and "systemName" are single-select option objects
    { jiraFieldId: "customfield_10031", column: "urgency", extract: "value" },
    { jiraFieldId: "customfield_10004", column: "systemName", extract: "value" },
    // "ownerId" is a user object — extract the account ID
    { jiraFieldId: "customfield_10213", column: "ownerId", extract: "accountId" },
    // Sprint data: use the reserved "_sprint" column name to trigger sprint table upserts
    { jiraFieldId: "customfield_10020", column: "_sprint", extract: (r) => r },
    // Organization data: use "_organizations" to trigger organization table upserts
    { jiraFieldId: "customfield_10002", column: "_organizations", extract: (r) => r },
  ],
});

A different Jira instance simply uses different jiraFieldId values — the column names and extraction logic stay the same across deployments.

Any custom field that is not covered by a mapping is still captured in the issues.custom_fields JSONB column, so no data is lost.

Reserved column names for relational side-effects

Two column names have special meaning and trigger additional table upserts alongside the issue row:

| Column | Side-effect | | ---------------- | ------------------------------------------------------------------------------------ | | _sprint | Upserts the sprint into jira.sprints and populates issues.sprint_id | | _organizations | Upserts organizations into jira.organizations and syncs jira.issue_organizations |

Built-in extended columns

The following columns on issues are always populated from standard Jira fields when present — no mapping required:

| Column | Source | | --------------------- | --------------------------------------------- | | parentKey | fields.parent.key | | isDone | fields.status.statusCategory.key === "done" | | statusCategoryColor | fields.status.statusCategory.colorName | | timeEstimate | fields.timeoriginalestimate (seconds) | | timeSpent | fields.timespent (seconds) | | description | fields.description (ADF stored as JSONB) |

Querying synced data

The Drizzle schema tables are exported so you can query with your own drizzle instance:

import {
  issues,
  comments,
  worklogs,
  sprints,
  organizations,
  issueOrganizations,
} from "jira-sync-engine";
import { eq } from "drizzle-orm";

// `db` is the same instance you passed to JiraSync / runMigrations
const openIssues = await db.select().from(issues).where(eq(issues.status, "In Progress"));

const issueComments = await db.select().from(comments).where(eq(comments.issueId, "10001"));

// Query using the stable canonical columns populated via customFieldMappings
const criticalIssues = await db.select().from(issues).where(eq(issues.urgency, "Critical"));

// Query sprint membership
const sprintIssues = await db
  .select()
  .from(issues)
  .innerJoin(sprints, eq(issues.sprintId, sprints.jiraId.toString()))
  .where(eq(sprints.state, "active"));

Schema

All tables live in the jira PostgreSQL schema.

| Table | Key columns | | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | issues | id, key (unique), summary, status, issueType, priority, assignee, reporter, projectKey, epicId, sprintId, labels, components, customFields (JSONB bag for unmapped fields), impact, urgency, systemName, orderNumber, billingTarget, billingStatus, reporterField, ownerId, timeEstimate, timeSpent, parentKey, isDone, statusCategoryColor, description, deleted, created, updated, lastSyncedAt | | comments | id, issueId, issueKey, author, authorAccountId, authorName, authorAvatar, body (JSONB), jsdPublic, deleted, created, updated, lastSyncedAt | | worklogs | id, issueId, author, timeSpentSeconds, comment, started, deleted, created, updated, lastSyncedAt | | users | accountId, displayName, email, avatarUrl, lastSyncedAt | | projects | id, key, name, projectType, lastSyncedAt | | sprints | jiraId, name, state, boardId | | organizations | jiraId, name | | issue_organizations | issueKey, organizationId (join table, cascade-deleted with issue) |

Soft-deleted records have deleted = true and are retained in the database.

Acknowledgements

Inspired by stripe-sync-engine by Supabase, which pioneered the pattern of syncing third-party API data into PostgreSQL via webhooks and batch backfill. Licensed under Apache 2.0.

Development

vp install   # install dependencies
vp test      # run tests
vp pack      # build
vp run demo  # run the demo script against a real Jira instance (requires .env)