okengine 0.2.8 → 0.3.2

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 (180) hide show
  1. package/AGENTS.md +17 -15
  2. package/README.md +57 -29
  3. package/package.json +11 -16
  4. package/site/content/docs/ai/llms-txt.mdx +54 -0
  5. package/site/content/docs/ai/mcp.mdx +123 -0
  6. package/site/content/docs/ai/meta.json +5 -0
  7. package/site/content/docs/ai/skills.mdx +53 -0
  8. package/site/content/docs/console/access.mdx +29 -0
  9. package/site/content/docs/console/ai.mdx +35 -0
  10. package/site/content/docs/console/architecture.mdx +35 -0
  11. package/site/content/docs/console/channels.mdx +37 -0
  12. package/site/content/docs/console/clock.mdx +31 -0
  13. package/site/content/docs/console/flows.mdx +31 -0
  14. package/site/content/docs/console/gates.mdx +35 -0
  15. package/site/content/docs/console/manifest-diff.mdx +34 -0
  16. package/site/content/docs/console/meta.json +23 -0
  17. package/site/content/docs/console/overview.mdx +40 -0
  18. package/site/content/docs/console/plugins.mdx +41 -0
  19. package/site/content/docs/console/privacy.mdx +32 -0
  20. package/site/content/docs/console/runs.mdx +40 -0
  21. package/site/content/docs/console/signals.mdx +31 -0
  22. package/site/content/docs/console/store.mdx +32 -0
  23. package/site/content/docs/console/tenancy.mdx +32 -0
  24. package/site/content/docs/console/traces.mdx +34 -0
  25. package/site/content/docs/console/vault.mdx +37 -0
  26. package/site/content/docs/elements/ai.mdx +180 -0
  27. package/site/content/docs/elements/channel.mdx +167 -0
  28. package/site/content/docs/elements/clock.mdx +182 -0
  29. package/site/content/docs/elements/flow.mdx +288 -0
  30. package/site/content/docs/elements/gate.mdx +171 -0
  31. package/site/content/docs/elements/meta.json +5 -0
  32. package/site/content/docs/elements/signal.mdx +171 -0
  33. package/site/content/docs/elements/store.mdx +320 -0
  34. package/site/content/docs/elements/vault.mdx +263 -0
  35. package/site/content/docs/get-started/basic-usage.mdx +124 -0
  36. package/site/content/docs/get-started/comparison.mdx +65 -0
  37. package/site/content/docs/get-started/installation.mdx +113 -0
  38. package/site/content/docs/get-started/introduction.mdx +123 -0
  39. package/site/content/docs/get-started/meta.json +5 -0
  40. package/site/content/docs/index.mdx +63 -0
  41. package/site/content/docs/meta.json +5 -0
  42. package/site/content/docs/plugins/compression.mdx +60 -0
  43. package/site/content/docs/plugins/cors.mdx +92 -0
  44. package/site/content/docs/plugins/csrf.mdx +96 -0
  45. package/site/content/docs/plugins/ip-allowlist.mdx +92 -0
  46. package/site/content/docs/plugins/maintenance-mode.mdx +101 -0
  47. package/site/content/docs/plugins/meta.json +15 -0
  48. package/site/content/docs/plugins/security-headers.mdx +136 -0
  49. package/site/content/docs/reference/cli.md +101 -0
  50. package/site/content/docs/reference/configuration.mdx +159 -0
  51. package/site/content/docs/reference/environment-variables.mdx +87 -0
  52. package/site/content/docs/reference/errors.mdx +80 -0
  53. package/site/content/docs/reference/fx.mdx +117 -0
  54. package/site/content/docs/reference/meta.json +5 -0
  55. package/site/content/docs/reference/plugins.mdx +249 -0
  56. package/site/content/docs/reference/security.md +63 -0
  57. package/src/auth/auth.test.ts +3 -0
  58. package/src/cli/ask-dev-mode.ts +1 -1
  59. package/src/cli/db.ts +87 -17
  60. package/src/cli/dev-db-push.test.ts +32 -2
  61. package/src/cli/dev-schema-sync.test.ts +66 -0
  62. package/src/cli/dev-schema-sync.ts +139 -0
  63. package/src/cli/dev.test.ts +123 -1
  64. package/src/cli/dev.ts +150 -8
  65. package/src/cli/doc-staleness.test.ts +4 -4
  66. package/src/cli/docker-cli.test.ts +20 -0
  67. package/src/cli/docker.ts +10 -0
  68. package/src/cli/drizzle-env.test.ts +67 -0
  69. package/src/cli/drizzle-env.ts +78 -0
  70. package/src/cli/ensure-drizzle-config.ts +50 -0
  71. package/src/cli/hero-meta.test.ts +1 -1
  72. package/src/cli/load-config.ts +6 -0
  73. package/src/cli/mode.ts +24 -4
  74. package/src/cli/openbao-bootstrap.test.ts +147 -0
  75. package/src/cli/openbao-bootstrap.ts +280 -0
  76. package/src/cli/openbao-restart.integration.test.ts +136 -0
  77. package/src/cli/ports.test.ts +7 -5
  78. package/src/cli/ports.ts +6 -2
  79. package/src/cli/resolve-dev-sql-env.test.ts +48 -0
  80. package/src/cli/resolve-dev-sql-env.ts +42 -0
  81. package/src/cli/stack.ts +7 -4
  82. package/src/cli/vault-cmd.ts +63 -0
  83. package/src/client/types.ts +7 -1
  84. package/src/compiler/extract.test.ts +40 -0
  85. package/src/compiler/extract.ts +123 -1
  86. package/src/compiler/fixtures/skyport/oke.config.ts +2 -2
  87. package/src/compiler/fixtures/skyport.expected.json +1 -1
  88. package/src/compiler/response.ts +12 -0
  89. package/src/config/define-config.test.ts +4 -6
  90. package/src/config/index.ts +2 -15
  91. package/src/console/server/app.ts +2 -0
  92. package/src/console/server/vault.ts +21 -6
  93. package/src/docker/compose.ts +112 -16
  94. package/src/docker/derive.ts +7 -1
  95. package/src/docker/docker.test.ts +103 -0
  96. package/src/docker/index.ts +11 -1
  97. package/src/docker/recipes/index.ts +3 -2
  98. package/src/docker/recipes/openbao.ts +47 -0
  99. package/src/docker/recipes/redis.ts +5 -1
  100. package/src/docker/recipes/rustfs.ts +2 -3
  101. package/src/docker/stack-id.test.ts +43 -8
  102. package/src/docker/stack-id.ts +99 -20
  103. package/src/docker/stack.ts +36 -4
  104. package/src/docker/types.ts +3 -0
  105. package/src/docs-origin.ts +4 -4
  106. package/src/drivers/drizzle-dialect.test.ts +20 -0
  107. package/src/drivers/drizzle-dialect.ts +37 -0
  108. package/src/drivers/index.ts +1 -2
  109. package/src/drivers/memory.ts +278 -39
  110. package/src/drivers/s3.ts +10 -1
  111. package/src/drivers/vault-driver-removal.test.ts +55 -0
  112. package/src/drivers/vault-openbao.test.ts +97 -0
  113. package/src/drivers/vault-openbao.ts +102 -35
  114. package/src/drivers/vault-types.ts +3 -10
  115. package/src/elements/store/declare.ts +4 -1
  116. package/src/elements/store/resource-list-docs.fixture.ts +56 -0
  117. package/src/elements/store/resource-list-docs.test.ts +79 -0
  118. package/src/elements/store/resource.test.ts +253 -0
  119. package/src/elements/store/resource.ts +786 -0
  120. package/src/elements/store/sql-condition.test.ts +132 -0
  121. package/src/elements/store/sql-condition.ts +284 -46
  122. package/src/elements/store/sql-session.test.ts +86 -1
  123. package/src/elements/store/sql-session.ts +187 -27
  124. package/src/elements/store/table.ts +34 -4
  125. package/src/elements/store.ts +16 -0
  126. package/src/elements/vault/runtime.ts +1 -1
  127. package/src/elements/vault.test.ts +1 -28
  128. package/src/elements/vault.ts +1 -1
  129. package/src/kernel/app.ts +59 -25
  130. package/src/kernel/boot-bind/channel.test.ts +60 -0
  131. package/src/kernel/boot-bind/channel.ts +64 -2
  132. package/src/kernel/boot-bind/store.test.ts +10 -1
  133. package/src/kernel/boot-bind/store.ts +49 -2
  134. package/src/kernel/boot.test.ts +0 -1
  135. package/src/kernel/boot.ts +1 -1
  136. package/src/kernel/edge.test.ts +68 -0
  137. package/src/kernel/errors.registry.test.ts +1 -1
  138. package/src/kernel/flow.ts +8 -0
  139. package/src/kernel/fx.test.ts +23 -3
  140. package/src/kernel/fx.ts +115 -18
  141. package/src/kernel/hooks.test.ts +33 -0
  142. package/src/kernel/hooks.ts +22 -0
  143. package/src/kernel/index.ts +7 -0
  144. package/src/kernel/on.ts +44 -3
  145. package/src/kernel/plugin.ts +33 -3
  146. package/src/kernel/registry-isolation.test.ts +74 -0
  147. package/src/kernel/registry.ts +22 -1
  148. package/src/kernel/triggers.ts +59 -0
  149. package/src/manifest/fixtures/skyport.excerpt.json +1 -1
  150. package/src/manifest/fixtures/skyport.manifest.json +1 -1
  151. package/src/manifest/index.ts +1 -1
  152. package/src/manifest/types.ts +1 -1
  153. package/src/manifest/validate.ts +2 -2
  154. package/src/plugins/compression.test.ts +127 -0
  155. package/src/plugins/compression.ts +94 -0
  156. package/src/plugins/config-source.test.ts +204 -0
  157. package/src/plugins/config-source.ts +209 -0
  158. package/src/plugins/cors.test.ts +138 -0
  159. package/src/plugins/cors.ts +129 -0
  160. package/src/plugins/csrf.test.ts +102 -0
  161. package/src/plugins/csrf.ts +86 -0
  162. package/src/plugins/headers.ts +54 -0
  163. package/src/plugins/index.ts +26 -0
  164. package/src/plugins/ip-allowlist.test.ts +105 -0
  165. package/src/plugins/ip-allowlist.ts +76 -0
  166. package/src/plugins/maintenance-mode.test.ts +91 -0
  167. package/src/plugins/maintenance-mode.ts +85 -0
  168. package/src/plugins/security-headers.test.ts +243 -0
  169. package/src/plugins/security-headers.ts +255 -0
  170. package/src/release/measure.ts +1 -2
  171. package/src/test/create-test-app.ts +14 -2
  172. package/docs/spec/console.md +0 -762
  173. package/docs/spec/example.md +0 -1374
  174. package/docs/spec/four-applications.md +0 -1376
  175. package/docs/spec/unified-theory.md +0 -498
  176. package/src/cli/doc-drift.test.ts +0 -147
  177. package/src/cli/doc-drift.ts +0 -401
  178. package/src/cli/doctor-diff-examples.ts +0 -90
  179. package/src/drivers/vault-sops.ts +0 -246
  180. /package/{spec/manifest.v1.schema.json → manifest.v1.schema.json} +0 -0
@@ -2,7 +2,10 @@
2
2
  * Lazy channel binder — loaded only when Channel is declared.
3
3
  */
4
4
 
5
+ import { resolveDriverId, type ConfigEnv } from "../../config/index.ts";
5
6
  import { openConsoleChannel } from "../../drivers/channel-console.ts";
7
+ import { openSmtpChannel } from "../../drivers/channel-smtp.ts";
8
+ import type { ChannelDriver, ChannelOpenOptions } from "../../drivers/channel-types.ts";
6
9
  import { createChannelRuntime, type ChannelRuntime } from "../../elements/channel.ts";
7
10
  import type { BootOptions } from "../boot.ts";
8
11
 
@@ -10,12 +13,71 @@ import type { BootOptions } from "../boot.ts";
10
13
  * Construct a Channel runtime (console inbox default).
11
14
  *
12
15
  * @param options - Boot options
16
+ * @param env - Active environment
13
17
  * @param now - Clock
18
+ * @param docker - Prefer compose SMTP when active
14
19
  */
15
- export function bindChannel(options: BootOptions, now: () => number): ChannelRuntime {
20
+ export function bindChannel(
21
+ options: BootOptions,
22
+ env: ConfigEnv,
23
+ now: () => number,
24
+ docker = false,
25
+ ): ChannelRuntime {
16
26
  return createChannelRuntime({
17
27
  ...(options.channel ?? {}),
18
- drivers: options.channel?.drivers ?? [openConsoleChannel()],
28
+ drivers: options.channel?.drivers ?? [channelDriverFor(options, env, docker)],
19
29
  now,
20
30
  });
21
31
  }
32
+
33
+ function channelDriverFor(options: BootOptions, env: ConfigEnv, docker: boolean): ChannelDriver {
34
+ const id = resolveEmailDriverId(options, env, docker);
35
+ if (id === "console") return openConsoleChannel();
36
+ if (id === "smtp") return openSmtpChannel(smtpOptionsFromEnv(docker));
37
+ throw new Error(`oke boot: unknown email channel driver "${id}"`);
38
+ }
39
+
40
+ /**
41
+ * Resolve the configured email driver for one environment.
42
+ *
43
+ * @param options - Boot options
44
+ * @param env - Active environment
45
+ * @param docker - Docker mode
46
+ */
47
+ export function resolveEmailDriverId(
48
+ options: BootOptions,
49
+ env: ConfigEnv,
50
+ docker: boolean,
51
+ ): string {
52
+ return (
53
+ resolveDriverId(options.config?.drivers?.channel?.email, env) ?? (docker ? "smtp" : "console")
54
+ );
55
+ }
56
+
57
+ /**
58
+ * Resolve SMTP connection options from `SMTP_URL` plus optional auth overrides.
59
+ *
60
+ * @param docker - Whether to include the docker-specific missing URL hint
61
+ */
62
+ export function smtpOptionsFromEnv(docker = false): ChannelOpenOptions {
63
+ const raw = process.env.SMTP_URL ?? process.env.OKE_CHANNEL_EMAIL_URL;
64
+ if (!raw) {
65
+ throw new Error(
66
+ docker
67
+ ? "oke boot: smtp driver needs SMTP_URL (did `oke dev -d` write docker/.env.docker?)"
68
+ : "oke boot: smtp driver needs SMTP_URL",
69
+ );
70
+ }
71
+ const url = new URL(raw);
72
+ if (url.protocol !== "smtp:") throw new Error(`oke boot: SMTP_URL must use smtp://`);
73
+ const urlUser = url.username ? decodeURIComponent(url.username) : undefined;
74
+ const urlPass = url.password ? decodeURIComponent(url.password) : undefined;
75
+ const user = process.env.SMTP_USER ?? urlUser;
76
+ const pass = process.env.SMTP_PASSWORD ?? urlPass;
77
+ return {
78
+ host: url.hostname,
79
+ port: url.port ? Number(url.port) : 25,
80
+ ...(user ? { user } : {}),
81
+ ...(pass ? { pass } : {}),
82
+ };
83
+ }
@@ -3,13 +3,14 @@
3
3
  */
4
4
 
5
5
  import { afterEach, describe, expect, test } from "bun:test";
6
- import { resolveKvDriverId, resolveSqlDriverId } from "./store.ts";
6
+ import { resolveFilesDriverId, resolveKvDriverId, resolveSqlDriverId } from "./store.ts";
7
7
 
8
8
  describe("bindStore driver resolution", () => {
9
9
  const prev = {
10
10
  docker: process.env.OKE_DOCKER,
11
11
  sql: process.env.OKE_SQL_DRIVER,
12
12
  kv: process.env.OKE_KV_DRIVER,
13
+ files: process.env.OKE_FILES_DRIVER,
13
14
  };
14
15
 
15
16
  afterEach(() => {
@@ -19,6 +20,8 @@ describe("bindStore driver resolution", () => {
19
20
  else process.env.OKE_SQL_DRIVER = prev.sql;
20
21
  if (prev.kv === undefined) delete process.env.OKE_KV_DRIVER;
21
22
  else process.env.OKE_KV_DRIVER = prev.kv;
23
+ if (prev.files === undefined) delete process.env.OKE_FILES_DRIVER;
24
+ else process.env.OKE_FILES_DRIVER = prev.files;
22
25
  });
23
26
 
24
27
  test("local env keeps sqlite / memory from config", () => {
@@ -28,12 +31,14 @@ describe("bindStore driver resolution", () => {
28
31
  store: {
29
32
  sql: { local: "sqlite", docker: "postgres", prod: "postgres" },
30
33
  kv: { local: "memory", docker: "redis", prod: "redis" },
34
+ files: { local: "fs", docker: "s3", prod: "s3" },
31
35
  },
32
36
  },
33
37
  },
34
38
  };
35
39
  expect(resolveSqlDriverId(options, "local", false)).toBe("sqlite");
36
40
  expect(resolveKvDriverId(options, "local", false)).toBe("memory");
41
+ expect(resolveFilesDriverId(options, "local", false)).toBe("fs");
37
42
  });
38
43
 
39
44
  test("docker env uses docker profile (falls back to prod)", () => {
@@ -43,18 +48,22 @@ describe("bindStore driver resolution", () => {
43
48
  store: {
44
49
  sql: { local: "sqlite", docker: "postgres", prod: "postgres" },
45
50
  kv: { local: "memory", prod: "redis" },
51
+ files: { local: "fs", prod: "s3" },
46
52
  },
47
53
  },
48
54
  },
49
55
  };
50
56
  expect(resolveSqlDriverId(options, "docker", true)).toBe("postgres");
51
57
  expect(resolveKvDriverId(options, "docker", true)).toBe("redis");
58
+ expect(resolveFilesDriverId(options, "docker", true)).toBe("s3");
52
59
  });
53
60
 
54
61
  test("docker mode honours OKE_*_DRIVER overrides", () => {
55
62
  process.env.OKE_SQL_DRIVER = "postgres";
56
63
  process.env.OKE_KV_DRIVER = "redis";
64
+ process.env.OKE_FILES_DRIVER = "s3";
57
65
  expect(resolveSqlDriverId({}, "docker", true)).toBe("postgres");
58
66
  expect(resolveKvDriverId({}, "docker", true)).toBe("redis");
67
+ expect(resolveFilesDriverId({}, "docker", true)).toBe("s3");
59
68
  });
60
69
  });
@@ -3,11 +3,13 @@
3
3
  */
4
4
 
5
5
  import { resolveDomainDdlMode, resolveDriverId, type ConfigEnv } from "../../config/index.ts";
6
+ import { fsDriver } from "../../drivers/fs.ts";
6
7
  import { memoryDrivers } from "../../drivers/memory.ts";
7
8
  import { postgresDriver } from "../../drivers/postgres.ts";
8
9
  import { redisDriver } from "../../drivers/redis.ts";
10
+ import { s3Driver } from "../../drivers/s3.ts";
9
11
  import { sqliteDriver } from "../../drivers/sqlite.ts";
10
- import type { KvDriver, SqlDriver } from "../../drivers/types.ts";
12
+ import type { FilesDriver, KvDriver, SqlDriver } from "../../drivers/types.ts";
11
13
  import { createStoreRuntime, type StoreRuntime } from "../../elements/store.ts";
12
14
  import type { StoreDecl } from "../../elements/store/declare.ts";
13
15
  import type { BootOptions } from "../boot.ts"; // type-only — no cycle at runtime
@@ -31,11 +33,14 @@ export function bindStore(
31
33
  ): StoreRuntime {
32
34
  const sqlId = resolveSqlDriverId(options, env, docker);
33
35
  const kvId = resolveKvDriverId(options, env, docker);
36
+ const filesId = resolveFilesDriverId(options, env, docker);
34
37
  const sqlUrl = sqlUrlFor(sqlId, docker);
35
38
  const kvUrl = kvUrlFor(kvId, docker);
39
+ const filesRoot = filesRootFor(filesId);
36
40
 
37
41
  const sqlBindings: Record<string, { name: string; primary: { url: string } }> = {};
38
42
  const kvBindings: Record<string, { url?: string }> = {};
43
+ const filesBindings: Record<string, { root?: string }> = {};
39
44
 
40
45
  for (const decl of options.stores ?? []) {
41
46
  if (isSqlDecl(decl)) {
@@ -45,6 +50,8 @@ export function bindStore(
45
50
  };
46
51
  } else if (isKvDecl(decl)) {
47
52
  kvBindings[decl.name] = kvUrl !== undefined ? { url: kvUrl } : {};
53
+ } else if (isFilesDecl(decl)) {
54
+ filesBindings[decl.name] = filesRoot !== undefined ? { root: filesRoot } : {};
48
55
  }
49
56
  }
50
57
 
@@ -55,11 +62,12 @@ export function bindStore(
55
62
  drivers: {
56
63
  sql: sqlDriverFor(sqlId),
57
64
  kv: kvDriverFor(kvId),
58
- files: memoryDrivers.files,
65
+ files: filesDriverFor(filesId),
59
66
  index: memoryDrivers.index,
60
67
  },
61
68
  sql: sqlBindings,
62
69
  kv: kvBindings,
70
+ files: filesBindings,
63
71
  now,
64
72
  domainDdl,
65
73
  });
@@ -107,6 +115,23 @@ export function resolveKvDriverId(options: BootOptions, env: ConfigEnv, docker:
107
115
  return docker ? "redis" : "memory";
108
116
  }
109
117
 
118
+ /**
119
+ * @param options - Boot options
120
+ * @param env - Active env
121
+ * @param docker - Docker mode
122
+ */
123
+ export function resolveFilesDriverId(
124
+ options: BootOptions,
125
+ env: ConfigEnv,
126
+ docker: boolean,
127
+ ): string {
128
+ const fromEnv = process.env.OKE_FILES_DRIVER?.trim();
129
+ if (docker && fromEnv) return fromEnv;
130
+ const resolved = resolveDriverId(options.config?.drivers?.store?.files, env);
131
+ if (resolved) return resolved;
132
+ return docker ? "s3" : "memory";
133
+ }
134
+
110
135
  function sqlDriverFor(id: string): SqlDriver {
111
136
  switch (id) {
112
137
  case "postgres":
@@ -131,6 +156,19 @@ function kvDriverFor(id: string): KvDriver {
131
156
  }
132
157
  }
133
158
 
159
+ function filesDriverFor(id: string): FilesDriver {
160
+ switch (id) {
161
+ case "s3":
162
+ return s3Driver;
163
+ case "fs":
164
+ return fsDriver;
165
+ case "memory":
166
+ return memoryDrivers.files;
167
+ default:
168
+ throw new Error(`oke boot: unknown files driver "${id}"`);
169
+ }
170
+ }
171
+
134
172
  function sqlUrlFor(sqlId: string, docker: boolean): string {
135
173
  if (sqlId === "postgres") {
136
174
  const url = process.env.DATABASE_URL ?? process.env.OKE_STORE_SQL_URL ?? undefined;
@@ -162,6 +200,11 @@ function kvUrlFor(kvId: string, docker: boolean): string | undefined {
162
200
  return url;
163
201
  }
164
202
 
203
+ function filesRootFor(filesId: string): string | undefined {
204
+ if (filesId !== "s3") return undefined;
205
+ return process.env.S3_BUCKET ?? process.env.OKE_STORE_FILES_DB ?? undefined;
206
+ }
207
+
165
208
  function isSqlDecl(decl: StoreDecl): decl is Extract<StoreDecl, { facet: "sql" }> {
166
209
  return decl.facet === "sql";
167
210
  }
@@ -169,3 +212,7 @@ function isSqlDecl(decl: StoreDecl): decl is Extract<StoreDecl, { facet: "sql" }
169
212
  function isKvDecl(decl: StoreDecl): decl is Extract<StoreDecl, { facet: "kv" }> {
170
213
  return decl.facet === "kv";
171
214
  }
215
+
216
+ function isFilesDecl(decl: StoreDecl): decl is Extract<StoreDecl, { facet: "files" }> {
217
+ return decl.facet === "files";
218
+ }
@@ -100,7 +100,6 @@ describe("boot — lazy element needs", () => {
100
100
  external: [
101
101
  "@duckdb/node-api",
102
102
  "@duckdb/*",
103
- "age-encryption",
104
103
  "sently",
105
104
  "sently/*",
106
105
  "ajv",
@@ -374,7 +374,7 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
374
374
  // 5. Channel
375
375
  let channel = pre.channel;
376
376
  if (needs.channel && !channel) {
377
- channel = channelBind!.bindChannel(options, now);
377
+ channel = channelBind!.bindChannel(options, env, now, docker);
378
378
  }
379
379
 
380
380
  // 6. AI
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Plugin edge handlers — the contribution that answers HTTP requests no
3
+ * flow owns (CORS preflight being the prime example), with the plain 404
4
+ * preserved as the floor.
5
+ */
6
+
7
+ import { beforeEach, describe, expect, test } from "bun:test";
8
+ import { oke } from "./app.ts";
9
+ import { flow, resetFlowSeq } from "./flow.ts";
10
+ import { on, resetBindings } from "./on.ts";
11
+ import { plugin } from "./plugin.ts";
12
+ import { createPluginRegistry } from "./registry.ts";
13
+ import { http } from "./triggers.ts";
14
+
15
+ beforeEach(() => {
16
+ resetBindings();
17
+ resetFlowSeq();
18
+ });
19
+
20
+ describe("plugin edge handlers", () => {
21
+ test("an edge handler answers an unmatched request; the flow still owns matched ones", async () => {
22
+ on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
23
+ const edge = plugin("edge-test", { version: "0.0.1" }).edge((_request, info) => {
24
+ if (info.method === "OPTIONS") {
25
+ return new Response(null, { status: 204, headers: { "x-edge": "yes" } });
26
+ }
27
+ return undefined;
28
+ });
29
+ const app = oke({ name: "edge" }).plug(edge);
30
+
31
+ const preflight = await app.fetch(new Request("http://localhost/x", { method: "OPTIONS" }));
32
+ expect(preflight.status).toBe(204);
33
+ expect(preflight.headers.get("x-edge")).toBe("yes");
34
+
35
+ const matched = await app.fetch(new Request("http://localhost/x"));
36
+ expect(matched.status).toBe(200);
37
+ expect(matched.headers.get("x-edge")).toBeNull();
38
+ });
39
+
40
+ test("returning undefined falls through to the next handler, then the plain 404", async () => {
41
+ const pass = plugin("edge-pass", { version: "0.0.1" }).edge(() => undefined);
42
+ const answer = plugin("edge-answer", { version: "0.0.1" }).edge(
43
+ () => new Response("answered", { status: 418 }),
44
+ );
45
+ const app = oke({ name: "edge-order" }).plug(pass).plug(answer);
46
+
47
+ const res = await app.fetch(new Request("http://localhost/nowhere", { method: "OPTIONS" }));
48
+ expect(res.status).toBe(418);
49
+ expect(await res.text()).toBe("answered");
50
+
51
+ const noPlugins = oke({ name: "edge-none" });
52
+ const missing = await noPlugins.fetch(
53
+ new Request("http://localhost/nowhere", { method: "OPTIONS" }),
54
+ );
55
+ expect(missing.status).toBe(404);
56
+ expect(await missing.text()).toBe("Not Found");
57
+ });
58
+
59
+ test("edge is recorded as an intercept capability", () => {
60
+ const registry = createPluginRegistry();
61
+ const registration = registry.plug(
62
+ plugin("edge-cap", { version: "0.0.1" }).edge(() => undefined),
63
+ { kind: "app" },
64
+ );
65
+ expect(registration?.capabilities.intercepts).toContain("edge");
66
+ expect(registry.edgeHandlers()).toHaveLength(1);
67
+ });
68
+ });
@@ -26,7 +26,7 @@ describe("OKE error-code registry", () => {
26
26
  test("OkeError docsUrl matches docs origin /e/{code}", () => {
27
27
  for (const def of Object.values(OKE_ERRORS) as OkeErrorDefinition[]) {
28
28
  const err = new OkeError(def);
29
- expect(err.docsUrl).toBe(`https://okengine.vercel.app/e/${def.code}`);
29
+ expect(err.docsUrl).toBe(`https://oke.omqkhafi.dev/e/${def.code}`);
30
30
  expect(err.message).toContain(err.docsUrl);
31
31
  expect(err.message).toContain(`OKE${def.code}`);
32
32
  expect(err.message).toContain("→");
@@ -62,6 +62,11 @@ export interface FlowOptions<I = unknown, O = unknown, E extends FlowErrorMap =
62
62
  * Cross-plane invocation is a compile error.
63
63
  */
64
64
  readonly plane?: FlowPlane;
65
+ /**
66
+ * Acknowledge intentional Manifest contract breaks for this flow
67
+ * (`oke doctor --diff` / CI gate).
68
+ */
69
+ readonly breaking?: boolean;
65
70
  /** The behavior. */
66
71
  readonly do: FlowHandler<I, O>;
67
72
  }
@@ -150,6 +155,8 @@ export interface FlowDef<
150
155
  readonly slo: Slo | undefined;
151
156
  /** Plane (user vs operator). */
152
157
  readonly plane: FlowPlane | undefined;
158
+ /** Intentional contract-break acknowledgement for Manifest Diff. */
159
+ readonly breaking: boolean;
153
160
  /** Handler body. */
154
161
  readonly do: FlowHandler<I, O>;
155
162
  /** Triggers bound via {@link on} (zero or more). */
@@ -219,6 +226,7 @@ export function flow<Opts extends FlowOptions<any, any, any>>(
219
226
  cache: options.cache,
220
227
  slo: options.slo,
221
228
  plane: options.plane,
229
+ breaking: options.breaking ?? false,
222
230
  do: options.do as FlowHandler<InferFlowIn<Opts>, InferFlowOut<Opts>>,
223
231
  triggers,
224
232
  $trigger: undefined,
@@ -2,7 +2,13 @@ import { describe, expect, test } from "bun:test";
2
2
  import { createCapabilityToken } from "./capability.ts";
3
3
  import { createEffectLedger, reversibilityOf } from "./effects.ts";
4
4
  import { fail, formatOkeMessage, lookupOkeError, OKE_ERRORS, OkeError } from "./errors.ts";
5
- import { createFx, createFxContext, type Fx, type FxStubStoreHandle } from "./fx.ts";
5
+ import {
6
+ createFx,
7
+ createFxContext,
8
+ jsonResultBrand,
9
+ type Fx,
10
+ type FxStubStoreHandle,
11
+ } from "./fx.ts";
6
12
 
7
13
  /** Narrow stub handle for tests that exercise the in-memory store. */
8
14
  function stub(fx: Fx, ref: string): FxStubStoreHandle {
@@ -28,7 +34,7 @@ describe("fx — capability enforcement", () => {
28
34
  expect(oke.code).toBe(1001);
29
35
  expect(oke.causeText).toBe('Flow "bookings.create" reads "sql:users" without declaring it.');
30
36
  expect(oke.fix).toBe('Add "sql:users" to this flow\'s effects.reads.');
31
- expect(oke.docsUrl).toBe("https://okengine.vercel.app/e/1001");
37
+ expect(oke.docsUrl).toBe("https://oke.omqkhafi.dev/e/1001");
32
38
  expect(oke.message).toBe(formatOkeMessage(1001, oke.causeText, oke.fix, oke.docsUrl));
33
39
  });
34
40
 
@@ -158,6 +164,20 @@ describe("fx — wholesale swap", () => {
158
164
  operator: { id: null },
159
165
  tenant: { id: "t1" },
160
166
  fail,
167
+ json: {
168
+ ok(value, opts) {
169
+ return { [jsonResultBrand]: true, status: 200, value, meta: opts?.meta };
170
+ },
171
+ create(value) {
172
+ return { [jsonResultBrand]: true, status: 201, value };
173
+ },
174
+ empty() {
175
+ return { [jsonResultBrand]: true, status: 204 };
176
+ },
177
+ with(data, meta) {
178
+ return { [jsonResultBrand]: true, status: 200, value: data, meta };
179
+ },
180
+ },
161
181
  async step(_name, fn) {
162
182
  return fn();
163
183
  },
@@ -185,7 +205,7 @@ describe("errors — registry", () => {
185
205
  });
186
206
  expect(err.message).toContain("OKE1042");
187
207
  expect(err.message).toContain("no subscriber");
188
- expect(err.message).toContain("https://okengine.vercel.app/e/1042");
208
+ expect(err.message).toContain("https://oke.omqkhafi.dev/e/1042");
189
209
  expect(err.message).toContain("→");
190
210
  });
191
211
 
package/src/kernel/fx.ts CHANGED
@@ -10,7 +10,14 @@
10
10
  */
11
11
 
12
12
  import type { Effects, ResourceRef } from "../manifest/types.ts";
13
- import type { StoreDecl, StoreHandle, StoreRuntime, SqlStoreHandle } from "../elements/store.ts";
13
+ import type {
14
+ SelectOrderBuilder,
15
+ StoreDecl,
16
+ StoreHandle,
17
+ StoreRuntime,
18
+ SqlStoreHandle,
19
+ } from "../elements/store.ts";
20
+ import type { SqlRow } from "../drivers/types.ts";
14
21
  import type { SignalRuntime } from "../elements/signal.ts";
15
22
  import type { VaultRuntime } from "../elements/vault.ts";
16
23
  import type { ChannelRuntime } from "../elements/channel.ts";
@@ -183,6 +190,40 @@ export interface FxSearchOptions {
183
190
  readonly topK?: number;
184
191
  }
185
192
 
193
+ /** Brand for {@link JsonResult} (kept internal — flows never construct it). */
194
+ export const jsonResultBrand: unique symbol = Symbol.for("oke.json");
195
+
196
+ /** Carrier from {@link FxJson} — status + body read by the response encoder. */
197
+ export interface JsonResult<T = unknown> {
198
+ readonly [jsonResultBrand]: true;
199
+ readonly status: number;
200
+ readonly value?: T;
201
+ readonly meta?: Record<string, unknown>;
202
+ }
203
+
204
+ /** True when `value` is an {@link FxJson} carrier. */
205
+ export function isJsonResult(value: unknown): value is JsonResult {
206
+ return (
207
+ typeof value === "object" && value !== null && (value as JsonResult)[jsonResultBrand] === true
208
+ );
209
+ }
210
+
211
+ /**
212
+ * JSON response helpers. `fx.json.create` answers 201; `fx.json.ok` can carry
213
+ * a top-level `meta` (Stripe-style `{ data, meta?, error }`);
214
+ * `fx.json.empty` answers 204.
215
+ */
216
+ export interface FxJson {
217
+ /** 200 — body `{ data: value, meta?, error: null }`. */
218
+ ok<T>(value: T, opts?: { readonly meta?: Record<string, unknown> }): JsonResult<T>;
219
+ /** 201 — body `{ data: value, error: null }`. */
220
+ create<T>(value: T): JsonResult<T>;
221
+ /** 204 — no body. */
222
+ empty(): JsonResult<never>;
223
+ /** 200 — body `{ data, meta, error: null }` (paginated lists). */
224
+ with<T>(data: T, meta: Record<string, unknown>): JsonResult<T>;
225
+ }
226
+
186
227
  /**
187
228
  * The `fx` context object — v1 surface.
188
229
  *
@@ -287,6 +328,8 @@ export interface Fx {
287
328
  * @param opts - Optional message
288
329
  */
289
330
  fail<E>(code: string, data: E, opts?: FailOptions): FlowFailure<E>;
331
+ /** JSON response helpers (status + Stripe-style envelope). */
332
+ readonly json: FxJson;
290
333
  /**
291
334
  * Named durable step — never re-runs on journal replay.
292
335
  *
@@ -523,30 +566,53 @@ export function createFxContext(options: CreateFxOptions): FxContext {
523
566
  select(columns?) {
524
567
  return {
525
568
  from(table) {
526
- const all = gated("read", ref, async () => {
527
- const h = await ensure();
528
- return h.select(columns).from(table);
569
+ const run = (plan: {
570
+ where?: unknown;
571
+ orders?: readonly unknown[];
572
+ limit?: number;
573
+ offset?: number;
574
+ }): Promise<SqlRow[]> =>
575
+ gated("read", ref, async () => {
576
+ const h = await ensure();
577
+ const from = h.select(columns).from(table);
578
+ const filtered = plan.where === undefined ? from : from.where(plan.where);
579
+ const ordered =
580
+ plan.orders === undefined ? filtered : filtered.orderBy(...plan.orders);
581
+ if (plan.offset !== undefined) return ordered.offset(plan.offset);
582
+ return plan.limit === undefined ? ordered : ordered.limit(plan.limit);
583
+ });
584
+
585
+ const tail = (plan: {
586
+ where?: unknown;
587
+ orders?: readonly unknown[];
588
+ }): SelectOrderBuilder => ({
589
+ limit(n) {
590
+ return run({ ...plan, limit: n });
591
+ },
592
+ offset(n) {
593
+ return run({ ...plan, offset: n });
594
+ },
595
+ then(onfulfilled, onrejected) {
596
+ return run(plan).then(onfulfilled, onrejected);
597
+ },
529
598
  });
599
+
530
600
  return {
531
601
  where(where) {
532
- const filtered = gated("read", ref, async () => {
533
- const h = await ensure();
534
- return h.select(columns).from(table).where(where);
535
- });
536
602
  return {
537
- limit(n) {
538
- return gated("read", ref, async () => {
539
- const h = await ensure();
540
- return h.select(columns).from(table).where(where).limit(n);
541
- });
542
- },
543
- then(onfulfilled, onrejected) {
544
- return filtered.then(onfulfilled, onrejected);
545
- },
603
+ ...tail({ where }),
604
+ orderBy: (...orders: readonly unknown[]) => tail({ where, orders }),
546
605
  };
547
606
  },
607
+ orderBy: (...orders: readonly unknown[]) => tail({ orders }),
608
+ limit(n: number) {
609
+ return run({ limit: n });
610
+ },
611
+ offset(n: number) {
612
+ return run({ offset: n });
613
+ },
548
614
  then(onfulfilled, onrejected) {
549
- return all.then(onfulfilled, onrejected);
615
+ return run({}).then(onfulfilled, onrejected);
550
616
  },
551
617
  };
552
618
  },
@@ -635,6 +701,18 @@ export function createFxContext(options: CreateFxOptions): FxContext {
635
701
  return h.raw(sql, params);
636
702
  });
637
703
  },
704
+ count(table, where) {
705
+ return gated("read", ref, async () => {
706
+ const h = await ensure();
707
+ return h.count(table, where);
708
+ });
709
+ },
710
+ page(table, pageOptions) {
711
+ return gated("read", ref, async () => {
712
+ const h = await ensure();
713
+ return h.page(table, pageOptions);
714
+ });
715
+ },
638
716
  ensureTable(table) {
639
717
  return gated("write", ref, async () => {
640
718
  refuseDryRunWrite();
@@ -932,6 +1010,25 @@ export function createFxContext(options: CreateFxOptions): FxContext {
932
1010
  operator,
933
1011
  tenant,
934
1012
  fail,
1013
+ json: {
1014
+ ok<T>(value: T, opts?: { readonly meta?: Record<string, unknown> }): JsonResult<T> {
1015
+ return {
1016
+ [jsonResultBrand]: true,
1017
+ status: 200,
1018
+ value,
1019
+ ...(opts?.meta !== undefined ? { meta: opts.meta } : {}),
1020
+ } as JsonResult<T>;
1021
+ },
1022
+ create<T>(value: T): JsonResult<T> {
1023
+ return { [jsonResultBrand]: true, status: 201, value } as JsonResult<T>;
1024
+ },
1025
+ empty(): JsonResult<never> {
1026
+ return { [jsonResultBrand]: true, status: 204 } as JsonResult<never>;
1027
+ },
1028
+ with<T>(data: T, meta: Record<string, unknown>): JsonResult<T> {
1029
+ return { [jsonResultBrand]: true, status: 200, value: data, meta } as JsonResult<T>;
1030
+ },
1031
+ },
935
1032
  async step<T>(name: string, fn: () => T | Promise<T>): Promise<T> {
936
1033
  if (journal) {
937
1034
  return journal.step(name, fn);
@@ -146,6 +146,39 @@ describe("hooks — order and composition", () => {
146
146
  expect(order).toEqual(["onAuth", "onResponse"]);
147
147
  });
148
148
 
149
+ test("onResponse sees the serialized data response and may replace it", async () => {
150
+ let seenAtHook: Response | undefined;
151
+
152
+ on(
153
+ http.get("/data"),
154
+ flow({
155
+ name: "data",
156
+ do: () => ({ ok: true }),
157
+ }),
158
+ );
159
+
160
+ const app = oke({ name: "data-res" }).hook("onResponse", (ctx) => {
161
+ seenAtHook = ctx.response;
162
+ if (!ctx.response) return;
163
+ const headers = new Headers(ctx.response.headers);
164
+ headers.set("x-stamped", "yes");
165
+ ctx.response = new Response(ctx.response.body, {
166
+ status: ctx.response.status,
167
+ headers,
168
+ });
169
+ });
170
+
171
+ const res = await app.fetch(new Request("http://localhost/data"));
172
+ expect(res.status).toBe(200);
173
+ expect(res.headers.get("x-stamped")).toBe("yes");
174
+ // The hook saw the real serialized response, not undefined.
175
+ expect(seenAtHook).toBeDefined();
176
+ expect(seenAtHook!.headers.get("content-type")).toContain("application/json");
177
+ // The body survived the rebuild untouched.
178
+ const body = (await res.json()) as { data?: { ok: boolean } };
179
+ expect(body.data?.ok).toBe(true);
180
+ });
181
+
149
182
  test("hook short-circuits with FlowFailure and runs onError", async () => {
150
183
  const order: string[] = [];
151
184