@yarlisai/triggers-core
v0.1.0-alpha.1
Published
Generic primitives for poll-based triggers: cadence math, exponential backoff with jitter, and a provider-agnostic OAuth state machine.
Downloads
56
Maintainers
Readme
@yarlisai/triggers-core
Generic primitives for poll-based triggers — cadence math, exponential backoff with jitter, and a provider-agnostic OAuth resolver state machine. Built on the Port/Adapter pattern (ADR 0007). Wire your own state store via the TriggerProvider port; ship in-memory + Postgres adapters out of the box.
What this package does
If you're building any kind of polled integration — IMAP mailbox, Google Tasks, RSS feed, Atlassian Jira, GitHub issues, etc. — the same three problems show up every time:
- Cadence math — converting a tier label like
5m/15m/30m/60minto a wall-clocknext_poll_atafter a successful tick. - Backoff on failure — when the upstream is angry (rate limit, OAuth expiry, DNS), don't hammer it. Exponential 2× per failure past a threshold, capped at 4 hours, with ±20% jitter so parallel pollers don't retry in lockstep.
- OAuth resolution — a credential id stored on the trigger row → an access token, with a typed result discriminating "credential missing" / "row not found" / "refresh failed" / "token null" instead of throwing.
This package handles all three. Provider-specific code (the actual API call, payload shape, dedupe key) stays in your app.
Install
bun add @yarlisai/triggers-core@alphaZero runtime deps beyond @yarlisai/core (used only for typings). Pure TS — works in Node, Bun, edge, Workers.
Quick start
import {
computeNextPollAt,
computeNextPollAtWithBackoff,
createOAuthResolver,
createTriggersClient,
describeOAuthFailure,
memoryProvider,
} from '@yarlisai/triggers-core'
// 1. Backoff math (pure, no client needed)
const next = computeNextPollAtWithBackoff('5m', failureCount, new Date())
// 2. OAuth resolver bound to YOUR credential store + refresh helper
const resolveOAuth = createOAuthResolver({
lookupCredential: async (id) => myDb.findCredential(id), // returns { userId } | null
refreshAccessToken: async (id, userId, reqId) => myAuth.refresh(id, userId, reqId),
})
const result = await resolveOAuth({ credentialId, requestId: 'req-77' })
if (!result.ok) {
await persistLastError(describeOAuthFailure(result))
return
}
// use result.accessToken …
// 3. Optional client over a state store
const triggers = createTriggersClient({ provider: memoryProvider() })
const due = await triggers.getDueStates(new Date(), 100)Backoff policy
export interface BackoffPolicy {
startAfterFailures: number // default 3 — first 3 failures stay on cadence
multiplier: number // default 2
capMinutes: number // default 240 (4h)
jitterMin: number // default 0.8 (-20%)
jitterRange: number // default 0.4 (+20% range)
}All fields are overridable per-call:
computeNextPollAtWithBackoff('5m', 2, new Date(), Math.random, {
startAfterFailures: 1,
multiplier: 3,
})OAuth resolver result
type ResolveOAuthResult =
| { ok: true; accessToken: string }
| {
ok: false
reason: 'credential_missing' | 'credential_not_found' | 'refresh_failed' | 'token_null'
error?: string
}describeOAuthFailure(result) formats the failure into a oauth: <detail> string suitable for a last_error column, so DB inspection stays grep-friendly across providers.
TriggerProvider port
interface TriggerProvider {
readonly name: string
readonly isConfigured: boolean
getState(id: string): Promise<TriggerPollerState | null>
saveState(state: TriggerPollerState): Promise<void>
getDueStates(now: Date, limit?: number): Promise<TriggerPollerState[]>
}Built-in adapters
| Adapter | Factory | Notes |
|---|---|---|
| in-memory | memoryProvider() | Tests + single-process dev |
| Postgres | postgresProvider({ query }) | Driver-agnostic — pass any (sql, params) => rows function |
The Postgres adapter expects a poller_state table by default — column names and the table name are overridable via the tableName + columns options.
import { postgresProvider } from '@yarlisai/triggers-core'
// Wraps `pg` Pool
import { Pool } from 'pg'
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
const provider = postgresProvider({
query: async (sql, params) => (await pool.query(sql, [...params])).rows,
})Writing a custom adapter
import type { TriggerProvider } from '@yarlisai/triggers-core'
export function dynamoProvider(config: { tableName: string }): TriggerProvider {
return {
name: 'dynamo',
isConfigured: true,
async getState(id) { /* … */ return null },
async saveState(state) { /* … */ },
async getDueStates(now, limit) { /* … */ return [] },
}
}Drop it into createTriggersClient({ provider: dynamoProvider({...}) }) — done.
Build
bun install
cd packages/triggers-core
bun run buildRelated
@yarlisai/queue— task queue used by trigger dispatchers.@yarlisai/cache,@yarlisai/email— sibling packages following the same port/adapter pattern.- ADR 0007 — Port/Adapter protocol
License
MIT
