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.
Files changed (160) hide show
  1. package/AGENTS.md +4 -4
  2. package/LICENSE +202 -0
  3. package/NOTICE +2 -0
  4. package/README.md +15 -9
  5. package/manifest.v1.schema.json +10 -0
  6. package/package.json +22 -25
  7. package/site/content/docs/ai/skills.mdx +1 -1
  8. package/site/content/docs/client/calling.mdx +27 -19
  9. package/site/content/docs/client/index.mdx +1 -1
  10. package/site/content/docs/elements/channel/email.mdx +1 -1
  11. package/site/content/docs/elements/channel/index.mdx +1 -1
  12. package/site/content/docs/elements/flow/http.mdx +4 -4
  13. package/site/content/docs/elements/flow/index.mdx +3 -3
  14. package/site/content/docs/elements/flow/meta.json +1 -1
  15. package/site/content/docs/elements/gate/tenancy.mdx +1 -1
  16. package/site/content/docs/elements/vault/rotation.mdx +15 -0
  17. package/site/content/docs/plugins/otp.mdx +31 -31
  18. package/site/content/docs/recipes/mailpit.mdx +1 -1
  19. package/site/content/docs/recipes/meilisearch.mdx +1 -1
  20. package/site/content/docs/recipes/pgdog.mdx +1 -1
  21. package/site/content/docs/reference/configuration.mdx +2 -2
  22. package/site/content/docs/reference/errors.mdx +12 -2
  23. package/site/content/docs/reference/fx.mdx +28 -15
  24. package/site/content/docs/reference/idempotency.mdx +192 -0
  25. package/site/content/docs/reference/index.mdx +6 -1
  26. package/site/content/docs/reference/meta.json +2 -1
  27. package/site/content/docs/understand/meta.json +1 -1
  28. package/site/content/docs/{elements/flow → understand}/routing.mdx +11 -10
  29. package/site/content/docs/understand/the-architecture.mdx +14 -12
  30. package/site/content/docs/understand/try-it.mdx +3 -3
  31. package/src/cli/competitor-mention-removal.test.ts +3 -3
  32. package/src/cli/dev.test.ts +105 -2
  33. package/src/cli/dev.ts +37 -2
  34. package/src/cli/docker-cli.test.ts +1 -1
  35. package/src/cli/index.ts +9 -1
  36. package/src/cli/load-config.images.test.ts +10 -10
  37. package/src/cli/load-config.ts +1 -1
  38. package/src/cli/vault-cmd.test.ts +23 -0
  39. package/src/cli/vault-cmd.ts +7 -0
  40. package/src/client/create.ts +31 -27
  41. package/src/client/live.ts +44 -101
  42. package/src/client/sse.ts +20 -66
  43. package/src/client/stream.ts +25 -67
  44. package/src/client/transport.test.ts +225 -0
  45. package/src/client/transport.ts +158 -90
  46. package/src/client/types.ts +29 -3
  47. package/src/client/wire.ts +119 -0
  48. package/src/compiler/aot.ts +3 -32
  49. package/src/compiler/dynamic.ts +13 -11
  50. package/src/compiler/effects-infer.ts +726 -16
  51. package/src/compiler/extract.test.ts +37 -0
  52. package/src/compiler/extract.ts +113 -12
  53. package/src/compiler/fixtures/skyport.expected.json +24 -0
  54. package/src/compiler/fx-follow.test.ts +164 -0
  55. package/src/compiler/fx-index.ts +399 -0
  56. package/src/compiler/interpret.ts +45 -0
  57. package/src/console/server/flows-invoke.test.ts +2 -2
  58. package/src/console/ui-next/dist/assets/{access-page-tIbsiphz.js → access-page-Ze6ckjdd.js} +2 -2
  59. package/src/console/ui-next/dist/assets/agent-disclosure-Bkohc_8X.js +1 -0
  60. package/src/console/ui-next/dist/assets/{cache-glyph-BCC-DxKT.js → cache-glyph-rL0lHxat.js} +1 -1
  61. package/src/console/ui-next/dist/assets/{call-pii-button-jOmYISdc.js → call-pii-button-Deo4PP18.js} +1 -1
  62. package/src/console/ui-next/dist/assets/{collapsible-DC2xNaAb.js → collapsible-CoJ6amHf.js} +1 -1
  63. package/src/console/ui-next/dist/assets/{copy-inline-button-CAYD18cr.js → copy-inline-button-CWG5_ZOh.js} +1 -1
  64. package/src/console/ui-next/dist/assets/{detail-header-DVWNjWjg.js → detail-header-b_ZzIsmU.js} +1 -1
  65. package/src/console/ui-next/dist/assets/{dropdown-menu-4h2LOVXM.js → dropdown-menu-nwXEO1Ac.js} +1 -1
  66. package/src/console/ui-next/dist/assets/duration-tone-BbQ_8z50.js +9 -0
  67. package/src/console/ui-next/dist/assets/element-icons-CwyLpVXz.js +1 -0
  68. package/src/console/ui-next/dist/assets/{explorer-empty-CJs5A-wm.js → explorer-empty-C8sSCqYT.js} +1 -1
  69. package/src/console/ui-next/dist/assets/{flows-page-gT1lsWQK.js → flows-page-D2E7BtKK.js} +1 -1
  70. package/src/console/ui-next/dist/assets/{highlighted-json-C2GZEJNI.js → highlighted-json-CPYBzDh0.js} +1 -1
  71. package/src/console/ui-next/dist/assets/{http-method-BdYjcIrD.js → http-method-DTPevKmM.js} +1 -1
  72. package/src/console/ui-next/dist/assets/index-vTuwmeQz.js +58 -0
  73. package/src/console/ui-next/dist/assets/observability-page-SJTSzHFJ.js +4 -0
  74. package/src/console/ui-next/dist/assets/{replica-lag-C4QdAF7J.js → replica-lag-BM27lXld.js} +3 -3
  75. package/src/console/ui-next/dist/assets/{request-meta-C43DHyld.js → request-meta-BjFT7DNl.js} +1 -1
  76. package/src/console/ui-next/dist/assets/shortcut-keys-CUNegEV4.js +1 -0
  77. package/src/console/ui-next/dist/assets/store-page-Qj9ThRE-.js +41 -0
  78. package/src/console/ui-next/dist/assets/{trace-detail-sheet-Dcpo4Us_.js → trace-detail-sheet-Cmitq3da.js} +2 -2
  79. package/src/console/ui-next/dist/assets/{tree-expand-toggle-CKJTuv43.js → tree-expand-toggle-CbDIB-7x.js} +2 -2
  80. package/src/console/ui-next/dist/assets/units-page-_PuYqFty.js +1 -0
  81. package/src/console/ui-next/dist/assets/{use-vault-list-CT4-gajj.js → use-vault-list-Cs45Czkt.js} +1 -1
  82. package/src/console/ui-next/dist/assets/{vault-page-BwZ9YjTW.js → vault-page-BOMr0go9.js} +2 -2
  83. package/src/console/ui-next/dist/index.html +2 -2
  84. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +9 -0
  85. package/src/docker/docker.test.ts +13 -13
  86. package/src/docker/images-config.test.ts +12 -12
  87. package/src/docker/stack-id.test.ts +1 -1
  88. package/src/drivers/journal-postgres.ts +168 -3
  89. package/src/drivers/meilisearch.ts +2 -0
  90. package/src/drivers/vault-builtin.test.ts +10 -0
  91. package/src/drivers/vault-builtin.ts +13 -1
  92. package/src/drivers/vault-remote-bag.ts +5 -1
  93. package/src/drivers/vault-types.ts +6 -1
  94. package/src/elements/store/emit-drizzle.ts +2 -2
  95. package/src/elements/store/live-default.test.ts +2 -0
  96. package/src/elements/store/live-http.test.ts +1 -0
  97. package/src/elements/store/resource-list-docs.test.ts +2 -2
  98. package/src/elements/store/resource.test.ts +3 -3
  99. package/src/elements/store/schema-decl.test.ts +1 -0
  100. package/src/elements/store/sql-condition.ts +24 -7
  101. package/src/elements/store/sql-session.ts +20 -4
  102. package/src/elements/store/upsert-app.test.ts +1 -1
  103. package/src/elements/vault/audit.test.ts +137 -0
  104. package/src/elements/vault/audit.ts +106 -13
  105. package/src/elements/vault/boot-chain.ts +9 -1
  106. package/src/elements/vault/builtin-adapter.ts +33 -3
  107. package/src/i18n/catalogs/ar.ts +4 -0
  108. package/src/i18n/catalogs/en.ts +4 -0
  109. package/src/kernel/abort-scope.ts +33 -2
  110. package/src/kernel/app.ts +200 -20
  111. package/src/kernel/auto-cache.test.ts +6 -6
  112. package/src/kernel/boot-bind/vault.ts +1 -0
  113. package/src/kernel/boot.ts +19 -1
  114. package/src/kernel/builtin-errors.ts +8 -0
  115. package/src/kernel/client-descriptor.test.ts +78 -0
  116. package/src/kernel/client-descriptor.ts +24 -0
  117. package/src/kernel/concurrency.test.ts +33 -0
  118. package/src/kernel/effects-stamping.test.ts +2 -2
  119. package/src/kernel/errors-compiler.ts +20 -0
  120. package/src/kernel/errors-text.ts +99 -0
  121. package/src/kernel/errors.ts +90 -138
  122. package/src/kernel/external-effects.test.ts +36 -0
  123. package/src/kernel/flow.ts +24 -0
  124. package/src/kernel/fx-fetch.ts +5 -1
  125. package/src/kernel/fx-sql-handle.ts +305 -0
  126. package/src/kernel/fx.ts +42 -330
  127. package/src/kernel/idempotency-store.ts +578 -0
  128. package/src/kernel/idempotency.test.ts +556 -0
  129. package/src/kernel/idempotency.ts +304 -0
  130. package/src/kernel/index.ts +1 -0
  131. package/src/kernel/journal.ts +56 -0
  132. package/src/kernel/json-result.ts +59 -0
  133. package/src/kernel/project-out.ts +6 -1
  134. package/src/manifest/types.ts +8 -0
  135. package/src/okid-extended.ts +175 -0
  136. package/src/okid-shared.ts +103 -0
  137. package/src/okid.ts +30 -213
  138. package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
  139. package/src/plugins/otp.test.ts +65 -1
  140. package/src/plugins/otp.ts +59 -7
  141. package/src/release/absolute-regression.test.ts +63 -0
  142. package/src/release/build-lib.ts +9 -0
  143. package/src/release/http-graph.test.ts +25 -0
  144. package/src/release/http-graph.ts +90 -0
  145. package/src/release/index.ts +3 -0
  146. package/src/release/limits.ts +17 -2
  147. package/src/release/measure.ts +105 -14
  148. package/src/test/create-test-app.test.ts +3 -1
  149. package/src/test/create-test-app.ts +40 -0
  150. package/src/test/live-signals.test.ts +1 -0
  151. package/src/test/provisions.integration.test.ts +1 -0
  152. package/src/test/tenant-isolation.test.ts +1 -0
  153. package/src/console/ui-next/dist/assets/agent-disclosure-CSKumwS2.js +0 -1
  154. package/src/console/ui-next/dist/assets/duration-tone-CwoV56jn.js +0 -9
  155. package/src/console/ui-next/dist/assets/element-icons-BI8cJgdh.js +0 -1
  156. package/src/console/ui-next/dist/assets/index-Cul17AcV.js +0 -63
  157. package/src/console/ui-next/dist/assets/observability-page-oY9vdYBk.js +0 -4
  158. package/src/console/ui-next/dist/assets/shortcut-keys-3ILd8oGn.js +0 -1
  159. package/src/console/ui-next/dist/assets/store-page-CL0D9dOq.js +0 -41
  160. 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
+ }
@@ -212,6 +212,7 @@ export {
212
212
  currentAbortSignal,
213
213
  isAbortError,
214
214
  linkAbort,
215
+ requestSignal,
215
216
  withAbortSignal,
216
217
  } from "./abort-scope.ts";
217
218
 
@@ -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 { isJsonResult, isJsonStreamResult, jsonResultBrand, type JsonResult } from "./fx.ts";
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`.
@@ -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
+ }