@beignet/core 0.0.48 → 0.0.50

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 (173) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +224 -20
  3. package/dist/client/client.d.ts +0 -2
  4. package/dist/client/client.d.ts.map +1 -1
  5. package/dist/client/client.js +28 -25
  6. package/dist/client/client.js.map +1 -1
  7. package/dist/contracts/contract-builder.d.ts +7 -2
  8. package/dist/contracts/contract-builder.d.ts.map +1 -1
  9. package/dist/contracts/contract-builder.js +20 -2
  10. package/dist/contracts/contract-builder.js.map +1 -1
  11. package/dist/contracts/contract-group.d.ts.map +1 -1
  12. package/dist/contracts/contract-group.js +1 -0
  13. package/dist/contracts/contract-group.js.map +1 -1
  14. package/dist/contracts/contract-like.d.ts +2 -0
  15. package/dist/contracts/contract-like.d.ts.map +1 -1
  16. package/dist/contracts/contract-like.js +27 -1
  17. package/dist/contracts/contract-like.js.map +1 -1
  18. package/dist/contracts/index.d.ts +4 -0
  19. package/dist/contracts/index.d.ts.map +1 -1
  20. package/dist/contracts/index.js +4 -0
  21. package/dist/contracts/index.js.map +1 -1
  22. package/dist/contracts/query-transport.d.ts +126 -0
  23. package/dist/contracts/query-transport.d.ts.map +1 -0
  24. package/dist/contracts/query-transport.js +406 -0
  25. package/dist/contracts/query-transport.js.map +1 -0
  26. package/dist/contracts/schema-shape.d.ts +11 -0
  27. package/dist/contracts/schema-shape.d.ts.map +1 -1
  28. package/dist/contracts/schema-shape.js +13 -0
  29. package/dist/contracts/schema-shape.js.map +1 -1
  30. package/dist/contracts/types.d.ts +5 -0
  31. package/dist/contracts/types.d.ts.map +1 -1
  32. package/dist/contracts/types.js.map +1 -1
  33. package/dist/events/index.d.ts +54 -5
  34. package/dist/events/index.d.ts.map +1 -1
  35. package/dist/events/index.js +183 -32
  36. package/dist/events/index.js.map +1 -1
  37. package/dist/idempotency/index.d.ts +7 -3
  38. package/dist/idempotency/index.d.ts.map +1 -1
  39. package/dist/idempotency/index.js +45 -12
  40. package/dist/idempotency/index.js.map +1 -1
  41. package/dist/mail/index.d.ts.map +1 -1
  42. package/dist/mail/index.js +6 -3
  43. package/dist/mail/index.js.map +1 -1
  44. package/dist/openapi/index.d.ts +8 -0
  45. package/dist/openapi/index.d.ts.map +1 -1
  46. package/dist/openapi/index.js +79 -5
  47. package/dist/openapi/index.js.map +1 -1
  48. package/dist/outbox/index.d.ts +8 -5
  49. package/dist/outbox/index.d.ts.map +1 -1
  50. package/dist/outbox/index.js +17 -3
  51. package/dist/outbox/index.js.map +1 -1
  52. package/dist/ports/best-effort-work.d.ts +21 -0
  53. package/dist/ports/best-effort-work.d.ts.map +1 -0
  54. package/dist/ports/best-effort-work.js +2 -0
  55. package/dist/ports/best-effort-work.js.map +1 -0
  56. package/dist/ports/cache.d.ts +9 -1
  57. package/dist/ports/cache.d.ts.map +1 -1
  58. package/dist/ports/cache.js +20 -5
  59. package/dist/ports/cache.js.map +1 -1
  60. package/dist/ports/events.d.ts +7 -5
  61. package/dist/ports/events.d.ts.map +1 -1
  62. package/dist/ports/index.d.ts +7 -2
  63. package/dist/ports/index.d.ts.map +1 -1
  64. package/dist/ports/index.js +2 -1
  65. package/dist/ports/index.js.map +1 -1
  66. package/dist/ports/testing.d.ts +15 -0
  67. package/dist/ports/testing.d.ts.map +1 -1
  68. package/dist/ports/testing.js +38 -0
  69. package/dist/ports/testing.js.map +1 -1
  70. package/dist/providers/provider.d.ts +8 -5
  71. package/dist/providers/provider.d.ts.map +1 -1
  72. package/dist/providers/provider.js.map +1 -1
  73. package/dist/server/hooks/cors.d.ts +2 -2
  74. package/dist/server/hooks/cors.d.ts.map +1 -1
  75. package/dist/server/hooks/cors.js +2 -1
  76. package/dist/server/hooks/cors.js.map +1 -1
  77. package/dist/server/hooks/logging.d.ts +2 -2
  78. package/dist/server/hooks/logging.d.ts.map +1 -1
  79. package/dist/server/hooks/logging.js.map +1 -1
  80. package/dist/server/hooks/rate-limit.d.ts +16 -8
  81. package/dist/server/hooks/rate-limit.d.ts.map +1 -1
  82. package/dist/server/hooks/rate-limit.js +31 -17
  83. package/dist/server/hooks/rate-limit.js.map +1 -1
  84. package/dist/server/hooks/security.d.ts +2 -2
  85. package/dist/server/hooks/security.d.ts.map +1 -1
  86. package/dist/server/hooks/security.js.map +1 -1
  87. package/dist/server/http.d.ts +21 -2
  88. package/dist/server/http.d.ts.map +1 -1
  89. package/dist/server/index.d.ts +4 -0
  90. package/dist/server/index.d.ts.map +1 -1
  91. package/dist/server/index.js +4 -0
  92. package/dist/server/index.js.map +1 -1
  93. package/dist/server/instrumentation.d.ts.map +1 -1
  94. package/dist/server/instrumentation.js +5 -3
  95. package/dist/server/instrumentation.js.map +1 -1
  96. package/dist/server/request-executor.d.ts.map +1 -1
  97. package/dist/server/request-executor.js +18 -9
  98. package/dist/server/request-executor.js.map +1 -1
  99. package/dist/server/request-preparation.d.ts.map +1 -1
  100. package/dist/server/request-preparation.js +31 -11
  101. package/dist/server/request-preparation.js.map +1 -1
  102. package/dist/server/response-finalization.d.ts +2 -2
  103. package/dist/server/response-finalization.d.ts.map +1 -1
  104. package/dist/server/response-finalization.js +25 -8
  105. package/dist/server/response-finalization.js.map +1 -1
  106. package/dist/server/route-matching.d.ts.map +1 -1
  107. package/dist/server/route-matching.js +12 -1
  108. package/dist/server/route-matching.js.map +1 -1
  109. package/dist/server/server-sent-events.d.ts +94 -0
  110. package/dist/server/server-sent-events.d.ts.map +1 -0
  111. package/dist/server/server-sent-events.js +275 -0
  112. package/dist/server/server-sent-events.js.map +1 -0
  113. package/dist/server/server.d.ts.map +1 -1
  114. package/dist/server/server.js +43 -22
  115. package/dist/server/server.js.map +1 -1
  116. package/dist/server/trusted-proxy-internal.d.ts +4 -0
  117. package/dist/server/trusted-proxy-internal.d.ts.map +1 -1
  118. package/dist/server/trusted-proxy-internal.js +20 -0
  119. package/dist/server/trusted-proxy-internal.js.map +1 -1
  120. package/dist/server/trusted-proxy.d.ts.map +1 -1
  121. package/dist/server/trusted-proxy.js +3 -8
  122. package/dist/server/trusted-proxy.js.map +1 -1
  123. package/dist/server/use-case-route.d.ts +8 -5
  124. package/dist/server/use-case-route.d.ts.map +1 -1
  125. package/dist/server/use-case-route.js +44 -17
  126. package/dist/server/use-case-route.js.map +1 -1
  127. package/dist/testing/index.d.ts +17 -0
  128. package/dist/testing/index.d.ts.map +1 -1
  129. package/dist/testing/index.js +6 -1
  130. package/dist/testing/index.js.map +1 -1
  131. package/package.json +3 -3
  132. package/skills/app-architecture/SKILL.md +50 -4
  133. package/src/client/client.ts +29 -28
  134. package/src/contracts/contract-builder.ts +32 -2
  135. package/src/contracts/contract-group.ts +1 -0
  136. package/src/contracts/contract-like.ts +40 -1
  137. package/src/contracts/index.ts +23 -0
  138. package/src/contracts/query-transport.ts +697 -0
  139. package/src/contracts/schema-shape.ts +24 -0
  140. package/src/contracts/types.ts +5 -0
  141. package/src/events/index.ts +263 -38
  142. package/src/idempotency/index.ts +65 -17
  143. package/src/mail/index.ts +7 -3
  144. package/src/openapi/index.ts +126 -2
  145. package/src/outbox/index.ts +26 -5
  146. package/src/ports/best-effort-work.ts +21 -0
  147. package/src/ports/cache.ts +29 -7
  148. package/src/ports/events.ts +9 -4
  149. package/src/ports/index.ts +10 -1
  150. package/src/ports/testing.ts +45 -0
  151. package/src/providers/provider.ts +8 -5
  152. package/src/server/hooks/cors.ts +11 -5
  153. package/src/server/hooks/logging.ts +6 -2
  154. package/src/server/hooks/rate-limit.ts +50 -24
  155. package/src/server/hooks/security.ts +8 -4
  156. package/src/server/http.ts +23 -2
  157. package/src/server/index.ts +4 -0
  158. package/src/server/instrumentation.ts +12 -4
  159. package/src/server/request-executor.ts +31 -9
  160. package/src/server/request-preparation.ts +45 -12
  161. package/src/server/response-finalization.ts +51 -15
  162. package/src/server/route-matching.ts +24 -1
  163. package/src/server/server-sent-events.ts +415 -0
  164. package/src/server/server.ts +48 -22
  165. package/src/server/trusted-proxy-internal.ts +20 -0
  166. package/src/server/trusted-proxy.ts +6 -7
  167. package/src/server/use-case-route.ts +62 -23
  168. package/src/testing/index.ts +30 -0
  169. package/dist/query-codec.d.ts +0 -3
  170. package/dist/query-codec.d.ts.map +0 -1
  171. package/dist/query-codec.js +0 -110
  172. package/dist/query-codec.js.map +0 -1
  173. package/src/query-codec.ts +0 -130
@@ -11,6 +11,8 @@ import {
11
11
  getContractHeaderSchemas,
12
12
  methodSupportsRequestBody,
13
13
  parsePathTemplate,
14
+ type QueryFieldTransport,
15
+ queryTransportSchema,
14
16
  resolveContract,
15
17
  STANDARD_ERROR_RESPONSE_SCHEMA,
16
18
  } from "../contracts/index.js";
@@ -20,7 +22,9 @@ import {
20
22
  } from "../contracts/lifecycle.js";
21
23
  import {
22
24
  comparePathParamsToTemplate,
25
+ compareQueryTransportFields,
23
26
  formatPathParamsMismatch,
27
+ formatQueryTransportMismatch,
24
28
  } from "../contracts/schema-shape.js";
25
29
  import {
26
30
  createZodIntrospector,
@@ -157,6 +161,21 @@ export interface ParameterObject {
157
161
  * Parameter schema.
158
162
  */
159
163
  schema?: SchemaObject | ReferenceObject;
164
+ /**
165
+ * OpenAPI parameter serialization style.
166
+ */
167
+ style?:
168
+ | "form"
169
+ | "simple"
170
+ | "matrix"
171
+ | "label"
172
+ | "spaceDelimited"
173
+ | "pipeDelimited"
174
+ | "deepObject";
175
+ /**
176
+ * Whether arrays and objects are expanded into separate parameter values.
177
+ */
178
+ explode?: boolean;
160
179
  /**
161
180
  * Parameter description.
162
181
  */
@@ -627,12 +646,36 @@ function addQueryParams(
627
646
  state: GeneratorState,
628
647
  ): void {
629
648
  if (!contract.query) return;
649
+ const transport = contract.queryTransport;
650
+ if (!transport) {
651
+ throw new Error(
652
+ `Contract "${contract.name}" declares a query schema without a query transport.`,
653
+ );
654
+ }
630
655
 
631
656
  const shape = state.introspector.getShape(contract.query);
632
- if (!shape) return;
657
+ if (!shape) {
658
+ throw new Error(
659
+ `Unable to inspect query schema for contract "${contract.name}". Pass a schemaIntrospector that can read its fields so OpenAPI requiredness and constraints remain aligned with the declared query transport.`,
660
+ );
661
+ }
633
662
 
634
- for (const key of Object.keys(shape)) {
663
+ const schemaKeys = Object.keys(shape);
664
+ const transportKeys = Object.keys(transport.fields);
665
+ if (!compareQueryTransportFields({ schemaKeys, transportKeys })) {
666
+ throw new Error(
667
+ formatQueryTransportMismatch({
668
+ contractName: contract.name,
669
+ schemaKeys,
670
+ transportKeys,
671
+ }),
672
+ );
673
+ }
674
+
675
+ for (const key of Object.keys(transport.fields)) {
635
676
  const originalField = shape[key];
677
+ const fieldTransport = transport.fields[key];
678
+ if (!originalField || !fieldTransport) continue;
636
679
  const optional = state.introspector.isOptional(originalField);
637
680
 
638
681
  const field = originalField;
@@ -644,6 +687,7 @@ function addQueryParams(
644
687
  state,
645
688
  "input",
646
689
  );
690
+ applyQueryTransportSchema(paramSchemaRef, fieldTransport, state);
647
691
 
648
692
  const param: ParameterObject = {
649
693
  name: key,
@@ -651,12 +695,92 @@ function addQueryParams(
651
695
  required: !optional,
652
696
  schema: paramSchemaRef,
653
697
  description,
698
+ style: fieldTransport.kind === "deep-object" ? "deepObject" : "form",
699
+ explode: true,
654
700
  };
655
701
 
656
702
  addParameter(operation, param);
657
703
  }
658
704
  }
659
705
 
706
+ function applyQueryTransportSchema(
707
+ schema: SchemaObject | ReferenceObject,
708
+ transport: QueryFieldTransport,
709
+ state: GeneratorState,
710
+ ): void {
711
+ if (!("$ref" in schema) || typeof schema.$ref !== "string") return;
712
+ const prefix = "#/components/schemas/";
713
+ if (!schema.$ref.startsWith(prefix)) return;
714
+ const name = schema.$ref.slice(prefix.length);
715
+ const converted = state.components.schemas?.[name];
716
+ if (!converted || !state.components.schemas) return;
717
+ state.components.schemas[name] = mergeQueryTransportSchema(
718
+ converted,
719
+ transport,
720
+ );
721
+ }
722
+
723
+ function mergeQueryTransportSchema(
724
+ converted: SchemaObject,
725
+ transport: QueryFieldTransport,
726
+ ): SchemaObject {
727
+ const declared = queryTransportSchema(transport);
728
+ const merged: SchemaObject = {
729
+ ...converted,
730
+ type: declared.type,
731
+ };
732
+ if (declared.format) merged.format = declared.format;
733
+ if (transport.kind === "integer") {
734
+ merged.minimum = Math.max(
735
+ typeof converted.minimum === "number"
736
+ ? converted.minimum
737
+ : Number.MIN_SAFE_INTEGER,
738
+ Number.MIN_SAFE_INTEGER,
739
+ );
740
+ merged.maximum = Math.min(
741
+ typeof converted.maximum === "number"
742
+ ? converted.maximum
743
+ : Number.MAX_SAFE_INTEGER,
744
+ Number.MAX_SAFE_INTEGER,
745
+ );
746
+ }
747
+ if (declared["x-beignet-empty-query"]) {
748
+ merged["x-beignet-empty-query"] = declared["x-beignet-empty-query"];
749
+ }
750
+
751
+ if (transport.kind === "array") {
752
+ const convertedItems =
753
+ typeof converted.items === "object" && converted.items !== null
754
+ ? (converted.items as SchemaObject)
755
+ : {};
756
+ merged.items = mergeQueryTransportSchema(convertedItems, transport.item);
757
+ }
758
+
759
+ if (transport.kind === "deep-object") {
760
+ const convertedProperties =
761
+ typeof converted.properties === "object" && converted.properties !== null
762
+ ? (converted.properties as Record<string, unknown>)
763
+ : {};
764
+ merged.properties = Object.fromEntries(
765
+ Object.entries(transport.fields).map(([key, field]) => {
766
+ const convertedProperty = convertedProperties[key];
767
+ return [
768
+ key,
769
+ mergeQueryTransportSchema(
770
+ typeof convertedProperty === "object" && convertedProperty !== null
771
+ ? (convertedProperty as SchemaObject)
772
+ : {},
773
+ field,
774
+ ),
775
+ ];
776
+ }),
777
+ );
778
+ merged.additionalProperties = false;
779
+ }
780
+
781
+ return merged;
782
+ }
783
+
660
784
  /**
661
785
  * Add header parameters from contract to operation.
662
786
  */
@@ -555,7 +555,8 @@ export interface DrainOutboxOptions {
555
555
  */
556
556
  registry: OutboxRegistry;
557
557
  /**
558
- * Event bus used for event messages.
558
+ * Event bus used for event messages. Required when the registry contains
559
+ * events.
559
560
  */
560
561
  eventBus?: {
561
562
  publish<E extends EventPayloadDef>(
@@ -565,7 +566,8 @@ export interface DrainOutboxOptions {
565
566
  ): MaybePromise<void>;
566
567
  };
567
568
  /**
568
- * Job dispatcher used for job messages.
569
+ * Job dispatcher used for job messages. Required when the registry contains
570
+ * jobs.
569
571
  */
570
572
  jobs?: JobDispatcherPort;
571
573
  /**
@@ -1476,15 +1478,17 @@ async function deliverOutboxMessage(
1476
1478
  * Claim and deliver one batch of outbox messages.
1477
1479
  *
1478
1480
  * This does not loop forever; production workers should call it on their own
1479
- * polling cadence. Event and job messages require matching registry entries.
1480
- * Failed messages are retried with backoff until `maxAttempts`, then
1481
- * dead-lettered.
1481
+ * polling cadence. Event and job messages require matching registry entries
1482
+ * and delivery transports. Required transports are validated before a batch
1483
+ * is claimed. Failed messages are retried with backoff until `maxAttempts`,
1484
+ * then dead-lettered.
1482
1485
  */
1483
1486
  export async function drainOutbox(
1484
1487
  options: DrainOutboxOptions,
1485
1488
  ): Promise<DrainOutboxResult> {
1486
1489
  const batchSize = options.batchSize ?? 100;
1487
1490
  assertPositiveInteger("batchSize", batchSize);
1491
+ assertOutboxDeliveryCapabilities(options);
1488
1492
  const instrumentation = createProviderInstrumentation(
1489
1493
  options.instrumentation,
1490
1494
  {
@@ -1659,6 +1663,23 @@ export async function drainOutbox(
1659
1663
  return result;
1660
1664
  }
1661
1665
 
1666
+ function assertOutboxDeliveryCapabilities(options: DrainOutboxOptions): void {
1667
+ const missing: string[] = [];
1668
+
1669
+ if (options.registry.events.size > 0 && !options.eventBus) {
1670
+ missing.push("events require an event bus");
1671
+ }
1672
+ if (options.registry.jobs.size > 0 && !options.jobs) {
1673
+ missing.push("jobs require a job dispatcher");
1674
+ }
1675
+
1676
+ if (missing.length > 0) {
1677
+ throw new OutboxRegistryError(
1678
+ `Cannot drain this outbox registry: ${missing.join("; ")}.`,
1679
+ );
1680
+ }
1681
+ }
1682
+
1662
1683
  /**
1663
1684
  * Domain event recorder port re-exported for outbox integrations.
1664
1685
  */
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Non-durable work scheduled outside the originating application operation.
3
+ */
4
+ export type BestEffortWork = () => Promise<void> | void;
5
+
6
+ /**
7
+ * Non-durable, best-effort follow-up work.
8
+ *
9
+ * Use this for follow-up work that may be lost without changing the outcome
10
+ * of the originating operation. Required or retryable work belongs in a job
11
+ * or durable outbox instead.
12
+ */
13
+ export interface BestEffortWorkPort {
14
+ /**
15
+ * Schedule work without waiting for it in the originating operation.
16
+ *
17
+ * Adapters should isolate scheduling and callback failures from the caller
18
+ * and report them through their configured error observer.
19
+ */
20
+ defer(work: BestEffortWork): void;
21
+ }
@@ -3,11 +3,35 @@
3
3
  */
4
4
  export interface CacheSetOptions {
5
5
  /**
6
- * Time-to-live in seconds. Omit this for a value that does not expire.
6
+ * Time-to-live in seconds. Values must be positive safe integers. Omit this
7
+ * for a value that does not expire.
7
8
  */
8
9
  ttlSeconds?: number;
9
10
  }
10
11
 
12
+ /**
13
+ * Resolve and validate the optional TTL shared by cache adapters.
14
+ *
15
+ * @param options - Cache write options.
16
+ * @returns The positive TTL, or `undefined` for a persistent value.
17
+ */
18
+ export function resolveCacheTtlSeconds(
19
+ options?: CacheSetOptions,
20
+ ): number | undefined {
21
+ const ttlSeconds = options?.ttlSeconds;
22
+ if (ttlSeconds === undefined) {
23
+ return undefined;
24
+ }
25
+
26
+ if (!Number.isSafeInteger(ttlSeconds) || ttlSeconds <= 0) {
27
+ throw new RangeError(
28
+ "Cache ttlSeconds must be a positive safe integer when provided.",
29
+ );
30
+ }
31
+
32
+ return ttlSeconds;
33
+ }
34
+
11
35
  /**
12
36
  * App-facing string cache port.
13
37
  *
@@ -54,15 +78,12 @@ type MemoryCacheEntry = {
54
78
  };
55
79
 
56
80
  function resolveExpiresAt(options: CacheSetOptions | undefined): number | null {
57
- if (options?.ttlSeconds == null) {
81
+ const ttlSeconds = resolveCacheTtlSeconds(options);
82
+ if (ttlSeconds === undefined) {
58
83
  return null;
59
84
  }
60
85
 
61
- if (options.ttlSeconds <= 0) {
62
- return Date.now();
63
- }
64
-
65
- return Date.now() + options.ttlSeconds * 1000;
86
+ return Math.min(Number.MAX_SAFE_INTEGER, Date.now() + ttlSeconds * 1000);
66
87
  }
67
88
 
68
89
  function isExpired(entry: MemoryCacheEntry): boolean {
@@ -120,6 +141,7 @@ export function createMemoryCache(
120
141
  return (await getFreshEntry(key)) != null;
121
142
  },
122
143
  async remember(key, factory, options) {
144
+ resolveCacheTtlSeconds(options);
123
145
  const cached = await cache.get(key);
124
146
  if (cached != null) {
125
147
  return cached;
@@ -1,9 +1,13 @@
1
1
  import type {
2
2
  EventPayloadDef,
3
3
  EventPublishOptions,
4
+ EventSubscription,
4
5
  InferEventPayload as InferContractEventPayload,
5
6
  StandardSchema,
6
7
  } from "../events/index.js";
8
+
9
+ export type { EventSubscription } from "../events/index.js";
10
+
7
11
  import type {
8
12
  JobDef as ContractJobDef,
9
13
  InferJobPayload as InferContractJobPayload,
@@ -58,15 +62,16 @@ export type InferJobPayload<J extends JobDef> = InferContractJobPayload<J>;
58
62
  * const eventBus = createMemoryEventBus();
59
63
  *
60
64
  * // Subscribe to an event
61
- * const unsubscribe = eventBus.subscribe(UserRegistered, (payload) => {
65
+ * const subscription = eventBus.subscribe(UserRegistered, (payload) => {
62
66
  * console.log(`User registered: ${payload.email}`);
63
67
  * });
68
+ * await subscription.ready;
64
69
  *
65
70
  * // Publish an event
66
71
  * await eventBus.publish(UserRegistered, { userId: "123", email: "test@example.com" });
67
72
  *
68
73
  * // Unsubscribe when done
69
- * unsubscribe();
74
+ * await subscription.unsubscribe();
70
75
  * ```
71
76
  */
72
77
  export interface EventBusPort {
@@ -80,7 +85,7 @@ export interface EventBusPort {
80
85
  ): Promise<void> | void;
81
86
 
82
87
  /**
83
- * Subscribe to a domain event. Returns an unsubscribe function.
88
+ * Subscribe to a domain event. Returns initial readiness and cleanup.
84
89
  */
85
90
  subscribe<E extends DomainEventDef>(
86
91
  event: E,
@@ -88,7 +93,7 @@ export interface EventBusPort {
88
93
  payload: InferEventPayload<E>,
89
94
  options?: EventPublishOptions,
90
95
  ) => Promise<void> | void,
91
- ): () => void;
96
+ ): EventSubscription;
92
97
  }
93
98
 
94
99
  /**
@@ -8,6 +8,7 @@
8
8
  * - `definePorts` – helper to define a typed ports object
9
9
  * - `PortsContext` – a generic type that describes ctx objects that carry ports
10
10
  * - `EventBusPort` – interface for event bus implementations
11
+ * - `BestEffortWorkPort` – interface for non-durable follow-up work
11
12
  * - `JobDispatcherPort` – interface for job dispatch implementations
12
13
  * - `UnitOfWorkPort` – interface for app-owned transaction boundaries
13
14
  * - `OutboxPort` – interface for durable event/job delivery storage
@@ -468,6 +469,13 @@ export {
468
469
  requireUserId,
469
470
  TenantRequiredError,
470
471
  } from "./auth.js";
472
+ /**
473
+ * Best-effort work port exports.
474
+ */
475
+ export type {
476
+ BestEffortWork,
477
+ BestEffortWorkPort,
478
+ } from "./best-effort-work.js";
471
479
  /**
472
480
  * Ports builder exports.
473
481
  */
@@ -483,7 +491,7 @@ export type { CachePort, CacheSetOptions } from "./cache.js";
483
491
  /**
484
492
  * Cache helper exports.
485
493
  */
486
- export { createMemoryCache } from "./cache.js";
494
+ export { createMemoryCache, resolveCacheTtlSeconds } from "./cache.js";
487
495
  /**
488
496
  * Clock port exports.
489
497
  */
@@ -498,6 +506,7 @@ export { createFrozenClock, createSystemClock } from "./clock.js";
498
506
  export type {
499
507
  DomainEventDef,
500
508
  EventBusPort,
509
+ EventSubscription,
501
510
  InferEventPayload,
502
511
  InferJobPayload,
503
512
  JobDef,
@@ -38,6 +38,7 @@ import {
38
38
  createTenant,
39
39
  createUserActor,
40
40
  } from "./audit.js";
41
+ import type { BestEffortWork, BestEffortWorkPort } from "./best-effort-work.js";
41
42
  import type { EventBusPort, JobDef, JobDispatcherPort } from "./events.js";
42
43
  import {
43
44
  type CreateGateOptions,
@@ -114,6 +115,50 @@ export function createRecordingEventBus(): {
114
115
  return { bus, events };
115
116
  }
116
117
 
118
+ /**
119
+ * Recording best-effort-work adapter for deterministic tests.
120
+ *
121
+ * Calls to `defer(...)` append work without running it. `flush()` runs the
122
+ * currently pending batch in FIFO order, then rejects with the callback error
123
+ * or an `AggregateError` after every callback in that batch has been attempted.
124
+ * Work deferred by a running callback remains pending for the next flush so
125
+ * one flush stays bounded.
126
+ */
127
+ export function createRecordingBestEffortWork(): {
128
+ bestEffortWork: BestEffortWorkPort;
129
+ pending: BestEffortWork[];
130
+ flush(): Promise<void>;
131
+ } {
132
+ const pending: BestEffortWork[] = [];
133
+
134
+ return {
135
+ bestEffortWork: {
136
+ defer(work) {
137
+ pending.push(work);
138
+ },
139
+ },
140
+ pending,
141
+ async flush() {
142
+ const batch = pending.splice(0);
143
+ const errors: unknown[] = [];
144
+ for (const work of batch) {
145
+ try {
146
+ await work();
147
+ } catch (error) {
148
+ errors.push(error);
149
+ }
150
+ }
151
+
152
+ if (errors.length === 1) {
153
+ throw errors[0];
154
+ }
155
+ if (errors.length > 1) {
156
+ throw new AggregateError(errors, "Best-effort work batch failed.");
157
+ }
158
+ },
159
+ };
160
+ }
161
+
117
162
  /**
118
163
  * A job dispatch captured by `createRecordingJobDispatcher(...)`.
119
164
  */
@@ -55,9 +55,11 @@ export type MaybePromise<T> = T | Promise<T>;
55
55
  /**
56
56
  * Late-bound service context factory exposed to providers.
57
57
  *
58
- * Calling it before all providers have started throws, so providers should
59
- * only invoke it from runtime entrypoints such as job dispatch, listeners, or
60
- * scheduled work.
58
+ * Calling it during provider setup or after provider shutdown throws. Start
59
+ * and stop hooks may invoke it after every provider has contributed ports and
60
+ * the server has verified that no deferred port remains unbound. Runtime
61
+ * entrypoints such as job dispatch, listeners, or scheduled work can close
62
+ * over it for later use.
61
63
  *
62
64
  * App-local providers can type the factory by declaring `Context` and
63
65
  * `ServiceInput` through the curried `createProvider<Requires, Context,
@@ -222,8 +224,9 @@ export interface ServiceProvider<
222
224
  * @param ctx.ports - Ports contributed by previous providers
223
225
  * @param ctx.config - Validated config (if config was defined), or undefined
224
226
  * @param ctx.createServiceContext - Late-bound service context factory.
225
- * Throws until all providers have started, so call it lazily from runtime
226
- * entrypoints such as job dispatchers and event listeners.
227
+ * Throws during setup. Start and stop hooks may invoke it after all provider
228
+ * ports have been contributed and validated; runtime entrypoints such as job
229
+ * dispatchers and event listeners may call it lazily afterward.
227
230
  */
228
231
  setup(ctx: {
229
232
  ports: Readonly<Ports>;
@@ -2,7 +2,11 @@
2
2
  * CORS hook utilities for @beignet/core/server
3
3
  */
4
4
 
5
- import type { HttpRequestLike, ServerHook } from "../types.js";
5
+ import type {
6
+ HttpRequestLike,
7
+ HttpResponseHeaders,
8
+ ServerHook,
9
+ } from "../types.js";
6
10
 
7
11
  /**
8
12
  * CORS configuration for `createCorsHooks(...)`.
@@ -55,10 +59,12 @@ function resolveCorsConfig(config: CorsConfig): Required<CorsConfig> {
55
59
  return resolved;
56
60
  }
57
61
 
58
- function appendVaryOrigin(headers: Record<string, string>): void {
62
+ function appendVaryOrigin(headers: HttpResponseHeaders): void {
59
63
  const varyKey =
60
64
  Object.keys(headers).find((key) => key.toLowerCase() === "vary") ?? "Vary";
61
- const current = headers[varyKey];
65
+ const currentValue = headers[varyKey];
66
+ const current =
67
+ typeof currentValue === "string" ? currentValue : currentValue?.join(", ");
62
68
  const values = current?.split(",").map((value) => value.trim().toLowerCase());
63
69
  if (values?.includes("origin")) return;
64
70
 
@@ -72,7 +78,7 @@ function appendVaryOrigin(headers: Record<string, string>): void {
72
78
  * when cookies or authorization headers are allowed cross-origin.
73
79
  */
74
80
  export function applyCorsHeaders(
75
- headers: Record<string, string>,
81
+ headers: HttpResponseHeaders,
76
82
  req: HttpRequestLike,
77
83
  corsConfig: CorsConfig,
78
84
  ): void {
@@ -121,7 +127,7 @@ export function createCorsHooks<Ctx>(config: CorsConfig): ServerHook<Ctx> {
121
127
  ) {
122
128
  return undefined;
123
129
  }
124
- const headers: Record<string, string> = {};
130
+ const headers: HttpResponseHeaders = {};
125
131
  applyCorsHeaders(headers, req, corsConfig);
126
132
  return {
127
133
  status: 204,
@@ -7,7 +7,11 @@ import {
7
7
  resolveTrustedRequest,
8
8
  type TrustedRequestInfo,
9
9
  } from "../trusted-proxy.js";
10
- import type { HttpRequestLike, ServerHook } from "../types.js";
10
+ import type {
11
+ HttpRequestLike,
12
+ HttpResponseHeaders,
13
+ ServerHook,
14
+ } from "../types.js";
11
15
  import { getRequestIdFromContext } from "./utils.js";
12
16
 
13
17
  /**
@@ -60,7 +64,7 @@ export interface LoggingConfig<Ctx> {
60
64
  ctx?: Ctx;
61
65
  req: HttpRequestLike;
62
66
  requestInfo: TrustedRequestInfo;
63
- res: { status: number; headers: Record<string, string> };
67
+ res: { status: number; headers: HttpResponseHeaders };
64
68
  durationMs: number;
65
69
  contract?: HttpContractConfig;
66
70
  error?: unknown;