@ayepi/work 0.1.0 → 0.2.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/README.md CHANGED
@@ -15,7 +15,7 @@ pnpm add @ayepi/work
15
15
  ```ts
16
16
  import { defineWork, createWork } from '@ayepi/work'
17
17
 
18
- const add = defineWork('add', (i: { a: number; b: number }) => i.a + i.b)
18
+ const add = defineWork('add', (i: { a: number; b: number }, ctx) => ctx.result(i.a + i.b))
19
19
  const w = createWork({ work: [add] as const })
20
20
 
21
21
  const sum = await w.enqueue(add({ a: 1, b: 2 })).result() // 3, typed as number
@@ -24,11 +24,20 @@ await w.stop()
24
24
 
25
25
  Bare `import` has **no side effects** — the default instance does not auto-start.
26
26
 
27
+ ## Handlers return a `WorkResult`
28
+
29
+ A handler **returns a `WorkResult`** describing what it produced:
30
+
31
+ - **`ctx.result(value, opts?)`** — contribute a value (its own `.result()` and the group). `opts`: `{ final }` locks the group result; `{ append }` accumulates.
32
+ - **`ctx.queue(items, opts?)`** — run sub-work (built `Work`s and/or nested results) in the same group; the work **delegates** (its `.result()` is `void`).
33
+ - **`ctx.void()`** — contribute nothing.
34
+ - **`.next(works, condition?, opts?)`** — a native dependency: queue `works` once the prior items satisfy `condition` (default `'all-success'`).
35
+
36
+ Each work then carries two inferred types — its *awaited-alone* result `S` (`.result()`) and its *group* contribution `G` (`.group()` / awaiting the handle). So `enqueue(root).group()` resolves to a **precise union from the workflow structure**, not the whole registry.
37
+
27
38
  ## Type safety
28
39
 
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:
40
+ `defineWork(name, handler, opts?)` returns a **callable builder** typed by its input and the `WorkResult` it returns. Pass a `const` tuple to `createWork`, and both `enqueue` forms are checked:
32
41
 
33
42
  ```ts
34
43
  w.enqueue(add({ a: 1, b: 2 })) // instance form
@@ -36,24 +45,27 @@ w.enqueue('add', { a: 1, b: 2 }) // name form (name ∈ registry, input typed
36
45
  add({ a: 1 }) // ✗ type error: missing `b`
37
46
  ```
38
47
 
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.
48
+ ## Groups & workflows
42
49
 
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.
50
+ `ctx.queue` composes sub-work into the same group; a returned tree is a workflow whose group
51
+ type is the precise union of its parts. `.next` adds native dependencies, and `ctx.result`
52
+ (with `final`/`append`) shapes the group's value (default: **last contributor to finish**).
48
53
 
49
54
  ```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
+ const fetch = defineWork('fetch', (i: { id: string }, ctx) => ctx.result(load(i.id)))
56
+ const report = defineWork('report', (_i, ctx) => ctx.result(summary()))
57
+ const root = defineWork('root', (i: { ids: string[] }, ctx) =>
58
+ ctx.queue(i.ids.map((id) => fetch({ id }))).next([report({})], 'all-success'),
59
+ )
60
+ const out = await w.enqueue(root({ ids: ['a', 'b'] })) // typed: fetch's | report's output
55
61
  ```
56
62
 
63
+ Per-work `ctx`: `id`, `groupId`, `attempt`, `parent` (who queued it), `dependents` (ids it
64
+ depended on). A handler must **return** every `ctx.queue`/`result`/`void` it creates — an
65
+ un-returned one throws (opt out with `strictReturn: false`). Set `deadline`/`timeout` to make
66
+ an item that doesn't start+finish in time go terminal with an `'expired'` event. `setIdGenerator`
67
+ overrides how work ids are minted.
68
+
57
69
  ## Instance options, retries, delay
58
70
 
59
71
  `delay`, `retry`, `priority`, and `group` are **per-instance options** — passed at queue
@@ -199,7 +211,7 @@ This package ships dense, machine-oriented reference docs written for **AI codin
199
211
  - [`ayepi-work-ports.md`](./ayepi-work-ports.md)
200
212
  - [`ayepi-work.md`](./ayepi-work.md)
201
213
 
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.
214
+ They ship with this package and also live in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/work).
203
215
 
204
216
  ## License
205
217
 
@@ -0,0 +1,312 @@
1
+ <!--
2
+ ayepi-work-deps-schedule.md — reference for `@ayepi/work` (dependencies & scheduling), written for coding agents.
3
+
4
+ Copy this file into any project that depends on `@ayepi/work` (e.g. into your repo's
5
+ `docs/` or `.claude/` directory) and reference it from your agents and slash commands.
6
+ It documents the public API, the patterns the package expects, and how it works under the
7
+ hood, with copy-pasteable examples. Keep it in sync with the installed package version.
8
+ -->
9
+
10
+ # `@ayepi/work` — dependencies & scheduling
11
+
12
+ Part of the `@ayepi/work` doc set. See [`ayepi-work.md`](./ayepi-work.md) for the core
13
+ API and [`ayepi-work-ports.md`](./ayepi-work-ports.md) for ports/codec. All durations are
14
+ **milliseconds**.
15
+
16
+ ---
17
+
18
+ ## Dependencies (fan-in gates)
19
+
20
+ A dependency is **itself a work item** — you enqueue it like anything else, often
21
+ alongside the works it waits `on`. It lives on the durable queue (so it survives a crash),
22
+ and its handler is **non-blocking**: each run reads the watched items' states and either
23
+ fires (queues its dependents, once) or **re-queues itself** with a small delay to check
24
+ again later. It never holds a worker slot waiting, so a backlog of dependencies can't
25
+ starve other work.
26
+
27
+ > **Prefer the native `.next` chain** ([below](#native-dependencies--next)) when you're
28
+ > building a workflow inside a handler — `ctx.queue([a, b]).next([c], 'all-success')` is the
29
+ > ergonomic form and widens the group type by `c`'s contribution. Reach for `dependency(...)`
30
+ > directly when you want to enqueue a standalone gate (e.g. alongside works at the top level).
31
+
32
+ ### `dependency(opts)`
33
+
34
+ ```ts
35
+ function dependency(opts: DependencyOptions): Work<DEPENDENCY_TYPE, void, void>
36
+
37
+ interface DependencyOptions {
38
+ readonly on: readonly (string | Work)[] // works (or their ids) to wait on
39
+ readonly queue: readonly Work[] // works to queue (into the same group) once satisfied
40
+ readonly config?: DependencyCondition // when to fire (default 'all-success')
41
+ readonly poll?: number // re-check interval ms (default 1000)
42
+ readonly timeout?: number // give up (dead-letter) after this long (ms)
43
+ }
44
+ ```
45
+
46
+ The built-in type name is exported as `DEPENDENCY_TYPE` (`'@work/dependency'`). Every work
47
+ system **auto-registers** the dependency handler — you never define it yourself.
48
+
49
+ ### `DependencyCondition`
50
+
51
+ JSON-serializable, so the dependency evaluates identically on any instance:
52
+
53
+ ```ts
54
+ type DependencyCondition =
55
+ | 'all-done' // every watched item reached a terminal state
56
+ | 'all-success' // every watched item succeeded
57
+ | { readonly count: number; readonly of?: 'done' | 'success' } // ≥ count reached the state ('done' default)
58
+ ```
59
+
60
+ Terminal states are `success`, `failed`, `dead`. `'done'` means any terminal state;
61
+ `'success'` means specifically succeeded.
62
+
63
+ ### `conditionMet(condition, states)`
64
+
65
+ The pure evaluator, exported for testing/inspection. A **missing** state counts as
66
+ "not yet done":
67
+
68
+ ```ts
69
+ function conditionMet(condition: DependencyCondition, states: readonly (WorkState | undefined)[]): boolean
70
+ ```
71
+
72
+ ```ts
73
+ import { conditionMet } from '@ayepi/work'
74
+
75
+ conditionMet('all-done', [stSuccess, stDead]) // true
76
+ conditionMet('all-success', [stSuccess, stDead]) // false
77
+ conditionMet({ count: 2 }, [stSuccess, stFailed, undefined]) // true
78
+ conditionMet({ count: 2, of: 'success' }, [stSuccess, stDead]) // false
79
+ ```
80
+
81
+ ### Example: a dependency-gated finalizer
82
+
83
+ Enqueue the dependency alongside the works it waits on. Awaiting the dependency's handle
84
+ resolves once its queued dependents (in the same group) finish:
85
+
86
+ ```ts
87
+ import { defineWork, createWork, dependency } from '@ayepi/work'
88
+
89
+ const stepA = defineWork('stepA', (_i, ctx) => ctx.result('a'))
90
+ const stepB = defineWork('stepB', (_i, ctx) => ctx.result('b'))
91
+ const finalize = defineWork('finalize', (_i, ctx) => ctx.void()) // runs once both steps succeed
92
+
93
+ const w = createWork({ work: [stepA, stepB, finalize] as const })
94
+
95
+ const a = stepA({}) // ids assigned at build time
96
+ const b = stepB({})
97
+ w.enqueue(a)
98
+ w.enqueue(b)
99
+ const gate = w.enqueue(dependency({
100
+ on: [a, b], // accepts Work instances or string ids
101
+ queue: [finalize({})], // queued into the gate's group once satisfied
102
+ config: 'all-success',
103
+ poll: 10,
104
+ }))
105
+ await gate // settles after the queued finalize() completes
106
+ ```
107
+
108
+ You can also queue everything together inside a handler — **return** the result so it runs
109
+ (and so the group type reflects it):
110
+
111
+ ```ts
112
+ const root = defineWork('root', (_i, ctx) => {
113
+ const a = stepA(), b = stepB()
114
+ return ctx.queue([a, b, dependency({ on: [a, b], queue: [finalize()], config: 'all-success' })])
115
+ })
116
+ ```
117
+
118
+ But the **native `.next` chain** says the same thing more directly (and types the group):
119
+
120
+ ```ts
121
+ const root = defineWork('root', (_i, ctx) =>
122
+ ctx.queue([stepA(), stepB()]).next([finalize()], 'all-success'),
123
+ )
124
+ ```
125
+
126
+ ### Timeouts
127
+
128
+ With `timeout`, the dependency records an absolute deadline (`Date.now() + timeout`) at
129
+ build time. If the condition never holds, the dependency **dead-letters** past the deadline
130
+ (it does not retry — `DEP_RETRY_ATTEMPTS = 1`), and its dependents are never queued.
131
+
132
+ ```ts
133
+ w.enqueue(dependency({ on: ['never-runs'], queue: [after({})], config: 'all-success', poll: 10, timeout: 30 }))
134
+ // after ~30ms the dependency dead-letters; `after` never runs
135
+ ```
136
+
137
+ ### How dependencies work under the hood
138
+
139
+ - **Fire-once.** Before queueing dependents, the handler does
140
+ `ctx.claim('dep:<key>:fired')` (a `Store.setIfNotExists`). The `key` is stable across the
141
+ dependency's self-re-queues and redeliveries, so dependents queue **exactly once** across
142
+ a multi-pod fleet.
143
+ - **Non-blocking re-queue.** When not yet satisfied, the handler queues a fresh copy of
144
+ itself (same `key`, fresh queue id) with `{ delay: poll }`, so it never blocks a slot.
145
+ - **Remembered terminal statuses.** The input carries a `resolved` map of terminal statuses
146
+ already observed, carried forward across self-re-queues. This lets the dependency skip
147
+ re-reading settled works **and** avoid mistaking a since-evicted state (results expire
148
+ after 24 h) for a failure.
149
+ - It reads watched states via `ctx.states(ids)`, which reads from the shared store
150
+ (cross-instance).
151
+
152
+ Exported dependency symbols: `dependency`, `conditionMet`, `DEPENDENCY_TYPE`, and the
153
+ `DependencyOptions` type.
154
+
155
+ ---
156
+
157
+ ## Native dependencies — `.next`
158
+
159
+ A `WorkResult` (what a handler returns) has a **`.next`** method — the ergonomic, typed form
160
+ of a fan-in `dependency`. It queues the next works once the works the prior result queued
161
+ satisfy a condition, and **widens the group type** by what those next works contribute:
162
+
163
+ ```ts
164
+ next<Ns>(next: Ns, condition?: DependencyCondition, options?: WorkInstanceOptions): WorkResult<S, G | GroupOf<Ns>>
165
+ ```
166
+
167
+ ```ts
168
+ const fetch = defineWork('fetch', (i: { id: string }, ctx) => ctx.result(load(i.id))) // string
169
+ const report = defineWork('report', (_i, ctx) => ctx.result(makeReport())) // Report
170
+ const flow = defineWork('flow', (i: { ids: string[] }, ctx) =>
171
+ ctx.queue(i.ids.map((id) => fetch({ id }))) // run all fetches in the group …
172
+ .next([report({})], 'all-success'), // … then report once they all succeed
173
+ )
174
+ // enqueue(flow(...)).group() → string | Report (the structural union)
175
+ ```
176
+
177
+ - The method is **`.next`**, *not* `.then` — a `then` member would make the `WorkResult` a
178
+ *thenable*, and the engine's `Promise.resolve(...)` of the handler's return would try to
179
+ adopt/await it.
180
+ - `condition` defaults to `'all-success'` and accepts the full `DependencyCondition` above.
181
+ - `.next` **chains**: `ctx.queue([a]).next([b], cond).next([c], cond2)` runs `c` after `b`'s
182
+ cohort satisfies `cond2`. Each link is a real `dependency` work under the hood (so it's
183
+ durable and fires exactly once), with the prior link's queued works as its `on` set.
184
+ - Works queued by a fired `.next` (or `dependency`) see `ctx.dependents` = the ids that gate
185
+ was waiting `on`; works queued by `ctx.queue` see `ctx.parent` = the queuing work's id.
186
+ - `options` is the usual `WorkInstanceOptions` (e.g. `{ priority }`) applied to the next
187
+ works. A `.next` you build but **don't return** trips strict-return (see
188
+ [the handler contract](./ayepi-work.md#the-handler-contract--returning-a-workresult)).
189
+
190
+ ---
191
+
192
+ ## Scheduling
193
+
194
+ Register a recurring schedule with `w.schedule(config)`. It fires on either a **5-field
195
+ cron expression** or a **next-time function**, and returns a cancel function. One instance
196
+ fires per occurrence (a `setIfNotExists` lease keyed by the fire time), so a cron never
197
+ double-fires across a fleet.
198
+
199
+ ### `ScheduleConfig`
200
+
201
+ ```ts
202
+ interface ScheduleConfig {
203
+ readonly name: string // unique; also the firing-lease key
204
+ readonly cron?: string // 5-field cron — mutually exclusive with `next`
205
+ readonly next?: (now: number) => number | Date | void // compute next fire time; void to stop
206
+ readonly run: () => Work | void // produce the work to enqueue (or enqueue yourself + return void)
207
+ }
208
+
209
+ // w.schedule(config: ScheduleConfig): () => void // returns a cancel function
210
+ ```
211
+
212
+ ### Example: cron + fn schedules
213
+
214
+ ```ts
215
+ // cron: every day at 03:00 (local time)
216
+ const cancelNightly = w.schedule({ name: 'nightly', cron: '0 3 * * *', run: () => report({}) })
217
+
218
+ // fn: every 60 seconds
219
+ const cancelTick = w.schedule({ name: 'tick', next: (now) => now + 60_000, run: () => poll({}) })
220
+
221
+ cancelNightly() // stop the schedule
222
+ ```
223
+
224
+ `run` returns a `Work` to enqueue it, or does its own enqueueing and returns `void`. A
225
+ `next` that returns `undefined`/`null` stops the schedule.
226
+
227
+ ### The cron parser — `parseCron` / `nextAfter`
228
+
229
+ The dependency-free 5-field cron parser is exported for direct use:
230
+
231
+ ```ts
232
+ function parseCron(expr: string): CronFields // throws on malformed expressions
233
+ function nextAfter(expr: string, fromMs: number): number | undefined // next match strictly after fromMs
234
+ ```
235
+
236
+ Format is `min hour dom mon dow`:
237
+
238
+ | Field | Range | Notes |
239
+ |---|---|---|
240
+ | minute | 0–59 | |
241
+ | hour | 0–23 | |
242
+ | day of month | 1–31 | |
243
+ | month | 1–12 | |
244
+ | day of week | 0–6 | 0 = Sunday |
245
+
246
+ Each field supports `*`, a number, an `a-b` range, a `<range>/<step>` step, and
247
+ comma-lists (e.g. `*/15`, `1-5`, `0,30`). When **both** day-of-month and day-of-week are
248
+ restricted (not `*`), standard cron **OR** semantics apply (matches either).
249
+
250
+ ```ts
251
+ import { parseCron, nextAfter } from '@ayepi/work'
252
+
253
+ parseCron('*/15 0 * * 1-5') // ok
254
+ parseCron('* * * *') // throws: expected 5 fields
255
+ nextAfter('* * * * *', Date.now()) // top of the next minute (epoch ms)
256
+ nextAfter('0 0 30 2 *', Date.now()) // undefined — Feb 30 never matches within ~a year
257
+ ```
258
+
259
+ Notes & constraints:
260
+ - **Minute-granular.** Cron's resolution is one minute; `nextAfter` returns the top of a
261
+ matching minute.
262
+ - **Local time.** Matching uses local `Date` getters (`getHours`, `getDay`, …), not UTC.
263
+ - **Bounded scan.** `nextAfter` scans at most ~1 year (`366 * 24 * 60` minutes) forward;
264
+ a never-matching expression returns `undefined`.
265
+
266
+ ### One-off scheduling — `runAt` & handler-thrown `WorkDelayError`
267
+
268
+ `w.schedule` is for **recurring** work. For a **one-off** future run, enqueue with an absolute
269
+ time instead — `enqueue(work, { runAt })` (epoch ms). `runAt` is an alternative to `delay` and
270
+ wins over it.
271
+
272
+ ```ts
273
+ // run once, far in the future
274
+ w.enqueue(report({ day }), { runAt: Date.parse('2030-01-01T03:00:00Z') })
275
+ ```
276
+
277
+ A running handler can also **defer its own item** to a later time by throwing `WorkDelayError`
278
+ — a **reschedule, not a retry**, so the `attempt` count is unchanged and a handler can defer
279
+ indefinitely (poll-style "not ready yet, try me later"):
280
+
281
+ ```ts
282
+ import { WorkDelayError } from '@ayepi/work'
283
+
284
+ const poll = defineWork('poll', async (input, ctx) => {
285
+ if (!(await upstreamReady())) throw new WorkDelayError({ delay: 5 * 60_000 }) // re-run in 5 min, same attempt
286
+ return ctx.result(await doWork(input))
287
+ })
288
+ ```
289
+
290
+ `WorkDelayError`'s `when` takes `{ runAt }` (absolute, wins) or `{ delay }` (relative ms).
291
+ The deferral re-enqueues the item at the resolved time and emits a `deferred` event. See
292
+ [`ayepi-work.md` → Deferral & scheduling](./ayepi-work.md#deferral--scheduling) for details.
293
+
294
+ **Far-future works on any backend.** Both `runAt` and a `WorkDelayError` deferral resolve to a
295
+ `startAt`. A backend that can't honor a long single delay (e.g. SQS caps `DelaySeconds` at
296
+ 15 min) hands the item back early; the engine re-checks `startAt` on pop and **re-defers**
297
+ until it's actually due, so an item scheduled arbitrarily far out still fires at the right time
298
+ (see [`ayepi-work-ports.md` → Early-arrival re-defer](./ayepi-work-ports.md#early-arrival-re-defer-far-future-scheduling)).
299
+
300
+ ### How scheduling works under the hood
301
+
302
+ A ~1 s tick (`SCHED_TICK = 1000`) checks whether the next fire time has arrived. When it
303
+ has, the instance tries to claim a `setIfNotExists` lease at
304
+ `<prefix>sched:<name>:<second-bucket>` (TTL `SCHED_LEASE_TTL = 90_000`). The instance that
305
+ wins the lease calls `run()` and enqueues the result; the next fire time is then recomputed.
306
+ Because the lease is keyed by the fire's second-bucket, exactly one instance fires per
307
+ occurrence across the fleet.
308
+
309
+ ---
310
+
311
+ See also: [`ayepi-work.md`](./ayepi-work.md) (core API, groups, retries, "how it works")
312
+ and [`ayepi-work-ports.md`](./ayepi-work-ports.md) (ports, custom backends, JSON codec).