okengine 0.21.1 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -4
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +15 -9
- package/manifest.v1.schema.json +10 -0
- package/package.json +22 -20
- package/site/content/docs/ai/skills.mdx +1 -1
- package/site/content/docs/client/calling.mdx +27 -19
- package/site/content/docs/client/index.mdx +1 -1
- package/site/content/docs/elements/channel/email.mdx +1 -1
- package/site/content/docs/elements/channel/index.mdx +1 -1
- package/site/content/docs/elements/flow/http.mdx +4 -4
- package/site/content/docs/elements/flow/index.mdx +3 -3
- package/site/content/docs/elements/flow/meta.json +1 -1
- package/site/content/docs/elements/gate/tenancy.mdx +1 -1
- package/site/content/docs/elements/vault/rotation.mdx +15 -0
- package/site/content/docs/plugins/otp.mdx +31 -31
- package/site/content/docs/recipes/mailpit.mdx +1 -1
- package/site/content/docs/recipes/meilisearch.mdx +1 -1
- package/site/content/docs/recipes/pgdog.mdx +1 -1
- package/site/content/docs/reference/configuration.mdx +2 -2
- package/site/content/docs/reference/errors.mdx +12 -2
- package/site/content/docs/reference/fx.mdx +28 -15
- package/site/content/docs/reference/idempotency.mdx +192 -0
- package/site/content/docs/reference/index.mdx +6 -1
- package/site/content/docs/reference/meta.json +2 -1
- package/site/content/docs/understand/meta.json +1 -1
- package/site/content/docs/{elements/flow → understand}/routing.mdx +11 -10
- package/site/content/docs/understand/the-architecture.mdx +14 -12
- package/site/content/docs/understand/try-it.mdx +3 -3
- package/src/cli/competitor-mention-removal.test.ts +3 -3
- package/src/cli/docker-cli.test.ts +1 -1
- package/src/cli/index.ts +9 -1
- package/src/cli/load-config.images.test.ts +10 -10
- package/src/cli/load-config.ts +1 -1
- package/src/cli/vault-cmd.test.ts +23 -0
- package/src/cli/vault-cmd.ts +7 -0
- package/src/client/create.ts +18 -1
- package/src/client/transport.test.ts +225 -0
- package/src/client/transport.ts +132 -19
- package/src/client/types.ts +29 -3
- package/src/compiler/effects-infer.ts +726 -16
- package/src/compiler/extract.test.ts +37 -0
- package/src/compiler/extract.ts +113 -12
- package/src/compiler/fixtures/skyport.expected.json +24 -0
- package/src/compiler/fx-follow.test.ts +164 -0
- package/src/compiler/fx-index.ts +399 -0
- package/src/console/server/flows-invoke.test.ts +2 -2
- package/src/console/ui-next/dist/assets/{access-page-Bgt9bq2r.js → access-page-Ze6ckjdd.js} +2 -2
- package/src/console/ui-next/dist/assets/agent-disclosure-Bkohc_8X.js +1 -0
- package/src/console/ui-next/dist/assets/{cache-glyph-92uM5MO7.js → cache-glyph-rL0lHxat.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-DtGVPtcs.js → call-pii-button-Deo4PP18.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-DN7l6zmC.js → collapsible-CoJ6amHf.js} +1 -1
- package/src/console/ui-next/dist/assets/{copy-inline-button-CAYD18cr.js → copy-inline-button-CWG5_ZOh.js} +1 -1
- package/src/console/ui-next/dist/assets/{detail-header-DVWNjWjg.js → detail-header-b_ZzIsmU.js} +1 -1
- package/src/console/ui-next/dist/assets/{dropdown-menu-4h2LOVXM.js → dropdown-menu-nwXEO1Ac.js} +1 -1
- package/src/console/ui-next/dist/assets/duration-tone-BbQ_8z50.js +9 -0
- package/src/console/ui-next/dist/assets/element-icons-CwyLpVXz.js +1 -0
- package/src/console/ui-next/dist/assets/{explorer-empty-CJs5A-wm.js → explorer-empty-C8sSCqYT.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-BMHs-IzK.js → flows-page-D2E7BtKK.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-BlAEVgNW.js → highlighted-json-CPYBzDh0.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-BDf7OAHv.js → http-method-DTPevKmM.js} +1 -1
- package/src/console/ui-next/dist/assets/index-vTuwmeQz.js +58 -0
- package/src/console/ui-next/dist/assets/observability-page-SJTSzHFJ.js +4 -0
- package/src/console/ui-next/dist/assets/{replica-lag-B3GLNVfF.js → replica-lag-BM27lXld.js} +3 -3
- package/src/console/ui-next/dist/assets/{request-meta-DMbnAe3f.js → request-meta-BjFT7DNl.js} +1 -1
- package/src/console/ui-next/dist/assets/shortcut-keys-CUNegEV4.js +1 -0
- package/src/console/ui-next/dist/assets/store-page-Qj9ThRE-.js +41 -0
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-Htk8Cm9t.js → trace-detail-sheet-Cmitq3da.js} +2 -2
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-CFMPWX4f.js → tree-expand-toggle-CbDIB-7x.js} +2 -2
- package/src/console/ui-next/dist/assets/units-page-_PuYqFty.js +1 -0
- package/src/console/ui-next/dist/assets/{use-vault-list-CT4-gajj.js → use-vault-list-Cs45Czkt.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-CBYl0LW_.js → vault-page-BOMr0go9.js} +2 -2
- package/src/console/ui-next/dist/index.html +2 -2
- package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +9 -0
- package/src/docker/docker.test.ts +13 -13
- package/src/docker/images-config.test.ts +12 -12
- package/src/docker/stack-id.test.ts +1 -1
- package/src/drivers/journal-postgres.ts +168 -3
- package/src/drivers/meilisearch.ts +2 -0
- package/src/drivers/vault-builtin.test.ts +10 -0
- package/src/drivers/vault-builtin.ts +13 -1
- package/src/drivers/vault-remote-bag.ts +5 -1
- package/src/drivers/vault-types.ts +6 -1
- package/src/elements/store/emit-drizzle.ts +2 -2
- package/src/elements/store/live-default.test.ts +2 -0
- package/src/elements/store/live-http.test.ts +1 -0
- package/src/elements/store/resource-list-docs.test.ts +2 -2
- package/src/elements/store/resource.test.ts +3 -3
- package/src/elements/store/schema-decl.test.ts +1 -0
- package/src/elements/store/sql-condition.ts +24 -7
- package/src/elements/store/upsert-app.test.ts +1 -1
- package/src/elements/vault/audit.test.ts +137 -0
- package/src/elements/vault/audit.ts +106 -13
- package/src/elements/vault/boot-chain.ts +9 -1
- package/src/elements/vault/builtin-adapter.ts +33 -3
- package/src/i18n/catalogs/ar.ts +4 -0
- package/src/i18n/catalogs/en.ts +4 -0
- package/src/kernel/abort-scope.ts +33 -2
- package/src/kernel/app.ts +191 -18
- package/src/kernel/auto-cache.test.ts +6 -6
- package/src/kernel/boot-bind/vault.ts +1 -0
- package/src/kernel/boot.ts +19 -1
- package/src/kernel/builtin-errors.ts +8 -0
- package/src/kernel/client-descriptor.ts +1 -0
- package/src/kernel/concurrency.test.ts +33 -0
- package/src/kernel/effects-stamping.test.ts +2 -2
- package/src/kernel/errors-compiler.ts +20 -0
- package/src/kernel/external-effects.test.ts +36 -0
- package/src/kernel/flow.ts +24 -0
- package/src/kernel/fx-fetch.ts +5 -1
- package/src/kernel/fx.ts +5 -1
- package/src/kernel/idempotency-store.ts +578 -0
- package/src/kernel/idempotency.test.ts +556 -0
- package/src/kernel/idempotency.ts +304 -0
- package/src/kernel/index.ts +1 -0
- package/src/kernel/journal.ts +56 -0
- package/src/manifest/types.ts +8 -0
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
- package/src/plugins/otp.test.ts +65 -1
- package/src/plugins/otp.ts +59 -7
- package/src/release/absolute-regression.test.ts +63 -0
- package/src/release/http-graph.test.ts +25 -0
- package/src/release/http-graph.ts +90 -0
- package/src/release/index.ts +3 -0
- package/src/release/limits.ts +17 -2
- package/src/release/measure.ts +105 -14
- package/src/test/create-test-app.test.ts +3 -1
- package/src/test/create-test-app.ts +40 -0
- package/src/test/live-signals.test.ts +1 -0
- package/src/test/provisions.integration.test.ts +1 -0
- package/src/test/tenant-isolation.test.ts +1 -0
- package/src/console/ui-next/dist/assets/agent-disclosure-B43CXZZR.js +0 -1
- package/src/console/ui-next/dist/assets/duration-tone-BmIR9FV8.js +0 -9
- package/src/console/ui-next/dist/assets/element-icons-BI8cJgdh.js +0 -1
- package/src/console/ui-next/dist/assets/index-BKpaes3n.js +0 -63
- package/src/console/ui-next/dist/assets/observability-page-WnVLI-0j.js +0 -4
- package/src/console/ui-next/dist/assets/shortcut-keys-3ILd8oGn.js +0 -1
- package/src/console/ui-next/dist/assets/store-page-KvFDingJ.js +0 -41
- package/src/console/ui-next/dist/assets/units-page-C4NdNuxP.js +0 -1
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP idempotency policy.
|
|
3
|
+
*
|
|
4
|
+
* A flow honors `Idempotency-Key` when it is reached over HTTP as REST
|
|
5
|
+
* non-GET or RPC, its inferred effects mutate or it uses `fx.raw`, and it
|
|
6
|
+
* is not a stream, live feed, or binary response. The IETF draft this header
|
|
7
|
+
* follows expired without becoming an RFC.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { parseDurationMs } from "../elements/clock/duration.ts";
|
|
13
|
+
import type { Effects } from "../manifest/types.ts";
|
|
14
|
+
import { fail } from "./errors.ts";
|
|
15
|
+
import type { EffectKind } from "./effects.ts";
|
|
16
|
+
import type { IdempotencyRow, StoredResponse } from "./idempotency-store.ts";
|
|
17
|
+
|
|
18
|
+
/** Request header. */
|
|
19
|
+
export const IDEMPOTENCY_HEADER = "idempotency-key";
|
|
20
|
+
|
|
21
|
+
/** Set on a replayed response. */
|
|
22
|
+
export const REPLAYED_HEADER = "idempotent-replayed";
|
|
23
|
+
|
|
24
|
+
/** Default TTL stamped when the flow omits one. */
|
|
25
|
+
export const IDEMPOTENCY_DEFAULT_TTL = "24h";
|
|
26
|
+
|
|
27
|
+
/** Author option on `flow({ idempotency })`. */
|
|
28
|
+
export type FlowIdempotencyOption =
|
|
29
|
+
| false
|
|
30
|
+
| "required"
|
|
31
|
+
| { readonly required?: boolean; readonly ttl?: string };
|
|
32
|
+
|
|
33
|
+
/** Manifest / client stamp. */
|
|
34
|
+
export interface ResolvedIdempotency {
|
|
35
|
+
readonly mode: "off" | "auto" | "required";
|
|
36
|
+
readonly ttl: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const MUTATING: ReadonlySet<EffectKind> = new Set([
|
|
40
|
+
"write",
|
|
41
|
+
"emit",
|
|
42
|
+
"send",
|
|
43
|
+
"call",
|
|
44
|
+
"fetch",
|
|
45
|
+
"ask",
|
|
46
|
+
"embed",
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
const KEY_PATTERN = /^[\x20-\x7E]{16,255}$/;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* True when inferred effects include anything besides reads and secrets.
|
|
53
|
+
*
|
|
54
|
+
* @param effects - Declared or inferred effect set
|
|
55
|
+
*/
|
|
56
|
+
export function hasMutatingEffects(effects: Effects | undefined): boolean {
|
|
57
|
+
if (effects === undefined) return false;
|
|
58
|
+
return (
|
|
59
|
+
(effects.writes?.length ?? 0) > 0 ||
|
|
60
|
+
(effects.emits?.length ?? 0) > 0 ||
|
|
61
|
+
(effects.sends?.length ?? 0) > 0 ||
|
|
62
|
+
(effects.calls?.length ?? 0) > 0 ||
|
|
63
|
+
(effects.fetches?.length ?? 0) > 0 ||
|
|
64
|
+
(effects.asks?.length ?? 0) > 0 ||
|
|
65
|
+
(effects.embeds?.length ?? 0) > 0
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* True when this HTTP entry should look at `Idempotency-Key`.
|
|
71
|
+
*
|
|
72
|
+
* @param args - Trigger shape and inferred effects
|
|
73
|
+
*/
|
|
74
|
+
export function idempotencyEligible(args: {
|
|
75
|
+
readonly hasRequest: boolean;
|
|
76
|
+
/** HTTP method. RPC is `POST`. */
|
|
77
|
+
readonly method: string | undefined;
|
|
78
|
+
readonly stream: boolean;
|
|
79
|
+
readonly live: boolean;
|
|
80
|
+
readonly effects: Effects | undefined;
|
|
81
|
+
readonly usesRaw: boolean;
|
|
82
|
+
}): boolean {
|
|
83
|
+
if (!args.hasRequest || args.stream || args.live) return false;
|
|
84
|
+
const method = (args.method ?? "POST").toUpperCase();
|
|
85
|
+
if (method === "GET") return false;
|
|
86
|
+
return args.usesRaw || hasMutatingEffects(args.effects);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** What this request should do with the header. */
|
|
90
|
+
export interface IdempotencyAttempt {
|
|
91
|
+
readonly claiming: boolean;
|
|
92
|
+
readonly mode: "off" | "auto" | "required";
|
|
93
|
+
readonly ttlMs: number;
|
|
94
|
+
readonly usesRaw: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Decide whether this request claims a key.
|
|
99
|
+
*
|
|
100
|
+
* A stamped `required` flow still ignores the header when this entry is not
|
|
101
|
+
* an eligible HTTP call (signal delivery, GET, stream, live, read-only).
|
|
102
|
+
*
|
|
103
|
+
* @param args - Flow stamp plus this request
|
|
104
|
+
*/
|
|
105
|
+
export function prepareIdempotencyAttempt(args: {
|
|
106
|
+
readonly hasRequest: boolean;
|
|
107
|
+
readonly method: string | undefined;
|
|
108
|
+
readonly stream: boolean;
|
|
109
|
+
readonly live: boolean;
|
|
110
|
+
readonly effects: Effects | undefined;
|
|
111
|
+
readonly usesRaw: boolean;
|
|
112
|
+
readonly option: FlowIdempotencyOption | undefined;
|
|
113
|
+
readonly resolved: ResolvedIdempotency | undefined;
|
|
114
|
+
readonly header: string | null;
|
|
115
|
+
}): IdempotencyAttempt {
|
|
116
|
+
const eligible = idempotencyEligible(args);
|
|
117
|
+
const resolved = args.resolved ?? resolveIdempotencyMode(args.option, eligible);
|
|
118
|
+
const mode = eligible ? resolved.mode : "off";
|
|
119
|
+
const headerPresent = args.header !== null && args.header.trim().length > 0;
|
|
120
|
+
const ttlMs = idempotencyTtlMs(resolved.ttl);
|
|
121
|
+
return {
|
|
122
|
+
claiming: mode === "required" || (mode === "auto" && headerPresent),
|
|
123
|
+
mode,
|
|
124
|
+
ttlMs: ttlMs > 0 ? ttlMs : 24 * 60 * 60 * 1000,
|
|
125
|
+
usesRaw: args.usesRaw,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Resolve `off | auto | required` and the TTL string.
|
|
131
|
+
*
|
|
132
|
+
* Ineligible flows are `off` even when the option says required. Callers
|
|
133
|
+
* that parse source should reject that combination before calling this.
|
|
134
|
+
*
|
|
135
|
+
* @param option - Author option
|
|
136
|
+
* @param eligible - {@link idempotencyEligible}
|
|
137
|
+
*/
|
|
138
|
+
export function resolveIdempotencyMode(
|
|
139
|
+
option: FlowIdempotencyOption | undefined,
|
|
140
|
+
eligible: boolean,
|
|
141
|
+
): ResolvedIdempotency {
|
|
142
|
+
const ttl = ttlOf(option);
|
|
143
|
+
if (!eligible || option === false) return { mode: "off", ttl };
|
|
144
|
+
if (option === "required") return { mode: "required", ttl };
|
|
145
|
+
if (typeof option === "object" && option.required === true) return { mode: "required", ttl };
|
|
146
|
+
return { mode: "auto", ttl };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Parse a TTL string. `0` means the string is not a duration.
|
|
151
|
+
*
|
|
152
|
+
* @param ttl - Duration such as `24h`
|
|
153
|
+
*/
|
|
154
|
+
export function idempotencyTtlMs(ttl: string): number {
|
|
155
|
+
return parseDurationMs(ttl);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* `user:<id>`, else `apikey:<id>`, else `anon`.
|
|
160
|
+
*
|
|
161
|
+
* @param auth - Principal after the gate
|
|
162
|
+
*/
|
|
163
|
+
export function idempotencyPrincipal(auth: {
|
|
164
|
+
readonly userId: string | null;
|
|
165
|
+
readonly apiKeyId?: string | null;
|
|
166
|
+
}): string {
|
|
167
|
+
if (auth.userId !== null && auth.userId.length > 0) return `user:${auth.userId}`;
|
|
168
|
+
if (auth.apiKeyId !== null && auth.apiKeyId !== undefined && auth.apiKeyId.length > 0) {
|
|
169
|
+
return `apikey:${auth.apiKeyId}`;
|
|
170
|
+
}
|
|
171
|
+
return "anon";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* SHA-256 of canonical JSON `{ flow, input }`. Computed once at claim.
|
|
176
|
+
*
|
|
177
|
+
* @param flow - Flow name
|
|
178
|
+
* @param input - Validated input
|
|
179
|
+
*/
|
|
180
|
+
export function idempotencyFingerprint(flow: string, input: unknown): string {
|
|
181
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
182
|
+
hasher.update(canonicalJson({ flow, input }));
|
|
183
|
+
return hasher.digest("hex");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Classify a raw header value.
|
|
188
|
+
*
|
|
189
|
+
* @param header - Header value, or null when absent
|
|
190
|
+
*/
|
|
191
|
+
export function classifyIdempotencyKey(header: string | null): "missing" | "invalid" | string {
|
|
192
|
+
if (header === null) return "missing";
|
|
193
|
+
const key = header.trim();
|
|
194
|
+
if (!KEY_PATTERN.test(key)) return "invalid";
|
|
195
|
+
return key;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* True when this attempt recorded a non-read effect. Secrets do not count.
|
|
200
|
+
* Failed attempts stay on the ledger.
|
|
201
|
+
*
|
|
202
|
+
* @param entries - This run's ledger
|
|
203
|
+
*/
|
|
204
|
+
export function attemptedMutation(entries: readonly { readonly kind: EffectKind }[]): boolean {
|
|
205
|
+
return entries.some((entry) => MUTATING.has(entry.kind));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Replay a stored response and mark it `Idempotent-Replayed`.
|
|
210
|
+
*
|
|
211
|
+
* @param row - Completed row
|
|
212
|
+
*/
|
|
213
|
+
export function replayResponse(row: IdempotencyRow): Response {
|
|
214
|
+
const headers = new Headers();
|
|
215
|
+
const contentType = row.responseHeaders?.contentType;
|
|
216
|
+
const location = row.responseHeaders?.location;
|
|
217
|
+
if (contentType !== undefined && contentType.length > 0) headers.set("content-type", contentType);
|
|
218
|
+
if (location !== undefined && location.length > 0) headers.set("location", location);
|
|
219
|
+
headers.set(REPLAYED_HEADER, "true");
|
|
220
|
+
const status = row.responseStatus ?? 200;
|
|
221
|
+
if (status === 204) return new Response(null, { status, headers });
|
|
222
|
+
return new Response(row.responseBody ?? "", { status, headers });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Protocol failure. `IdempotencyInProgress` also sets `Retry-After`.
|
|
227
|
+
*
|
|
228
|
+
* @param code - Builtin idempotency code
|
|
229
|
+
* @param retryAfterSeconds - Seconds until the lease expires, for 409
|
|
230
|
+
*/
|
|
231
|
+
export function idempotencyErrorResponse(
|
|
232
|
+
code:
|
|
233
|
+
| "IdempotencyKeyMissing"
|
|
234
|
+
| "IdempotencyKeyInvalid"
|
|
235
|
+
| "IdempotencyKeyReused"
|
|
236
|
+
| "IdempotencyInProgress",
|
|
237
|
+
retryAfterSeconds?: number,
|
|
238
|
+
): Response {
|
|
239
|
+
const failure = fail(
|
|
240
|
+
code,
|
|
241
|
+
code === "IdempotencyInProgress" ? { retryAfter: retryAfterSeconds } : {},
|
|
242
|
+
);
|
|
243
|
+
const headers = new Headers();
|
|
244
|
+
if (code === "IdempotencyInProgress") {
|
|
245
|
+
headers.set("retry-after", String(Math.max(1, retryAfterSeconds ?? 1)));
|
|
246
|
+
}
|
|
247
|
+
const status =
|
|
248
|
+
code === "IdempotencyKeyReused" ? 422 : code === "IdempotencyInProgress" ? 409 : 400;
|
|
249
|
+
return Response.json({ data: null, error: failure.error }, { status, headers });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Buffer a JSON envelope for storage. Streams and non-JSON bodies return
|
|
254
|
+
* undefined so the caller deletes the in-progress row.
|
|
255
|
+
*
|
|
256
|
+
* @param res - Encoded response (not the live stream)
|
|
257
|
+
*/
|
|
258
|
+
export async function storedFromResponse(res: {
|
|
259
|
+
readonly status: number;
|
|
260
|
+
readonly headers: { get(name: string): string | null };
|
|
261
|
+
text(): Promise<string>;
|
|
262
|
+
}): Promise<StoredResponse | undefined> {
|
|
263
|
+
const contentType = res.headers.get("content-type") ?? undefined;
|
|
264
|
+
if (contentType?.includes("text/event-stream")) return undefined;
|
|
265
|
+
if (
|
|
266
|
+
contentType !== undefined &&
|
|
267
|
+
!contentType.includes("json") &&
|
|
268
|
+
!contentType.includes("text/") &&
|
|
269
|
+
res.status !== 204
|
|
270
|
+
) {
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
const location = res.headers.get("location") ?? undefined;
|
|
274
|
+
const body = res.status === 204 ? "" : await res.text();
|
|
275
|
+
return {
|
|
276
|
+
status: res.status,
|
|
277
|
+
body,
|
|
278
|
+
...(contentType !== undefined ? { contentType } : {}),
|
|
279
|
+
...(location !== undefined ? { location } : {}),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Author TTL, or {@link IDEMPOTENCY_DEFAULT_TTL}.
|
|
285
|
+
*
|
|
286
|
+
* @param option - Author option
|
|
287
|
+
*/
|
|
288
|
+
export function ttlOf(option: FlowIdempotencyOption | undefined): string {
|
|
289
|
+
if (typeof option === "object" && option !== null && typeof option.ttl === "string") {
|
|
290
|
+
return option.ttl;
|
|
291
|
+
}
|
|
292
|
+
return IDEMPOTENCY_DEFAULT_TTL;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function canonicalJson(value: unknown): string {
|
|
296
|
+
if (value === undefined) return "null";
|
|
297
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
298
|
+
if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
|
|
299
|
+
const obj = value as Record<string, unknown>;
|
|
300
|
+
const keys = Object.keys(obj)
|
|
301
|
+
.filter((key) => obj[key] !== undefined)
|
|
302
|
+
.sort();
|
|
303
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(obj[key])}`).join(",")}}`;
|
|
304
|
+
}
|
package/src/kernel/index.ts
CHANGED
package/src/kernel/journal.ts
CHANGED
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
import { mkdir } from "node:fs/promises";
|
|
11
11
|
import { dirname } from "node:path";
|
|
12
12
|
import { okid } from "../okid.ts";
|
|
13
|
+
import type { IdempotencyStore } from "./idempotency-store.ts";
|
|
13
14
|
import { JournalSuspend } from "./journal-suspend.ts";
|
|
15
|
+
import { lazyRequire } from "./lazy-require.ts";
|
|
14
16
|
|
|
15
17
|
export { JournalSuspend, isJournalSuspend } from "./journal-suspend.ts";
|
|
16
18
|
|
|
@@ -151,8 +153,21 @@ export interface JournalLeaseStore {
|
|
|
151
153
|
listOrphans(now: number): Promise<readonly JournalRun[]>;
|
|
152
154
|
}
|
|
153
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Load the idempotency table implementation without a static import.
|
|
158
|
+
* Computed stem so the edge profile does not inline it.
|
|
159
|
+
*/
|
|
160
|
+
function loadIdempotencyStore(): typeof import("./idempotency-store.ts") {
|
|
161
|
+
return lazyRequire(import.meta.dir, ["idempotency", "store"].join("-"));
|
|
162
|
+
}
|
|
163
|
+
|
|
154
164
|
/** Persistence backend for journal runs. */
|
|
155
165
|
export interface JournalStore extends Partial<JournalLeaseStore> {
|
|
166
|
+
/**
|
|
167
|
+
* Idempotency records on this same driver. Absent on a custom store that
|
|
168
|
+
* only implements run `get` / `put`.
|
|
169
|
+
*/
|
|
170
|
+
readonly idempotency?: IdempotencyStore;
|
|
156
171
|
/**
|
|
157
172
|
* Load a run by id.
|
|
158
173
|
*
|
|
@@ -286,6 +301,11 @@ export function createMemoryJournalStore(seed?: readonly JournalRun[]): JournalS
|
|
|
286
301
|
return [...runs.values()].map(cloneRun);
|
|
287
302
|
},
|
|
288
303
|
...leaseMethods(load),
|
|
304
|
+
get idempotency(): IdempotencyStore {
|
|
305
|
+
const created = loadIdempotencyStore().createMemoryIdempotencyStore();
|
|
306
|
+
Object.defineProperty(this, "idempotency", { value: created });
|
|
307
|
+
return created;
|
|
308
|
+
},
|
|
289
309
|
};
|
|
290
310
|
}
|
|
291
311
|
|
|
@@ -332,6 +352,13 @@ export function createFileJournalStore(path: string): JournalStore {
|
|
|
332
352
|
},
|
|
333
353
|
// Single-host file: leases coordinate same-machine processes only.
|
|
334
354
|
...leaseMethods(load, flush),
|
|
355
|
+
get idempotency(): IdempotencyStore {
|
|
356
|
+
const created = loadIdempotencyStore().createFileIdempotencyStore(
|
|
357
|
+
`${dirname(path)}/idempotency.json`,
|
|
358
|
+
);
|
|
359
|
+
Object.defineProperty(this, "idempotency", { value: created });
|
|
360
|
+
return created;
|
|
361
|
+
},
|
|
335
362
|
};
|
|
336
363
|
}
|
|
337
364
|
|
|
@@ -428,6 +455,35 @@ export interface JournalSession {
|
|
|
428
455
|
): Promise<void>;
|
|
429
456
|
}
|
|
430
457
|
|
|
458
|
+
/**
|
|
459
|
+
* A journal session that forwards to a real session once one is assigned.
|
|
460
|
+
*
|
|
461
|
+
* Idempotent durable flows start the journal only after the claim, which is
|
|
462
|
+
* after `fx` has already been created.
|
|
463
|
+
*/
|
|
464
|
+
export function createJournalSlot(): {
|
|
465
|
+
readonly slot: { session?: JournalSession };
|
|
466
|
+
readonly facade: JournalSession;
|
|
467
|
+
} {
|
|
468
|
+
const slot: { session?: JournalSession } = {};
|
|
469
|
+
const facade = new Proxy({} as JournalSession, {
|
|
470
|
+
get(_target, prop, receiver) {
|
|
471
|
+
const session = slot.session;
|
|
472
|
+
if (!session) {
|
|
473
|
+
if (prop === "runId") return "";
|
|
474
|
+
if (prop === "stampTenant") return async () => undefined;
|
|
475
|
+
if (prop === "rewind") return () => undefined;
|
|
476
|
+
return undefined;
|
|
477
|
+
}
|
|
478
|
+
const value = Reflect.get(session, prop, receiver);
|
|
479
|
+
return typeof value === "function"
|
|
480
|
+
? (value as (...args: unknown[]) => unknown).bind(session)
|
|
481
|
+
: value;
|
|
482
|
+
},
|
|
483
|
+
});
|
|
484
|
+
return { slot, facade };
|
|
485
|
+
}
|
|
486
|
+
|
|
431
487
|
/** Journal facade. */
|
|
432
488
|
export interface Journal {
|
|
433
489
|
readonly store: JournalStore;
|
package/src/manifest/types.ts
CHANGED
|
@@ -201,6 +201,14 @@ export interface Flow {
|
|
|
201
201
|
source?: string;
|
|
202
202
|
plane?: FlowPlane;
|
|
203
203
|
durable?: boolean;
|
|
204
|
+
/**
|
|
205
|
+
* HTTP idempotency stamp. `off` ignores `Idempotency-Key`.
|
|
206
|
+
* `auto` honors it when present. `required` rejects a missing header.
|
|
207
|
+
* `ttl` is how long a stored response (possibly PII) is kept.
|
|
208
|
+
*/
|
|
209
|
+
idempotency?: { mode: "off" | "auto" | "required"; ttl: string };
|
|
210
|
+
/** True when the body calls `fx.raw`. Throws then keep the idempotency record. */
|
|
211
|
+
usesRaw?: boolean;
|
|
204
212
|
/** Live signal name when this flow streams `delivery: "live"` SSE. */
|
|
205
213
|
live?: string;
|
|
206
214
|
cache?: boolean | string;
|
|
@@ -18,7 +18,7 @@ import { otp } from "./otp.ts";
|
|
|
18
18
|
import { magicLink } from "./magic-link.ts";
|
|
19
19
|
|
|
20
20
|
const SECRET = "test-secret-at-least-16";
|
|
21
|
-
const MAILPIT_IMAGE = "axllent/mailpit:v1.31.
|
|
21
|
+
const MAILPIT_IMAGE = "axllent/mailpit:v1.31.2";
|
|
22
22
|
|
|
23
23
|
afterEach(() => {
|
|
24
24
|
resetBindings();
|
package/src/plugins/otp.test.ts
CHANGED
|
@@ -117,7 +117,71 @@ describe("otp() provider mode boot", () => {
|
|
|
117
117
|
const body = (await res.json()) as { data: { ok: true; devOtp?: string } };
|
|
118
118
|
expect(body.data.ok).toBe(true);
|
|
119
119
|
expect(body.data.devOtp).toBeUndefined();
|
|
120
|
-
expect(app.router.match("POST", "/auth/otp/resend")).
|
|
120
|
+
expect(app.router.match("POST", "/auth/otp/resend")).toBeTruthy();
|
|
121
|
+
await app.stop();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("SMS resend issues a new request id, honors cooldown, and rejects another channel", async () => {
|
|
125
|
+
let now = 1_000_000;
|
|
126
|
+
const requestIds: string[] = [];
|
|
127
|
+
const verifications = createVerificationStore();
|
|
128
|
+
const driver = mockOtpSmsDriver();
|
|
129
|
+
const transport = driver.smsTransport as unknown as SmsOtpTransport;
|
|
130
|
+
const original = transport.sendOtp.bind(transport);
|
|
131
|
+
transport.sendOtp = async (opts) => {
|
|
132
|
+
requestIds.push(opts.requestId);
|
|
133
|
+
return original(opts);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const app = oke({
|
|
137
|
+
name: `otp-provider-resend-${crypto.randomUUID()}`,
|
|
138
|
+
env: "test",
|
|
139
|
+
registry: "ignore",
|
|
140
|
+
gate: { auth: { secret: SECRET } },
|
|
141
|
+
channel: { drivers: [driver] },
|
|
142
|
+
fx: { now: () => now },
|
|
143
|
+
}).plug(
|
|
144
|
+
otp({
|
|
145
|
+
mode: "provider",
|
|
146
|
+
resendCooldownMs: 60_000,
|
|
147
|
+
verifications,
|
|
148
|
+
now: () => now,
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
await app.boot({ env: "test" });
|
|
152
|
+
|
|
153
|
+
const phone = "+15551234567";
|
|
154
|
+
expect((await app.fetch(jsonPost("/auth/otp/request", { phone }))).status).toBe(200);
|
|
155
|
+
const first = findActiveVerification(verifications, `otp:${phone}`, now);
|
|
156
|
+
expect(first?.value.startsWith("provider:")).toBe(true);
|
|
157
|
+
expect(first?.sealedOtp).toBeNull();
|
|
158
|
+
const firstId = first?.value.slice("provider:".length);
|
|
159
|
+
|
|
160
|
+
const cooled = await app.fetch(jsonPost("/auth/otp/resend", { phone }));
|
|
161
|
+
const cooledBody = (await cooled.json()) as { error?: { data?: { reason?: string } } };
|
|
162
|
+
expect(cooledBody.error?.data?.reason).toBe("resend_cooldown");
|
|
163
|
+
|
|
164
|
+
const other = await app.fetch(jsonPost("/auth/otp/resend", { phone, channel: "email" }));
|
|
165
|
+
const otherBody = (await other.json()) as { error?: { data?: { reason?: string } } };
|
|
166
|
+
expect(otherBody.error?.data?.reason).toBe("invalid_channel");
|
|
167
|
+
|
|
168
|
+
now += 60_001;
|
|
169
|
+
const resent = await app.fetch(jsonPost("/auth/otp/resend", { phone, channel: "sms" }));
|
|
170
|
+
expect(resent.status).toBe(200);
|
|
171
|
+
const resentBody = (await resent.json()) as { data: { channel: string; devOtp?: string } };
|
|
172
|
+
expect(resentBody.data.channel).toBe("sms");
|
|
173
|
+
expect(resentBody.data.devOtp).toBeUndefined();
|
|
174
|
+
|
|
175
|
+
const second = findActiveVerification(verifications, `otp:${phone}`, now);
|
|
176
|
+
const secondId = second?.value.slice("provider:".length);
|
|
177
|
+
expect(secondId).toBeDefined();
|
|
178
|
+
expect(secondId).not.toBe(firstId);
|
|
179
|
+
if (firstId === undefined || secondId === undefined) {
|
|
180
|
+
throw new Error("expected provider request ids");
|
|
181
|
+
}
|
|
182
|
+
expect(requestIds).toEqual([firstId, secondId]);
|
|
183
|
+
expect(findActiveVerification(verifications, `otp:${phone}`, now)?.sealedOtp).toBeNull();
|
|
184
|
+
|
|
121
185
|
await app.stop();
|
|
122
186
|
});
|
|
123
187
|
});
|
package/src/plugins/otp.ts
CHANGED
|
@@ -77,7 +77,8 @@ export interface OtpProviderModeOptions extends OtpBaseOptions {
|
|
|
77
77
|
readonly channels?: never;
|
|
78
78
|
/** Forbidden in provider mode — code never exists server-side. */
|
|
79
79
|
readonly exposeDevOtp?: never;
|
|
80
|
-
|
|
80
|
+
/** Spacing between provider SMS resends. Default 60s. */
|
|
81
|
+
readonly resendCooldownMs?: number;
|
|
81
82
|
readonly from?: never;
|
|
82
83
|
}
|
|
83
84
|
|
|
@@ -216,10 +217,7 @@ export function otp(opts: OtpOptions): PluginDef {
|
|
|
216
217
|
const identities = resolveSharedIdentities(opts);
|
|
217
218
|
const verifications = opts.verifications ?? createVerificationStore();
|
|
218
219
|
const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
219
|
-
const resendCooldownMs =
|
|
220
|
-
opts.mode === "app"
|
|
221
|
-
? (opts.resendCooldownMs ?? DEFAULT_RESEND_COOLDOWN_MS)
|
|
222
|
-
: DEFAULT_RESEND_COOLDOWN_MS;
|
|
220
|
+
const resendCooldownMs = opts.resendCooldownMs ?? DEFAULT_RESEND_COOLDOWN_MS;
|
|
223
221
|
const channels = opts.mode === "app" ? opts.channels : ([] as const);
|
|
224
222
|
const exposeDevOtp = opts.mode === "app" && opts.exposeDevOtp === true;
|
|
225
223
|
const from = opts.mode === "app" ? opts.from : undefined;
|
|
@@ -288,8 +286,61 @@ export function otp(opts: OtpOptions): PluginDef {
|
|
|
288
286
|
attempts: 0,
|
|
289
287
|
phone,
|
|
290
288
|
sealedOtp: null,
|
|
289
|
+
lastDeliveredAt: now,
|
|
290
|
+
lastChannel: "sms",
|
|
291
|
+
});
|
|
292
|
+
return { ok: true as const, channel: "sms" as const };
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const resendContract = {
|
|
297
|
+
in: z.object({
|
|
298
|
+
phone: z.string().min(8),
|
|
299
|
+
lang: z.enum(["en", "ar"]).optional(),
|
|
300
|
+
channel: z.enum(["sms", "whatsapp", "email"]).optional(),
|
|
301
|
+
}),
|
|
302
|
+
out: requestOut,
|
|
303
|
+
errors: { AuthFailed, AuthRateLimited },
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const resend = flow("auth.resendOtp", {
|
|
307
|
+
plane: "user",
|
|
308
|
+
effects: { sends: ["sms-otp"] },
|
|
309
|
+
do: async (input, fx) => {
|
|
310
|
+
if (input.channel !== undefined && input.channel !== "sms") {
|
|
311
|
+
return fail("AuthFailed", { reason: "invalid_channel" });
|
|
312
|
+
}
|
|
313
|
+
const phone = input.phone.trim();
|
|
314
|
+
if (!E164.test(phone)) return fail("AuthFailed", { reason: "invalid_phone" });
|
|
315
|
+
const now = runtime.now();
|
|
316
|
+
const row = findActiveVerification(verifications, `otp:${phone}`, now);
|
|
317
|
+
if (!row || row.sealedOtp) {
|
|
318
|
+
return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
319
|
+
}
|
|
320
|
+
if (row.lastDeliveredAt !== undefined && now - row.lastDeliveredAt < resendCooldownMs) {
|
|
321
|
+
return fail("AuthFailed", { reason: "resend_cooldown" });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const requestId = okid();
|
|
325
|
+
await fx.sendOtp({
|
|
326
|
+
to: phone,
|
|
327
|
+
requestId,
|
|
328
|
+
...(input.lang ? { lang: input.lang } : {}),
|
|
329
|
+
});
|
|
330
|
+
invalidateVerifications(verifications, `otp:${phone}`, now);
|
|
331
|
+
putVerification(verifications, {
|
|
332
|
+
id: okid(),
|
|
333
|
+
identifier: `otp:${phone}`,
|
|
334
|
+
value: `provider:${requestId}`,
|
|
335
|
+
expiresAt: now + ttlMs,
|
|
336
|
+
createdAt: now,
|
|
337
|
+
consumedAt: null,
|
|
338
|
+
attempts: 0,
|
|
339
|
+
phone,
|
|
340
|
+
sealedOtp: null,
|
|
341
|
+
lastDeliveredAt: now,
|
|
342
|
+
lastChannel: "sms",
|
|
291
343
|
});
|
|
292
|
-
// Provider mode: resend-via-different-channel is impossible — provider owns the code.
|
|
293
344
|
return { ok: true as const, channel: "sms" as const };
|
|
294
345
|
},
|
|
295
346
|
});
|
|
@@ -373,7 +424,8 @@ export function otp(opts: OtpOptions): PluginDef {
|
|
|
373
424
|
return plugin("otp", { version: "0.0.1", config: configSnapshot })
|
|
374
425
|
.needs("auth")
|
|
375
426
|
.binding(bindPublicAuth("/otp/request", request, "otp", requestContract))
|
|
376
|
-
.binding(bindPublicAuth("/otp/verify", verify, "otp", verifyContract))
|
|
427
|
+
.binding(bindPublicAuth("/otp/verify", verify, "otp", verifyContract))
|
|
428
|
+
.binding(bindPublicAuth("/otp/resend", resend, "otp", resendContract));
|
|
377
429
|
}
|
|
378
430
|
|
|
379
431
|
// ── App mode ───────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Absolute budgets with room under the cap also fail at 2× the last value.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import {
|
|
7
|
+
absoluteBudgetOk,
|
|
8
|
+
absoluteRegressionCeiling,
|
|
9
|
+
formatBudgetsReport,
|
|
10
|
+
type BudgetsSnapshot,
|
|
11
|
+
} from "./measure.ts";
|
|
12
|
+
import {
|
|
13
|
+
ABSOLUTE_REGRESSION_RATIO,
|
|
14
|
+
CLIENT_BUDGET_BYTES,
|
|
15
|
+
COLD_START_BUDGET_MS,
|
|
16
|
+
CONSOLE_BUDGET_BYTES,
|
|
17
|
+
KERNEL_EDGE_BUDGET_BYTES,
|
|
18
|
+
} from "./limits.ts";
|
|
19
|
+
|
|
20
|
+
describe("absolute regression ceilings", () => {
|
|
21
|
+
test("ratio is 2× so a confirmed doubling fails and a single noisy run can be re-measured", () => {
|
|
22
|
+
expect(ABSOLUTE_REGRESSION_RATIO).toBe(2);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("cold start and console initial load grow a ceiling; kernel, client, and a zero baseline do not", () => {
|
|
26
|
+
expect(absoluteRegressionCeiling(undefined, COLD_START_BUDGET_MS, "ms")).toBeUndefined();
|
|
27
|
+
expect(absoluteRegressionCeiling(0, 1, "ms")).toBeUndefined();
|
|
28
|
+
expect(absoluteRegressionCeiling(8.915, COLD_START_BUDGET_MS, "ms")).toBe(17.83);
|
|
29
|
+
expect(absoluteRegressionCeiling(353_621, CONSOLE_BUDGET_BYTES, "bytes")).toBe(707_242);
|
|
30
|
+
expect(absoluteRegressionCeiling(12_556, KERNEL_EDGE_BUDGET_BYTES, "bytes")).toBeUndefined();
|
|
31
|
+
expect(absoluteRegressionCeiling(4598, CLIENT_BUDGET_BYTES, "bytes")).toBeUndefined();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("ok requires both the cap and the ceiling", () => {
|
|
35
|
+
expect(absoluteBudgetOk(8.915, COLD_START_BUDGET_MS, 17.83)).toBe(true);
|
|
36
|
+
expect(absoluteBudgetOk(17.83, COLD_START_BUDGET_MS, 17.83)).toBe(false);
|
|
37
|
+
expect(absoluteBudgetOk(20, COLD_START_BUDGET_MS, 17.83)).toBe(false);
|
|
38
|
+
expect(absoluteBudgetOk(80, COLD_START_BUDGET_MS, undefined)).toBe(false);
|
|
39
|
+
expect(absoluteBudgetOk(12_556, KERNEL_EDGE_BUDGET_BYTES, undefined)).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("the CI log prints the 2× ceiling and the absolute cap", () => {
|
|
43
|
+
const snapshot: BudgetsSnapshot = {
|
|
44
|
+
measuredAt: "2026-09-23T00:00:00.000Z",
|
|
45
|
+
version: "0.0.0",
|
|
46
|
+
budgets: [
|
|
47
|
+
{
|
|
48
|
+
id: "coldStartMedianMs",
|
|
49
|
+
label: "Cold start on Bun",
|
|
50
|
+
value: 20,
|
|
51
|
+
limit: COLD_START_BUDGET_MS,
|
|
52
|
+
regressionLimit: 17.83,
|
|
53
|
+
unit: "ms",
|
|
54
|
+
gate: "absolute",
|
|
55
|
+
group: "core",
|
|
56
|
+
ok: false,
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
const report = formatBudgetsReport(snapshot);
|
|
61
|
+
expect(report).toContain("[FAIL] Cold start on Bun: 20.000 ms < 17.830 ms (cap 75.000 ms)");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `okengine/http` must not statically import heavy modules.
|
|
3
|
+
* Forbidden ids live in {@link HTTP_STATIC_GRAPH_FORBIDDEN}.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { forbiddenHttpGraphHits, readStaticGraphSpecifiers } from "./http-graph.ts";
|
|
8
|
+
|
|
9
|
+
describe("okengine/http static import graph", () => {
|
|
10
|
+
test("zod and the hybrid-search runtime are not in the graph", async () => {
|
|
11
|
+
const specifiers = await readStaticGraphSpecifiers();
|
|
12
|
+
expect(specifiers.length).toBeGreaterThan(10);
|
|
13
|
+
expect(forbiddenHttpGraphHits(specifiers)).toEqual([]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("the forbidden list matches zod and search-runtime, and nothing nearby", () => {
|
|
17
|
+
expect(forbiddenHttpGraphHits(["node_modules/zod/index.js"])).toEqual(["zod"]);
|
|
18
|
+
expect(forbiddenHttpGraphHits(["zod"])).toEqual(["zod"]);
|
|
19
|
+
expect(forbiddenHttpGraphHits(["src/elements/store/search-runtime.ts"])).toEqual([
|
|
20
|
+
"hybrid-search runtime",
|
|
21
|
+
]);
|
|
22
|
+
expect(forbiddenHttpGraphHits(["src/elements/store/search-lsh.ts"])).toEqual([]);
|
|
23
|
+
expect(forbiddenHttpGraphHits(["src/kernel/app.ts"])).toEqual([]);
|
|
24
|
+
});
|
|
25
|
+
});
|