@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.
- package/CHANGELOG.md +86 -0
- package/README.md +48 -2
- package/dist/domain/entity.d.ts +37 -6
- package/dist/domain/entity.d.ts.map +1 -1
- package/dist/domain/entity.js +9 -7
- package/dist/domain/entity.js.map +1 -1
- package/dist/domain/index.d.ts +4 -4
- package/dist/domain/index.d.ts.map +1 -1
- package/dist/domain/index.js +4 -4
- package/dist/domain/index.js.map +1 -1
- package/dist/flags/index.d.ts +5 -4
- package/dist/flags/index.d.ts.map +1 -1
- package/dist/flags/index.js +10 -9
- package/dist/flags/index.js.map +1 -1
- package/dist/idempotency/index.d.ts +11 -1
- package/dist/idempotency/index.d.ts.map +1 -1
- package/dist/idempotency/index.js +4 -3
- package/dist/idempotency/index.js.map +1 -1
- package/dist/jobs/index.d.ts +35 -6
- package/dist/jobs/index.d.ts.map +1 -1
- package/dist/jobs/index.js +82 -11
- package/dist/jobs/index.js.map +1 -1
- package/dist/memo/index.d.ts +107 -0
- package/dist/memo/index.d.ts.map +1 -0
- package/dist/memo/index.js +205 -0
- package/dist/memo/index.js.map +1 -0
- package/dist/outbox/index.d.ts +20 -2
- package/dist/outbox/index.d.ts.map +1 -1
- package/dist/outbox/index.js +23 -8
- package/dist/outbox/index.js.map +1 -1
- package/dist/ports/events.d.ts +2 -2
- package/dist/providers/provider.d.ts +10 -2
- package/dist/providers/provider.d.ts.map +1 -1
- package/dist/providers/provider.js.map +1 -1
- package/dist/search/index.d.ts +2 -1
- package/dist/search/index.d.ts.map +1 -1
- package/dist/search/index.js +1 -0
- package/dist/search/index.js.map +1 -1
- package/dist/server/providers/loadProviderConfig.d.ts.map +1 -1
- package/dist/server/providers/loadProviderConfig.js +15 -2
- package/dist/server/providers/loadProviderConfig.js.map +1 -1
- package/dist/server/server.d.ts +1 -1
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +43 -3
- package/dist/server/server.js.map +1 -1
- package/dist/testing/index.d.ts +6 -0
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +12 -3
- package/dist/testing/index.js.map +1 -1
- package/dist/uploads/index.d.ts +111 -2
- package/dist/uploads/index.d.ts.map +1 -1
- package/dist/uploads/index.js +124 -61
- package/dist/uploads/index.js.map +1 -1
- package/dist/webhooks/index.d.ts +1 -0
- package/dist/webhooks/index.d.ts.map +1 -1
- package/dist/webhooks/index.js +1 -0
- package/dist/webhooks/index.js.map +1 -1
- package/package.json +5 -1
- package/src/domain/entity.ts +61 -13
- package/src/domain/index.ts +8 -7
- package/src/flags/index.ts +23 -19
- package/src/idempotency/index.ts +17 -3
- package/src/jobs/index.ts +131 -16
- package/src/memo/index.ts +310 -0
- package/src/outbox/index.ts +52 -8
- package/src/ports/events.ts +2 -2
- package/src/providers/provider.ts +11 -2
- package/src/search/index.ts +3 -1
- package/src/server/providers/loadProviderConfig.ts +19 -2
- package/src/server/server.ts +73 -18
- package/src/testing/index.ts +19 -3
- package/src/uploads/index.ts +174 -62
- package/src/webhooks/index.ts +2 -0
- 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
|
+
}
|
package/src/outbox/index.ts
CHANGED
|
@@ -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(
|
|
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 ??
|
|
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 =
|
|
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 ??
|
|
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 ??
|
|
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
|
|
package/src/ports/events.ts
CHANGED
|
@@ -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 -
|
|
14
|
-
*
|
|
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
|
|
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_",
|
package/src/search/index.ts
CHANGED
|
@@ -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
|
|
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) {
|
package/src/server/server.ts
CHANGED
|
@@ -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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
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,
|