@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/dist/index.cjs CHANGED
@@ -88,6 +88,16 @@ const defaultCodec = {
88
88
  */
89
89
  /** A v4 UUID. Thin wrapper over `node:crypto` so callers don't import it directly. */
90
90
  const uuid = () => (0, node_crypto.randomUUID)();
91
+ /** The active work-id generator (default {@link uuid}); swapped by {@link setIdGenerator}. */
92
+ let idGenerator = uuid;
93
+ /**
94
+ * Override how work/group ids are generated process-wide — e.g. sortable or prefixed ids. Affects
95
+ * **build-time** work ids (builders are minted outside a system) and any engine ids without their own
96
+ * `generateId`. Pass nothing to reset to the default UUID generator.
97
+ */
98
+ const setIdGenerator = (fn) => void (idGenerator = fn ?? uuid);
99
+ /** Generate an id via the active generator. */
100
+ const genId = () => idGenerator();
91
101
  /** Resolve after `ms` (an unref'd timer, so it never keeps the process alive). */
92
102
  function sleep(ms) {
93
103
  return new Promise((resolve) => {
@@ -426,7 +436,7 @@ const rehydrate = (s) => ({
426
436
  });
427
437
  /** Build a {@link DEPENDENCY_TYPE} work item from a (re-usable) input — a fresh queue id, same key. */
428
438
  const buildDependency = (input) => ({
429
- id: uuid(),
439
+ id: genId(),
430
440
  type: DEPENDENCY_TYPE,
431
441
  input
432
442
  });
@@ -436,7 +446,7 @@ const buildDependency = (input) => ({
436
446
  */
437
447
  function dependency(opts) {
438
448
  return buildDependency({
439
- key: uuid(),
449
+ key: genId(),
440
450
  on: opts.on.map(toId),
441
451
  queue: opts.queue.map(toSerialized),
442
452
  config: opts.config ?? "all-success",
@@ -459,12 +469,9 @@ const dependencyHandler = async (input, ctx) => {
459
469
  });
460
470
  const freshById = new Map(unknownIds.map((id, i) => [id, fresh[i]]));
461
471
  const states = input.on.map((id) => resolved[id] ? { status: resolved[id] } : freshById.get(id));
462
- if (conditionMet(input.config, states)) {
463
- if (await ctx.claim(`dep:${input.key}:fired`)) ctx.queue(input.queue.map(rehydrate));
464
- return;
465
- }
472
+ if (conditionMet(input.config, states)) return await ctx.claim(`dep:${input.key}:fired`) ? ctx.queue(input.queue.map(rehydrate)) : ctx.void();
466
473
  if (input.deadline !== void 0 && Date.now() >= input.deadline) throw new Error(`dependency: timed out waiting for [${input.on.join(", ")}]`);
467
- ctx.queue(buildDependency({
474
+ return ctx.queue(buildDependency({
468
475
  ...input,
469
476
  resolved
470
477
  }), { delay: input.poll });
@@ -611,6 +618,7 @@ const WORK_METRICS = {
611
618
  retried: "work.retried",
612
619
  deferred: "work.deferred",
613
620
  rescheduled: "work.rescheduled",
621
+ expired: "work.expired",
614
622
  active: "work.active",
615
623
  pending: "work.pending",
616
624
  running: "work.running",
@@ -643,6 +651,7 @@ const createWorkStats = (metrics = (0, _ayepi_core_stats.createMetrics)()) => {
643
651
  retried: metrics.counter(WORK_METRICS.retried, l, { description: "attempt-advancing retries" }),
644
652
  deferred: metrics.counter(WORK_METRICS.deferred, l, { description: "handler/classifier reschedules" }),
645
653
  rescheduled: metrics.counter(WORK_METRICS.rescheduled, l, { description: "early-arrival re-pushes" }),
654
+ expired: metrics.counter(WORK_METRICS.expired, l, { description: "items expired past their deadline" }),
646
655
  active: metrics.gauge(WORK_METRICS.active, l, { description: "items in flight (pending + running)" }),
647
656
  pending: metrics.gauge(WORK_METRICS.pending, l, { description: "items admitted, awaiting a doer slot" }),
648
657
  running: metrics.gauge(WORK_METRICS.running, l, { description: "items executing" }),
@@ -738,6 +747,7 @@ const createWorkStats = (metrics = (0, _ayepi_core_stats.createMetrics)()) => {
738
747
  h.lastFailed.set(at);
739
748
  },
740
749
  retried: (type) => void handles(type).retried.inc(),
750
+ expired: (type) => void handles(type).expired.inc(),
741
751
  deferred: (type, delayMs) => {
742
752
  const h = handles(type);
743
753
  h.deferred.inc();
@@ -757,23 +767,26 @@ const createWorkStats = (metrics = (0, _ayepi_core_stats.createMetrics)()) => {
757
767
  *
758
768
  * Defining a work type with {@link defineWork} yields a **callable builder**: call it
759
769
  * with the work's exact input and you get a type-checked, queueable {@link Work} (with
760
- * a build-time id) that also carries its output type. {@link createWork} takes a
761
- * `const` tuple of builders and produces a {@link WorkSystem} whose `enqueue` is fully
762
- * checked by instance (`enqueue(add({ a, b }))`) or by name (`enqueue('add', { a, b })`).
770
+ * a build-time id). A handler **returns a {@link WorkResult}** `ctx.result(value)`,
771
+ * `ctx.queue(...)`, `ctx.void()`, or a `.next(...)` dependency chain so each work carries
772
+ * two inferred types: its *awaited-alone* result `S` and its *group* contribution `G`.
773
+ * {@link createWork} takes a `const` tuple of builders and produces a {@link WorkSystem} whose
774
+ * `enqueue` is fully checked — by instance or by name.
763
775
  *
764
- * The **group result type** is the union of every registered work's non-void output
765
- * ({@link GroupResult}). All durations are **milliseconds**.
776
+ * `enqueue(root).group()` resolves to `root`'s `G` a **precise union from the workflow
777
+ * structure**, not the whole registry. All durations are **milliseconds**.
766
778
  *
767
779
  * @module
768
780
  */
769
781
  const build = (name, input) => ({
770
- id: uuid(),
782
+ id: genId(),
771
783
  type: name,
772
784
  input
773
785
  });
774
786
  /**
775
- * Define a work type. Returns a {@link WorkBuilder} — a function that builds queueable
776
- * instancestyped by its input `I` and output `O`.
787
+ * Define a work type. The handler **returns a {@link WorkResult}**`ctx.result(value)`,
788
+ * `ctx.queue(...)`, `ctx.void()`, or a `.next(...)` chain and its `S`/`G` types are inferred
789
+ * from that return: `S` is what `.result()` resolves to alone, `G` what it contributes to the group.
777
790
  */
778
791
  function defineWork(name, handler, opts = {}) {
779
792
  return Object.assign((input) => build(name, input), {
@@ -786,10 +799,10 @@ function defineWork(name, handler, opts = {}) {
786
799
  });
787
800
  }
788
801
  /**
789
- * Define a **batched** work type. Items still enqueue, retry, prioritize, and join
790
- * groups individually, but execute together via {@link BatchConfig.run} once `size`
791
- * accumulate or `maxWait` ms elapse — so each `.result()` resolves to its aligned
792
- * output. The per-type {@link WorkOptions.doer} governs how many *batches* run at once.
802
+ * Define a **batched** work type. Items still enqueue, retry, prioritize, and join groups
803
+ * individually, but execute together via {@link BatchConfig.run} once `size` accumulate or `maxWait`
804
+ * ms elapse — so each `.result()` resolves to its aligned output `O` (which is also its group
805
+ * contribution). The per-type {@link WorkOptions.doer} governs how many *batches* run at once.
793
806
  */
794
807
  function defineBatchWork(name, config) {
795
808
  const { size, maxWait, run, ...options } = config;
@@ -798,7 +811,7 @@ function defineBatchWork(name, config) {
798
811
  maxWait,
799
812
  run
800
813
  };
801
- const handler = async (input) => (await run([input]))[0];
814
+ const handler = async (input, ctx) => ctx.result((await run([input]))[0]);
802
815
  return Object.assign((input) => build(name, input), {
803
816
  type: name,
804
817
  def: {
@@ -859,6 +872,8 @@ const DEP_RETRY_ATTEMPTS = 1;
859
872
  const REDRIVE_DEFAULT = 10;
860
873
  const unref = (t) => void t.unref?.();
861
874
  const errString = (err) => err instanceof Error ? `${err.name}: ${err.message}` : String(err);
875
+ /** Brand stamped on a runtime `WorkResult` node — distinguishes a handler instruction from a raw value. */
876
+ const WR_BRAND = Symbol.for("ayepi.work.result");
862
877
  /**
863
878
  * Create a work system. Zero-config (`createWork()`) uses the bundled in-memory backend
864
879
  * and an {@link unlimitedDoer}; pass `work: [...] as const` for a typed registry, a
@@ -881,6 +896,8 @@ function createWork(opts = {}) {
881
896
  const logWith = opts.logWith ?? identityLogWith;
882
897
  const now = opts.now ?? Date.now;
883
898
  const random = opts.random ?? Math.random;
899
+ const newId = opts.generateId ?? genId;
900
+ const strictReturnDefault = opts.strictReturn ?? true;
884
901
  const attemptsOf = (r) => r.attempts ?? (0, _ayepi_core_retry.getDefaultRetryOptions)().attempts ?? 1;
885
902
  const stats = createWorkStats(opts.metrics);
886
903
  const k = (suffix) => prefix + suffix;
@@ -944,11 +961,13 @@ function createWork(opts = {}) {
944
961
  const resolveOptions = (type, input, qOpts) => {
945
962
  const tOpts = registry.get(type)?.def.options;
946
963
  const computed = tOpts?.options?.(input) ?? {};
964
+ const timeout = qOpts?.timeout ?? computed.timeout ?? tOpts?.timeout;
947
965
  return {
948
966
  delay: qOpts?.delay ?? computed.delay ?? 0,
949
967
  runAt: qOpts?.runAt ?? computed.runAt,
950
968
  priority: qOpts?.priority ?? computed.priority ?? tOpts?.priority ?? 0,
951
969
  group: qOpts?.group ?? computed.group ?? tOpts?.group,
970
+ deadline: qOpts?.deadline ?? computed.deadline ?? (timeout !== void 0 ? now() + timeout : void 0),
952
971
  retry: {
953
972
  ...(0, _ayepi_core_retry.getDefaultRetryOptions)(),
954
973
  ...opts.retry,
@@ -1010,16 +1029,56 @@ function createWork(opts = {}) {
1010
1029
  });
1011
1030
  maybeUnhandled(groupId);
1012
1031
  };
1013
- const makeContext = (impl) => {
1014
- const queue = (works, options) => Array.isArray(works) ? works.map((w) => impl.enqueueOne(w, options)) : impl.enqueueOne(works, options);
1032
+ const isWR = (x) => typeof x === "object" && x !== null && x[WR_BRAND] === true;
1033
+ const toItems = (x) => Array.isArray(x) ? [...x] : [x];
1034
+ const makeNode = (data) => {
1035
+ const node = {
1036
+ [WR_BRAND]: true,
1037
+ data,
1038
+ next(nextItems, condition = "all-success", options) {
1039
+ if (data.kind !== "queue") throw new Error(".next() is only valid on ctx.queue(...)");
1040
+ const q = toItems(nextItems);
1041
+ data.chains.push({
1042
+ on: data.frontier,
1043
+ queue: q,
1044
+ condition,
1045
+ options
1046
+ });
1047
+ data.frontier = q;
1048
+ return node;
1049
+ }
1050
+ };
1051
+ return node;
1052
+ };
1053
+ /** Collect the Work ids to wait on within items (recursing into nested `queue` nodes). */
1054
+ const workIdsOf = (items) => items.flatMap((it) => isWR(it) ? it.data.kind === "queue" ? workIdsOf(it.data.items) : [] : [it.id]);
1055
+ const makeContext = (meta, created) => {
1056
+ const reg = (n) => (created.add(n), n);
1015
1057
  return {
1016
- id: impl.id,
1017
- groupId: impl.groupId,
1018
- attempt: impl.attempt,
1019
- queue,
1020
- setResult: impl.setResult,
1021
- states: impl.states,
1022
- claim: impl.claim
1058
+ id: meta.id,
1059
+ groupId: meta.groupId,
1060
+ attempt: meta.attempt,
1061
+ parent: meta.parent,
1062
+ dependents: meta.dependents,
1063
+ result: (value, options) => reg(makeNode({
1064
+ kind: "result",
1065
+ value,
1066
+ final: !!options?.final,
1067
+ append: options?.append
1068
+ })),
1069
+ queue: (items, options) => {
1070
+ const arr = toItems(items);
1071
+ return reg(makeNode({
1072
+ kind: "queue",
1073
+ items: arr,
1074
+ options,
1075
+ chains: [],
1076
+ frontier: arr
1077
+ }));
1078
+ },
1079
+ void: () => reg(makeNode({ kind: "void" })),
1080
+ states: storeStates,
1081
+ claim: storeClaim
1023
1082
  };
1024
1083
  };
1025
1084
  const storeStates = (ids) => Promise.all(ids.map(readState));
@@ -1028,7 +1087,7 @@ function createWork(opts = {}) {
1028
1087
  const pushEnvelope = (env, delay) => port(() => Promise.resolve(queueFor(env.type).push(JSON.stringify(env), { delay })).then(() => void 0));
1029
1088
  /** Resolve when a deferred/scheduled item should next run (absolute epoch ms). */
1030
1089
  const resolveRunAt = (when) => when.runAt ?? now() + (when.delay ?? 0);
1031
- const submitQueued = (id, type, input, groupId, ro) => {
1090
+ const submitQueued = (id, type, input, groupId, ro, meta) => {
1032
1091
  const queueAt = now();
1033
1092
  const startAt = ro.runAt ?? queueAt + ro.delay;
1034
1093
  return (async () => {
@@ -1054,7 +1113,10 @@ function createWork(opts = {}) {
1054
1113
  attempt: 1,
1055
1114
  priority: ro.priority,
1056
1115
  group: ro.group,
1057
- retry: ro.retry
1116
+ retry: ro.retry,
1117
+ deadline: ro.deadline,
1118
+ parent: meta?.parent,
1119
+ dependents: meta?.dependents
1058
1120
  }, Math.max(0, startAt - queueAt));
1059
1121
  } catch (err) {
1060
1122
  await undoHold(groupId);
@@ -1066,10 +1128,14 @@ function createWork(opts = {}) {
1066
1128
  id,
1067
1129
  type,
1068
1130
  groupId,
1131
+ parent: meta?.parent,
1132
+ dependents: meta?.dependents,
1069
1133
  at: queueAt
1070
1134
  });
1071
1135
  })();
1072
1136
  };
1137
+ /** Submit a built {@link Work} (used by the result-apply walk), carrying its parent/dependents metadata. */
1138
+ const submitWork = (work, groupId, options, meta) => submitQueued(work.id, work.type, work.input, groupId, resolveOptions(work.type, work.input, options), meta);
1073
1139
  /** Re-enter the queue for a retry: a fresh delivery with `attempt + 1` and a recomputed `startAt`. */
1074
1140
  const rePush = async (env, delay) => {
1075
1141
  const startAt = now() + delay;
@@ -1092,11 +1158,11 @@ function createWork(opts = {}) {
1092
1158
  };
1093
1159
  const enqueueImpl = (a, b, c) => {
1094
1160
  const fromName = typeof a === "string";
1095
- const id = fromName ? uuid() : a.id;
1161
+ const id = fromName ? newId() : a.id;
1096
1162
  const type = fromName ? a : a.type;
1097
1163
  const input = fromName ? b : a.input;
1098
1164
  const qOpts = fromName ? c : b;
1099
- const groupId = uuid();
1165
+ const groupId = newId();
1100
1166
  const ro = resolveOptions(type, input, qOpts);
1101
1167
  return makeHandle(id, type, groupId, ro.skipQueue ? runImmediate(id, type, input, groupId, ro) : submitQueued(id, type, input, groupId, ro));
1102
1168
  };
@@ -1367,6 +1433,50 @@ function createWork(opts = {}) {
1367
1433
  attempt: env.attempt,
1368
1434
  error,
1369
1435
  willRetry: false,
1436
+ parent: env.parent,
1437
+ dependents: env.dependents,
1438
+ at
1439
+ });
1440
+ await settleGroup(env.groupId);
1441
+ };
1442
+ /** Whether an item is past its deadline (so it should expire rather than run or retry). */
1443
+ const expired = (env) => env.deadline !== void 0 && now() > env.deadline;
1444
+ /** Terminal expiry: like a dead-letter, but emits `'expired'` (the item didn't start+finish by its deadline). */
1445
+ const expireItem = async (p, env, q) => {
1446
+ const at = now();
1447
+ const error = "deadline exceeded";
1448
+ stats.expired(env.type);
1449
+ await port(() => Promise.resolve(store.set(k(`item:${env.id}:error`), error, RESULT_TTL)));
1450
+ await setState({
1451
+ id: env.id,
1452
+ type: env.type,
1453
+ status: "dead",
1454
+ attempt: env.attempt,
1455
+ error,
1456
+ queueAt: env.queueAt,
1457
+ startAt: env.startAt,
1458
+ endAt: at,
1459
+ priority: env.priority,
1460
+ group: env.group
1461
+ }, env.groupId);
1462
+ if (p && q) {
1463
+ await Promise.resolve(q.deadLetter?.(p.body, error));
1464
+ await port(() => Promise.resolve(q.ack(p)));
1465
+ }
1466
+ notify({
1467
+ kind: "done",
1468
+ id: env.id,
1469
+ groupId: env.groupId,
1470
+ error
1471
+ });
1472
+ emit({
1473
+ kind: "expired",
1474
+ id: env.id,
1475
+ type: env.type,
1476
+ groupId: env.groupId,
1477
+ deadline: env.deadline,
1478
+ parent: env.parent,
1479
+ dependents: env.dependents,
1370
1480
  at
1371
1481
  });
1372
1482
  await settleGroup(env.groupId);
@@ -1399,6 +1509,8 @@ function createWork(opts = {}) {
1399
1509
  type: env.type,
1400
1510
  groupId: env.groupId,
1401
1511
  attempt: env.attempt,
1512
+ parent: env.parent,
1513
+ dependents: env.dependents,
1402
1514
  at: runAt
1403
1515
  });
1404
1516
  };
@@ -1436,6 +1548,8 @@ function createWork(opts = {}) {
1436
1548
  groupId: env.groupId,
1437
1549
  attempt: env.attempt,
1438
1550
  result: output,
1551
+ parent: env.parent,
1552
+ dependents: env.dependents,
1439
1553
  at
1440
1554
  });
1441
1555
  await settleGroup(env.groupId);
@@ -1443,6 +1557,10 @@ function createWork(opts = {}) {
1443
1557
  const finishFailure = async (p, env, runAt, err, q) => {
1444
1558
  if (env.attempt < attemptsOf(env.retry)) {
1445
1559
  const delay = (0, _ayepi_core_retry.backoff)(env.attempt, env.retry, random);
1560
+ if (env.deadline !== void 0 && now() + delay > env.deadline) {
1561
+ await expireItem(p, env, q);
1562
+ return;
1563
+ }
1446
1564
  stats.retried(env.type);
1447
1565
  emit({
1448
1566
  kind: "failed",
@@ -1452,6 +1570,8 @@ function createWork(opts = {}) {
1452
1570
  attempt: env.attempt,
1453
1571
  error: errString(err),
1454
1572
  willRetry: true,
1573
+ parent: env.parent,
1574
+ dependents: env.dependents,
1455
1575
  at: now()
1456
1576
  });
1457
1577
  if (p && q) await port(() => Promise.resolve(q.ack(p)));
@@ -1500,35 +1620,85 @@ function createWork(opts = {}) {
1500
1620
  }
1501
1621
  await finishFailure(p, env, runAt, err, q);
1502
1622
  };
1503
- const queuedContext = (env, pending) => makeContext({
1504
- id: env.id,
1505
- groupId: env.groupId,
1506
- attempt: env.attempt,
1507
- enqueueOne: (work, options) => {
1508
- pending.push(submitQueued(work.id, work.type, work.input, env.groupId, resolveOptions(work.type, work.input, options)));
1509
- return work.id;
1510
- },
1511
- setResult: (r) => {
1512
- pending.push(port(() => Promise.resolve(store.set(k(`group:${env.groupId}:result`), globalCodec.stringify(r), RESULT_TTL)).then(() => void 0)));
1513
- },
1514
- states: storeStates,
1515
- claim: storeClaim
1516
- });
1623
+ /** Write a group-result contribution: `final` locks it; `append` folds into the existing value (read-modify-write, best-effort under concurrency). */
1624
+ const writeGroupResult = async (groupId, value, final, append) => {
1625
+ const resultKey = k(`group:${groupId}:result`);
1626
+ const finalKey = k(`group:${groupId}:final`);
1627
+ if (await Promise.resolve(store.get(finalKey)) !== void 0) return;
1628
+ let toWrite = value;
1629
+ if (append) {
1630
+ const ex = await Promise.resolve(store.get(resultKey));
1631
+ toWrite = append(ex === void 0 ? void 0 : globalCodec.parse(ex));
1632
+ }
1633
+ await port(() => Promise.resolve(store.set(resultKey, globalCodec.stringify(toWrite), RESULT_TTL)).then(() => void 0));
1634
+ if (final) await port(() => Promise.resolve(store.set(finalKey, "1", RESULT_TTL)).then(() => void 0));
1635
+ };
1636
+ /**
1637
+ * Run the returned WorkResult tree: enqueue works, build native dependencies (`.next`), write group
1638
+ * contributions. `assignDeps` (the `on` list when this is a fired dependency) tags non-dependency
1639
+ * children's `dependents`.
1640
+ */
1641
+ const applyNode = (node, env, pending, assignDeps) => {
1642
+ const d = node.data;
1643
+ if (d.kind === "void") return;
1644
+ if (d.kind === "result") {
1645
+ pending.push(writeGroupResult(env.groupId, d.value, d.final, d.append));
1646
+ return;
1647
+ }
1648
+ for (const item of d.items) if (isWR(item)) applyNode(item, env, pending, assignDeps);
1649
+ else pending.push(submitWork(item, env.groupId, d.options, {
1650
+ parent: env.id,
1651
+ dependents: item.type === "@work/dependency" ? void 0 : assignDeps
1652
+ }));
1653
+ for (const chain of d.chains) {
1654
+ const dep = dependency({
1655
+ on: workIdsOf(chain.on),
1656
+ queue: chain.queue,
1657
+ config: chain.condition
1658
+ });
1659
+ pending.push(submitWork(dep, env.groupId, chain.options, { parent: env.id }));
1660
+ }
1661
+ };
1662
+ /** Strict-return: every created WorkResult must be reachable from the returned `root` (else it silently does nothing). */
1663
+ const reachableCheck = (root, created) => {
1664
+ const seen = /* @__PURE__ */ new Set();
1665
+ const walk = (n) => {
1666
+ if (seen.has(n)) return;
1667
+ seen.add(n);
1668
+ if (n.data.kind === "queue") {
1669
+ for (const it of n.data.items) if (isWR(it)) walk(it);
1670
+ }
1671
+ };
1672
+ walk(root);
1673
+ for (const n of created) if (!seen.has(n)) throw new Error(`work "${root.data.kind}" created a ctx.queue()/result()/void() that was not returned (set strictReturn:false to allow detached instructions)`);
1674
+ };
1517
1675
  /**
1518
1676
  * The task handed to a doer: run a single item (leased from queue `q`, or `skipQueue` with `p`/`q`
1519
1677
  * null). `release` is the {@link claim} teardown — {@link scoped} runs it in `finally`, so the
1520
- * active-set entry + heartbeat are always cleaned up regardless of how the run ends.
1678
+ * active-set entry + heartbeat are always cleaned up regardless of how the run ends. The handler
1679
+ * returns a {@link WorkResult}; we then apply its tree (enqueue children, write group contributions).
1521
1680
  */
1522
1681
  const execute = (p, env, input, def, q, release) => scoped(release, async () => {
1523
1682
  const runAt = now();
1524
1683
  markRunningSync(env, runAt);
1525
1684
  const running = persistRunning(env, runAt).catch((err) => report(err, "commit"));
1526
- const pending = [];
1527
- const ctx = queuedContext(env, pending);
1528
- let output;
1685
+ const created = /* @__PURE__ */ new Set();
1686
+ const ctx = makeContext({
1687
+ id: env.id,
1688
+ groupId: env.groupId,
1689
+ attempt: env.attempt,
1690
+ parent: env.parent,
1691
+ dependents: env.dependents
1692
+ }, created);
1693
+ let itemValue;
1529
1694
  try {
1530
- output = await logWith(merge(opts.logContext?.(input, env.type), def.options.logContext?.(input)), () => Promise.resolve(def.handler(input, ctx)));
1695
+ const ret = await logWith(merge(opts.logContext?.(input, env.type), def.options.logContext?.(input)), () => Promise.resolve(def.handler(input, ctx)));
1696
+ if (!isWR(ret)) throw new Error(`work "${env.type}" handler must return a WorkResult — ctx.result(...), ctx.queue(...), or ctx.void()`);
1697
+ if (def.options.strictReturn ?? strictReturnDefault) reachableCheck(ret, created);
1698
+ const pending = [];
1699
+ applyNode(ret, env, pending, env.type === "@work/dependency" ? input.on : void 0);
1531
1700
  await Promise.all(pending);
1701
+ itemValue = ret.data.kind === "result" ? ret.data.value : void 0;
1532
1702
  } catch (err) {
1533
1703
  await running;
1534
1704
  await handleFailure(p, env, runAt, err, q);
@@ -1536,7 +1706,7 @@ function createWork(opts = {}) {
1536
1706
  }
1537
1707
  try {
1538
1708
  await running;
1539
- await finishSuccess(p, env, runAt, output, q);
1709
+ await finishSuccess(p, env, runAt, itemValue, q);
1540
1710
  } catch (err) {
1541
1711
  report(err, "commit");
1542
1712
  }
@@ -1555,7 +1725,7 @@ function createWork(opts = {}) {
1555
1725
  await Promise.all(items.map((it) => handleFailure(it.p, it.env, runAt, err, it.q)));
1556
1726
  return;
1557
1727
  }
1558
- await Promise.all(items.map((it, i) => Promise.resolve(finishSuccess(it.p, it.env, runAt, outputs[i], it.q)).catch((err) => report(err, "commit"))));
1728
+ await Promise.all(items.map((it, i) => Promise.resolve(writeGroupResult(it.env.groupId, outputs[i], false)).then(() => finishSuccess(it.p, it.env, runAt, outputs[i], it.q)).catch((err) => report(err, "commit"))));
1559
1729
  });
1560
1730
  const batchers = /* @__PURE__ */ new Map();
1561
1731
  const batcherFor = (type) => {
@@ -1608,6 +1778,10 @@ function createWork(opts = {}) {
1608
1778
  await Promise.resolve(q.ack(p));
1609
1779
  return false;
1610
1780
  }
1781
+ if (expired(env)) {
1782
+ await expireItem(p, env, q);
1783
+ return false;
1784
+ }
1611
1785
  const due = now();
1612
1786
  if (env.startAt - due > SCHED_TOLERANCE) {
1613
1787
  await reschedule(p, env, q, env.startAt);
@@ -1781,7 +1955,10 @@ function createWork(opts = {}) {
1781
1955
  await sleep(pollInterval);
1782
1956
  }
1783
1957
  };
1784
- registry.set(DEPENDENCY_TYPE, defineWork(DEPENDENCY_TYPE, dependencyHandler, { retry: { attempts: DEP_RETRY_ATTEMPTS } }));
1958
+ registry.set(DEPENDENCY_TYPE, defineWork(DEPENDENCY_TYPE, dependencyHandler, {
1959
+ retry: { attempts: DEP_RETRY_ATTEMPTS },
1960
+ strictReturn: false
1961
+ }));
1785
1962
  const start = () => {
1786
1963
  if (running) return;
1787
1964
  running = true;
@@ -1893,7 +2070,7 @@ function adaptiveDelay(opts = {}) {
1893
2070
  * ```ts
1894
2071
  * import { defineWork, createWork } from '@ayepi/work'
1895
2072
  *
1896
- * const add = defineWork('add', (i: { a: number; b: number }) => i.a + i.b)
2073
+ * const add = defineWork('add', (i: { a: number; b: number }, ctx) => ctx.result(i.a + i.b))
1897
2074
  * const w = createWork({ work: [add] as const })
1898
2075
  *
1899
2076
  * const sum = await w.enqueue(add({ a: 1, b: 2 })).result() // 3, typed as number
@@ -2009,6 +2186,7 @@ Object.defineProperty(exports, "setDefaultRetryOptions", {
2009
2186
  return _ayepi_core_retry.setDefaultRetryOptions;
2010
2187
  }
2011
2188
  });
2189
+ exports.setIdGenerator = setIdGenerator;
2012
2190
  exports.start = start;
2013
2191
  exports.stop = stop;
2014
2192
  Object.defineProperty(exports, "unlimitedDoer", {