okengine 0.10.2 → 0.11.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 (343) hide show
  1. package/package.json +76 -10
  2. package/site/content/docs/ai/mcp.mdx +0 -1
  3. package/site/content/docs/deployment/docker-swarm.mdx +48 -111
  4. package/site/content/docs/deployment/docker.mdx +43 -36
  5. package/site/content/docs/deployment/index.mdx +9 -9
  6. package/site/content/docs/deployment/kubernetes.mdx +4 -4
  7. package/site/content/docs/elements/ai.mdx +7 -13
  8. package/site/content/docs/elements/channel.mdx +9 -13
  9. package/site/content/docs/elements/clock.mdx +8 -14
  10. package/site/content/docs/elements/flow.mdx +46 -54
  11. package/site/content/docs/elements/gate.mdx +6 -11
  12. package/site/content/docs/elements/signal.mdx +2 -8
  13. package/site/content/docs/elements/store.mdx +79 -93
  14. package/site/content/docs/elements/vault.mdx +89 -76
  15. package/site/content/docs/get-started/basic-usage.mdx +8 -14
  16. package/site/content/docs/get-started/installation.mdx +36 -36
  17. package/site/content/docs/get-started/introduction.mdx +11 -0
  18. package/site/content/docs/get-started/why.mdx +3 -4
  19. package/site/content/docs/index.mdx +0 -1
  20. package/site/content/docs/meta.json +0 -1
  21. package/site/content/docs/plugins/anonymous.mdx +1 -1
  22. package/site/content/docs/plugins/magic-link.mdx +11 -11
  23. package/site/content/docs/plugins/otp.mdx +2 -2
  24. package/site/content/docs/plugins/passkey.mdx +1 -1
  25. package/site/content/docs/plugins/two-factor.mdx +1 -1
  26. package/site/content/docs/plugins/username.mdx +1 -1
  27. package/site/content/docs/recipes/caddy.mdx +1 -1
  28. package/site/content/docs/recipes/cockroachdb.mdx +2 -2
  29. package/site/content/docs/recipes/dragonfly.mdx +1 -1
  30. package/site/content/docs/recipes/index.mdx +1 -2
  31. package/site/content/docs/recipes/llama-cpp.mdx +21 -23
  32. package/site/content/docs/recipes/mailpit.mdx +11 -11
  33. package/site/content/docs/recipes/meilisearch.mdx +10 -10
  34. package/site/content/docs/recipes/meta.json +0 -1
  35. package/site/content/docs/recipes/ollama.mdx +1 -1
  36. package/site/content/docs/recipes/pgdog.mdx +12 -9
  37. package/site/content/docs/recipes/postgres.mdx +3 -3
  38. package/site/content/docs/recipes/redis.mdx +2 -2
  39. package/site/content/docs/recipes/rustfs.mdx +10 -7
  40. package/site/content/docs/recipes/sglang.mdx +1 -1
  41. package/site/content/docs/recipes/supabase-docker.mdx +1 -1
  42. package/site/content/docs/recipes/timescale.mdx +2 -2
  43. package/site/content/docs/recipes/valkey.mdx +1 -1
  44. package/site/content/docs/recipes/vllm.mdx +1 -1
  45. package/site/content/docs/recipes/yugabytedb.mdx +2 -2
  46. package/site/content/docs/reference/cli.md +61 -38
  47. package/site/content/docs/reference/configuration.mdx +145 -70
  48. package/site/content/docs/reference/environment-variables.mdx +36 -28
  49. package/site/content/docs/reference/errors.mdx +28 -29
  50. package/site/content/docs/reference/fx.mdx +14 -7
  51. package/site/content/docs/reference/i18n.mdx +17 -6
  52. package/site/content/docs/reference/index.mdx +5 -0
  53. package/site/content/docs/reference/meta.json +1 -0
  54. package/site/content/docs/reference/migrating-environments.mdx +158 -0
  55. package/site/content/docs/reference/plugins.mdx +2 -2
  56. package/src/auth/gate-auth.test.ts +12 -10
  57. package/src/cli/ai-setup/ai-setup.test.ts +30 -16
  58. package/src/cli/ai-setup/apply.ts +111 -15
  59. package/src/cli/ai-setup/catalog.ts +3 -3
  60. package/src/cli/ai-setup/index.ts +1 -1
  61. package/src/cli/ai-setup/prompts.ts +1 -1
  62. package/src/cli/ai-setup/recommend.test.ts +1 -1
  63. package/src/cli/ask-seed.test.ts +3 -3
  64. package/src/cli/build.ts +8 -9
  65. package/src/cli/db-auto-push.test.ts +6 -3
  66. package/src/cli/db-auto-push.ts +7 -5
  67. package/src/cli/db-seed.ts +7 -8
  68. package/src/cli/db.test.ts +70 -71
  69. package/src/cli/db.ts +86 -12
  70. package/src/cli/dev-controller.test.ts +35 -0
  71. package/src/cli/dev-controller.ts +298 -0
  72. package/src/cli/dev-controls.ts +11 -65
  73. package/src/cli/dev-db-push.test.ts +47 -15
  74. package/src/cli/dev-schema-sync.test.ts +11 -11
  75. package/src/cli/dev-schema-sync.ts +10 -10
  76. package/src/cli/dev-session-lock.test.ts +106 -0
  77. package/src/cli/dev-session-lock.ts +209 -0
  78. package/src/cli/dev.test.ts +246 -114
  79. package/src/cli/dev.ts +153 -205
  80. package/src/cli/doc-staleness.test.ts +3 -6
  81. package/src/cli/docker-clean.test.ts +33 -1
  82. package/src/cli/docker-clean.ts +28 -33
  83. package/src/cli/docker-cli.test.ts +12 -11
  84. package/src/cli/docker.ts +95 -20
  85. package/src/cli/doctor.test.ts +40 -0
  86. package/src/cli/doctor.ts +142 -9
  87. package/src/cli/drizzle-env.test.ts +11 -13
  88. package/src/cli/drizzle-env.ts +18 -11
  89. package/src/cli/ensure-drizzle-config.ts +11 -14
  90. package/src/cli/hero-meta.test.ts +15 -15
  91. package/src/cli/hero-meta.ts +10 -9
  92. package/src/cli/index.ts +36 -6
  93. package/src/cli/load-config.images.test.ts +44 -10
  94. package/src/cli/load-config.source.test.ts +50 -0
  95. package/src/cli/load-config.ts +35 -29
  96. package/src/cli/meilisearch-local.ts +1 -2
  97. package/src/cli/mode.ts +7 -58
  98. package/src/cli/registry.ts +175 -19
  99. package/src/cli/resolve-dev-sql-env.test.ts +8 -16
  100. package/src/cli/resolve-dev-sql-env.ts +13 -15
  101. package/src/cli/safe-defaults.test.ts +29 -11
  102. package/src/cli/schema.ts +65 -16
  103. package/src/cli/test.ts +58 -0
  104. package/src/cli/tui/App.tsx +252 -0
  105. package/src/cli/tui/DevLive.tsx +116 -0
  106. package/src/cli/tui/components/ConfirmDialog.tsx +48 -0
  107. package/src/cli/tui/components/HeaderBar.tsx +67 -0
  108. package/src/cli/tui/components/LogStream.tsx +47 -0
  109. package/src/cli/tui/components/PanelCard.tsx +39 -0
  110. package/src/cli/tui/components/SelectRow.tsx +37 -0
  111. package/src/cli/tui/components/SlashPalette.tsx +172 -0
  112. package/src/cli/tui/components/Spinner.tsx +32 -0
  113. package/src/cli/tui/components/StatusBar.tsx +51 -0
  114. package/src/cli/tui/components/StatusDot.tsx +37 -0
  115. package/src/cli/tui/components/TabBar.tsx +49 -0
  116. package/src/cli/tui/components/Wordmark.tsx +43 -0
  117. package/src/cli/tui/format.ts +38 -0
  118. package/src/cli/tui/keys.test.ts +18 -0
  119. package/src/cli/tui/keys.ts +26 -0
  120. package/src/cli/tui/launch.test.ts +18 -0
  121. package/src/cli/tui/launch.ts +57 -0
  122. package/src/cli/tui/panels/Dashboard.tsx +201 -0
  123. package/src/cli/tui/panels/Database.tsx +198 -0
  124. package/src/cli/tui/panels/Dev.tsx +257 -0
  125. package/src/cli/tui/panels/Docker.tsx +357 -0
  126. package/src/cli/tui/panels/Navigator.test.ts +22 -0
  127. package/src/cli/tui/panels/Navigator.tsx +213 -0
  128. package/src/cli/tui/slash-catalog.test.ts +31 -0
  129. package/src/cli/tui/slash-catalog.ts +184 -0
  130. package/src/cli/tui/slash-run.ts +54 -0
  131. package/src/cli/tui/state/ports.ts +95 -0
  132. package/src/cli/tui/theme.ts +24 -0
  133. package/src/cli/vault-cmd.test.ts +402 -0
  134. package/src/cli/vault-cmd.ts +727 -69
  135. package/src/cli/vault-secure-input.test.ts +56 -0
  136. package/src/cli/vault-secure-input.ts +124 -0
  137. package/src/compiler/effects-infer.ts +7 -0
  138. package/src/compiler/extract.ts +12 -0
  139. package/src/compiler/fixtures/skyport/oke.config.ts +7 -5
  140. package/src/compiler/fixtures/skyport/src/flows/payments/index.ts +1 -1
  141. package/src/compiler/fixtures/skyport.expected.json +1 -1
  142. package/src/compiler/generate-adopt.ts +27 -1
  143. package/src/compiler-entry.ts +7 -0
  144. package/src/config/define-config.test.ts +111 -47
  145. package/src/config/driver-defaults.test.ts +115 -0
  146. package/src/config/driver-defaults.ts +156 -0
  147. package/src/config/index.ts +246 -131
  148. package/src/config/resolve-driver.test.ts +14 -19
  149. package/src/console/server/app.ts +4 -2
  150. package/src/console/server/console.test.ts +58 -1
  151. package/src/console/server/flows.ts +22 -2
  152. package/src/console/server/index.ts +3 -0
  153. package/src/console/server/operator-db.test.ts +55 -13
  154. package/src/console/server/operator-db.ts +305 -263
  155. package/src/console/server/serve.ts +16 -7
  156. package/src/console/server/state.ts +31 -9
  157. package/src/console/server/vault.test.ts +120 -5
  158. package/src/console/server/vault.ts +208 -12
  159. package/src/console/ui/dist/assets/{index-D-9KFce5.js → index--Y0Uaz-m.js} +2 -2
  160. package/src/console/ui/dist/assets/panel-vault-BkZdJPX8.js +1 -0
  161. package/src/console/ui/dist/assets/style-C3L349iZ.css +3 -0
  162. package/src/console/ui/dist/index.html +2 -2
  163. package/src/console/ui/shell/client.ts +16 -0
  164. package/src/console/ui/shell/panels/vault/VaultPanel.tsx +54 -1
  165. package/src/console/ui/vault/VaultA11yView.tsx +33 -1
  166. package/src/console/ui/vault/backend.test.ts +75 -0
  167. package/src/console/ui/vault/backend.ts +117 -0
  168. package/src/console/ui/vault/fixture.ts +21 -2
  169. package/src/console/ui/vault/index.ts +11 -1
  170. package/src/console/ui/vault/types.ts +26 -0
  171. package/src/docker/compose.ts +164 -61
  172. package/src/docker/derive.ts +106 -12
  173. package/src/docker/docker.test.ts +156 -110
  174. package/src/docker/helpers.ts +98 -0
  175. package/src/docker/images-config.test.ts +163 -0
  176. package/src/docker/index.ts +30 -5
  177. package/src/docker/recipes/index.ts +0 -3
  178. package/src/docker/recipes/llama-cpp.ts +4 -4
  179. package/src/docker/recipes/pgdog.ts +9 -2
  180. package/src/docker/resources.test.ts +71 -0
  181. package/src/docker/resources.ts +176 -0
  182. package/src/docker/stack-id.test.ts +11 -4
  183. package/src/docker/stack-id.ts +42 -32
  184. package/src/docker/stack.integration.test.ts +4 -15
  185. package/src/docker/types.ts +35 -3
  186. package/src/drivers/ai-ollama.integration.test.ts +2 -2
  187. package/src/drivers/conformance.test.ts +11 -12
  188. package/src/drivers/drizzle-dialect.test.ts +0 -4
  189. package/src/drivers/drizzle-dialect.ts +7 -13
  190. package/src/drivers/index.ts +24 -7
  191. package/src/drivers/journal-postgres.test.ts +3 -1
  192. package/src/drivers/journal-postgres.ts +4 -2
  193. package/src/drivers/meilisearch.ts +1 -1
  194. package/src/drivers/pglite.ts +28 -5
  195. package/src/drivers/types.ts +9 -11
  196. package/src/drivers/vault-aws-secrets-manager.test.ts +159 -0
  197. package/src/drivers/vault-aws-secrets-manager.ts +333 -0
  198. package/src/drivers/vault-builtin.test.ts +110 -0
  199. package/src/drivers/vault-builtin.ts +184 -0
  200. package/src/drivers/vault-driver-removal.test.ts +13 -0
  201. package/src/drivers/vault-managed.test.ts +95 -0
  202. package/src/drivers/vault-managed.ts +101 -25
  203. package/src/drivers/vault-types.ts +25 -5
  204. package/src/elements/ai/runtime.ts +1 -1
  205. package/src/elements/clock/chaos.test.ts +4 -1
  206. package/src/elements/clock/durable.ts +57 -4
  207. package/src/elements/clock.test.ts +13 -14
  208. package/src/elements/gate/constants.ts +18 -0
  209. package/src/elements/gate.test.ts +1 -1
  210. package/src/elements/gate.ts +2 -2
  211. package/src/elements/store/domain-ddl.test.ts +5 -12
  212. package/src/elements/store/emit-drizzle.ts +44 -19
  213. package/src/elements/store/index-boot.test.ts +15 -116
  214. package/src/elements/store/load-plugin-tables.ts +1 -1
  215. package/src/elements/store/resource-list-docs.fixture.ts +12 -6
  216. package/src/elements/store/resource-list-docs.test.ts +64 -44
  217. package/src/elements/store/resource.test.ts +17 -10
  218. package/src/elements/store/runtime.ts +9 -12
  219. package/src/elements/store/schema-decl.test.ts +16 -32
  220. package/src/elements/store/seed.test.ts +1 -2
  221. package/src/elements/store/seed.ts +7 -8
  222. package/src/elements/store/sql-condition.test.ts +2 -2
  223. package/src/elements/store/sql-session.test.ts +8 -10
  224. package/src/elements/store/sql-session.ts +10 -2
  225. package/src/elements/store.test.ts +13 -13
  226. package/src/elements/vault/audit.ts +233 -0
  227. package/src/elements/vault/boot-chain.test.ts +78 -0
  228. package/src/elements/vault/boot-chain.ts +43 -39
  229. package/src/elements/vault/builtin-adapter.test.ts +479 -0
  230. package/src/elements/vault/builtin-adapter.ts +1184 -0
  231. package/src/elements/vault/crypto.test.ts +237 -0
  232. package/src/elements/vault/crypto.ts +431 -0
  233. package/src/elements/vault/declare.ts +164 -3
  234. package/src/elements/vault/errors.ts +56 -0
  235. package/src/elements/vault/kms-unseal.test.ts +76 -0
  236. package/src/elements/vault/kms-unseal.ts +181 -0
  237. package/src/elements/vault/path.ts +90 -0
  238. package/src/elements/vault/resilience.test.ts +102 -0
  239. package/src/elements/vault/resilience.ts +107 -0
  240. package/src/elements/vault/runtime.ts +36 -4
  241. package/src/elements/vault/security-checklist.test.ts +130 -0
  242. package/src/elements/vault/storage.ts +448 -0
  243. package/src/elements/vault/types.ts +288 -0
  244. package/src/elements/vault/unseal.ts +150 -0
  245. package/src/elements/vault.test.ts +72 -4
  246. package/src/elements/vault.ts +91 -2
  247. package/src/full.ts +248 -0
  248. package/src/http.ts +41 -0
  249. package/src/i18n/interpolate-simple.ts +37 -0
  250. package/src/i18n/messages.ts +51 -4
  251. package/src/i18n-entry.ts +7 -0
  252. package/src/index.ts +35 -145
  253. package/src/journal-entry.ts +20 -0
  254. package/src/kernel/adopt-barrel-fresh.test.ts +25 -10
  255. package/src/kernel/app.ts +40 -42
  256. package/src/kernel/auto-registry.test.ts +2 -2
  257. package/src/kernel/boot-bind/ai.test.ts +5 -5
  258. package/src/kernel/boot-bind/ai.ts +2 -1
  259. package/src/kernel/boot-bind/channel.test.ts +6 -6
  260. package/src/kernel/boot-bind/channel.ts +5 -4
  261. package/src/kernel/boot-bind/clock.ts +5 -5
  262. package/src/kernel/boot-bind/gate.ts +6 -7
  263. package/src/kernel/boot-bind/honor-config.test.ts +42 -42
  264. package/src/kernel/boot-bind/journal.ts +5 -5
  265. package/src/kernel/boot-bind/signal.ts +3 -1
  266. package/src/kernel/boot-bind/store.test.ts +55 -37
  267. package/src/kernel/boot-bind/store.ts +24 -34
  268. package/src/kernel/boot-bind/vault.ts +19 -4
  269. package/src/kernel/boot.test.ts +12 -4
  270. package/src/kernel/boot.ts +31 -19
  271. package/src/kernel/budget-entry.ts +1 -1
  272. package/src/kernel/compensate.test.ts +288 -1
  273. package/src/kernel/compensate.ts +126 -0
  274. package/src/kernel/concurrency.test.ts +10 -9
  275. package/src/kernel/effects-stamping.test.ts +27 -2
  276. package/src/kernel/element-registries.ts +5 -3
  277. package/src/kernel/flow.ts +4 -2
  278. package/src/kernel/fx.test.ts +119 -3
  279. package/src/kernel/fx.ts +215 -40
  280. package/src/kernel/index.ts +14 -0
  281. package/src/kernel/journal.ts +130 -7
  282. package/src/kernel/pipeline.test.ts +20 -6
  283. package/src/kernel/redacted.test.ts +35 -0
  284. package/src/kernel/redacted.ts +24 -2
  285. package/src/kernel/router/create-default.ts +16 -0
  286. package/src/kernel/router/create-edge.ts +18 -0
  287. package/src/kernel/router/create.ts +23 -0
  288. package/src/kernel/router/index.ts +23 -0
  289. package/src/kernel/router/linear.ts +88 -0
  290. package/src/kernel/router/regexp.ts +188 -0
  291. package/src/kernel/router/smart.ts +120 -0
  292. package/src/kernel/router/trie.ts +108 -0
  293. package/src/kernel/router/types.ts +56 -0
  294. package/src/kernel/router.ts +18 -532
  295. package/src/kernel-entry.ts +43 -0
  296. package/src/manifest/fixtures/skyport.excerpt.json +1 -1
  297. package/src/manifest/fixtures/skyport.manifest.json +1 -1
  298. package/src/plugins/config-source.test.ts +7 -7
  299. package/src/release/build-lib.ts +58 -0
  300. package/src/release/exports.test.ts +4 -2
  301. package/src/release/exports.ts +30 -4
  302. package/src/release/measure.ts +101 -22
  303. package/src/runs/duckdb.ts +7 -2
  304. package/src/shared/optional-peer.ts +41 -0
  305. package/src/term.test.ts +3 -44
  306. package/src/test/create-test-app.ts +12 -0
  307. package/src/test/provisions.integration.test.ts +1 -1
  308. package/src/test/reset-element-registries.ts +2 -0
  309. package/src/upgrade/codemods.test.ts +7 -20
  310. package/src/upgrade/codemods.ts +93 -60
  311. package/site/content/docs/console/access.mdx +0 -29
  312. package/site/content/docs/console/ai.mdx +0 -35
  313. package/site/content/docs/console/architecture.mdx +0 -35
  314. package/site/content/docs/console/channels.mdx +0 -37
  315. package/site/content/docs/console/clock.mdx +0 -51
  316. package/site/content/docs/console/flows.mdx +0 -31
  317. package/site/content/docs/console/gates.mdx +0 -73
  318. package/site/content/docs/console/index.mdx +0 -54
  319. package/site/content/docs/console/manifest-diff.mdx +0 -34
  320. package/site/content/docs/console/meta.json +0 -24
  321. package/site/content/docs/console/overview.mdx +0 -40
  322. package/site/content/docs/console/plugins.mdx +0 -41
  323. package/site/content/docs/console/privacy.mdx +0 -32
  324. package/site/content/docs/console/runs.mdx +0 -50
  325. package/site/content/docs/console/signals.mdx +0 -40
  326. package/site/content/docs/console/store.mdx +0 -32
  327. package/site/content/docs/console/tenancy.mdx +0 -32
  328. package/site/content/docs/console/traces.mdx +0 -38
  329. package/site/content/docs/console/vault.mdx +0 -41
  330. package/site/content/docs/recipes/openbao.mdx +0 -141
  331. package/src/cli/ask-dev-mode.ts +0 -40
  332. package/src/cli/dev-mode.test.ts +0 -45
  333. package/src/cli/dev-mode.ts +0 -78
  334. package/src/cli/openbao-bootstrap.test.ts +0 -150
  335. package/src/cli/openbao-bootstrap.ts +0 -321
  336. package/src/cli/openbao-restart.integration.test.ts +0 -145
  337. package/src/console/ui/dist/assets/panel-vault-Za1GyJfM.js +0 -1
  338. package/src/console/ui/dist/assets/style-C8MxEWPd.css +0 -3
  339. package/src/docker/recipes/openbao.ts +0 -47
  340. package/src/drivers/libsql.ts +0 -179
  341. package/src/drivers/sqlite.ts +0 -44
  342. package/src/drivers/vault-openbao.test.ts +0 -97
  343. package/src/drivers/vault-openbao.ts +0 -143
@@ -4,7 +4,7 @@
4
4
  * Physics: secrets · config · environment.
5
5
  */
6
6
 
7
- import { secretRegistry } from "../../kernel/element-registries.ts";
7
+ import { requiredEnvRegistry, secretRegistry } from "../../kernel/element-registries.ts";
8
8
 
9
9
  /** Options for {@link vault} / {@link vault.secret} / {@link vault.config}. */
10
10
  export interface VaultSecretOptions {
@@ -105,6 +105,163 @@ function declareConfig(name: string, options: VaultSecretOptions = {}): VaultSec
105
105
  };
106
106
  }
107
107
 
108
+ /**
109
+ * Synchronous environment-variable helpers on `vault.env`.
110
+ *
111
+ * Plain process configuration — no boot chain, no driver, no redaction.
112
+ * Reach for {@link vault.secret} when the value is a contract the runtime
113
+ * must resolve, fingerprint, and keep out of logs.
114
+ */
115
+ export interface VaultEnvApi {
116
+ /**
117
+ * Raw value, or `undefined` when unset / empty.
118
+ *
119
+ * @param name - Environment variable name
120
+ */
121
+ (name: string): string | undefined;
122
+ /**
123
+ * Value that must exist. Registers `name` so boot reports every missing
124
+ * variable at once alongside secret gaps.
125
+ *
126
+ * @param name - Environment variable name
127
+ * @throws TypeError when the variable is unset or empty
128
+ */
129
+ required(name: string): string;
130
+ /**
131
+ * Integer value.
132
+ *
133
+ * @param name - Environment variable name
134
+ * @param defaultValue - Used when the variable is unset or empty
135
+ * @throws TypeError when missing without a default, or not an integer
136
+ */
137
+ int(name: string, defaultValue?: number): number;
138
+ /**
139
+ * Boolean value (`1`/`true`/`yes`/`on` · `0`/`false`/`no`/`off`).
140
+ *
141
+ * @param name - Environment variable name
142
+ * @param defaultValue - Used when the variable is unset or empty
143
+ * @throws TypeError when missing without a default, or not boolean-shaped
144
+ */
145
+ bool(name: string, defaultValue?: boolean): boolean;
146
+ /**
147
+ * JSON-parsed value, or `undefined` when unset / empty.
148
+ *
149
+ * @param name - Environment variable name
150
+ * @throws TypeError when the value is not valid JSON
151
+ */
152
+ json<T = unknown>(name: string): T | undefined;
153
+ }
154
+
155
+ /**
156
+ * Read one environment variable, treating empty strings as unset.
157
+ *
158
+ * @param name - Environment variable name
159
+ */
160
+ export function readEnv(name: string): string | undefined {
161
+ const value =
162
+ (typeof Bun !== "undefined" ? Bun.env[name] : undefined) ?? process.env[name] ?? undefined;
163
+ return value === undefined || value.length === 0 ? undefined : value;
164
+ }
165
+
166
+ /**
167
+ * Snapshot of every name passed to {@link vault.env.required} since the last
168
+ * reset. Boot turns the missing ones into {@link VaultGap}s.
169
+ */
170
+ export function listRequiredEnvNames(): readonly string[] {
171
+ return requiredEnvRegistry.slice();
172
+ }
173
+
174
+ /**
175
+ * Clear the required-env registry (tests / fresh app adopt).
176
+ *
177
+ * @internal
178
+ */
179
+ export function resetRequiredEnvNames(): void {
180
+ requiredEnvRegistry.length = 0;
181
+ }
182
+
183
+ /**
184
+ * @param name - Environment variable name
185
+ */
186
+ function envValue(name: string): string | undefined {
187
+ if (!name) throw new TypeError("vault.env: name is required");
188
+ return readEnv(name);
189
+ }
190
+
191
+ /**
192
+ * @param name - Environment variable name
193
+ */
194
+ function envRequired(name: string): string {
195
+ if (!name) throw new TypeError("vault.env.required: name is required");
196
+ if (!requiredEnvRegistry.includes(name)) requiredEnvRegistry.push(name);
197
+ const value = readEnv(name);
198
+ if (value === undefined) {
199
+ throw new TypeError(`vault.env.required: ${name} is not set`);
200
+ }
201
+ return value;
202
+ }
203
+
204
+ /**
205
+ * @param name - Environment variable name
206
+ * @param defaultValue - Used when the variable is unset
207
+ */
208
+ function envInt(name: string, defaultValue?: number): number {
209
+ const raw = envValue(name);
210
+ if (raw === undefined) {
211
+ if (defaultValue === undefined) {
212
+ throw new TypeError(`vault.env.int: ${name} is not set and has no default`);
213
+ }
214
+ return defaultValue;
215
+ }
216
+ const parsed = Number(raw);
217
+ if (!Number.isInteger(parsed)) {
218
+ throw new TypeError(`vault.env.int: ${name} is not an integer`);
219
+ }
220
+ return parsed;
221
+ }
222
+
223
+ const TRUE_VALUES = new Set(["1", "true", "yes", "on"]);
224
+ const FALSE_VALUES = new Set(["0", "false", "no", "off"]);
225
+
226
+ /**
227
+ * @param name - Environment variable name
228
+ * @param defaultValue - Used when the variable is unset
229
+ */
230
+ function envBool(name: string, defaultValue?: boolean): boolean {
231
+ const raw = envValue(name);
232
+ if (raw === undefined) {
233
+ if (defaultValue === undefined) {
234
+ throw new TypeError(`vault.env.bool: ${name} is not set and has no default`);
235
+ }
236
+ return defaultValue;
237
+ }
238
+ const normalized = raw.trim().toLowerCase();
239
+ if (TRUE_VALUES.has(normalized)) return true;
240
+ if (FALSE_VALUES.has(normalized)) return false;
241
+ throw new TypeError(`vault.env.bool: ${name} is not a boolean (got "${raw}")`);
242
+ }
243
+
244
+ /**
245
+ * @param name - Environment variable name
246
+ */
247
+ function envJson<T = unknown>(name: string): T | undefined {
248
+ const raw = envValue(name);
249
+ if (raw === undefined) return undefined;
250
+ try {
251
+ return JSON.parse(raw) as T;
252
+ } catch {
253
+ throw new TypeError(`vault.env.json: ${name} is not valid JSON`);
254
+ }
255
+ }
256
+
257
+ /** Sync env helpers exposed as {@link vault.env}. */
258
+ export const vaultEnvApi: VaultEnvApi = Object.assign(envValue, {
259
+ required: envRequired,
260
+ int: envInt,
261
+ bool: envBool,
262
+ json: envJson,
263
+ });
264
+
108
265
  /**
109
266
  * Marker prefix for {@link vault.fromDocker} local fallbacks.
110
267
  * Resolved from `docker/.env.docker` by `oke dev --docker` / vault boot.
@@ -144,18 +301,22 @@ export function fromDockerRole(value: string): string {
144
301
  }
145
302
 
146
303
  /**
147
- * Vault element — `vault("NAME", opts)` · `vault.secret` · `vault.config`.
304
+ * Vault element — `vault("NAME", opts)` · `vault.secret` · `vault.config`
305
+ * · `vault.env`.
148
306
  *
149
307
  * A declaration is a contract, not a value. Values resolve at boot through
150
- * the configured driver chain.
308
+ * the configured driver chain. `vault.env` is the escape hatch for plain
309
+ * process configuration that needs no contract.
151
310
  */
152
311
  export const vault: {
153
312
  (name: string, options?: VaultSecretOptions): VaultSecretDecl;
154
313
  secret(name: string, options?: VaultSecretOptions): VaultSecretDecl;
155
314
  config(name: string, options?: VaultSecretOptions): VaultSecretDecl;
156
315
  fromDocker(role: string): string;
316
+ env: VaultEnvApi;
157
317
  } = Object.assign(declareSecret, {
158
318
  secret: declareSecret,
159
319
  config: declareConfig,
160
320
  fromDocker,
321
+ env: vaultEnvApi,
161
322
  });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Vault error taxonomy — one class, one closed code union.
3
+ *
4
+ * `VaultError` deliberately never carries a `cause`. An underlying driver or
5
+ * Web Crypto error can hold key material, ciphertext, or plaintext in its
6
+ * message or attached buffers; re-throwing it would leak that material into
7
+ * logs and traces. Callers translate foreign errors into a code + a message
8
+ * they authored themselves.
9
+ */
10
+
11
+ /** Closed set of Vault failure reasons. */
12
+ export type VaultErrorCode =
13
+ | "SEALED"
14
+ | "NOT_INITIALIZED"
15
+ | "SECRET_NOT_FOUND"
16
+ | "INVALID_KEY"
17
+ | "ALREADY_INITIALIZED"
18
+ | "READONLY"
19
+ | "EXPIRED"
20
+ | "PERMISSION_DENIED"
21
+ | "BACKEND_ERROR"
22
+ | "INVALID_PATH"
23
+ | "UNSUPPORTED"
24
+ | "MISSING_PEER";
25
+
26
+ /**
27
+ * Vault failure with a machine-readable {@link VaultErrorCode}.
28
+ *
29
+ * Never attach a `cause`: upstream crypto/driver errors may hold secret
30
+ * material. Author a safe message instead.
31
+ */
32
+ export class VaultError extends Error {
33
+ /** Machine-readable reason. */
34
+ readonly code: VaultErrorCode;
35
+
36
+ /**
37
+ * @param code - Machine-readable reason
38
+ * @param message - Safe, secret-free description
39
+ */
40
+ constructor(code: VaultErrorCode, message: string) {
41
+ super(message);
42
+ this.name = "VaultError";
43
+ this.code = code;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Whether a value is a {@link VaultError} (optionally of a specific code).
49
+ *
50
+ * @param value - Candidate
51
+ * @param code - Narrow to a single code
52
+ */
53
+ export function isVaultError(value: unknown, code?: VaultErrorCode): value is VaultError {
54
+ if (!(value instanceof VaultError)) return false;
55
+ return code === undefined || value.code === code;
56
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * KMS unseal — injected seams, key hygiene, and the missing-peer message.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { deriveVerifyHash, generateMasterKey, MASTER_KEY_BYTES } from "./crypto.ts";
7
+ import { isVaultError } from "./errors.ts";
8
+ import { createAwsKmsUnsealer, wrapMasterWithAwsKms } from "./kms-unseal.ts";
9
+
10
+ const WRAPPED = Uint8Array.from([0xde, 0xad, 0xbe, 0xef]);
11
+
12
+ describe("aws kms unseal", () => {
13
+ test("an injected decrypt yields a working unsealer", async () => {
14
+ const masterKey = generateMasterKey();
15
+ const unsealer = await createAwsKmsUnsealer({
16
+ keyId: "alias/oke-vault",
17
+ wrappedMaster: WRAPPED,
18
+ decryptFn: async (ciphertext) => {
19
+ expect([...ciphertext]).toEqual([...WRAPPED]);
20
+ return Uint8Array.from(masterKey);
21
+ },
22
+ });
23
+
24
+ expect(unsealer.sealed).toBe(false);
25
+ expect(await unsealer.verifyHash()).toBe(await deriveVerifyHash(masterKey));
26
+ unsealer.seal();
27
+ expect(unsealer.sealed).toBe(true);
28
+ });
29
+
30
+ test("the plaintext handed back by kms is zeroed after import", async () => {
31
+ const masterKey = generateMasterKey();
32
+ const plaintext = Uint8Array.from(masterKey);
33
+ const unsealer = await createAwsKmsUnsealer({
34
+ keyId: "alias/oke-vault",
35
+ wrappedMaster: WRAPPED,
36
+ decryptFn: async () => plaintext,
37
+ });
38
+
39
+ expect([...plaintext]).toEqual(new Array<number>(MASTER_KEY_BYTES).fill(0));
40
+ // The unsealer kept its own copy, so it still derives the real hash.
41
+ expect(await unsealer.verifyHash()).toBe(await deriveVerifyHash(masterKey));
42
+ });
43
+
44
+ test("a wrong-length plaintext is rejected", async () => {
45
+ const failure = await createAwsKmsUnsealer({
46
+ keyId: "alias/oke-vault",
47
+ wrappedMaster: WRAPPED,
48
+ decryptFn: async () => new Uint8Array(16),
49
+ }).catch((e: unknown) => e);
50
+
51
+ expect(isVaultError(failure, "INVALID_KEY")).toBe(true);
52
+ });
53
+
54
+ test("wrapMasterWithAwsKms uses the injected encrypt and checks key length", async () => {
55
+ const masterKey = generateMasterKey();
56
+ const blob = await wrapMasterWithAwsKms("alias/oke-vault", masterKey, {
57
+ encryptFn: async (plaintext) => Uint8Array.from([1, ...plaintext]),
58
+ });
59
+
60
+ expect(blob).toHaveLength(MASTER_KEY_BYTES + 1);
61
+ const failure = await wrapMasterWithAwsKms("alias/oke-vault", new Uint8Array(8), {
62
+ encryptFn: async (plaintext) => plaintext,
63
+ }).catch((e: unknown) => e);
64
+ expect(isVaultError(failure, "INVALID_KEY")).toBe(true);
65
+ });
66
+
67
+ test("without the optional peer the failure names the install command", async () => {
68
+ const failure = await createAwsKmsUnsealer({
69
+ keyId: "alias/oke-vault",
70
+ wrappedMaster: WRAPPED,
71
+ }).catch((e: unknown) => e);
72
+
73
+ expect(isVaultError(failure, "MISSING_PEER")).toBe(true);
74
+ expect((failure as Error).message).toContain("bun add @aws-sdk/client-kms");
75
+ });
76
+ });
@@ -0,0 +1,181 @@
1
+ /**
2
+ * AWS KMS unseal path — the master key never exists outside KMS at rest.
3
+ *
4
+ * At init the plaintext master key is sent to KMS `Encrypt` once; the
5
+ * returned ciphertext blob is what gets stored (`oke_vault_master.wrapped_master`).
6
+ * At boot the blob goes back to KMS `Decrypt` and the plaintext is handed
7
+ * straight to an {@link Unsealer}, then zeroed — it is never written to disk,
8
+ * an environment variable, or a log.
9
+ *
10
+ * `@aws-sdk/client-kms` is an optional peer, imported dynamically so a
11
+ * project that unseals from `env` or a file never pays for it. Tests inject
12
+ * `decryptFn` / `encryptFn` and skip the SDK entirely.
13
+ */
14
+
15
+ import { MASTER_KEY_BYTES, zeroBytes } from "./crypto.ts";
16
+ import { VaultError } from "./errors.ts";
17
+ import { createEnvUnsealer, type Unsealer } from "./unseal.ts";
18
+
19
+ /** Package name of the optional peer, resolved at call time. */
20
+ const KMS_PACKAGE = "@aws-sdk/client-kms";
21
+
22
+ /** Options for {@link createAwsKmsUnsealer}. */
23
+ export interface CreateAwsKmsUnsealerOptions {
24
+ /** KMS key id, alias, or ARN that wrapped the master key. */
25
+ readonly keyId: string;
26
+ /** Ciphertext blob returned by {@link wrapMasterWithAwsKms}. */
27
+ readonly wrappedMaster: Uint8Array;
28
+ /** AWS region. Falls back to the SDK's own resolution chain. */
29
+ readonly region?: string;
30
+ /** Decrypt seam for tests — skips the SDK import entirely. */
31
+ readonly decryptFn?: (ciphertext: Uint8Array) => Promise<Uint8Array>;
32
+ }
33
+
34
+ /** Options for {@link wrapMasterWithAwsKms}. */
35
+ export interface WrapMasterWithAwsKmsOptions {
36
+ /** AWS region. Falls back to the SDK's own resolution chain. */
37
+ readonly region?: string;
38
+ /** Encrypt seam for tests — skips the SDK import entirely. */
39
+ readonly encryptFn?: (plaintext: Uint8Array) => Promise<Uint8Array>;
40
+ }
41
+
42
+ /** KMS response fields this module reads. */
43
+ interface KmsResult {
44
+ readonly Plaintext?: Uint8Array;
45
+ readonly CiphertextBlob?: Uint8Array;
46
+ }
47
+
48
+ /** Structural client interface — the SDK's `KMSClient` satisfies it. */
49
+ interface KmsClientLike {
50
+ send(command: unknown): Promise<KmsResult>;
51
+ destroy?(): void;
52
+ }
53
+
54
+ /** Constructors pulled off the dynamically imported module. */
55
+ interface KmsBindings {
56
+ readonly KMSClient: new (config: { region?: string }) => KmsClientLike;
57
+ readonly DecryptCommand: new (input: { KeyId: string; CiphertextBlob: Uint8Array }) => unknown;
58
+ readonly EncryptCommand: new (input: { KeyId: string; Plaintext: Uint8Array }) => unknown;
59
+ }
60
+
61
+ /**
62
+ * Unseal from a KMS-wrapped master key.
63
+ *
64
+ * @param opts - Key id, wrapped blob, region, and the test decrypt seam
65
+ * @throws VaultError `MISSING_PEER` when `@aws-sdk/client-kms` is absent
66
+ * @throws VaultError `INVALID_KEY` when KMS returns something other than 32 bytes
67
+ */
68
+ export async function createAwsKmsUnsealer(opts: CreateAwsKmsUnsealerOptions): Promise<Unsealer> {
69
+ const decrypt = opts.decryptFn ?? (await bindDecrypt(opts.keyId, opts.region));
70
+ const plaintext = await decrypt(opts.wrappedMaster);
71
+ try {
72
+ if (plaintext.byteLength !== MASTER_KEY_BYTES) {
73
+ throw new VaultError(
74
+ "INVALID_KEY",
75
+ `vault: kms master key must be ${MASTER_KEY_BYTES} bytes`,
76
+ );
77
+ }
78
+ return createEnvUnsealer(plaintext);
79
+ } finally {
80
+ zeroBytes(plaintext);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Wrap a freshly generated master key with KMS for storage at init.
86
+ *
87
+ * The caller still owns `masterKey` and must zero it afterwards.
88
+ *
89
+ * @param keyId - KMS key id, alias, or ARN
90
+ * @param masterKey - Raw 32-byte master key
91
+ * @param opts - Region and the test encrypt seam
92
+ * @throws VaultError `MISSING_PEER` when `@aws-sdk/client-kms` is absent
93
+ */
94
+ export async function wrapMasterWithAwsKms(
95
+ keyId: string,
96
+ masterKey: Uint8Array,
97
+ opts: WrapMasterWithAwsKmsOptions = {},
98
+ ): Promise<Uint8Array> {
99
+ if (masterKey.byteLength !== MASTER_KEY_BYTES) {
100
+ throw new VaultError("INVALID_KEY", `vault: master key must be ${MASTER_KEY_BYTES} bytes`);
101
+ }
102
+ if (opts.encryptFn) return opts.encryptFn(masterKey);
103
+
104
+ const { KMSClient, EncryptCommand } = await loadKms();
105
+ const client = new KMSClient(opts.region === undefined ? {} : { region: opts.region });
106
+ try {
107
+ const result = await client.send(new EncryptCommand({ KeyId: keyId, Plaintext: masterKey }));
108
+ const blob = result.CiphertextBlob;
109
+ if (!blob || blob.byteLength === 0) {
110
+ throw new VaultError("BACKEND_ERROR", "vault: kms returned an empty ciphertext blob");
111
+ }
112
+ return new Uint8Array(blob);
113
+ } catch (error) {
114
+ if (error instanceof VaultError) throw error;
115
+ // Never surface the SDK error: it can echo request payloads.
116
+ throw new VaultError("BACKEND_ERROR", "vault: kms encrypt failed");
117
+ } finally {
118
+ client.destroy?.();
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Build a decrypt closure backed by a real KMS client.
124
+ *
125
+ * @param keyId - KMS key id, alias, or ARN
126
+ * @param region - AWS region
127
+ */
128
+ async function bindDecrypt(
129
+ keyId: string,
130
+ region: string | undefined,
131
+ ): Promise<(ciphertext: Uint8Array) => Promise<Uint8Array>> {
132
+ const { KMSClient, DecryptCommand } = await loadKms();
133
+ return async (ciphertext: Uint8Array) => {
134
+ const client = new KMSClient(region === undefined ? {} : { region });
135
+ try {
136
+ const result = await client.send(
137
+ new DecryptCommand({ KeyId: keyId, CiphertextBlob: ciphertext }),
138
+ );
139
+ const plaintext = result.Plaintext;
140
+ if (!plaintext || plaintext.byteLength === 0) {
141
+ throw new VaultError("BACKEND_ERROR", "vault: kms returned an empty plaintext");
142
+ }
143
+ return new Uint8Array(plaintext);
144
+ } catch (error) {
145
+ if (error instanceof VaultError) throw error;
146
+ // Never surface the SDK error: it can echo key material or ciphertext.
147
+ throw new VaultError("BACKEND_ERROR", "vault: kms decrypt failed");
148
+ } finally {
149
+ client.destroy?.();
150
+ }
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Import the optional `@aws-sdk/client-kms` peer.
156
+ *
157
+ * @throws VaultError `MISSING_PEER` with the exact install command
158
+ */
159
+ async function loadKms(): Promise<KmsBindings> {
160
+ // Indirect specifier: the peer is optional, so it must not become a
161
+ // static dependency of the module graph.
162
+ const specifier: string = KMS_PACKAGE;
163
+ let mod: unknown;
164
+ try {
165
+ mod = await import(specifier);
166
+ } catch {
167
+ throw new VaultError("MISSING_PEER", `Install peer: bun add ${KMS_PACKAGE}`);
168
+ }
169
+ const bindings = mod as Partial<KmsBindings>;
170
+ if (!bindings.KMSClient || !bindings.DecryptCommand || !bindings.EncryptCommand) {
171
+ throw new VaultError(
172
+ "MISSING_PEER",
173
+ `vault: \`${KMS_PACKAGE}\` is installed but does not export KMSClient / DecryptCommand / EncryptCommand`,
174
+ );
175
+ }
176
+ return {
177
+ KMSClient: bindings.KMSClient,
178
+ DecryptCommand: bindings.DecryptCommand,
179
+ EncryptCommand: bindings.EncryptCommand,
180
+ };
181
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Vault path canonicalization.
3
+ *
4
+ * A path is a slash-separated key with **no** leading or trailing slash:
5
+ * `prod/api/stripe`. Canonicalization runs before every read, write, and
6
+ * audit row so the same logical secret always hashes into the same AAD.
7
+ */
8
+
9
+ import { VaultError } from "./errors.ts";
10
+
11
+ /** Maximum canonical path length in UTF-16 code units. */
12
+ export const MAX_VAULT_PATH_LENGTH = 512;
13
+
14
+ /**
15
+ * Normalize and validate a Vault path.
16
+ *
17
+ * Trims surrounding whitespace, strips leading/trailing slashes, and
18
+ * collapses duplicate separators. Rejects empty paths, `..` traversal,
19
+ * NUL bytes, backslashes, and anything longer than
20
+ * {@link MAX_VAULT_PATH_LENGTH}.
21
+ *
22
+ * @param input - Raw path from a caller
23
+ * @throws VaultError `INVALID_PATH` when the path cannot be canonicalized
24
+ */
25
+ export function canonicalizePath(input: string): string {
26
+ if (typeof input !== "string") {
27
+ throw new VaultError("INVALID_PATH", "vault: path must be a string");
28
+ }
29
+ const trimmed = input.trim();
30
+ if (trimmed.length === 0) {
31
+ throw new VaultError("INVALID_PATH", "vault: path must not be empty");
32
+ }
33
+ if (trimmed.length > MAX_VAULT_PATH_LENGTH) {
34
+ throw new VaultError("INVALID_PATH", `vault: path exceeds ${MAX_VAULT_PATH_LENGTH} characters`);
35
+ }
36
+ if (trimmed.includes("\0")) {
37
+ throw new VaultError("INVALID_PATH", "vault: path must not contain NUL bytes");
38
+ }
39
+ if (trimmed.includes("\\")) {
40
+ throw new VaultError("INVALID_PATH", "vault: path must not contain backslashes");
41
+ }
42
+
43
+ const segments: string[] = [];
44
+ for (const raw of trimmed.split("/")) {
45
+ const segment = raw.trim();
46
+ if (segment.length === 0) continue;
47
+ if (segment === ".") {
48
+ throw new VaultError("INVALID_PATH", 'vault: path must not contain "." segments');
49
+ }
50
+ if (segment === "..") {
51
+ throw new VaultError("INVALID_PATH", 'vault: path must not contain ".." segments');
52
+ }
53
+ segments.push(segment);
54
+ }
55
+
56
+ if (segments.length === 0) {
57
+ throw new VaultError("INVALID_PATH", "vault: path must contain at least one segment");
58
+ }
59
+
60
+ const canonical = segments.join("/");
61
+ if (canonical.length > MAX_VAULT_PATH_LENGTH) {
62
+ throw new VaultError("INVALID_PATH", `vault: path exceeds ${MAX_VAULT_PATH_LENGTH} characters`);
63
+ }
64
+ return canonical;
65
+ }
66
+
67
+ /**
68
+ * Canonicalize a prefix filter for list queries.
69
+ *
70
+ * Unlike {@link canonicalizePath}, an empty prefix is legal and means
71
+ * "every path".
72
+ *
73
+ * @param input - Raw prefix, or `undefined` for no filter
74
+ */
75
+ export function canonicalizePrefix(input: string | undefined): string | undefined {
76
+ if (input === undefined) return undefined;
77
+ const trimmed = input.trim();
78
+ if (trimmed.length === 0 || trimmed === "/") return undefined;
79
+ return canonicalizePath(trimmed);
80
+ }
81
+
82
+ /**
83
+ * Whether a canonical path sits under a canonical prefix.
84
+ *
85
+ * @param path - Canonical path
86
+ * @param prefix - Canonical prefix
87
+ */
88
+ export function pathHasPrefix(path: string, prefix: string): boolean {
89
+ return path === prefix || path.startsWith(`${prefix}/`);
90
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Unit tests for Vault SQL resilience (retry / no-retry).
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import {
7
+ createResilientSqlExec,
8
+ DEFAULT_RETRY,
9
+ isRetryableError,
10
+ withResilience,
11
+ type RetryConfig,
12
+ } from "./resilience.ts";
13
+ import type { SqlExec } from "./storage.ts";
14
+
15
+ const FAST: RetryConfig = {
16
+ ...DEFAULT_RETRY,
17
+ baseDelayMs: 1,
18
+ maxDelayMs: 2,
19
+ };
20
+
21
+ describe("isRetryableError", () => {
22
+ test("matches known connection codes", () => {
23
+ expect(isRetryableError({ code: "ECONNRESET" })).toBe(true);
24
+ expect(isRetryableError({ code: "08006" })).toBe(true);
25
+ expect(isRetryableError(new Error("connect ETIMEDOUT"))).toBe(true);
26
+ });
27
+
28
+ test("rejects business / unknown errors", () => {
29
+ expect(isRetryableError(new Error("INVALID_PATH"))).toBe(false);
30
+ expect(isRetryableError({ code: "42P01" })).toBe(false);
31
+ expect(isRetryableError(null)).toBe(false);
32
+ });
33
+ });
34
+
35
+ describe("withResilience", () => {
36
+ test("retries retryable failures then succeeds", async () => {
37
+ let attempts = 0;
38
+ const value = await withResilience(async () => {
39
+ attempts += 1;
40
+ if (attempts < 3) {
41
+ const err = new Error("connection reset");
42
+ (err as { code?: string }).code = "ECONNRESET";
43
+ throw err;
44
+ }
45
+ return "ok";
46
+ }, FAST);
47
+ expect(value).toBe("ok");
48
+ expect(attempts).toBe(3);
49
+ });
50
+
51
+ test("exhausts retries then fails", async () => {
52
+ let attempts = 0;
53
+ await expect(
54
+ withResilience(
55
+ async () => {
56
+ attempts += 1;
57
+ const err = new Error("gone");
58
+ (err as { code?: string }).code = "57P01";
59
+ throw err;
60
+ },
61
+ { ...FAST, maxRetries: 3 },
62
+ ),
63
+ ).rejects.toMatchObject({ code: "57P01" });
64
+ // First try + 3 retries.
65
+ expect(attempts).toBe(4);
66
+ });
67
+
68
+ test("non-retryable errors fail immediately", async () => {
69
+ let attempts = 0;
70
+ await expect(
71
+ withResilience(async () => {
72
+ attempts += 1;
73
+ throw new Error("INVALID_PATH");
74
+ }, FAST),
75
+ ).rejects.toThrow("INVALID_PATH");
76
+ expect(attempts).toBe(1);
77
+ });
78
+ });
79
+
80
+ describe("createResilientSqlExec", () => {
81
+ test("wraps query with the same retry policy", async () => {
82
+ let attempts = 0;
83
+ const raw: SqlExec = {
84
+ async query<T>() {
85
+ attempts += 1;
86
+ if (attempts === 1) {
87
+ const err = new Error("blip");
88
+ (err as { code?: string }).code = "ETIMEDOUT";
89
+ throw err;
90
+ }
91
+ return [{ id: 1 }] as T[];
92
+ },
93
+ async execute() {
94
+ /* unused */
95
+ },
96
+ };
97
+ const db = createResilientSqlExec(raw, FAST);
98
+ const rows = await db.query<{ id: number }>("SELECT 1");
99
+ expect(rows).toEqual([{ id: 1 }]);
100
+ expect(attempts).toBe(2);
101
+ });
102
+ });