okengine 0.21.0 → 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 -25
- 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/dev.test.ts +105 -2
- package/src/cli/dev.ts +37 -2
- 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 +31 -27
- package/src/client/live.ts +44 -101
- package/src/client/sse.ts +20 -66
- package/src/client/stream.ts +25 -67
- package/src/client/transport.test.ts +225 -0
- package/src/client/transport.ts +158 -90
- package/src/client/types.ts +29 -3
- package/src/client/wire.ts +119 -0
- package/src/compiler/aot.ts +3 -32
- package/src/compiler/dynamic.ts +13 -11
- 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/compiler/interpret.ts +45 -0
- package/src/console/server/flows-invoke.test.ts +2 -2
- package/src/console/ui-next/dist/assets/{access-page-tIbsiphz.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-BCC-DxKT.js → cache-glyph-rL0lHxat.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-jOmYISdc.js → call-pii-button-Deo4PP18.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-DC2xNaAb.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-gT1lsWQK.js → flows-page-D2E7BtKK.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-C2GZEJNI.js → highlighted-json-CPYBzDh0.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-BdYjcIrD.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-C4QdAF7J.js → replica-lag-BM27lXld.js} +3 -3
- package/src/console/ui-next/dist/assets/{request-meta-C43DHyld.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-Dcpo4Us_.js → trace-detail-sheet-Cmitq3da.js} +2 -2
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-CKJTuv43.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-BwZ9YjTW.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/sql-session.ts +20 -4
- 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 +200 -20
- 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.test.ts +78 -0
- package/src/kernel/client-descriptor.ts +24 -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/errors-text.ts +99 -0
- package/src/kernel/errors.ts +90 -138
- 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-sql-handle.ts +305 -0
- package/src/kernel/fx.ts +42 -330
- 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/kernel/json-result.ts +59 -0
- package/src/kernel/project-out.ts +6 -1
- package/src/manifest/types.ts +8 -0
- package/src/okid-extended.ts +175 -0
- package/src/okid-shared.ts +103 -0
- package/src/okid.ts +30 -213
- 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/build-lib.ts +9 -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-CSKumwS2.js +0 -1
- package/src/console/ui-next/dist/assets/duration-tone-CwoV56jn.js +0 -9
- package/src/console/ui-next/dist/assets/element-icons-BI8cJgdh.js +0 -1
- package/src/console/ui-next/dist/assets/index-Cul17AcV.js +0 -63
- package/src/console/ui-next/dist/assets/observability-page-oY9vdYBk.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-CL0D9dOq.js +0 -41
- package/src/console/ui-next/dist/assets/units-page-1PlT19ft.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;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fx.json` carriers — brand and guards, with no `fx` / Store graph.
|
|
3
|
+
*
|
|
4
|
+
* `project-out` and the HTTP encoder need these on the cold-start path.
|
|
5
|
+
* Importing them from `fx.ts` would evaluate the Store barrel (Zod, hybrid
|
|
6
|
+
* search) before the server listens.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Brand for {@link JsonResult} (kept internal — flows never construct it). */
|
|
10
|
+
export const jsonResultBrand: unique symbol = Symbol.for("oke.json");
|
|
11
|
+
|
|
12
|
+
/** Carrier from `fx.json` — status + body read by the response encoder. */
|
|
13
|
+
export interface JsonResult<T = unknown> {
|
|
14
|
+
readonly [jsonResultBrand]: true;
|
|
15
|
+
readonly status: number;
|
|
16
|
+
readonly value?: T;
|
|
17
|
+
readonly meta?: Record<string, unknown>;
|
|
18
|
+
readonly kind?: undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** SSE carrier from `fx.json.stream` / `fx.live`. */
|
|
22
|
+
export interface JsonStreamResult {
|
|
23
|
+
readonly [jsonResultBrand]: true;
|
|
24
|
+
readonly kind: "stream";
|
|
25
|
+
readonly status: 200;
|
|
26
|
+
readonly chunks: AsyncIterable<unknown>;
|
|
27
|
+
/** Awaited before the 200 SSE body; throws OKE1210 on a missing resume cursor. */
|
|
28
|
+
ready?: () => Promise<void>;
|
|
29
|
+
/** Set by the kernel to commit journal / Runs after the stream settles. */
|
|
30
|
+
finalize?: () => Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* True when `value` is an `fx.json` envelope carrier.
|
|
35
|
+
*
|
|
36
|
+
* @param value - Unknown handler output
|
|
37
|
+
*/
|
|
38
|
+
export function isJsonResult(value: unknown): value is JsonResult {
|
|
39
|
+
return (
|
|
40
|
+
typeof value === "object" &&
|
|
41
|
+
value !== null &&
|
|
42
|
+
(value as JsonResult)[jsonResultBrand] === true &&
|
|
43
|
+
(value as JsonStreamResult).kind !== "stream"
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* True when `value` is an SSE stream carrier from `fx.json.stream`.
|
|
49
|
+
*
|
|
50
|
+
* @param value - Unknown handler output
|
|
51
|
+
*/
|
|
52
|
+
export function isJsonStreamResult(value: unknown): value is JsonStreamResult {
|
|
53
|
+
return (
|
|
54
|
+
typeof value === "object" &&
|
|
55
|
+
value !== null &&
|
|
56
|
+
(value as JsonStreamResult)[jsonResultBrand] === true &&
|
|
57
|
+
(value as JsonStreamResult).kind === "stream"
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -12,7 +12,12 @@
|
|
|
12
12
|
|
|
13
13
|
import type { SchemaInput } from "../validation/standard-schema.ts";
|
|
14
14
|
import { validate } from "../validation/standard-schema.ts";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
isJsonResult,
|
|
17
|
+
isJsonStreamResult,
|
|
18
|
+
jsonResultBrand,
|
|
19
|
+
type JsonResult,
|
|
20
|
+
} from "./json-result.ts";
|
|
16
21
|
|
|
17
22
|
/**
|
|
18
23
|
* Project `output` through `schema` when the exposure declared `out`.
|
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;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `okid({ … })` options path — sortable, prefix, and alphabet toggles.
|
|
3
|
+
*
|
|
4
|
+
* Kept off the kernel edge profile. `okid()` and `okid(length)` never load it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
assertLength,
|
|
9
|
+
OKID_ALPHABET,
|
|
10
|
+
OKID_DEFAULT_LENGTH,
|
|
11
|
+
OKID_LOOKALIKE_CHARS,
|
|
12
|
+
OKID_MAX_PREFIX_LENGTH,
|
|
13
|
+
OKID_MIN_LENGTH,
|
|
14
|
+
OKID_SORTABLE_ALPHABET,
|
|
15
|
+
OKID_SORTABLE_MIN_LENGTH,
|
|
16
|
+
type OkidOptions,
|
|
17
|
+
} from "./okid-shared.ts";
|
|
18
|
+
|
|
19
|
+
/** Character groups addressable through {@link OkidOptions} toggles. */
|
|
20
|
+
const GROUPS = {
|
|
21
|
+
numbers: "0123456789",
|
|
22
|
+
lowercase: "abcdefghijklmnopqrstuvwxyz",
|
|
23
|
+
uppercase: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
|
24
|
+
symbols: "-_",
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
/** Resolved alphabet + encoding metadata for one options combination. */
|
|
28
|
+
interface ResolvedAlphabet {
|
|
29
|
+
readonly chars: string;
|
|
30
|
+
readonly size: number;
|
|
31
|
+
/** Bitmask covering `size` values (`size` is always a power of two here). */
|
|
32
|
+
readonly mask: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Memoized resolutions keyed by the toggle bitmask (32 combinations max). */
|
|
36
|
+
const ALPHABET_CACHE = new Map<number, ResolvedAlphabet>();
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve a toggle combination to an alphabet and rejection-sampling mask.
|
|
40
|
+
*
|
|
41
|
+
* @param numbers - Include `0-9`
|
|
42
|
+
* @param lowercase - Include `a-z`
|
|
43
|
+
* @param uppercase - Include `A-Z`
|
|
44
|
+
* @param symbols - Include `-` and `_`
|
|
45
|
+
* @param lookAlikes - Include visually confusable characters
|
|
46
|
+
*/
|
|
47
|
+
function resolveAlphabet(
|
|
48
|
+
numbers: boolean,
|
|
49
|
+
lowercase: boolean,
|
|
50
|
+
uppercase: boolean,
|
|
51
|
+
symbols: boolean,
|
|
52
|
+
lookAlikes: boolean,
|
|
53
|
+
): ResolvedAlphabet {
|
|
54
|
+
const key =
|
|
55
|
+
(numbers ? 1 : 0) |
|
|
56
|
+
(lowercase ? 2 : 0) |
|
|
57
|
+
(uppercase ? 4 : 0) |
|
|
58
|
+
(symbols ? 8 : 0) |
|
|
59
|
+
(lookAlikes ? 0 : 16);
|
|
60
|
+
const cached = ALPHABET_CACHE.get(key);
|
|
61
|
+
if (cached) return cached;
|
|
62
|
+
|
|
63
|
+
let chars = "";
|
|
64
|
+
if (numbers) chars += GROUPS.numbers;
|
|
65
|
+
if (lowercase) chars += GROUPS.lowercase;
|
|
66
|
+
if (uppercase) chars += GROUPS.uppercase;
|
|
67
|
+
if (symbols) chars += GROUPS.symbols;
|
|
68
|
+
if (!chars) {
|
|
69
|
+
throw new RangeError("okid: alphabet is empty — enable at least one character group");
|
|
70
|
+
}
|
|
71
|
+
if (!lookAlikes) {
|
|
72
|
+
chars = [...chars].filter((c) => !OKID_LOOKALIKE_CHARS.includes(c)).join("");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Round up to a power of two for mask-based rejection sampling: bytes below
|
|
76
|
+
// `size` map uniformly, bytes above are discarded and re-drawn — unbiased at
|
|
77
|
+
// every alphabet size, unlike naive modulo.
|
|
78
|
+
const rawSize = chars.length;
|
|
79
|
+
const size = 1 << Math.ceil(Math.log2(rawSize));
|
|
80
|
+
const resolved: ResolvedAlphabet = { chars, size: rawSize, mask: size - 1 };
|
|
81
|
+
ALPHABET_CACHE.set(key, resolved);
|
|
82
|
+
return resolved;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Encode one random byte stream into `length` characters of `alphabet`.
|
|
87
|
+
*
|
|
88
|
+
* @param alphabet - Resolved charset
|
|
89
|
+
* @param length - Output length
|
|
90
|
+
*/
|
|
91
|
+
function encodeAlphabet(alphabet: ResolvedAlphabet, length: number): string {
|
|
92
|
+
const { chars, size, mask } = alphabet;
|
|
93
|
+
const bytes = new Uint8Array(length + Math.ceil(length >> 2));
|
|
94
|
+
crypto.getRandomValues(bytes.subarray(0, length));
|
|
95
|
+
let out = "";
|
|
96
|
+
let i = 0;
|
|
97
|
+
while (out.length < length && i < bytes.length) {
|
|
98
|
+
const byte = bytes[i++]!;
|
|
99
|
+
if ((byte & mask) < size) out += chars[byte & mask];
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Pack epoch-ms into exactly 8 codepoint-ordered characters (48 bits).
|
|
106
|
+
*
|
|
107
|
+
* @param nowMs - Epoch milliseconds
|
|
108
|
+
*/
|
|
109
|
+
function encodeTimestamp(nowMs: number): string {
|
|
110
|
+
let t = nowMs % 2 ** 48;
|
|
111
|
+
let out = "";
|
|
112
|
+
for (let i = 0; i < 8; i++) {
|
|
113
|
+
out = OKID_SORTABLE_ALPHABET[t & 63]! + out;
|
|
114
|
+
t = Math.floor(t / 64);
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Assert a semantic prefix is within bounds and stays on the URL-safe alphabet.
|
|
121
|
+
*
|
|
122
|
+
* @param prefix - Caller-supplied label
|
|
123
|
+
*/
|
|
124
|
+
function assertPrefix(prefix: string): void {
|
|
125
|
+
if (prefix.length > OKID_MAX_PREFIX_LENGTH) {
|
|
126
|
+
throw new RangeError(
|
|
127
|
+
`okid: prefix length ${prefix.length} exceeds max ${OKID_MAX_PREFIX_LENGTH}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
for (const char of prefix) {
|
|
131
|
+
if (!OKID_ALPHABET.includes(char)) {
|
|
132
|
+
throw new RangeError(
|
|
133
|
+
`okid: prefix contains invalid character ${JSON.stringify(char)} — use characters from OKID_ALPHABET`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Generate an id from an options object.
|
|
141
|
+
*
|
|
142
|
+
* @param options - Length, prefix, sortable, and alphabet toggles
|
|
143
|
+
*/
|
|
144
|
+
export function okidWithOptions(options: OkidOptions): string {
|
|
145
|
+
const {
|
|
146
|
+
length = OKID_DEFAULT_LENGTH,
|
|
147
|
+
prefix = "",
|
|
148
|
+
sortable = false,
|
|
149
|
+
lowercase = true,
|
|
150
|
+
uppercase = true,
|
|
151
|
+
numbers = true,
|
|
152
|
+
symbols = true,
|
|
153
|
+
lookAlikes = true,
|
|
154
|
+
} = options;
|
|
155
|
+
|
|
156
|
+
if (prefix) assertPrefix(prefix);
|
|
157
|
+
|
|
158
|
+
let body: string;
|
|
159
|
+
if (sortable) {
|
|
160
|
+
assertLength(length, OKID_SORTABLE_MIN_LENGTH, "length");
|
|
161
|
+
// Time ordering requires lexicographic encoding, which requires the full
|
|
162
|
+
// codepoint-ordered alphabet — partial subsets cannot preserve both the
|
|
163
|
+
// caller's charset choice AND cross-ms ordering, so toggles are ignored.
|
|
164
|
+
const alphabet = resolveAlphabet(true, true, true, true, true);
|
|
165
|
+
body = encodeTimestamp(Date.now()) + encodeAlphabet(alphabet, length - 8);
|
|
166
|
+
} else {
|
|
167
|
+
assertLength(length, OKID_MIN_LENGTH, "length");
|
|
168
|
+
body = encodeAlphabet(
|
|
169
|
+
resolveAlphabet(numbers, lowercase, uppercase, symbols, lookAlikes),
|
|
170
|
+
length,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return prefix ? prefix + body : body;
|
|
175
|
+
}
|