@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 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