@beignet/core 0.0.30 → 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 (74) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +48 -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/memo/index.d.ts +107 -0
  24. package/dist/memo/index.d.ts.map +1 -0
  25. package/dist/memo/index.js +205 -0
  26. package/dist/memo/index.js.map +1 -0
  27. package/dist/outbox/index.d.ts +20 -2
  28. package/dist/outbox/index.d.ts.map +1 -1
  29. package/dist/outbox/index.js +23 -8
  30. package/dist/outbox/index.js.map +1 -1
  31. package/dist/ports/events.d.ts +2 -2
  32. package/dist/providers/provider.d.ts +10 -2
  33. package/dist/providers/provider.d.ts.map +1 -1
  34. package/dist/providers/provider.js.map +1 -1
  35. package/dist/search/index.d.ts +2 -1
  36. package/dist/search/index.d.ts.map +1 -1
  37. package/dist/search/index.js +1 -0
  38. package/dist/search/index.js.map +1 -1
  39. package/dist/server/providers/loadProviderConfig.d.ts.map +1 -1
  40. package/dist/server/providers/loadProviderConfig.js +15 -2
  41. package/dist/server/providers/loadProviderConfig.js.map +1 -1
  42. package/dist/server/server.d.ts +1 -1
  43. package/dist/server/server.d.ts.map +1 -1
  44. package/dist/server/server.js +43 -3
  45. package/dist/server/server.js.map +1 -1
  46. package/dist/testing/index.d.ts +6 -0
  47. package/dist/testing/index.d.ts.map +1 -1
  48. package/dist/testing/index.js +12 -3
  49. package/dist/testing/index.js.map +1 -1
  50. package/dist/uploads/index.d.ts +111 -2
  51. package/dist/uploads/index.d.ts.map +1 -1
  52. package/dist/uploads/index.js +124 -61
  53. package/dist/uploads/index.js.map +1 -1
  54. package/dist/webhooks/index.d.ts +1 -0
  55. package/dist/webhooks/index.d.ts.map +1 -1
  56. package/dist/webhooks/index.js +1 -0
  57. package/dist/webhooks/index.js.map +1 -1
  58. package/package.json +5 -1
  59. package/src/domain/entity.ts +61 -13
  60. package/src/domain/index.ts +8 -7
  61. package/src/flags/index.ts +23 -19
  62. package/src/idempotency/index.ts +17 -3
  63. package/src/jobs/index.ts +131 -16
  64. package/src/memo/index.ts +310 -0
  65. package/src/outbox/index.ts +52 -8
  66. package/src/ports/events.ts +2 -2
  67. package/src/providers/provider.ts +11 -2
  68. package/src/search/index.ts +3 -1
  69. package/src/server/providers/loadProviderConfig.ts +19 -2
  70. package/src/server/server.ts +73 -18
  71. package/src/testing/index.ts +19 -3
  72. package/src/uploads/index.ts +174 -62
  73. package/src/webhooks/index.ts +2 -0
  74. package/src/domain/events.ts +0 -59
@@ -0,0 +1,310 @@
1
+ /**
2
+ * @beignet/core/memo
3
+ *
4
+ * Request-scoped memoization for port lookups and other async functions.
5
+ *
6
+ * `createMemo(fn)` deduplicates calls with the same arguments for the
7
+ * lifetime of one request: the server enters a memo scope around every HTTP
8
+ * request and `runServiceContext(...)` execution, and the scope's cache dies
9
+ * with it. There is no TTL and no cross-request state, so memoized reads can
10
+ * never serve data staler than the request that fetched them.
11
+ *
12
+ * Outside any scope — plain scripts, `createServiceContext(...)` callers —
13
+ * memoized functions call straight through without caching.
14
+ */
15
+
16
+ import { AsyncLocalStorage } from "node:async_hooks";
17
+
18
+ /**
19
+ * Error thrown when a default cache key cannot be derived from arguments.
20
+ */
21
+ export class MemoKeyError extends Error {
22
+ constructor(message: string) {
23
+ super(message);
24
+ this.name = "MemoKeyError";
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Instrumentation event emitted by memoized functions inside a scope that
30
+ * carries a recorder.
31
+ */
32
+ export interface MemoInstrumentationEvent {
33
+ /** Whether the call was served from the scope cache or filled it. */
34
+ kind: "hit" | "miss";
35
+ /** Memo name from `createMemo` options, defaulting to the function name. */
36
+ memo: string;
37
+ /** Encoded argument key for the call. */
38
+ key: string;
39
+ /** Fill duration for misses, rounded to two decimals. */
40
+ durationMs?: number;
41
+ /** Set when a miss's underlying call rejected (the entry is evicted). */
42
+ failed?: boolean;
43
+ }
44
+
45
+ type MemoScopeStore = {
46
+ entries: Map<string, unknown>;
47
+ record?: (event: MemoInstrumentationEvent) => void;
48
+ };
49
+
50
+ const memoScope = new AsyncLocalStorage<MemoScopeStore>();
51
+
52
+ /**
53
+ * Run a function inside a memo scope with an explicit recorder.
54
+ *
55
+ * Internal to the server runtime — apps should use `runMemoScope(...)`.
56
+ * Always uses `AsyncLocalStorage.run` (never `enterWith`), so callers'
57
+ * continuations never resume through a dangling frame.
58
+ */
59
+ export function runWithMemoScope<T>(
60
+ options: { record?: (event: MemoInstrumentationEvent) => void },
61
+ fn: () => T,
62
+ ): T {
63
+ return memoScope.run({ entries: new Map(), record: options.record }, fn);
64
+ }
65
+
66
+ /**
67
+ * Run a function inside a fresh memo scope.
68
+ *
69
+ * Use this in scripts and unit tests to give memoized functions a cache
70
+ * lifetime. Nested scopes start empty; an active recorder is inherited so
71
+ * instrumentation keeps flowing.
72
+ */
73
+ export function runMemoScope<T>(fn: () => T): T {
74
+ return runWithMemoScope({ record: memoScope.getStore()?.record }, fn);
75
+ }
76
+
77
+ /**
78
+ * Memoized function returned by `createMemo(...)`.
79
+ */
80
+ export type Memoized<F extends (...args: never[]) => unknown> = F & {
81
+ /**
82
+ * Drop the current scope's entry for these arguments, so the next call
83
+ * re-executes. Call this from mutation methods that make a memoized read
84
+ * stale within the same request.
85
+ *
86
+ * @returns Whether an entry existed.
87
+ */
88
+ invalidate(...args: Parameters<F>): boolean;
89
+ /**
90
+ * Drop all of this function's entries in the current scope.
91
+ */
92
+ clear(): void;
93
+ };
94
+
95
+ /**
96
+ * Options for `createMemo(...)`.
97
+ */
98
+ export interface CreateMemoOptions<F extends (...args: never[]) => unknown> {
99
+ /**
100
+ * Name used in instrumentation events and key errors. Defaults to the
101
+ * wrapped function's name.
102
+ */
103
+ name?: string;
104
+ /**
105
+ * Build the cache key from the call arguments. Defaults to a structural,
106
+ * type-tagged encoding of the arguments that throws `MemoKeyError` for
107
+ * values it cannot encode deterministically (functions, symbols, class
108
+ * instances, circular references).
109
+ */
110
+ key?: (...args: Parameters<F>) => string;
111
+ }
112
+
113
+ type EncodedMemoValue = readonly [string, ...unknown[]];
114
+
115
+ function describeArgType(value: unknown): string {
116
+ if (value === null) return "null";
117
+ if (typeof value !== "object") return typeof value;
118
+ const ctor = (value as object).constructor?.name;
119
+ return ctor && ctor !== "Object" ? `class instance (${ctor})` : "object";
120
+ }
121
+
122
+ function encodeMemoValue(
123
+ value: unknown,
124
+ memoName: string,
125
+ position: number,
126
+ seen: WeakSet<object>,
127
+ ): EncodedMemoValue {
128
+ if (value === null) return ["null"];
129
+
130
+ switch (typeof value) {
131
+ case "string":
132
+ return ["string", value];
133
+ case "number":
134
+ return ["number", Object.is(value, -0) ? "-0" : String(value)];
135
+ case "boolean":
136
+ return ["boolean", value ? "true" : "false"];
137
+ case "undefined":
138
+ return ["undefined"];
139
+ case "bigint":
140
+ return ["bigint", value.toString()];
141
+ case "object":
142
+ break;
143
+ default:
144
+ throw new MemoKeyError(
145
+ `[Beignet memo] Cannot build a default cache key for "${memoName}": argument ${position} is a ${typeof value}. Pass options.key to compute keys for these arguments.`,
146
+ );
147
+ }
148
+
149
+ if (value instanceof Date) {
150
+ return ["date", value.toISOString()];
151
+ }
152
+
153
+ if (seen.has(value as object)) {
154
+ throw new MemoKeyError(
155
+ `[Beignet memo] Cannot build a default cache key for "${memoName}": argument ${position} contains a circular reference. Pass options.key to compute keys for these arguments.`,
156
+ );
157
+ }
158
+
159
+ if (Array.isArray(value)) {
160
+ seen.add(value);
161
+ const encoded: EncodedMemoValue = [
162
+ "array",
163
+ value.map((item) => encodeMemoValue(item, memoName, position, seen)),
164
+ ];
165
+ seen.delete(value);
166
+ return encoded;
167
+ }
168
+
169
+ const proto = Object.getPrototypeOf(value);
170
+ if (proto !== null && proto !== Object.prototype) {
171
+ throw new MemoKeyError(
172
+ `[Beignet memo] Cannot build a default cache key for "${memoName}": argument ${position} is a ${describeArgType(value)}. Pass options.key to compute keys for these arguments.`,
173
+ );
174
+ }
175
+
176
+ seen.add(value as object);
177
+ const record = value as Record<string, unknown>;
178
+ const encoded: EncodedMemoValue = [
179
+ "object",
180
+ Object.keys(record)
181
+ .sort()
182
+ .map((key) => [
183
+ key,
184
+ encodeMemoValue(record[key], memoName, position, seen),
185
+ ]),
186
+ ];
187
+ seen.delete(value as object);
188
+ return encoded;
189
+ }
190
+
191
+ function defaultArgsKey(memoName: string, args: readonly unknown[]): string {
192
+ return JSON.stringify(
193
+ args.map((arg, index) =>
194
+ encodeMemoValue(arg, memoName, index, new WeakSet()),
195
+ ),
196
+ );
197
+ }
198
+
199
+ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
200
+ return (
201
+ value !== null &&
202
+ (typeof value === "object" || typeof value === "function") &&
203
+ typeof (value as { then?: unknown }).then === "function"
204
+ );
205
+ }
206
+
207
+ function roundDuration(startedAt: number): number {
208
+ return Math.round((performance.now() - startedAt) * 100) / 100;
209
+ }
210
+
211
+ let nextMemoInstance = 0;
212
+
213
+ /**
214
+ * Wrap an async lookup so calls with the same arguments run once per request.
215
+ *
216
+ * Within a memo scope the first call executes and every later call with the
217
+ * same key returns the same value — including the same in-flight promise, so
218
+ * concurrent calls share one execution. Rejected promises are evicted, so a
219
+ * failed lookup is retried by the next call rather than memoized. Outside a
220
+ * scope the function calls through uncached.
221
+ *
222
+ * Memoize reads, not mutations. When a mutation in the same infra module
223
+ * makes a memoized read stale, call `memoized.invalidate(...)` with the
224
+ * read's arguments.
225
+ *
226
+ * @param fn - Function to memoize. `this` is not forwarded.
227
+ * @param options - Optional name and key builder.
228
+ * @returns The function with `invalidate(...)` and `clear()` attached.
229
+ */
230
+ export function createMemo<F extends (...args: never[]) => unknown>(
231
+ fn: F,
232
+ options: CreateMemoOptions<F> = {},
233
+ ): Memoized<F> {
234
+ const name = options.name ?? (fn.name || "anonymous");
235
+ // The map key is namespaced by instance, not name, so two memos that share
236
+ // a name can never read each other's entries. The unit separator keeps
237
+ // prefixes unambiguous: instance 1 never prefix-matches instance 11.
238
+ nextMemoInstance += 1;
239
+ const instancePrefix = `${nextMemoInstance}\u001f`;
240
+ const keyOf = (args: Parameters<F>): string =>
241
+ options.key ? options.key(...args) : defaultArgsKey(name, args);
242
+
243
+ const memoized = ((...args: Parameters<F>): unknown => {
244
+ const store = memoScope.getStore();
245
+ if (!store) return fn(...args);
246
+
247
+ const argsKey = keyOf(args);
248
+ const mapKey = instancePrefix + argsKey;
249
+ if (store.entries.has(mapKey)) {
250
+ store.record?.({ kind: "hit", memo: name, key: argsKey });
251
+ return store.entries.get(mapKey);
252
+ }
253
+
254
+ const startedAt = performance.now();
255
+ const value = fn(...args);
256
+ store.entries.set(mapKey, value);
257
+
258
+ if (isPromiseLike(value)) {
259
+ Promise.resolve(value).then(
260
+ () => {
261
+ store.record?.({
262
+ kind: "miss",
263
+ memo: name,
264
+ key: argsKey,
265
+ durationMs: roundDuration(startedAt),
266
+ });
267
+ },
268
+ () => {
269
+ if (store.entries.get(mapKey) === value) {
270
+ store.entries.delete(mapKey);
271
+ }
272
+ store.record?.({
273
+ kind: "miss",
274
+ memo: name,
275
+ key: argsKey,
276
+ durationMs: roundDuration(startedAt),
277
+ failed: true,
278
+ });
279
+ },
280
+ );
281
+ } else {
282
+ store.record?.({
283
+ kind: "miss",
284
+ memo: name,
285
+ key: argsKey,
286
+ durationMs: roundDuration(startedAt),
287
+ });
288
+ }
289
+
290
+ return value;
291
+ }) as Memoized<F>;
292
+
293
+ memoized.invalidate = (...args: Parameters<F>): boolean => {
294
+ const store = memoScope.getStore();
295
+ if (!store) return false;
296
+ return store.entries.delete(instancePrefix + keyOf(args));
297
+ };
298
+
299
+ memoized.clear = (): void => {
300
+ const store = memoScope.getStore();
301
+ if (!store) return;
302
+ for (const key of [...store.entries.keys()]) {
303
+ if (key.startsWith(instancePrefix)) {
304
+ store.entries.delete(key);
305
+ }
306
+ }
307
+ };
308
+
309
+ return memoized;
310
+ }
@@ -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,
@@ -36,6 +36,15 @@ export interface ProviderConfigDef<CfgSchema extends StandardSchemaV1> {
36
36
  * and pass them to the schema for validation.
37
37
  */
38
38
  envPrefix?: string;
39
+
40
+ /**
41
+ * Field-level config overrides, keyed by schema field name. Defined values
42
+ * are merged over the env-derived (or server-supplied) input before
43
+ * validation, so factory options win over environment variables and still
44
+ * satisfy required fields when the env var is absent. `undefined` values
45
+ * are ignored.
46
+ */
47
+ overrides?: Record<string, unknown>;
39
48
  }
40
49
 
41
50
  /**
@@ -161,8 +170,8 @@ export interface ServiceProviderMetadata {
161
170
  *
162
171
  * @example
163
172
  * ```ts
164
- * const redisProvider = createProvider({
165
- * name: "redis",
173
+ * const redisCacheProvider = createProvider({
174
+ * name: "cache-redis",
166
175
  * config: {
167
176
  * schema: z.object({ URL: z.string().url() }),
168
177
  * envPrefix: "REDIS_",
@@ -47,6 +47,7 @@ type SearchField<TDocument extends SearchDocumentBase> = Extract<
47
47
  export type SearchIndexDef<
48
48
  TDocument extends SearchDocumentBase = SearchDocument,
49
49
  > = {
50
+ kind: "search-index";
50
51
  name: string;
51
52
  primaryKey: SearchField<TDocument>;
52
53
  searchableAttributes?: readonly SearchField<TDocument>[];
@@ -60,7 +61,7 @@ export type SearchIndexDef<
60
61
  * Options accepted when defining a search index.
61
62
  */
62
63
  export type DefineSearchIndexOptions<TDocument extends SearchDocumentBase> =
63
- Omit<SearchIndexDef<TDocument>, "name" | "primaryKey"> & {
64
+ Omit<SearchIndexDef<TDocument>, "kind" | "name" | "primaryKey"> & {
64
65
  primaryKey?: SearchField<TDocument>;
65
66
  };
66
67
 
@@ -216,6 +217,7 @@ export function defineSearchIndex<
216
217
  if (!name) throw new SearchOptionsError("Search index name is required.");
217
218
 
218
219
  return {
220
+ kind: "search-index",
219
221
  name,
220
222
  primaryKey: options.primaryKey ?? ("id" as SearchField<TDocument>),
221
223
  searchableAttributes: options.searchableAttributes,
@@ -11,6 +11,7 @@ import type { ServiceProvider } from "../../providers/index.js";
11
11
  type ProviderConfigDef<Schema extends StandardSchemaV1<unknown, unknown>> = {
12
12
  schema: Schema;
13
13
  envPrefix?: string;
14
+ overrides?: Record<string, unknown>;
14
15
  };
15
16
 
16
17
  async function parseStandardSchema<
@@ -47,14 +48,30 @@ export async function loadProviderConfig<
47
48
  const configDef = provider.config as ProviderConfigDef<Schema> | undefined;
48
49
  if (!configDef) return undefined;
49
50
 
50
- const { schema, envPrefix } = configDef;
51
+ const { schema, envPrefix, overrides: fieldOverrides } = configDef;
51
52
  const hasOverride = Object.hasOwn(overrides, provider.name);
52
- const input = hasOverride
53
+ const base = hasOverride
53
54
  ? overrides[provider.name]
54
55
  : envPrefix
55
56
  ? readEnv({ env, prefix: envPrefix })
56
57
  : {};
57
58
 
59
+ // Field-level overrides from the provider's own config definition (factory
60
+ // options) win over env-derived and server-supplied values. Defined values
61
+ // only, so absent options fall back to the base input and schema defaults.
62
+ let input = base;
63
+ if (fieldOverrides) {
64
+ const defined = Object.fromEntries(
65
+ Object.entries(fieldOverrides).filter(([, value]) => value !== undefined),
66
+ );
67
+ if (Object.keys(defined).length > 0) {
68
+ input = {
69
+ ...(typeof base === "object" && base !== null ? base : {}),
70
+ ...defined,
71
+ };
72
+ }
73
+ }
74
+
58
75
  try {
59
76
  return await parseStandardSchema(schema, input);
60
77
  } catch (error) {
@@ -24,6 +24,10 @@ import {
24
24
  IdempotencyConflictError,
25
25
  IdempotencyInProgressError,
26
26
  } from "../idempotency/index.js";
27
+ import {
28
+ type MemoInstrumentationEvent,
29
+ runWithMemoScope,
30
+ } from "../memo/index.js";
27
31
  import type { AnyPorts } from "../ports/index.js";
28
32
  import {
29
33
  AuthUnauthorizedError,
@@ -32,10 +36,13 @@ import {
32
36
  isUnboundPort,
33
37
  TenantRequiredError,
34
38
  } from "../ports/index.js";
35
- import type {
36
- InferProviderPorts,
37
- ProviderSetupResult,
38
- ServiceProvider,
39
+ import {
40
+ createProviderInstrumentation,
41
+ type InferProviderPorts,
42
+ type ProviderInstrumentationTarget,
43
+ type ProviderSetupResult,
44
+ resolveProviderInstrumentationPort,
45
+ type ServiceProvider,
39
46
  } from "../providers/index.js";
40
47
  import type {
41
48
  ContextSeed,
@@ -2126,6 +2133,41 @@ function createRequestExecutor<
2126
2133
  };
2127
2134
  }
2128
2135
 
2136
+ /**
2137
+ * Build a memo instrumentation recorder from the app ports, or undefined when
2138
+ * no instrumentation sink is wired.
2139
+ *
2140
+ * Resolved per execution rather than at route-build time because providers
2141
+ * contribute `ports.instrumentation`/`ports.devtools` during setup, after
2142
+ * routes are registered.
2143
+ */
2144
+ function createMemoScopeRecorder(
2145
+ ports: AnyPorts,
2146
+ ): ((event: MemoInstrumentationEvent) => void) | undefined {
2147
+ const target = ports as ProviderInstrumentationTarget;
2148
+ if (!resolveProviderInstrumentationPort(target)) return undefined;
2149
+
2150
+ const instrumentation = createProviderInstrumentation(target, {
2151
+ providerName: "memo",
2152
+ watcher: "memo",
2153
+ });
2154
+ return (event) => {
2155
+ instrumentation.custom({
2156
+ name: `memo.${event.kind}`,
2157
+ label: event.kind === "hit" ? "Memo hit" : "Memo fill",
2158
+ summary: `Memo ${event.kind} for ${event.memo}`,
2159
+ details: {
2160
+ memo: event.memo,
2161
+ key: event.key,
2162
+ ...(event.durationMs !== undefined
2163
+ ? { durationMs: event.durationMs }
2164
+ : {}),
2165
+ ...(event.failed ? { failed: true } : {}),
2166
+ },
2167
+ });
2168
+ };
2169
+ }
2170
+
2129
2171
  function buildHandler<
2130
2172
  Ctx,
2131
2173
  Ports extends AnyPorts,
@@ -2177,8 +2219,12 @@ function buildHandler<
2177
2219
  responseValidationExemptStatus,
2178
2220
  };
2179
2221
 
2222
+ // Every HTTP execution runs inside a fresh memo scope so createMemo(...)
2223
+ // wrappers dedupe lookups for exactly one request.
2180
2224
  return (req, preMatchedParams) =>
2181
- execute(executionTarget, req, preMatchedParams);
2225
+ runWithMemoScope({ record: createMemoScopeRecorder(finalPorts) }, () =>
2226
+ execute(executionTarget, req, preMatchedParams),
2227
+ );
2182
2228
  }
2183
2229
 
2184
2230
  /**
@@ -2322,19 +2368,28 @@ export async function createServer<
2322
2368
  // AsyncLocalStorage.run scopes the ambient frame to this callback, so the
2323
2369
  // caller's continuation never resumes through an enterWith frame — the
2324
2370
  // pattern that crashes Bun 1.3.x in plain scripts under top-level await.
2325
- return runWithActiveRequestContext(ambient, async () => {
2326
- const ctx = finalizeContext(
2327
- await serviceFactory({
2328
- ports: finalPorts,
2329
- input,
2330
- requestId,
2331
- trace,
2332
- }),
2333
- );
2334
- ambient.actor = readContextActor(ctx);
2335
- ambient.tenant = readContextTenant(ctx);
2336
- return await fn(ctx);
2337
- });
2371
+ // The memo scope shares the callback's lifetime, so createMemo(...)
2372
+ // wrappers dedupe lookups across the context factory and fn. The
2373
+ // createServiceContext(...) form has no such boundary and stays
2374
+ // unscoped: memoized functions call through uncached there.
2375
+ return runWithActiveRequestContext(ambient, () =>
2376
+ runWithMemoScope(
2377
+ { record: createMemoScopeRecorder(finalPorts) },
2378
+ async () => {
2379
+ const ctx = finalizeContext(
2380
+ await serviceFactory({
2381
+ ports: finalPorts,
2382
+ input,
2383
+ requestId,
2384
+ trace,
2385
+ }),
2386
+ );
2387
+ ambient.actor = readContextActor(ctx);
2388
+ ambient.tenant = readContextTenant(ctx);
2389
+ return await fn(ctx);
2390
+ },
2391
+ ),
2392
+ );
2338
2393
  };
2339
2394
  const contextRuntime = {
2340
2395
  createRequestContext,