@nurv-llc/runce
v1.0.4
Published
Run one-time tasks for Node.js services - like migrations, but for arbitrary TypeScript
Maintainers
Readme
@nurv-llc/runce
Run one-time runce tasks for your Node service—like migrations, but for arbitrary TypeScript/JavaScript. Ships with MongoDB, PostgreSQL, and File trackers with a pluggable interface for others.
Features
- ✅ Run TS/JS tasks exactly once
- ✅ Pluggable Tracker (MongoDB, PostgreSQL + File tracker out of the box)
- ✅ CLI + programmatic API
- ✅ Distributed lock (MongoDB & PostgreSQL lease) to prevent concurrent runners
- ✅ Dry-run, filters, JSON list, rich logs
- ✅ Task scaffolding
Quick Start
npm i @nurv-llc/runce mongodb
# OR for PostgreSQL
npm i @nurv-llc/runce pg
npx runce make "initialize queues"Generate a config:
// config/runce.config.ts
import { defineConfig } from '@nurv-llc/runce';
export default defineConfig({
tasksDir: './tasks',
tracker: {
type: 'mongo', // 'postgresql', or 'file' for development
options: { uri: process.env.MONGO_URI, db: 'infra' },
},
lock: { enabled: true, ttlMs: 60000, owner: process.env.HOSTNAME },
});Or use JavaScript:
// config/runce.config.js
export default {
tasksDir: './tasks',
tracker: {
type: 'mongo', // 'postgresql', or 'file' for development
options: { uri: process.env.MONGO_URI, db: 'infra' },
},
lock: { enabled: true, ttlMs: 60000, owner: process.env.HOSTNAME },
};Run pending tasks:
MONGO_URI="mongodb://localhost:27017" npx runce runList applied:
npx runce list --json | jq .Writing a Task
Tasks can be written in either TypeScript or JavaScript:
TypeScript (Recommended):
// tasks/001-setup-db.ts
import { RunceTask } from '@nurv-llc/runce';
export default {
id: '001-setup-db',
title: 'Set up database indexes',
async run({ log }) {
log('Setting up database indexes...');
// Your one-time setup logic here
log('Database indexes created successfully');
},
// Optional: check if task is already done
async alreadyDone({ log }) {
// Return true if task should be skipped
return false;
},
} satisfies RunceTask;Run-Always Tasks:
// tasks/backup-logs.ts
import { RunceTask } from '@nurv-llc/runce';
export default {
id: 'backup-logs',
title: 'Backup application logs',
runAlways: true, // 🔄 Runs every time, not tracked for completion
async run({ log }) {
log('Backing up logs...');
// This runs on every execution
log('Logs backed up successfully');
},
// Optional: still respected for runAlways tasks
async alreadyDone({ log }) {
// Can still conditionally skip if needed
return false;
},
} satisfies RunceTask;JavaScript:
// tasks/20251003T120000.init-queues.js
const task = {
id: '20251003T120000.init-queues',
title: 'Initialize message queues',
async run({ log }) {
log('creating SQS queues ...');
// ... do one-time setup
},
};
export default task;Naming: prefix with an ISO timestamp for natural ordering. id must be unique and stable.
Task Organization
Tasks can be organized in subdirectories for better structure:
tasks/
├── 001-setup-db.ts # Run-once migration
├── 002-create-indexes.ts # Run-once migration
├── daily/
│ ├── backup-logs.ts # runAlways: true
│ └── cleanup-temp.ts # runAlways: true
├── monitoring/
│ └── health-check.ts # runAlways: true
└── migrations/
└── 003-add-column.ts # Run-once migrationKey Points:
- 📁 Recursive Loading: Tasks are loaded from all subdirectories
- 🔄 Run-Always First: Tasks with
runAlways: trueexecute before run-once tasks - 📝 Flexible Organization: Organize tasks however makes sense for your project
- 🎯 Execution Order: Within each category, tasks run in alphabetical order by task ID
Configuration
export interface Config {
tasksDir: string;
tracker: { type: string; options: Record<string, unknown> };
lock?: { enabled: boolean; ttlMs: number; owner?: string };
dryRun?: boolean;
}You can keep config in JS or TS. The CLI resolves via --config or defaults to TypeScript config files.
Swapping Trackers
Change tracker.type and provide matching options.
export default {
tracker: { type: 'file', options: { path: '.runce.json' } }
}PostgreSQL Example:
export default {
tracker: {
type: 'postgresql',
options: {
connectionString: 'postgresql://user:password@localhost:5432/mydb'
// OR use individual connection parameters:
// host: 'localhost', port: 5432, database: 'mydb',
// username: 'user', password: 'password'
}
}
}Add your own by implementing ITracker and registering it. No changes to tasks or runner needed.
CLI
# Create a new task file (generates TypeScript by default)
runce make "human readable name"
# Run tasks (loads from all subdirectories)
runce run [--config ./config/runce.config.ts] [--tasks-dir ./tasks] [--dry-run]
[--only id1,id2] [--since 2025-09-01] [--until 2025-10-01]
# List applied tasks
runce list [--config ./config/runce.config.ts] [--json]
# Get help
runce --help
runce run --helpExecution Order
When you run runce run, tasks execute in this order:
- 🔄 Always-run tasks (
runAlways: true) - Execute every time - 🎯 Run-once tasks - Execute only if not already applied
Within each category, tasks run in alphabetical order by task ID.
Programmatic API
import { runWithConfig, runWithConfigObject, listApplied, listAppliedWithConfig, defineConfig } from '@nurv-llc/runce';
// File-based configuration
await runWithConfig('./config/runce.config.ts');
// Or pass config object directly (useful for dynamic configuration)
const config = defineConfig({
tasksDir: './tasks',
tracker: {
type: 'mongo',
options: { uri: process.env.MONGO_URI, db: 'infra' },
},
});
await runWithConfigObject(config);
// List applied tasks
const records = await listApplied('./config/runce.config.ts');
// Or with config object
const records2 = await listAppliedWithConfig(config);Use Cases for Config Objects
Passing config objects directly is useful when:
- Environment-based configuration: Build config dynamically based on environment variables
- Testing: Create test configs without filesystem dependencies
- Serverless: Configure runce without requiring config files in deployment packages
- Multi-tenant: Different configurations per tenant or database
// Example: Environment-based configuration
const getTrackerConfig = () => {
if (process.env.NODE_ENV === 'production') {
return process.env.DATABASE_TYPE === 'postgres'
? { type: 'postgresql', options: { connectionString: process.env.DATABASE_URL } }
: { type: 'mongo', options: { uri: process.env.MONGO_URI, db: process.env.DB_NAME } };
}
return { type: 'file', options: { path: './.runce-dev.json' } };
};
const config = defineConfig({
tasksDir: './tasks',
tracker: getTrackerConfig(),
lock: {
enabled: process.env.NODE_ENV === 'production',
ttlMs: 60000,
owner: process.env.HOSTNAME
}
});
await runWithConfigObject(config);Tracker Interface
export interface AppliedRecord {
id: string; name: string; checksum: string; appliedAt: Date; durationMs: number; status: 'success'|'failed'; error?: string;
}
export interface ITracker {
init(options: Record<string, unknown>): Promise<void>;
getAppliedIds(): Promise<Set<string>>;
recordApplied(rec: AppliedRecord): Promise<void>;
listApplied(): Promise<AppliedRecord[]>;
}MongoDB Tracker Options
{
uri: string; // Mongo connection string
db: string; // database name
appliedCollection?: string; // default: runce.applied
lockCollection?: string; // default: runce.lock
}PostgreSQL Tracker Options
{
// Option 1: Connection string
connectionString: string; // e.g., 'postgresql://user:pass@localhost:5432/dbname'
// Option 2: Individual parameters
host?: string; // default: localhost
port?: number; // default: 5432
database?: string; // database name
username?: string; // database user
password?: string; // database password
// Optional settings
appliedTable?: string; // default: runce_applied
lockTable?: string; // default: runce_lock
schema?: string; // default: public
ssl?: boolean; // default: false
}File Tracker Options
{
path?: string; // default: .runce.json
}Locking
The MongoDB and PostgreSQL trackers use a lease document/record (_id: 'global' or name: 'global') updated with leaseUntil. If another process holds a valid lease, run exits with code 1. The file tracker doesn't support locking.
Idempotency Patterns
- Prefer external state checks inside tasks (e.g., does the bucket/queue exist?).
- Use
alreadyDone()for cheap pre-checks when possible. - Avoid destructive operations; make tasks additive or guarded.
Checksums & Drift
A SHA‑256 checksum of task files is stored. If the on-disk checksum changes after apply, you can detect drift by comparing stored vs current checksums.
Development
npm i
npm run build # Compile TypeScript
npm run lint # Run ESLint
npm run test # Run tests with Jest
npm run test:coverage # Run tests with coverage
npm run test:watch # Run tests in watch mode
npm run dev # Watch mode compilationTypeScript Support
Runce fully supports TypeScript out of the box:
- Task Files: Write tasks in
.tsfiles with full type safety - Config Files: Use TypeScript config with
defineConfig()helper - Runtime: Uses
tsxfor TypeScript execution at runtime - Type Safety: Full IntelliSense and compile-time checking
No compilation step required - TypeScript files run directly!
Testing
- Unit tests for loader, runner, filters, checksum.
- MongoDB tracker tests using
mongodb-memory-server. - Tests run with Jest and support TypeScript out of the box.
Extending with a New Tracker
- Implement
ITrackerinsrc/trackers/my-tracker.ts. - Export it via the tracker factory map.
- Use
tracker.type = 'my'in config.
Examples
Database Index Creation
const task = {
id: '20251003T120000.user-indexes',
title: 'Create user indexes',
async run({ log }) {
log('Creating user email index...');
// await db.collection('users').createIndex({ email: 1 }, { unique: true });
log('User indexes created');
},
async alreadyDone({ log }) {
// Check if index already exists
// const indexes = await db.collection('users').indexes();
// return indexes.some(idx => idx.key.email);
return false;
},
};
export default task;S3 Bucket Setup
const task = {
id: '20251003T120001.s3-buckets',
title: 'Initialize S3 buckets',
async run({ log }) {
log('Creating upload bucket...');
// await s3.createBucket({ Bucket: 'app-uploads' });
log('S3 buckets ready');
},
};
export default task;FAQ
Why not Prisma? Prisma tracks SQL schema, not arbitrary JS. Runce tasks handle service setup and data chores.
Can I rollback? Not built-in. Author tasks to be safe to re-run or provide explicit compensating logic.
Can I run in Docker entrypoint? Yes: runce run in your container startup before the app listener.
License
MIT
