@ayepi/work 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/dist/index.cjs +2020 -0
- package/dist/index.d.cts +1167 -0
- package/dist/index.d.ts +1167 -0
- package/dist/index.js +1920 -0
- package/package.json +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Philip Diffenderfer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# @ayepi/work
|
|
2
|
+
|
|
3
|
+
Type-safe **distributed work / job-queue + workflow** engine. Define work types, get
|
|
4
|
+
typed queueable builders, and `enqueue` is fully checked. Work is traced as a **group**:
|
|
5
|
+
work queued inside a handler joins the same group, and awaiting a handle resolves to the
|
|
6
|
+
group's result. Retries, fan-in dependencies, cron/fn scheduling, distributed
|
|
7
|
+
wait-for-result, rate limiting, instance affinity, lifecycle events, and an orphan hook
|
|
8
|
+
are all included — on three pluggable ports with an **in-memory backend bundled** so it
|
|
9
|
+
runs zero-config.
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
pnpm add @ayepi/work
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { defineWork, createWork } from '@ayepi/work'
|
|
17
|
+
|
|
18
|
+
const add = defineWork('add', (i: { a: number; b: number }) => i.a + i.b)
|
|
19
|
+
const w = createWork({ work: [add] as const })
|
|
20
|
+
|
|
21
|
+
const sum = await w.enqueue(add({ a: 1, b: 2 })).result() // 3, typed as number
|
|
22
|
+
await w.stop()
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Bare `import` has **no side effects** — the default instance does not auto-start.
|
|
26
|
+
|
|
27
|
+
## Type safety
|
|
28
|
+
|
|
29
|
+
`defineWork(name, handler, opts?)` returns a **callable builder** typed by its input and
|
|
30
|
+
output. Pass a `const` tuple of builders to `createWork`, and both `enqueue` forms are
|
|
31
|
+
checked:
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
w.enqueue(add({ a: 1, b: 2 })) // instance form
|
|
35
|
+
w.enqueue('add', { a: 1, b: 2 }) // name form (name ∈ registry, input typed)
|
|
36
|
+
add({ a: 1 }) // ✗ type error: missing `b`
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Awaiting a handle resolves to the **group result** — the union of the registry's non-void
|
|
40
|
+
outputs (`GroupResult<Defs>`). `.result()` resolves this item's own output; `.group()` is
|
|
41
|
+
the explicit group form.
|
|
42
|
+
|
|
43
|
+
## Groups & context
|
|
44
|
+
|
|
45
|
+
A handler receives a `ctx`. `ctx.queue(child(input))` queues child work **into the same
|
|
46
|
+
group** (so awaiting the group waits for the children); `ctx.setResult(value)` records the
|
|
47
|
+
group's result.
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
const root = defineWork('root', (i: { ids: string[] }, ctx) => {
|
|
51
|
+
for (const id of i.ids) ctx.queue(process({ id }))
|
|
52
|
+
ctx.setResult({ queued: i.ids.length })
|
|
53
|
+
})
|
|
54
|
+
const out = await w.enqueue(root({ ids: ['a', 'b'] })) // resolves after both children settle
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Instance options, retries, delay
|
|
58
|
+
|
|
59
|
+
`delay`, `retry`, `priority`, and `group` are **per-instance options** — passed at queue
|
|
60
|
+
time, set as per-type constants, or computed from the input — and are **serialized with
|
|
61
|
+
the item** so the worker that runs it applies the same policy:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
w.enqueue(sendEmail({ to }), { delay: 5_000, priority: 10, group: to }) // at queue time
|
|
65
|
+
const send = defineWork('send', handler, {
|
|
66
|
+
retry: { attempts: 5, base: 1000 }, // per-type default
|
|
67
|
+
options: (i: { to: string }) => ({ group: i.to, priority: 0 }), // computed per instance
|
|
68
|
+
})
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- **`delay`** sets `startAt = queueAt + delay` (recorded on the item).
|
|
72
|
+
- **`retry`** — `@ayepi/core`'s [`RetryOptions`](packages/core/src/retry.ts) (`{ attempts, base, factor, max, jitter }`, exponential backoff with jitter; set fleet-wide defaults with `setDefaultRetryOptions`). A retry **re-enters the queue** as a fresh delivery (`attempt + 1`); on exhaustion the item is dead-lettered.
|
|
73
|
+
- **`priority`** / **`group`** feed the doer (below).
|
|
74
|
+
- **`skipQueue`** runs the first attempt in-process (no queue hop) for low latency; a failure still **re-enqueues durably** (`attempt + 1`), so the retry survives a crash and any instance can pick it up.
|
|
75
|
+
|
|
76
|
+
Per type you can also set `onEvent` (a per-type lifecycle hook) and `accept` is set on
|
|
77
|
+
the engine. Each item tracks `queueAt` / `startAt` (scheduled) / `runAt` (actual) /
|
|
78
|
+
`endAt` timestamps (see `list()`), and `active()` returns the work this instance has
|
|
79
|
+
polled and accepted (will not be skipped).
|
|
80
|
+
|
|
81
|
+
## Doers — concurrency, ordering, rate limiting
|
|
82
|
+
|
|
83
|
+
A **doer** (`@ayepi/core/doer`, re-exported here) decides how many items to pull and
|
|
84
|
+
which to run next. Set one globally or per type:
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import { balancedDoer, priorityDoer } from '@ayepi/work' // ← from @ayepi/core/doer
|
|
88
|
+
import { rateLimitedDoer } from '@ayepi/rate'
|
|
89
|
+
|
|
90
|
+
createWork({ work: [...] as const, doer: balancedDoer({ max: 20 }) }) // fair across `group`s
|
|
91
|
+
const send = defineWork('send', handler, { doer: rateLimitedDoer({ limit: 100, window: 60_000 }) })
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
- **`unlimitedDoer`** — run everything, no cap.
|
|
95
|
+
- **`balancedDoer({ max })`** — cap N; share slots fairly across `group`s, then priority, then age.
|
|
96
|
+
- **`priorityDoer({ max })`** — cap N; highest priority first, then age.
|
|
97
|
+
- **`ageDoer({ max })`** — cap N; oldest first.
|
|
98
|
+
- **`rateLimitedDoer({ limit, window, algorithm?, store? })`** (from `@ayepi/rate`) — cap the **start rate**; pass a distributed store to limit across a fleet.
|
|
99
|
+
|
|
100
|
+
**`accept(info)`** (engine-level) returns `false` to decline an item on this instance so
|
|
101
|
+
another picks it up — shard work types across a fleet. **`onEvent(event)`** fires
|
|
102
|
+
`queued` / `started` / `succeeded` / `failed` (with `willRetry`) / `group-done`.
|
|
103
|
+
|
|
104
|
+
## Batching
|
|
105
|
+
|
|
106
|
+
When per-item work is wasteful but a bulk call is cheap (embeddings, bulk inserts,
|
|
107
|
+
batched API calls), define the type with `defineBatchWork`. Items still enqueue, retry,
|
|
108
|
+
prioritize, and join groups individually, but **execute together** once `size`
|
|
109
|
+
accumulate or `maxWait` elapses. The batch runs as a single task on the type's doer
|
|
110
|
+
(which governs how many batches run at once), and each `.result()` resolves to its
|
|
111
|
+
index-aligned output:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
import { defineBatchWork, createWork } from '@ayepi/work'
|
|
115
|
+
|
|
116
|
+
const embed = defineBatchWork('embed', {
|
|
117
|
+
size: 50,
|
|
118
|
+
maxWait: 100,
|
|
119
|
+
run: (inputs: { text: string }[]) => embedAll(inputs.map((i) => i.text)), // number[][], aligned to inputs
|
|
120
|
+
doer: priorityDoer({ max: 2 }), // ≤ 2 batches at a time
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const w = createWork({ work: [embed] as const })
|
|
124
|
+
const vec = await w.enqueue(embed({ text: 'hello' })).result() // its own embedding
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Batching is a stage **in front of the doer**: pulled items accumulate per type, flush
|
|
128
|
+
into a batch, and the batch is what the doer schedules. If `run` throws, every item in
|
|
129
|
+
the batch follows its **own** retry policy (re-enqueued, possibly landing in a different
|
|
130
|
+
batch next time). A batch handler gets no per-item `ctx` — it's for leaf work.
|
|
131
|
+
|
|
132
|
+
## Dependencies (fan-in)
|
|
133
|
+
|
|
134
|
+
A dependency is **itself a work item** — enqueue it like anything else (often alongside
|
|
135
|
+
the works it waits `on`). It survives a crash on the durable queue, and its handler is
|
|
136
|
+
**non-blocking**: each run checks state and either queues its dependents or re-queues
|
|
137
|
+
itself to check again later, so it never holds a worker slot:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
import { dependency } from '@ayepi/work'
|
|
141
|
+
|
|
142
|
+
const a = stepA(), b = stepB()
|
|
143
|
+
w.enqueue(a)
|
|
144
|
+
w.enqueue(b)
|
|
145
|
+
w.enqueue(dependency({ on: [a, b], queue: [finalize()], config: 'all-success' }))
|
|
146
|
+
// or queue them together inside a handler:
|
|
147
|
+
// ctx.queue([a, b, dependency({ on: [a, b], queue: [finalize()] })])
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`config` is `'all-done' | 'all-success' | { count, of? }` — declarative and
|
|
151
|
+
JSON-serializable, so the dependency evaluates identically on any instance and queues its
|
|
152
|
+
dependents **exactly once** (a `claim()`/`setIfNotExists` lock survives redelivery and a
|
|
153
|
+
multi-pod fleet). It remembers terminal statuses internally, so a since-evicted state is
|
|
154
|
+
never mistaken for a failure.
|
|
155
|
+
|
|
156
|
+
## Scheduling
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
w.schedule({ name: 'nightly', cron: '0 3 * * *', run: () => report({}) })
|
|
160
|
+
w.schedule({ name: 'tick', next: (now) => now + 60_000, run: () => poll({}) })
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
A 5-field cron expression (dependency-free parser) or a next-time function. One instance
|
|
164
|
+
fires per occurrence (a `setIfNotExists` lease keyed by the fire time).
|
|
165
|
+
|
|
166
|
+
## Ports & backends
|
|
167
|
+
|
|
168
|
+
Everything sits on three interfaces — `Queue` (durable log with visibility-timeout
|
|
169
|
+
leases + heartbeat), `PubSub` (best-effort fanout), and `Store` (get/set/`setIfNotExists`/`increment`).
|
|
170
|
+
`@ayepi/work` bundles an in-memory implementation (`memoryBackend()`), and the same
|
|
171
|
+
engine runs distributed by swapping the ports for Redis/SQS/etc.:
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
import { createWork, memoryBackend } from '@ayepi/work'
|
|
175
|
+
|
|
176
|
+
const backend = memoryBackend()
|
|
177
|
+
const podA = createWork({ ...backend, work: [add] as const }) // share one backend
|
|
178
|
+
const podB = createWork({ ...backend, work: [add] as const }) // = two pods
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
The bundled queue can also be **file-backed** for single-process durability — pending work
|
|
182
|
+
survives a restart, no Redis/SQS needed (state is written atomically after each change and
|
|
183
|
+
reloaded on startup, redelivering anything that was in flight):
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
const backend = memoryBackend({ queue: { file: './work-queue.json' } })
|
|
187
|
+
const work = createWork({ ...backend, work: [add] as const })
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Non-native values in inputs/outputs (`Date`, `BigInt`, `Map`, `Set`, `undefined`,
|
|
191
|
+
`Error`) round-trip through `defaultCodec`; pass your own `codec` globally or per type.
|
|
192
|
+
|
|
193
|
+
## For AI coding agents
|
|
194
|
+
|
|
195
|
+
This package ships dense, machine-oriented reference docs written for **AI coding agents**
|
|
196
|
+
(Claude Code, Cursor, and the like) to understand and drive the package — point your agent at them:
|
|
197
|
+
|
|
198
|
+
- [`ayepi-work-deps-schedule.md`](./ayepi-work-deps-schedule.md)
|
|
199
|
+
- [`ayepi-work-ports.md`](./ayepi-work-ports.md)
|
|
200
|
+
- [`ayepi-work.md`](./ayepi-work.md)
|
|
201
|
+
|
|
202
|
+
They live next to the source in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/work) and are **not** shipped in the npm tarball.
|
|
203
|
+
|
|
204
|
+
## License
|
|
205
|
+
|
|
206
|
+
MIT © Philip Diffenderfer
|