@beignet/core 0.0.31 → 0.0.32

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.
Files changed (64) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +2 -2
  3. package/dist/domain/entity.d.ts +37 -6
  4. package/dist/domain/entity.d.ts.map +1 -1
  5. package/dist/domain/entity.js +9 -7
  6. package/dist/domain/entity.js.map +1 -1
  7. package/dist/domain/index.d.ts +4 -4
  8. package/dist/domain/index.d.ts.map +1 -1
  9. package/dist/domain/index.js +4 -4
  10. package/dist/domain/index.js.map +1 -1
  11. package/dist/flags/index.d.ts +5 -4
  12. package/dist/flags/index.d.ts.map +1 -1
  13. package/dist/flags/index.js +10 -9
  14. package/dist/flags/index.js.map +1 -1
  15. package/dist/idempotency/index.d.ts +11 -1
  16. package/dist/idempotency/index.d.ts.map +1 -1
  17. package/dist/idempotency/index.js +4 -3
  18. package/dist/idempotency/index.js.map +1 -1
  19. package/dist/jobs/index.d.ts +35 -6
  20. package/dist/jobs/index.d.ts.map +1 -1
  21. package/dist/jobs/index.js +82 -11
  22. package/dist/jobs/index.js.map +1 -1
  23. package/dist/outbox/index.d.ts +20 -2
  24. package/dist/outbox/index.d.ts.map +1 -1
  25. package/dist/outbox/index.js +23 -8
  26. package/dist/outbox/index.js.map +1 -1
  27. package/dist/ports/events.d.ts +2 -2
  28. package/dist/providers/provider.d.ts +10 -2
  29. package/dist/providers/provider.d.ts.map +1 -1
  30. package/dist/providers/provider.js.map +1 -1
  31. package/dist/search/index.d.ts +2 -1
  32. package/dist/search/index.d.ts.map +1 -1
  33. package/dist/search/index.js +1 -0
  34. package/dist/search/index.js.map +1 -1
  35. package/dist/server/providers/loadProviderConfig.d.ts.map +1 -1
  36. package/dist/server/providers/loadProviderConfig.js +15 -2
  37. package/dist/server/providers/loadProviderConfig.js.map +1 -1
  38. package/dist/testing/index.d.ts +6 -0
  39. package/dist/testing/index.d.ts.map +1 -1
  40. package/dist/testing/index.js +12 -3
  41. package/dist/testing/index.js.map +1 -1
  42. package/dist/uploads/index.d.ts +111 -2
  43. package/dist/uploads/index.d.ts.map +1 -1
  44. package/dist/uploads/index.js +124 -61
  45. package/dist/uploads/index.js.map +1 -1
  46. package/dist/webhooks/index.d.ts +1 -0
  47. package/dist/webhooks/index.d.ts.map +1 -1
  48. package/dist/webhooks/index.js +1 -0
  49. package/dist/webhooks/index.js.map +1 -1
  50. package/package.json +1 -1
  51. package/src/domain/entity.ts +61 -13
  52. package/src/domain/index.ts +8 -7
  53. package/src/flags/index.ts +23 -19
  54. package/src/idempotency/index.ts +17 -3
  55. package/src/jobs/index.ts +131 -16
  56. package/src/outbox/index.ts +52 -8
  57. package/src/ports/events.ts +2 -2
  58. package/src/providers/provider.ts +11 -2
  59. package/src/search/index.ts +3 -1
  60. package/src/server/providers/loadProviderConfig.ts +19 -2
  61. package/src/testing/index.ts +19 -3
  62. package/src/uploads/index.ts +174 -62
  63. package/src/webhooks/index.ts +2 -0
  64. package/src/domain/events.ts +0 -59
@@ -66,9 +66,47 @@ function validateSchemaSync<T>(
66
66
  // biome-ignore lint/suspicious/noExplicitAny: Base methods type for flexibility
67
67
  type AnyMethods = Record<string, any>;
68
68
 
69
+ /**
70
+ * Base entity instance passed to the `.methods(...)` builder: validated props
71
+ * plus the `with` and `toJSON` helpers, before domain methods attach.
72
+ *
73
+ * `with(...)` is typed as returning the base instance here because the final
74
+ * methods object is still being defined while the builder runs. At runtime
75
+ * the returned instance carries the entity's methods, and `EntityInstance`
76
+ * maps base-instance returns back to the full instance type so consumers keep
77
+ * typed method chaining.
78
+ */
79
+ export type EntityBaseInstance<Schema extends StandardSchema> =
80
+ InferOutput<Schema> & {
81
+ /**
82
+ * Create a new validated entity instance with patched properties.
83
+ */
84
+ with(
85
+ patch: Partial<InferOutput<Schema>>,
86
+ ): EntityBaseInstance<Schema> | Promise<EntityBaseInstance<Schema>>;
87
+ /**
88
+ * Convert the entity to a plain JSON object for persistence.
89
+ */
90
+ toJSON(): InferOutput<Schema>;
91
+ };
92
+
93
+ /**
94
+ * Replace base-instance returns with the full entity instance type, unwrapping
95
+ * promises, so methods that return `self` or `self.with(...)` chain with the
96
+ * entity's methods attached.
97
+ */
98
+ type WithEntityInstance<R, Instance, Schema extends StandardSchema> =
99
+ R extends Promise<infer P>
100
+ ? Promise<WithEntityInstance<P, Instance, Schema>>
101
+ : R extends EntityBaseInstance<Schema>
102
+ ? Instance
103
+ : R;
104
+
69
105
  /**
70
106
  * Entity instance with validated props, attached methods, and immutable update
71
- * helpers.
107
+ * helpers. Method returns that are typed as the base instance (`self` or
108
+ * `self.with(...)`) are mapped to the full instance type so chaining keeps
109
+ * the entity's methods.
72
110
  */
73
111
  export type EntityInstance<
74
112
  Name extends string,
@@ -87,7 +125,13 @@ export type EntityInstance<
87
125
  * Convert the entity to a plain JSON object for persistence.
88
126
  */
89
127
  toJSON(): InferOutput<Schema>;
90
- } & Methods;
128
+ } & {
129
+ [K in keyof Methods]: Methods[K] extends (...args: infer A) => infer R
130
+ ? (
131
+ ...args: A
132
+ ) => WithEntityInstance<R, EntityInstance<Name, Schema, Methods>, Schema>
133
+ : Methods[K];
134
+ };
91
135
 
92
136
  /**
93
137
  * Entity definition returned by `defineEntity(...).build()`.
@@ -135,8 +179,7 @@ class EntityBuilder<
135
179
  private readonly cfg: {
136
180
  name: Name;
137
181
  schema?: Schema;
138
- // biome-ignore lint/suspicious/noExplicitAny: Methods builder receives base instance
139
- methods?: (self: any) => Methods;
182
+ methods?: (self: EntityBaseInstance<Schema>) => Methods;
140
183
  },
141
184
  ) {}
142
185
 
@@ -147,17 +190,22 @@ class EntityBuilder<
147
190
  return new EntityBuilder<Name, S, Methods>({
148
191
  name: this.cfg.name,
149
192
  schema,
150
- methods: this.cfg.methods,
193
+ // Methods declared before props were typed against the previous schema;
194
+ // canonical builder order is props-then-methods, so this widening only
195
+ // affects the discouraged reverse order.
196
+ methods: this.cfg.methods as unknown as
197
+ | ((self: EntityBaseInstance<S>) => Methods)
198
+ | undefined,
151
199
  });
152
200
  }
153
201
 
154
202
  /**
155
203
  * Define methods to attach to entity instances.
156
- * The method builder receives the base instance (with props, with, and toJSON).
204
+ * The method builder receives the typed base instance: validated props plus
205
+ * `with` and `toJSON`.
157
206
  */
158
207
  methods<M extends AnyMethods>(
159
- // biome-ignore lint/suspicious/noExplicitAny: Methods builder receives base instance with props
160
- build: (self: any) => M,
208
+ build: (self: EntityBaseInstance<Schema>) => M,
161
209
  ): EntityBuilder<Name, Schema, M> {
162
210
  return new EntityBuilder<Name, Schema, M>({
163
211
  name: this.cfg.name,
@@ -190,8 +238,7 @@ class EntityBuilder<
190
238
  // Cast parsed to a plain object for spreading
191
239
  const parsedObj = parsed as Record<string, unknown>;
192
240
 
193
- // biome-ignore lint/suspicious/noExplicitAny: Dynamic object construction
194
- const base: any = {
241
+ const base = {
195
242
  ...parsedObj,
196
243
  // Immutable update - creates a new validated instance
197
244
  // Note: Re-validates the merged props for data integrity
@@ -205,7 +252,7 @@ class EntityBuilder<
205
252
  toJSON() {
206
253
  return parsed;
207
254
  },
208
- };
255
+ } as EntityBaseInstance<Schema>;
209
256
 
210
257
  const methods = methodsBuilder?.(base) ?? {};
211
258
  const instance = Object.freeze({
@@ -262,6 +309,7 @@ class EntityBuilder<
262
309
  * ```
263
310
  */
264
311
  export function defineEntity<Name extends string>(name: Name) {
265
- // biome-ignore lint/suspicious/noExplicitAny: Initial schema type will be set via .props()
266
- return new EntityBuilder<Name, any, Record<string, never>>({ name } as any);
312
+ return new EntityBuilder<Name, StandardSchema, Record<string, never>>({
313
+ name,
314
+ });
267
315
  }
@@ -1,18 +1,19 @@
1
1
  /**
2
2
  * @beignet/core/domain
3
3
  *
4
- * Domain modeling helpers for Beignet - value objects, entities, and domain events.
4
+ * Domain modeling helpers for Beignet - value objects and entities.
5
5
  *
6
6
  * This package provides small, framework-agnostic helpers for domain modeling:
7
7
  * - A Value Object builder (defineValueObject)
8
8
  * - An Entity/Aggregate builder (defineEntity)
9
- * - A Domain Event helper (defineDomainEvent)
9
+ *
10
+ * Domain events are declared with `defineEvent` from `@beignet/core/events`.
10
11
  */
11
12
 
12
- export { defineEntity, type EntityDef, type EntityInstance } from "./entity.js";
13
13
  export {
14
- type DomainEventDef,
15
- defineDomainEvent,
16
- type InferEventPayload,
17
- } from "./events.js";
14
+ defineEntity,
15
+ type EntityBaseInstance,
16
+ type EntityDef,
17
+ type EntityInstance,
18
+ } from "./entity.js";
18
19
  export { defineValueObject, type ValueObjectDef } from "./value-object.js";
@@ -38,7 +38,7 @@ export type FlagKey = string;
38
38
  /**
39
39
  * Feature flag value kind.
40
40
  */
41
- export type FlagKind = "boolean" | "string" | "number" | "object";
41
+ export type FlagValueKind = "boolean" | "string" | "number" | "object";
42
42
 
43
43
  /**
44
44
  * Subject used for provider-neutral flag targeting.
@@ -104,8 +104,9 @@ export type FlagEvaluationContext = {
104
104
  * Flag definition registered through `defineFlags(...)`.
105
105
  */
106
106
  export type FlagDef<TValue extends FlagValue = FlagValue> = {
107
+ kind: "flag";
107
108
  key: FlagKey;
108
- kind: FlagKindForValue<TValue>;
109
+ valueKind: FlagValueKindForValue<TValue>;
109
110
  defaultValue: TValue;
110
111
  description?: string;
111
112
  metadata?: Record<string, unknown>;
@@ -120,13 +121,14 @@ export type InferFlagValue<TFlag> =
120
121
  /**
121
122
  * Map a value type to its runtime flag kind.
122
123
  */
123
- export type FlagKindForValue<TValue extends FlagValue> = TValue extends boolean
124
- ? "boolean"
125
- : TValue extends string
126
- ? "string"
127
- : TValue extends number
128
- ? "number"
129
- : "object";
124
+ export type FlagValueKindForValue<TValue extends FlagValue> =
125
+ TValue extends boolean
126
+ ? "boolean"
127
+ : TValue extends string
128
+ ? "string"
129
+ : TValue extends number
130
+ ? "number"
131
+ : "object";
130
132
 
131
133
  /**
132
134
  * Options accepted when declaring a flag.
@@ -184,7 +186,7 @@ export type FlagEvaluationDetails<TValue extends FlagValue = FlagValue> = {
184
186
  key: string;
185
187
  value: TValue;
186
188
  defaultValue: TValue;
187
- kind: FlagKindForValue<TValue>;
189
+ valueKind: FlagValueKindForValue<TValue>;
188
190
  reason: FlagEvaluationReason;
189
191
  defaulted: boolean;
190
192
  variant?: string;
@@ -320,20 +322,21 @@ export type MemoryFlagsPort = FlagsPort & {
320
322
  };
321
323
 
322
324
  function createFlag<TValue extends FlagValue>(
323
- kind: FlagKind,
325
+ valueKind: FlagValueKind,
324
326
  key: string,
325
327
  options: DefineFlagOptions<TValue>,
326
328
  ): FlagDef<TValue> {
327
329
  const defaultValue = options.default;
328
- if (!matchesKind(defaultValue, kind)) {
330
+ if (!matchesValueKind(defaultValue, valueKind)) {
329
331
  throw new Error(
330
- `Flag "${key}" default value does not match "${kind}" flag kind.`,
332
+ `Flag "${key}" default value does not match "${valueKind}" flag value kind.`,
331
333
  );
332
334
  }
333
335
 
334
336
  return {
337
+ kind: "flag",
335
338
  key,
336
- kind: kind as FlagKindForValue<TValue>,
339
+ valueKind: valueKind as FlagValueKindForValue<TValue>,
337
340
  defaultValue,
338
341
  description: options.description,
339
342
  metadata: options.metadata,
@@ -454,9 +457,9 @@ export function createMemoryFlags(
454
457
  ...port,
455
458
  values,
456
459
  set(flag, value) {
457
- if (!matchesKind(value, flag.kind)) {
460
+ if (!matchesValueKind(value, flag.valueKind)) {
458
461
  throw new Error(
459
- `Flag "${flag.key}" value does not match "${flag.kind}" flag kind.`,
462
+ `Flag "${flag.key}" value does not match "${flag.valueKind}" flag value kind.`,
460
463
  );
461
464
  }
462
465
  values.set(flag.key, value);
@@ -485,13 +488,14 @@ function createFlagsFromStore(
485
488
  const values = readValues();
486
489
  const stored = values[flag.key];
487
490
  const hasStored = Object.hasOwn(values, flag.key);
488
- const usedStoredValue = hasStored && matchesKind(stored, flag.kind);
491
+ const usedStoredValue =
492
+ hasStored && matchesValueKind(stored, flag.valueKind);
489
493
  const value = usedStoredValue
490
494
  ? (stored as typeof defaultValue)
491
495
  : defaultValue;
492
496
  const details: FlagEvaluationDetails<typeof defaultValue> = {
493
497
  key: flag.key,
494
- kind: flag.kind as FlagKindForValue<typeof defaultValue>,
498
+ valueKind: flag.valueKind as FlagValueKindForValue<typeof defaultValue>,
495
499
  value,
496
500
  defaultValue,
497
501
  reason: usedStoredValue ? "static" : "default",
@@ -551,7 +555,7 @@ function createFlagsFromStore(
551
555
  return flags;
552
556
  }
553
557
 
554
- function matchesKind(value: unknown, kind: FlagKind): boolean {
558
+ function matchesValueKind(value: unknown, kind: FlagValueKind): boolean {
555
559
  switch (kind) {
556
560
  case "boolean":
557
561
  return typeof value === "boolean";
@@ -507,13 +507,27 @@ function reservationFromRecord(
507
507
  };
508
508
  }
509
509
 
510
+ /**
511
+ * Options for `createMemoryIdempotencyStore(...)`.
512
+ */
513
+ export interface MemoryIdempotencyStoreOptions {
514
+ /**
515
+ * Clock used for reservation and completion timestamps. Defaults to the
516
+ * system clock.
517
+ */
518
+ now?: () => Date;
519
+ }
520
+
510
521
  /**
511
522
  * Create an in-memory idempotency store for tests and local examples.
512
523
  *
513
524
  * The memory store is process-local and not suitable for multi-process
514
525
  * production deployments.
515
526
  */
516
- export function createMemoryIdempotencyStore(): MemoryIdempotencyStore {
527
+ export function createMemoryIdempotencyStore(
528
+ options: MemoryIdempotencyStoreOptions = {},
529
+ ): MemoryIdempotencyStore {
530
+ const storeNow = options.now ?? (() => new Date());
517
531
  const records = new Map<string, MemoryRecord>();
518
532
 
519
533
  return {
@@ -527,7 +541,7 @@ export function createMemoryIdempotencyStore(): MemoryIdempotencyStore {
527
541
  assertNonEmptyString("fingerprint", input.fingerprint);
528
542
  assertTtl(input.ttlSec);
529
543
 
530
- const now = new Date();
544
+ const now = storeNow();
531
545
  const storageKey = createIdempotencyStorageKey(input);
532
546
  const existing = records.get(storageKey);
533
547
 
@@ -573,7 +587,7 @@ export function createMemoryIdempotencyStore(): MemoryIdempotencyStore {
573
587
 
574
588
  existing.status = "completed";
575
589
  existing.result = input.result;
576
- existing.completedAt = new Date();
590
+ existing.completedAt = storeNow();
577
591
  },
578
592
 
579
593
  async fail(input) {
package/src/jobs/index.ts CHANGED
@@ -284,18 +284,50 @@ export interface InlineJobDispatcherOptions<Ctx> {
284
284
  */
285
285
  ctx?: Ctx | (() => MaybePromise<Ctx>);
286
286
  /**
287
- * Called when a dispatched inline job fails. When omitted, errors are
288
- * rethrown to the caller.
287
+ * Called when a dispatched inline job fails all attempts allowed by its
288
+ * retry policy. When omitted, the final error is rethrown to the caller.
289
289
  */
290
290
  onError?: (error: unknown, job: JobDef<string, StandardSchema, Ctx>) => void;
291
+ /**
292
+ * Sleep implementation used between retry attempts. Defaults to a real
293
+ * `setTimeout` delay; inject a fake in tests to keep retries instant.
294
+ */
295
+ sleep?: (ms: number) => Promise<void>;
296
+ /**
297
+ * Honor the job's declared retry policy inline. Defaults to `true`. Set
298
+ * `false` when another layer owns execution retries for every dispatch
299
+ * through this dispatcher.
300
+ */
301
+ retry?: boolean;
291
302
  }
292
303
 
304
+ /**
305
+ * Well-known symbol under which the inline dispatcher exposes a
306
+ * single-attempt dispatch. Delivery systems that own execution retries
307
+ * themselves — the outbox drain — call it instead of `dispatch(...)` so a
308
+ * job's retry policy runs in exactly one layer. Registered with `Symbol.for`
309
+ * so multiple core copies in one process agree on the key.
310
+ */
311
+ export const SINGLE_ATTEMPT_DISPATCH: unique symbol = Symbol.for(
312
+ "beignet.jobs.singleAttemptDispatch",
313
+ );
314
+
315
+ /**
316
+ * Shape of the single-attempt dispatch exposed under
317
+ * `SINGLE_ATTEMPT_DISPATCH`.
318
+ */
319
+ export type SingleAttemptJobDispatch = <J extends JobDef>(
320
+ job: J,
321
+ payload: InferJobPayload<J>,
322
+ ) => Promise<void>;
323
+
293
324
  /**
294
325
  * Local/test job dispatcher that executes job handlers inline.
295
326
  */
296
327
  export interface InlineJobDispatcher<Ctx = unknown> {
297
328
  /**
298
- * Validate a payload and run the job handler immediately.
329
+ * Validate a payload and run the job handler inline, honoring the job's
330
+ * declared retry policy before failing.
299
331
  */
300
332
  dispatch<J extends JobDef<string, StandardSchema, Ctx>>(
301
333
  job: J,
@@ -400,7 +432,20 @@ function durationToMs(name: string, value: JobRetryDuration): number {
400
432
  }
401
433
  }
402
434
 
435
+ const VALIDATED_RETRY_OPTIONS = Symbol("beignet.jobs.validatedRetryOptions");
436
+
403
437
  function validateJobRetryOptions(options: JobRetryOptions): JobRetryOptions {
438
+ // Options returned by this function are branded so repeated validation —
439
+ // per attempt in the retry helpers — short-circuits instead of re-parsing
440
+ // duration strings and reallocating.
441
+ if (
442
+ (options as { [VALIDATED_RETRY_OPTIONS]?: boolean })[
443
+ VALIDATED_RETRY_OPTIONS
444
+ ]
445
+ ) {
446
+ return options;
447
+ }
448
+
404
449
  const strategy = options.strategy ?? "exponential";
405
450
 
406
451
  if (!["none", "fixed", "exponential"].includes(strategy)) {
@@ -436,11 +481,16 @@ function validateJobRetryOptions(options: JobRetryOptions): JobRetryOptions {
436
481
  }
437
482
  }
438
483
 
439
- return {
484
+ const validated = {
440
485
  ...options,
441
486
  strategy,
442
487
  attempts,
443
488
  };
489
+ Object.defineProperty(validated, VALIDATED_RETRY_OPTIONS, {
490
+ value: true,
491
+ enumerable: false,
492
+ });
493
+ return validated;
444
494
  }
445
495
 
446
496
  /**
@@ -584,28 +634,93 @@ export async function parseJobPayload<J extends JobDef>(
584
634
 
585
635
  /**
586
636
  * Create a local/test dispatcher that runs job handlers inline.
637
+ *
638
+ * Dispatch honors the job's declared retry policy: failed attempts retry with
639
+ * the policy's delays until the policy is exhausted. Payload validation
640
+ * failures never retry. Jobs without a retry policy run exactly once.
587
641
  */
588
642
  export function createInlineJobDispatcher<Ctx>(
589
643
  options: InlineJobDispatcherOptions<Ctx> = {},
590
644
  ): InlineJobDispatcher<Ctx> {
591
- return {
592
- async dispatch<J extends JobDef<string, StandardSchema, Ctx>>(
593
- job: J,
594
- payload: InferJobPayload<J>,
595
- ) {
645
+ const sleep =
646
+ options.sleep ??
647
+ ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
648
+
649
+ async function run<J extends JobDef<string, StandardSchema, Ctx>>(
650
+ job: J,
651
+ payload: InferJobPayload<J>,
652
+ retryEnabled: boolean,
653
+ ): Promise<void> {
654
+ const fail = (error: unknown): void => {
655
+ options.onError?.(error, job);
656
+ if (!options.onError) throw error;
657
+ };
658
+
659
+ let parsed: InferJobPayload<J>;
660
+ let ctx: Ctx;
661
+ try {
662
+ parsed = await parseJobPayload(job, payload);
663
+ // Resolve once per dispatch so a ctx factory does not run (and cannot
664
+ // produce different contexts) across retry attempts.
665
+ ctx = await resolveCtx(options.ctx);
666
+ } catch (error) {
667
+ fail(error);
668
+ return;
669
+ }
670
+
671
+ const policy = retryEnabled ? job.retry : undefined;
672
+ const maxAttempts = policy ? (getJobRetryMaxAttempts(policy) ?? 1) : 1;
673
+
674
+ for (let attempt = 1; ; attempt += 1) {
596
675
  try {
597
- const parsed = await parseJobPayload(job, payload);
598
676
  await job.handle({
599
677
  job,
600
678
  payload: parsed,
601
- ctx: await resolveCtx(options.ctx),
679
+ ctx,
602
680
  });
681
+ return;
603
682
  } catch (error) {
604
- options.onError?.(error, job);
605
- if (!options.onError) throw error;
683
+ const willRetry = shouldRetryJob(policy, {
684
+ error,
685
+ attempt,
686
+ maxAttempts,
687
+ jobName: job.name,
688
+ });
689
+ if (!willRetry) {
690
+ fail(error);
691
+ return;
692
+ }
693
+
694
+ const delayMs = getJobRetryDelayMs(policy, {
695
+ error,
696
+ attempt,
697
+ jobName: job.name,
698
+ });
699
+ if (delayMs > 0) await sleep(delayMs);
606
700
  }
701
+ }
702
+ }
703
+
704
+ const dispatcher: InlineJobDispatcher<Ctx> = {
705
+ async dispatch<J extends JobDef<string, StandardSchema, Ctx>>(
706
+ job: J,
707
+ payload: InferJobPayload<J>,
708
+ ) {
709
+ await run(job, payload, options.retry !== false);
607
710
  },
608
711
  };
712
+
713
+ // Non-enumerable so spreads and serialization keep treating the dispatcher
714
+ // as a plain port; the outbox drain discovers it by symbol.
715
+ Object.defineProperty(dispatcher, SINGLE_ATTEMPT_DISPATCH, {
716
+ value: <J extends JobDef<string, StandardSchema, Ctx>>(
717
+ job: J,
718
+ payload: InferJobPayload<J>,
719
+ ) => run(job, payload, false),
720
+ enumerable: false,
721
+ });
722
+
723
+ return dispatcher;
609
724
  }
610
725
 
611
726
  /**
@@ -617,9 +732,9 @@ export function createInlineJobDispatcher<Ctx>(
617
732
  * export const { defineJob } = createJobs<AppContext>();
618
733
  * ```
619
734
  *
620
- * Retry options are provider hints. Inline dispatchers validate payloads and
621
- * call `handle(...)` immediately; durable providers may enqueue or schedule the
622
- * job according to their own runtime.
735
+ * Retry options describe the job's retry policy. Inline dispatchers run the
736
+ * policy in-process with real delays; durable providers map the policy onto
737
+ * their own runtime and reject options they cannot honor.
623
738
  */
624
739
  export function createJobs<Ctx>(): Jobs<Ctx> {
625
740
  return {
@@ -16,6 +16,8 @@ import {
16
16
  type InferJobPayload,
17
17
  type JobDef,
18
18
  parseJobPayload,
19
+ SINGLE_ATTEMPT_DISPATCH,
20
+ type SingleAttemptJobDispatch,
19
21
  shouldRetryJob,
20
22
  } from "../jobs/index.js";
21
23
  import type { JobDispatcherPort } from "../ports/events.js";
@@ -311,9 +313,13 @@ export interface MemoryOutboxPort extends OutboxPort {
311
313
  */
312
314
  export interface CreateOutboxMessageOptions {
313
315
  /**
314
- * Generated message ID override.
316
+ * Generated message ID override. Wins over `input.id`.
315
317
  */
316
318
  id?: string;
319
+ /**
320
+ * Fallback ID factory used when neither `id` nor `input.id` is provided.
321
+ */
322
+ createId?: () => string;
317
323
  /**
318
324
  * Timestamp used for created/updated/available dates.
319
325
  */
@@ -677,7 +683,7 @@ export function createOutboxMessage(
677
683
 
678
684
  const now = options.now ?? new Date();
679
685
  return {
680
- id: options.id ?? input.id ?? createId(),
686
+ id: options.id ?? input.id ?? options.createId?.() ?? createId(),
681
687
  kind: input.kind,
682
688
  name: input.name,
683
689
  payload: toOutboxJsonValue(input.payload),
@@ -709,12 +715,31 @@ function isEligible(message: OutboxMessage, now: Date): boolean {
709
715
  );
710
716
  }
711
717
 
718
+ /**
719
+ * Options for `createMemoryOutbox(...)`.
720
+ */
721
+ export interface MemoryOutboxOptions {
722
+ /**
723
+ * Message and claim-token ID factory. Defaults to `crypto.randomUUID()`.
724
+ */
725
+ id?: () => string;
726
+ /**
727
+ * Clock used for enqueue, claim, and completion timestamps when a call does
728
+ * not supply its own `now`. Defaults to the system clock.
729
+ */
730
+ now?: () => Date;
731
+ }
732
+
712
733
  /**
713
734
  * Create an in-memory outbox for tests and local examples.
714
735
  *
715
736
  * The memory outbox is process-local and not durable.
716
737
  */
717
- export function createMemoryOutbox(): MemoryOutboxPort {
738
+ export function createMemoryOutbox(
739
+ storeOptions: MemoryOutboxOptions = {},
740
+ ): MemoryOutboxPort {
741
+ const createStoreId = storeOptions.id ?? createId;
742
+ const storeNow = storeOptions.now ?? (() => new Date());
718
743
  const messages = new Map<string, OutboxMessage>();
719
744
 
720
745
  function getClaimedOrThrow(id: string, claimToken: string): OutboxMessage {
@@ -740,7 +765,10 @@ export function createMemoryOutbox(): MemoryOutboxPort {
740
765
  },
741
766
 
742
767
  async enqueue(input) {
743
- const message = createOutboxMessage(input);
768
+ const message = createOutboxMessage(input, {
769
+ createId: createStoreId,
770
+ now: storeNow(),
771
+ });
744
772
  if (messages.has(message.id)) {
745
773
  throw new Error(`Outbox message "${message.id}" already exists.`);
746
774
  }
@@ -750,7 +778,7 @@ export function createMemoryOutbox(): MemoryOutboxPort {
750
778
 
751
779
  async claimBatch(options) {
752
780
  assertPositiveInteger("limit", options.limit);
753
- const now = options.now ?? new Date();
781
+ const now = options.now ?? storeNow();
754
782
  const leaseMs = options.leaseMs ?? DEFAULT_OUTBOX_LEASE_MS;
755
783
  assertPositiveInteger("leaseMs", leaseMs);
756
784
  const lockedUntil = new Date(now.getTime() + leaseMs);
@@ -768,7 +796,7 @@ export function createMemoryOutbox(): MemoryOutboxPort {
768
796
  for (const message of eligible) {
769
797
  message.status = "claimed";
770
798
  message.attempts += 1;
771
- message.claimToken = createId();
799
+ message.claimToken = createStoreId();
772
800
  message.claimedAt = cloneDate(now);
773
801
  message.lockedUntil = cloneDate(lockedUntil);
774
802
  message.updatedAt = cloneDate(now);
@@ -782,7 +810,7 @@ export function createMemoryOutbox(): MemoryOutboxPort {
782
810
  assertNonEmptyString("id", input.id);
783
811
  assertNonEmptyString("claimToken", input.claimToken);
784
812
  const message = getClaimedOrThrow(input.id, input.claimToken);
785
- const now = input.now ?? new Date();
813
+ const now = input.now ?? storeNow();
786
814
 
787
815
  message.status = "delivered";
788
816
  message.deliveredAt = cloneDate(now);
@@ -796,7 +824,7 @@ export function createMemoryOutbox(): MemoryOutboxPort {
796
824
  assertNonEmptyString("id", input.id);
797
825
  assertNonEmptyString("claimToken", input.claimToken);
798
826
  const message = getClaimedOrThrow(input.id, input.claimToken);
799
- const now = input.now ?? new Date();
827
+ const now = input.now ?? storeNow();
800
828
 
801
829
  message.status = input.deadLetter ? "deadLettered" : "pending";
802
830
  message.lastError = serializeOutboxError(input.error);
@@ -1018,6 +1046,22 @@ async function deliverOutboxMessage(
1018
1046
  }
1019
1047
 
1020
1048
  const payload = await parseJobPayload(job, message.payload);
1049
+
1050
+ // The drain owns execution retries: failed deliveries are rescheduled with
1051
+ // the job's own policy via markFailed/retryAt. When the dispatcher exposes
1052
+ // a single-attempt dispatch (the inline dispatcher does), use it so the
1053
+ // retry policy runs in exactly one layer. Durable providers do not expose
1054
+ // it — for them dispatch is an enqueue and the queue owns execution.
1055
+ const singleAttempt = (
1056
+ options.jobs as {
1057
+ [SINGLE_ATTEMPT_DISPATCH]?: SingleAttemptJobDispatch;
1058
+ }
1059
+ )[SINGLE_ATTEMPT_DISPATCH];
1060
+ if (singleAttempt) {
1061
+ await singleAttempt(job, payload);
1062
+ return;
1063
+ }
1064
+
1021
1065
  await options.jobs.dispatch(job, payload);
1022
1066
  }
1023
1067
 
@@ -10,8 +10,8 @@ import type {
10
10
 
11
11
  /**
12
12
  * Represents a defined Domain Event with name and payload schema.
13
- * This is a minimal interface that ports need - implementations can use
14
- * the full DomainEventDef from @beignet/core/domain if desired.
13
+ * This is a minimal structural interface that ports need - event
14
+ * declarations from `defineEvent` in @beignet/core/events satisfy it.
15
15
  */
16
16
  export interface DomainEventDef<
17
17
  Name extends string = string,