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
|
@@ -209,10 +209,25 @@ Builtin encrypted store:
|
|
|
209
209
|
|
|
210
210
|
`--url` overrides the SQL URL (`DATABASE_URL` / `OKE_STORE_SQL_URL`).
|
|
211
211
|
|
|
212
|
+
## Audit sinks
|
|
213
|
+
|
|
214
|
+
| `vault.audit.sink` | Recorded as | `oke vault audit` |
|
|
215
|
+
| ------------------ | --------------------------------- | ------------------- |
|
|
216
|
+
| `db` (default) | SQL hash chain | list, verify, purge |
|
|
217
|
+
| `stdout` | One secret-free JSON line | Refused |
|
|
218
|
+
| `webhook` | POST of that JSON to `webhookUrl` | Refused |
|
|
219
|
+
|
|
220
|
+
`webhookUrl` must be an absolute `http:` or `https:` URL. The POST uses `redirect: "error"` and a 5 second timeout. A failed delivery does not undo the vault write. `audit.enabled: false` records nothing. The body never contains a secret value.
|
|
221
|
+
|
|
212
222
|
## Troubleshooting
|
|
213
223
|
|
|
214
224
|
<Accordions>
|
|
215
225
|
|
|
226
|
+
<Accordion title="audit verify requires audit.sink db">
|
|
227
|
+
`stdout` and `webhook` sinks are not a local hash chain. `oke vault audit` (list, verify, purge)
|
|
228
|
+
throws `UNSUPPORTED`. Use `sink: "db"` when you need `verify`.
|
|
229
|
+
</Accordion>
|
|
230
|
+
|
|
216
231
|
<Accordion title="oke vault: no such secret">
|
|
217
232
|
Rotate/get targeted a path that was never set. `oke vault list` (or Console) for live paths;
|
|
218
233
|
remember per-tenant storage uses `{tenantId}/{name}`.
|
|
@@ -69,7 +69,7 @@ invalidated. Delivery follows `channels` order for addresses you pass.
|
|
|
69
69
|
</Step>
|
|
70
70
|
|
|
71
71
|
<Step>
|
|
72
|
-
### Resend
|
|
72
|
+
### Resend
|
|
73
73
|
|
|
74
74
|
```typescript
|
|
75
75
|
const { data } = await api.auth.resendOtp({
|
|
@@ -79,8 +79,8 @@ const { data } = await api.auth.resendOtp({
|
|
|
79
79
|
});
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
App mode keeps the same code and TTL. Provider mode is SMS only and asks the
|
|
83
|
+
provider for a new code. Default cooldown is 60 seconds either way.
|
|
84
84
|
|
|
85
85
|
</Step>
|
|
86
86
|
|
|
@@ -108,18 +108,18 @@ const { data } = await api.auth.verifyOtp({
|
|
|
108
108
|
|
|
109
109
|
## Modes
|
|
110
110
|
|
|
111
|
-
|
|
|
112
|
-
|
|
|
113
|
-
| Config
|
|
114
|
-
| Who owns the code
|
|
115
|
-
| Delivery
|
|
116
|
-
| Channels
|
|
117
|
-
| Resend
|
|
118
|
-
| `exposeDevOtp`
|
|
111
|
+
| | Provider mode | App mode |
|
|
112
|
+
| ----------------- | ----------------------------- | --------------------------------------------- |
|
|
113
|
+
| Config | `otp({ mode: "provider" })` | `otp({ mode: "app", channels: [...] })` |
|
|
114
|
+
| Who owns the code | Provider (Verify API) | Your app |
|
|
115
|
+
| Delivery | `fx.sendOtp` / `fx.verifyOtp` | `fx.deliverOtp` (Channel templates) |
|
|
116
|
+
| Channels | SMS only | `sms` · `whatsapp` · `email` (declared order) |
|
|
117
|
+
| Resend | SMS only, new provider code | Same code, any declared channel |
|
|
118
|
+
| `exposeDevOtp` | Forbidden | Optional (default off) |
|
|
119
119
|
|
|
120
120
|
<Callout type="warn" title="Provider mode limitation">
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
A provider resend is a new SMS code (`resendCooldownMs`, default 60s). The code never reaches OKE,
|
|
122
|
+
so SMS → email for the same code needs `mode: "app"`.
|
|
123
123
|
</Callout>
|
|
124
124
|
|
|
125
125
|
### Provider mode setup
|
|
@@ -154,27 +154,27 @@ Override copy with `oke({ channel.catalog })` or `.channelCatalog(…)` (later l
|
|
|
154
154
|
|
|
155
155
|
## Options
|
|
156
156
|
|
|
157
|
-
| Option | Type | Default | Meaning
|
|
158
|
-
| ------------------ | -------------------------------- | -------------------------- |
|
|
159
|
-
| `mode` | `"provider" \| "app"` | required | Delivery mechanism — no auto
|
|
160
|
-
| `channels` | `("sms"\|"whatsapp"\|"email")[]` | required in app mode | Build-time preferred order
|
|
161
|
-
| `ttlMs` | `number` | 10m | Challenge lifetime
|
|
162
|
-
| `resendCooldownMs` | `number` | 60s |
|
|
163
|
-
| `exposeDevOtp` | `boolean` | `false` | App mode only — raw OTP in response
|
|
164
|
-
| `from` | `string` | `OKE <no-reply@oke.local>` | Email template From
|
|
165
|
-
| `secret` | `string` | active\* | Auth secret (\*from `gate.auth`)
|
|
166
|
-
| `sessions` | `SessionStore` | active\* | Session store
|
|
167
|
-
| `identities` | `IdentityStore` | new | Email → user
|
|
168
|
-
| `phones` | `PhoneStore` | new | Phone → user
|
|
169
|
-
| `verifications` | `VerificationStore` | new | Challenge store
|
|
157
|
+
| Option | Type | Default | Meaning |
|
|
158
|
+
| ------------------ | -------------------------------- | -------------------------- | ------------------------------------ |
|
|
159
|
+
| `mode` | `"provider" \| "app"` | required | Delivery mechanism — no auto |
|
|
160
|
+
| `channels` | `("sms"\|"whatsapp"\|"email")[]` | required in app mode | Build-time preferred order |
|
|
161
|
+
| `ttlMs` | `number` | 10m | Challenge lifetime |
|
|
162
|
+
| `resendCooldownMs` | `number` | 60s | Spacing between resends (both modes) |
|
|
163
|
+
| `exposeDevOtp` | `boolean` | `false` | App mode only — raw OTP in response |
|
|
164
|
+
| `from` | `string` | `OKE <no-reply@oke.local>` | Email template From |
|
|
165
|
+
| `secret` | `string` | active\* | Auth secret (\*from `gate.auth`) |
|
|
166
|
+
| `sessions` | `SessionStore` | active\* | Session store |
|
|
167
|
+
| `identities` | `IdentityStore` | new | Email → user |
|
|
168
|
+
| `phones` | `PhoneStore` | new | Phone → user |
|
|
169
|
+
| `verifications` | `VerificationStore` | new | Challenge store |
|
|
170
170
|
|
|
171
171
|
## Surfaces
|
|
172
172
|
|
|
173
|
-
| Flow | Path | Gate | Mode
|
|
174
|
-
| ----------------- | ------------------------ | ------------------------ |
|
|
175
|
-
| `auth.requestOtp` | `POST /auth/otp/request` | `gate.public` + otp rate | both
|
|
176
|
-
| `auth.verifyOtp` | `POST /auth/otp/verify` | `gate.public` + otp rate | both
|
|
177
|
-
| `auth.resendOtp` | `POST /auth/otp/resend` | `gate.public` + otp rate |
|
|
173
|
+
| Flow | Path | Gate | Mode |
|
|
174
|
+
| ----------------- | ------------------------ | ------------------------ | ------------------------- |
|
|
175
|
+
| `auth.requestOtp` | `POST /auth/otp/request` | `gate.public` + otp rate | both |
|
|
176
|
+
| `auth.verifyOtp` | `POST /auth/otp/verify` | `gate.public` + otp rate | both |
|
|
177
|
+
| `auth.resendOtp` | `POST /auth/otp/resend` | `gate.public` + otp rate | both (provider: SMS only) |
|
|
178
178
|
|
|
179
179
|
## Troubleshooting
|
|
180
180
|
|
|
@@ -26,7 +26,7 @@ create-oke asks **Add PgDog connection pooling…?** (or pass `--pgdog`). Manual
|
|
|
26
26
|
```typescript title="oke.config.ts"
|
|
27
27
|
images: {
|
|
28
28
|
"store.sql": "postgres:18-alpine",
|
|
29
|
-
pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.
|
|
29
|
+
pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.59",
|
|
30
30
|
},
|
|
31
31
|
```
|
|
32
32
|
|
|
@@ -153,8 +153,8 @@ images: {
|
|
|
153
153
|
kv: "redis:8-alpine",
|
|
154
154
|
files: "rustfs/rustfs:1.0.0",
|
|
155
155
|
},
|
|
156
|
-
channel: { email: "axllent/mailpit:v1.31.
|
|
157
|
-
pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.
|
|
156
|
+
channel: { email: "axllent/mailpit:v1.31.2" },
|
|
157
|
+
pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.59",
|
|
158
158
|
// proxy: "caddy:2-alpine",
|
|
159
159
|
},
|
|
160
160
|
```
|
|
@@ -120,6 +120,10 @@ at **404**. Clients distinguish by envelope, not status. Domain codes stay **400
|
|
|
120
120
|
| Code | Status | Helper |
|
|
121
121
|
| ------------------------------------------------ | ----------- | ------------------------------------------------------ |
|
|
122
122
|
| `ValidationError` | `422` | none — failed `in`, `do` never runs |
|
|
123
|
+
| `IdempotencyKeyMissing` | `400` | none — `idempotency: "required"` and no header |
|
|
124
|
+
| `IdempotencyKeyInvalid` | `400` | none — header is not 16–255 printable ASCII |
|
|
125
|
+
| `IdempotencyKeyReused` | `422` | none — same key, different payload |
|
|
126
|
+
| `IdempotencyInProgress` | `409` | none — `Retry-After` until the in-flight call finishes |
|
|
123
127
|
| `Unauthorized` | `401` | `unauthorized` · also a gate denial |
|
|
124
128
|
| `Forbidden` | `403` | `forbidden` · also a gate denial |
|
|
125
129
|
| `NotFound` | `404` | `notFound` |
|
|
@@ -301,8 +305,14 @@ Thrown by specific subsystems — each names its own cause:
|
|
|
301
305
|
<Accordions>
|
|
302
306
|
|
|
303
307
|
<Accordion title="I hit OKE1001–1009">
|
|
304
|
-
|
|
305
|
-
`effects` block
|
|
308
|
+
The capability token does not include what the Flow touched. Add the missing ledger entry. An
|
|
309
|
+
`effects` block must include every effect inference can see.
|
|
310
|
+
</Accordion>
|
|
311
|
+
|
|
312
|
+
<Accordion title="OKE1900 fx hidden from inference">
|
|
313
|
+
Cause: the Flow renames `fx`, lets a chain alias escape, or passes `fx` to a callee inference
|
|
314
|
+
cannot resolve. Fix: name the parameter `fx`, keep `const q = fx.store(db)` in the same function,
|
|
315
|
+
and include every effect the walk can see. Extra keys in an `effects` block are allowed.
|
|
306
316
|
</Accordion>
|
|
307
317
|
|
|
308
318
|
<Accordion title="OKE1040 pathless HTTP never stamped">
|
|
@@ -54,7 +54,9 @@ async function loadNote(id: string, fx: Fx) {
|
|
|
54
54
|
}
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
-
A narrower structural type will not match `store()` overloads. See [Flow](/docs/elements/flow).
|
|
57
|
+
Name that parameter `fx`. A narrower structural type will not match `store()` overloads. See [Flow](/docs/elements/flow).
|
|
58
|
+
|
|
59
|
+
Inference follows `fx` in four shapes: a direct `fx.*` call, a chain alias kept in the same function (`const q = fx.store(db)` then `q.select()`), a project helper whose parameter is named `fx`, and two package intrinsics — `liveQuery(fx, table, …)` records `reads: ["sql:<table>"]`, and `applySearchEmbedCdc` records the static `sqlRef` plus each column's embed model.
|
|
58
60
|
|
|
59
61
|
</Step>
|
|
60
62
|
|
|
@@ -155,11 +157,13 @@ on(
|
|
|
155
157
|
| `fx.auth.createTenant({ name, slug?, id? })` | `write` `auth:tenants` | Creator becomes a member. Session only |
|
|
156
158
|
| `fx.auth.upsertTenantRole({ tenantId, roleName, scopes })` | `write` `auth:tenants` | Application scopes only — `console:*` is unknown_scope |
|
|
157
159
|
|
|
158
|
-
`fx.call` starts the callee with an **empty** `fx.auth` (fail-closed
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
160
|
+
`fx.call` starts the callee with an **empty** `fx.auth` (fail-closed) and propagates `fx.tenant.id`.
|
|
161
|
+
Read `fx.principal` for audit — it does not copy into `fx.auth`.
|
|
162
|
+
|
|
163
|
+
<Callout>
|
|
164
|
+
Gates never consult `fx.principal`. `return fx.fail(...)` is a `FlowFailure`; an unhandled `throw`
|
|
165
|
+
rethrows. The same rules apply to `app.call`.
|
|
166
|
+
</Callout>
|
|
163
167
|
|
|
164
168
|
## Concurrency and retry
|
|
165
169
|
|
|
@@ -190,11 +194,14 @@ const charge = await fx.step("charge", () =>
|
|
|
190
194
|
| `when` | thrown | Predicate; skips `AbortError` and sleep park |
|
|
191
195
|
|
|
192
196
|
<Callout title="Cooperative cancel">
|
|
193
|
-
Losing branches see `fx.signal` abort.
|
|
194
|
-
in
|
|
195
|
-
`Promise.all`.
|
|
197
|
+
Losing branches see `fx.signal` abort. `fx.fetch`, Meilisearch, remote Vault HTTP, and AI streams
|
|
198
|
+
cancel in flight. An effect that has not started throws `AbortError`.
|
|
196
199
|
</Callout>
|
|
197
200
|
|
|
201
|
+
SQL (`Bun.sql`), Redis, and a Channel send already on the wire through sently still finish. Those
|
|
202
|
+
clients expose no abort hook, and the shared connection stays open. Prefer `fx.all` over bare
|
|
203
|
+
`Promise.all`.
|
|
204
|
+
|
|
198
205
|
`fx.using(acquire, release, use)` scopes a process-local resource to one attempt: `release` runs
|
|
199
206
|
exactly once when `use` settles **or** when the ambient signal aborts (a sibling `fx.race` winner,
|
|
200
207
|
a failing `fx.all` sibling). It is not journaled — do not hold handles across durable park/resume.
|
|
@@ -221,9 +228,9 @@ contact a provider. Bodies live on `.template({ catalog })` (`{{field}}`, not IC
|
|
|
221
228
|
|
|
222
229
|
## Outbound HTTP
|
|
223
230
|
|
|
224
|
-
| Signature | Records | Notes
|
|
225
|
-
| ---------------------- | --------------------------- |
|
|
226
|
-
| `fx.fetch(url, init?)` | `fetch` on the URL hostname |
|
|
231
|
+
| Signature | Records | Notes |
|
|
232
|
+
| ---------------------- | --------------------------- | -------------------------------------------------------------------------- |
|
|
233
|
+
| `fx.fetch(url, init?)` | `fetch` on the URL hostname | Stamps `external: { host, kind: "third-party" }`. Aborts with `fx.signal`. |
|
|
227
234
|
|
|
228
235
|
Declare hosts in `effects.fetches` (e.g. `["api.stripe.com"]`). Prefer `fx.step`; use
|
|
229
236
|
`fx.retry` only when the remote API is safe to repeat. Dry runs never hit the network.
|
|
@@ -340,9 +347,15 @@ to the key — see [Gate](/docs/elements/gate#api-keys).
|
|
|
340
347
|
<Accordions>
|
|
341
348
|
|
|
342
349
|
<Accordion title="OKE1001–1007 / 1008 / 1009 undeclared effect">
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
block
|
|
350
|
+
The Manifest token does not include what `do` touched. Add the missing ledger entry (`reads` ·
|
|
351
|
+
`writes` · `emits` · `sends` · `asks` · `embeds` · `secrets` · `calls` · `fetches`). An `effects`
|
|
352
|
+
block must include every effect inference can see ([Errors](/docs/reference/errors)).
|
|
353
|
+
</Accordion>
|
|
354
|
+
|
|
355
|
+
<Accordion title="OKE1900 fx hidden from inference">
|
|
356
|
+
Name the parameter `fx` and keep chain aliases in the same function. Passing `fx` to an unresolved
|
|
357
|
+
callee, or returning, reassigning, or storing `fx` or a chain alias, fails extract. An `effects`
|
|
358
|
+
block may add keys. It must include every effect the walk can see.
|
|
346
359
|
</Accordion>
|
|
347
360
|
|
|
348
361
|
<Accordion title="OKE1240 orphan emit">
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Idempotency"
|
|
3
|
+
description: "Send one Idempotency-Key so a retried mutating call runs once and a later retry replays the stored response."
|
|
4
|
+
icon: "KeyRound"
|
|
5
|
+
source: "docs/spec/unified-theory.md"
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
A checkout that times out should not charge the card twice. Send one `Idempotency-Key` for that attempt. The server runs `do` once and replays the stored response when the same key comes back.
|
|
9
|
+
|
|
10
|
+
<Callout title="The one rule">
|
|
11
|
+
Mint one key per logical call and send it on every retry of that call. Two submits are two calls
|
|
12
|
+
unless your app passes the same key. Provider webhooks do not send this header — keep a unique
|
|
13
|
+
constraint on the provider's event id.
|
|
14
|
+
</Callout>
|
|
15
|
+
|
|
16
|
+
## Quick start
|
|
17
|
+
|
|
18
|
+
<Steps>
|
|
19
|
+
|
|
20
|
+
<Step>
|
|
21
|
+
### Mark the flow
|
|
22
|
+
|
|
23
|
+
`idempotency: "required"` rejects a call that omits the header. Leave it off and the header is
|
|
24
|
+
optional. `"required"` on a read, GET, stream, or live flow fails extract.
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
import { on, flow, http } from "okengine";
|
|
28
|
+
|
|
29
|
+
export const charge = on(
|
|
30
|
+
http.post("/charges", { in: ChargeIn, out: ChargeOut }),
|
|
31
|
+
flow("payments.charge", {
|
|
32
|
+
idempotency: "required",
|
|
33
|
+
do: async (input, fx) => {
|
|
34
|
+
return fx.store(db).insert(charges).values(input);
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
</Step>
|
|
41
|
+
|
|
42
|
+
<Step>
|
|
43
|
+
### Call it
|
|
44
|
+
|
|
45
|
+
The client sends `Idempotency-Key` on every non-GET call. Pass a string to reuse a key across
|
|
46
|
+
your own retries, or `false` to send none.
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
await api.payments.charge({ amount: 1000 });
|
|
50
|
+
await api.payments.charge({ amount: 1000 }, { idempotencyKey: "checkout-attempt-9f3a2c" });
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
</Step>
|
|
54
|
+
|
|
55
|
+
<Step>
|
|
56
|
+
### Read the replay
|
|
57
|
+
|
|
58
|
+
The first response is the live result. A later call with the same key, principal, and payload
|
|
59
|
+
returns that stored response and sets `Idempotent-Replayed: true`. The client copies that onto
|
|
60
|
+
`meta.idempotentReplayed`.
|
|
61
|
+
|
|
62
|
+
</Step>
|
|
63
|
+
|
|
64
|
+
</Steps>
|
|
65
|
+
|
|
66
|
+
## When the header is honored
|
|
67
|
+
|
|
68
|
+
All of these are required. Otherwise the header is ignored.
|
|
69
|
+
|
|
70
|
+
| Check | Honored when |
|
|
71
|
+
| ------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
|
72
|
+
| Entry | HTTP, and the method is not GET. RPC counts. `fx.call` has no request, so it does not. |
|
|
73
|
+
| Effects | Inferred effects include a write, emit, send, call, fetch, ask, or embed — or the flow uses `fx.raw`. Secrets alone do not qualify. |
|
|
74
|
+
| Shape | Not `stream`, and not a live SSE feed. |
|
|
75
|
+
| Option | Omitted or `{ ttl }` is `auto` (header optional). `"required"` or `{ required: true }` demands the header. `false` ignores it. |
|
|
76
|
+
|
|
77
|
+
`QUERY` is not GET. It qualifies when the rest of the row matches.
|
|
78
|
+
|
|
79
|
+
| Option | Meaning |
|
|
80
|
+
| ------------------------------- | --------------------------------------------------- |
|
|
81
|
+
| omitted | `auto` when the flow qualifies. Default TTL `24h`. |
|
|
82
|
+
| `"required"` | Missing header is `400 IdempotencyKeyMissing`. |
|
|
83
|
+
| `{ ttl: "1h" }` | `auto`, with that TTL. `ms`, `s`, `m`, `h`, or `d`. |
|
|
84
|
+
| `{ required: true, ttl: "1h" }` | Required, with that TTL. |
|
|
85
|
+
| `false` | Header ignored. |
|
|
86
|
+
|
|
87
|
+
An unparseable TTL, or `required` on a flow that cannot honor the header, fails extract.
|
|
88
|
+
|
|
89
|
+
The key is 16–255 printable ASCII characters (`0x20`–`0x7E`). Anything else is
|
|
90
|
+
`400 IdempotencyKeyInvalid`.
|
|
91
|
+
|
|
92
|
+
The scope is tenant, principal, and flow. The principal is `user:<id>`, else `apikey:<id>`, else
|
|
93
|
+
`anon`. The same key from two people is two records. The fingerprint is a hash of the flow name
|
|
94
|
+
and the validated input. The same key with a different payload is `422 IdempotencyKeyReused`.
|
|
95
|
+
|
|
96
|
+
## What a second request sees
|
|
97
|
+
|
|
98
|
+
The claim happens after the gate and after input validation. A `401`, `403`, or validation
|
|
99
|
+
failure stores nothing, so the same key can still succeed.
|
|
100
|
+
|
|
101
|
+
| Row | Response |
|
|
102
|
+
| ----------------------------- | -------------------------------------------------------------------------------------- |
|
|
103
|
+
| No row, or the TTL has passed | `do` runs. The response is stored until the TTL. |
|
|
104
|
+
| Same payload, completed | Stored status, body, `content-type`, and `location`, plus `Idempotent-Replayed: true`. |
|
|
105
|
+
| Same payload, still running | `409 IdempotencyInProgress` and `Retry-After` (seconds until the lease, at least 1). |
|
|
106
|
+
| Different payload | `422 IdempotencyKeyReused`. |
|
|
107
|
+
|
|
108
|
+
A stream, an SSE body, or a raw `Response` that is not a buffered JSON envelope is not stored.
|
|
109
|
+
The live response is returned and the row is deleted.
|
|
110
|
+
|
|
111
|
+
A throw before any non-read effect deletes the row. The same key may run again. A throw after a
|
|
112
|
+
write, emit, send, call, fetch, ask, embed, or `fx.raw` stores the `500 InternalError` envelope.
|
|
113
|
+
A new key is required. A declared `fx.fail(...)` is stored as that failure, not as a 500.
|
|
114
|
+
|
|
115
|
+
## Crashes
|
|
116
|
+
|
|
117
|
+
The in-progress lease is 30 seconds and is renewed while `do` runs. A disconnect does not cancel
|
|
118
|
+
a claimed `do`. The lease is what notices a crash.
|
|
119
|
+
|
|
120
|
+
| Flow | After the lease expires |
|
|
121
|
+
| ----------- | ----------------------------------------------------------------------------- |
|
|
122
|
+
| Not durable | The next request with the same key runs `do` again. That is at-least-once. |
|
|
123
|
+
| Durable | The next request resumes that journal run. Completed steps are not run again. |
|
|
124
|
+
|
|
125
|
+
A durable sleep stores the `204` and parks the journal run. A retry replays the `204`. The
|
|
126
|
+
scheduler continues the run.
|
|
127
|
+
|
|
128
|
+
## Where the body lives
|
|
129
|
+
|
|
130
|
+
Rows sit in `oke_idempotency` on the journal driver: memory in tests, `.oke/idempotency.json`
|
|
131
|
+
beside the journal file, and Postgres when that journal driver is bound.
|
|
132
|
+
|
|
133
|
+
The stored body can contain personal data and stays until the TTL. Expired rows are deleted on
|
|
134
|
+
the next claim and by a periodic sweep.
|
|
135
|
+
|
|
136
|
+
The Console flow contract shows an idempotency pill when the mode is `auto` or `required`.
|
|
137
|
+
|
|
138
|
+
## Troubleshooting
|
|
139
|
+
|
|
140
|
+
<Accordions>
|
|
141
|
+
<Accordion title="400 IdempotencyKeyMissing">
|
|
142
|
+
|
|
143
|
+
The catalog message is `This call requires an Idempotency-Key header.` The flow is `required`
|
|
144
|
+
and this request did not send the header. Send a key, or drop `"required"` if the header should
|
|
145
|
+
stay optional.
|
|
146
|
+
|
|
147
|
+
</Accordion>
|
|
148
|
+
<Accordion title="400 IdempotencyKeyInvalid">
|
|
149
|
+
|
|
150
|
+
The catalog message is `Idempotency-Key must be 16–255 printable ASCII characters.` Lengthen the
|
|
151
|
+
key or drop characters outside printable ASCII.
|
|
152
|
+
|
|
153
|
+
</Accordion>
|
|
154
|
+
<Accordion title="422 IdempotencyKeyReused">
|
|
155
|
+
|
|
156
|
+
The catalog message is `This Idempotency-Key was already used with a different request.` The
|
|
157
|
+
payload changed. Mint a new key for the new payload.
|
|
158
|
+
|
|
159
|
+
</Accordion>
|
|
160
|
+
<Accordion title="409 IdempotencyInProgress">
|
|
161
|
+
|
|
162
|
+
The catalog message is `This Idempotency-Key is still running. Retry after the given delay.`
|
|
163
|
+
Wait for `Retry-After`, then send the same key. The client does this when `retry` is configured.
|
|
164
|
+
|
|
165
|
+
</Accordion>
|
|
166
|
+
</Accordions>
|
|
167
|
+
|
|
168
|
+
## Learn more
|
|
169
|
+
|
|
170
|
+
- [Calling](/docs/client/calling) — `idempotencyKey` and the retry rule
|
|
171
|
+
- [Errors](/docs/reference/errors) — the four idempotency codes and their statuses
|
|
172
|
+
- [The Architecture](/docs/understand/the-architecture) — a retry of one call runs once
|
|
173
|
+
|
|
174
|
+
## Next
|
|
175
|
+
|
|
176
|
+
<Cards>
|
|
177
|
+
<Card
|
|
178
|
+
title="Calling"
|
|
179
|
+
description="Client retry and Idempotency-Key."
|
|
180
|
+
href="/docs/client/calling"
|
|
181
|
+
/>
|
|
182
|
+
<Card
|
|
183
|
+
title="Errors"
|
|
184
|
+
description="Statuses for the four idempotency codes."
|
|
185
|
+
href="/docs/reference/errors"
|
|
186
|
+
/>
|
|
187
|
+
<Card
|
|
188
|
+
title="The Architecture"
|
|
189
|
+
description="One retry runs once. Two submits are two calls."
|
|
190
|
+
href="/docs/understand/the-architecture"
|
|
191
|
+
/>
|
|
192
|
+
</Cards>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: "Reference"
|
|
3
3
|
description: "Lookup pages — CLI, config, fx, errors, security, and plugins."
|
|
4
|
-
icon: "
|
|
4
|
+
icon: "BookBookmark"
|
|
5
5
|
source: "docs/spec/unified-theory.md"
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -23,6 +23,11 @@ Dense tables and command lists. Reach for these when you already know what you a
|
|
|
23
23
|
/>
|
|
24
24
|
<Card title="fx" description="The complete fx surface and effects." href="/docs/reference/fx" />
|
|
25
25
|
<Card title="Errors" description="OKE codes, denials, fixes." href="/docs/reference/errors" />
|
|
26
|
+
<Card
|
|
27
|
+
title="Idempotency"
|
|
28
|
+
description="One Idempotency-Key, one execution."
|
|
29
|
+
href="/docs/reference/idempotency"
|
|
30
|
+
/>
|
|
26
31
|
<Card
|
|
27
32
|
title="Security"
|
|
28
33
|
description="Host, Origin, planes, MCP posture."
|
|
@@ -588,28 +588,29 @@ plus a handwritten `http.get("/links")`.
|
|
|
588
588
|
|
|
589
589
|
## Learn more
|
|
590
590
|
|
|
591
|
+
- [The Architecture](/docs/understand/the-architecture) — derived routes, no hand-written table
|
|
592
|
+
- [Try it](/docs/understand/try-it) — scaffold, run, and hit a stamped path
|
|
591
593
|
- [HTTP](/docs/elements/flow/http) — verbs, envelopes, `http.resource`, live SSE
|
|
592
594
|
- [Client](/docs/client/calling) — `api.links.get`, REST vs RPC, `$routes`
|
|
593
595
|
- [Errors](/docs/reference/errors) — OKE1040 · OKE1030 · OKE1041 · OKE1045 · OKE1070 · OKE1072
|
|
594
|
-
- [The Architecture](/docs/understand/the-architecture) — derived routes, no hand-written table
|
|
595
596
|
- [Gate](/docs/elements/gate) — `.gate(...)` / `.public()` on the same trigger
|
|
596
597
|
|
|
597
598
|
## Next
|
|
598
599
|
|
|
599
600
|
<Cards>
|
|
600
601
|
<Card
|
|
601
|
-
title="
|
|
602
|
-
description="
|
|
603
|
-
href="/docs/
|
|
602
|
+
title="The Architecture"
|
|
603
|
+
description="One law, eight elements, derived Manifest — where routes come from."
|
|
604
|
+
href="/docs/understand/the-architecture"
|
|
604
605
|
/>
|
|
605
606
|
<Card
|
|
606
|
-
title="
|
|
607
|
-
description="
|
|
608
|
-
href="/docs/
|
|
607
|
+
title="Try it"
|
|
608
|
+
description="Scaffold shorter, run oke dev, and call a stamped path."
|
|
609
|
+
href="/docs/understand/try-it"
|
|
609
610
|
/>
|
|
610
611
|
<Card
|
|
611
|
-
title="
|
|
612
|
-
description="
|
|
613
|
-
href="/docs/elements/flow/
|
|
612
|
+
title="HTTP"
|
|
613
|
+
description="REST verbs, RFC 10008 QUERY, CRUD mounts, and live SSE on Flow."
|
|
614
|
+
href="/docs/elements/flow/http"
|
|
614
615
|
/>
|
|
615
616
|
</Cards>
|
|
@@ -27,11 +27,11 @@ app.post("/signup", async (req, res) => {
|
|
|
27
27
|
|
|
28
28
|
It works. It ships. Then a traffic spike, a silent failure, a compliance question, and a double-submit turn those four lines into six files — endpoint, queue, worker, Redis connection, mail client, audit table — that never agreed with each other about anything.
|
|
29
29
|
|
|
30
|
-
**A payment webhook.** A provider confirms a charge. Mark the order paid.
|
|
30
|
+
**A payment webhook.** A provider confirms a charge. Mark the order paid. The provider retries the same event during a hiccup. That attempt is a second run, visible in `fx.runs`. The fix is one line: a unique constraint or upsert on the provider's event id.
|
|
31
31
|
|
|
32
32
|
**A nightly report.** Summarize yesterday's activity and email it to managers. Trivial — until a manager's access gets revoked at 11:58pm and the report that runs at midnight has no idea the permission it checked when the feature was built isn't the permission that holds right now.
|
|
33
33
|
|
|
34
|
-
Three teams. Three domains. Nobody on any of them talked to the other two. And all three land on the identical fork: **something has to happen later,
|
|
34
|
+
Three teams. Three domains. Nobody on any of them talked to the other two. And all three land on the identical fork: **something has to happen later, and the original four lines never said where the retry, the receipt, or the duplicate would be visible.** The door makes that visible. The fix is one line.
|
|
35
35
|
|
|
36
36
|
### Follow one all the way through
|
|
37
37
|
|
|
@@ -49,7 +49,7 @@ The endpoint is fast again. It's also no longer one system — it's an endpoint,
|
|
|
49
49
|
|
|
50
50
|
<SixSystemsDrift />
|
|
51
51
|
|
|
52
|
-
From here the same pattern repeats on a longer clock. A support ticket reveals a job failed silently — nobody had configured retries, so you add them, and now a specific number (3? 5? with what backoff?) lives in a file that nobody will remember the reasoning for in six weeks. Compliance asks for proof of every email sent — you add a table written to from inside the worker, and now that worker has two jobs instead of one, quietly capable of disagreeing with itself if the second write fails. Someone double-clicks submit — two jobs enqueue, two emails send
|
|
52
|
+
From here the same pattern repeats on a longer clock. A support ticket reveals a job failed silently — nobody had configured retries, so you add them, and now a specific number (3? 5? with what backoff?) lives in a file that nobody will remember the reasoning for in six weeks. Compliance asks for proof of every email sent — you add a table written to from inside the worker, and now that worker has two jobs instead of one, quietly capable of disagreeing with itself if the second write fails. Someone double-clicks submit — two jobs enqueue, two emails send. The duplicate is two runs. The fix is one line, a unique constraint or upsert, not a second protocol shared by a queue and a database.
|
|
53
53
|
|
|
54
54
|
None of these were mistakes. Each one was the correct call, made by a competent engineer, in direct response to something that actually happened.
|
|
55
55
|
|
|
@@ -74,7 +74,7 @@ OKE removes the disagreement by removing the choice. It's one rule, in two parts
|
|
|
74
74
|
|
|
75
75
|
**First: every trigger reduces to the same shape.** An HTTP request, a scheduled tick, a queue message, a database change — whatever wakes the code up, what follows has one identical anatomy: `on(Trigger) → Effects`. Not four systems that happen to look similar. One system, with four ways to wake it up.
|
|
76
76
|
|
|
77
|
-
**Second: every effect passes through one door.** Nothing is allowed to touch a database, send an email, check a permission, or read the clock on its own — all of it goes through a single surface.
|
|
77
|
+
**Second: every effect passes through one door.** Nothing is allowed to touch a database, send an email, check a permission, or read the clock on its own — all of it goes through a single surface. The Manifest lists every effect. Least privilege is enforced at runtime. Every run is recorded in `fx.runs`. Durability is one line in a known place — `signal.once` retries, or `durable` plus `fx.step` — not a new system.
|
|
78
78
|
|
|
79
79
|
<FlowShape />
|
|
80
80
|
|
|
@@ -193,7 +193,9 @@ do: async (input, fx) => {
|
|
|
193
193
|
};
|
|
194
194
|
```
|
|
195
195
|
|
|
196
|
-
This one rule is what made the month-8 drift
|
|
196
|
+
This one rule is what made the month-8 drift visible: every effect is in the Manifest, every run is in `fx.runs`, and a duplicate is a second run you can see.
|
|
197
|
+
A retried HTTP call can send one `Idempotency-Key`, so that retry runs once.
|
|
198
|
+
A provider event still needs a unique constraint, because the provider does not send that header.
|
|
197
199
|
|
|
198
200
|
### Putting the five pieces together
|
|
199
201
|
|
|
@@ -223,14 +225,14 @@ export const signup = on(
|
|
|
223
225
|
|
|
224
226
|
Nothing about the code above looks more complicated than the four lines that started the drift — because it isn't. The difference only shows up when the same pressure from that timeline hits it. Every fork was really the same question — _is this safe to retry, safe to audit, safe to run twice?_ — and now it has one home:
|
|
225
227
|
|
|
226
|
-
| Then: a new system per incident | Now:
|
|
227
|
-
| -------------------------------------------------------------------- |
|
|
228
|
-
| **Week 2 spike** — hand-build a queue + worker to run the send later |
|
|
229
|
-
| **Month 2 silence** — a retry count in a worker nobody remembers |
|
|
230
|
-
| **Month 4 audit** — a `sent_emails` table written by hand from a job |
|
|
231
|
-
| **Month 6 double-submit** — dedup split across a queue and a DB |
|
|
228
|
+
| Then: a new system per incident | Now: the door makes it visible, and the fix is one line |
|
|
229
|
+
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
230
|
+
| **Week 2 spike** — hand-build a queue + worker to run the send later | The send is a Signal consumer. The Manifest lists it. The run is in `fx.runs`. |
|
|
231
|
+
| **Month 2 silence** — a retry count in a worker nobody remembers | The retry count lives on `signal.once` (default 3) or on `durable` + `fx.step`, which skips a step already journaled. A failed HTTP request is not retried by `fx`. |
|
|
232
|
+
| **Month 4 audit** — a `sent_emails` table written by hand from a job | The send is an effect in the Manifest and a run in `fx.runs`. Channel records a receipt. The default receipt ledger is process-local memory. |
|
|
233
|
+
| **Month 6 double-submit** — dedup split across a queue and a DB | A retry of one call runs once when it sends `Idempotency-Key`. Two submits are two calls unless the app passes the same key. Provider webhooks still need a unique constraint on the provider's event id, because providers do not send that header. `{ key }` serializes `once` per key. It does not deduplicate. |
|
|
232
234
|
|
|
233
|
-
|
|
235
|
+
The door makes each of those problems visible, and the fix is one line in a known place. The signup sample is safe to run twice once the user row has a unique constraint or an upsert. The second attempt is a second run in `fx.runs`.
|
|
234
236
|
|
|
235
237
|
### Five kinds of trigger
|
|
236
238
|
|