okengine 0.7.0 → 0.9.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 (125) hide show
  1. package/package.json +3 -2
  2. package/site/content/docs/deployment/docker-swarm.mdx +228 -0
  3. package/site/content/docs/deployment/docker.mdx +212 -0
  4. package/site/content/docs/deployment/index.mdx +83 -0
  5. package/site/content/docs/deployment/kubernetes.mdx +176 -0
  6. package/site/content/docs/deployment/meta.json +5 -0
  7. package/site/content/docs/deployment/reverse-proxy.mdx +216 -0
  8. package/site/content/docs/elements/channel.mdx +25 -12
  9. package/site/content/docs/elements/clock.mdx +17 -15
  10. package/site/content/docs/elements/flow.mdx +6 -2
  11. package/site/content/docs/elements/signal.mdx +10 -8
  12. package/site/content/docs/elements/store.mdx +165 -0
  13. package/site/content/docs/get-started/index.mdx +5 -0
  14. package/site/content/docs/get-started/installation.mdx +18 -16
  15. package/site/content/docs/index.mdx +5 -0
  16. package/site/content/docs/meta.json +10 -1
  17. package/site/content/docs/plugins/index.mdx +1 -2
  18. package/site/content/docs/plugins/magic-link.mdx +44 -2
  19. package/site/content/docs/plugins/meta.json +1 -2
  20. package/site/content/docs/plugins/otp.mdx +202 -0
  21. package/site/content/docs/plugins/two-factor.mdx +2 -1
  22. package/site/content/docs/reference/cli.md +5 -2
  23. package/site/content/docs/reference/configuration.mdx +21 -3
  24. package/site/content/docs/reference/environment-variables.mdx +21 -8
  25. package/site/content/docs/reference/plugins.mdx +1 -1
  26. package/src/auth/auth.test.ts +36 -0
  27. package/src/auth/bindings.ts +3 -12
  28. package/src/auth/identity.ts +33 -0
  29. package/src/auth/index.ts +5 -0
  30. package/src/auth/otp-capability.ts +119 -0
  31. package/src/auth/otp-seal.test.ts +61 -0
  32. package/src/auth/otp-seal.ts +84 -0
  33. package/src/auth/schema.ts +3 -0
  34. package/src/auth/sessions.ts +26 -27
  35. package/src/auth/tables.ts +4 -0
  36. package/src/auth/verification.ts +61 -1
  37. package/src/cli/db-seed.ts +359 -0
  38. package/src/cli/db.test.ts +341 -3
  39. package/src/cli/db.ts +75 -8
  40. package/src/cli/dev-app-runner.ts +4 -0
  41. package/src/cli/docker.ts +4 -1
  42. package/src/cli/load-config.images.test.ts +26 -0
  43. package/src/cli/load-config.ts +10 -2
  44. package/src/cli/registry.ts +38 -2
  45. package/src/compiler/effects-infer.ts +1 -0
  46. package/src/config/index.ts +4 -0
  47. package/src/console/server/operator-db.ts +34 -9
  48. package/src/docker/compose.ts +162 -6
  49. package/src/docker/derive.ts +60 -3
  50. package/src/docker/docker.test.ts +374 -1
  51. package/src/docker/helpers.ts +2 -0
  52. package/src/docker/index.ts +11 -0
  53. package/src/docker/recipes/caddy.ts +51 -0
  54. package/src/docker/recipes/dragonfly.ts +31 -0
  55. package/src/docker/recipes/index.ts +25 -2
  56. package/src/docker/recipes/pgdog.ts +84 -0
  57. package/src/docker/recipes/redis.ts +6 -3
  58. package/src/docker/recipes/traefik.ts +83 -0
  59. package/src/docker/recipes/valkey.ts +30 -0
  60. package/src/docker/stack-id.ts +5 -0
  61. package/src/docker/types.ts +18 -0
  62. package/src/drivers/channel-sently.test.ts +8 -0
  63. package/src/drivers/channel-taqnyat-mail.ts +34 -0
  64. package/src/drivers/channel-taqnyat-whatsapp.ts +94 -0
  65. package/src/drivers/channel-types.ts +72 -0
  66. package/src/drivers/clock-postgres.test.ts +258 -0
  67. package/src/drivers/clock-postgres.ts +410 -0
  68. package/src/drivers/index.ts +18 -0
  69. package/src/drivers/journal-postgres.test.ts +175 -0
  70. package/src/drivers/journal-postgres.ts +492 -0
  71. package/src/elements/channel/otp-delivery.test.ts +76 -0
  72. package/src/elements/channel/otp-delivery.ts +291 -0
  73. package/src/elements/channel/runtime.ts +203 -114
  74. package/src/elements/channel.test.ts +71 -0
  75. package/src/elements/channel.ts +12 -2
  76. package/src/elements/clock/chaos-child.ts +280 -41
  77. package/src/elements/clock/durable.ts +7 -0
  78. package/src/elements/clock/reconcile.ts +2 -2
  79. package/src/elements/clock/runtime.ts +5 -3
  80. package/src/elements/clock.ts +1 -1
  81. package/src/elements/store/seed.test.ts +27 -0
  82. package/src/elements/store/seed.ts +68 -0
  83. package/src/elements/store/sql-session.test.ts +39 -0
  84. package/src/elements/store/sql-session.ts +55 -0
  85. package/src/elements/store/upsert-app.test.ts +103 -0
  86. package/src/elements/store.ts +5 -0
  87. package/src/index.ts +18 -0
  88. package/src/kernel/app.ts +221 -14
  89. package/src/kernel/boot-bind/channel.test.ts +16 -0
  90. package/src/kernel/boot-bind/channel.ts +64 -0
  91. package/src/kernel/boot-bind/clock.ts +17 -6
  92. package/src/kernel/boot-bind/gate.ts +14 -19
  93. package/src/kernel/boot-bind/honor-config.test.ts +123 -4
  94. package/src/kernel/boot-bind/journal.ts +89 -0
  95. package/src/kernel/boot-bind/signal.ts +20 -0
  96. package/src/kernel/boot-bind/store.test.ts +82 -0
  97. package/src/kernel/boot-bind/store.ts +22 -0
  98. package/src/kernel/boot.test.ts +6 -4
  99. package/src/kernel/boot.ts +53 -13
  100. package/src/kernel/concurrency.ts +1 -1
  101. package/src/kernel/fx.test.ts +9 -0
  102. package/src/kernel/fx.ts +175 -5
  103. package/src/kernel/graceful-shutdown.test.ts +76 -0
  104. package/src/kernel/graceful-shutdown.ts +106 -0
  105. package/src/kernel/horizontal-child.ts +257 -0
  106. package/src/kernel/horizontal.integration.test.ts +229 -0
  107. package/src/kernel/index.ts +14 -0
  108. package/src/kernel/journal-boot.test.ts +397 -0
  109. package/src/kernel/journal-suspend.ts +35 -0
  110. package/src/kernel/journal.test.ts +142 -0
  111. package/src/kernel/journal.ts +202 -27
  112. package/src/kernel/ready.test.ts +76 -0
  113. package/src/plugins/auth-delivery.mailpit.integration.test.ts +5 -5
  114. package/src/plugins/auth-methods.security.test.ts +20 -27
  115. package/src/plugins/auth-methods.test.ts +7 -6
  116. package/src/plugins/index.ts +12 -8
  117. package/src/plugins/magic-link.ts +1 -23
  118. package/src/plugins/otp.test.ts +236 -0
  119. package/src/plugins/otp.ts +570 -0
  120. package/src/plugins/taqnyat.live.test.ts +172 -0
  121. package/src/release/official-plugins.ts +1 -2
  122. package/site/content/docs/plugins/email-otp.mdx +0 -117
  123. package/site/content/docs/plugins/phone-number.mdx +0 -111
  124. package/src/plugins/email-otp.ts +0 -214
  125. package/src/plugins/phone-number.ts +0 -149
@@ -13,6 +13,7 @@ import {
13
13
  type ClockDecl,
14
14
  type ClockRuntime,
15
15
  } from "../../elements/clock.ts";
16
+ import { createPostgresCronStore } from "../../drivers/clock-postgres.ts";
16
17
  import { resolveDriverId, type ConfigEnv } from "../../config/index.ts";
17
18
  import type { BootOptions } from "../boot.ts";
18
19
 
@@ -40,8 +41,7 @@ export function resolveClockDriverId(options: BootOptions, env: ConfigEnv): stri
40
41
  /**
41
42
  * Construct / adopt a Clock runtime, register decls, reconcile.
42
43
  *
43
- * Supported ids: `frozen` · `memory` · `file`. `postgres` (and any other id)
44
- * fail loud — there is no postgres CronStore.
44
+ * Supported ids: `frozen` · `memory` · `file` · `postgres`.
45
45
  *
46
46
  * @param options - Boot options
47
47
  * @param env - Active environment
@@ -76,12 +76,23 @@ export async function bindClock(
76
76
  store: createFileCronStore(path),
77
77
  });
78
78
  } else if (clockDriver === "postgres") {
79
- throw new Error(
80
- 'oke boot: clock driver "postgres" is not implemented — use "memory", "file", or "frozen"',
81
- );
79
+ const url = process.env.DATABASE_URL ?? process.env.OKE_STORE_SQL_URL ?? undefined;
80
+ if (!url) {
81
+ throw new Error(
82
+ env === "docker"
83
+ ? 'oke boot: clock driver "postgres" needs DATABASE_URL (did `oke dev -d` write docker/.env.docker?)'
84
+ : 'oke boot: clock driver "postgres" needs DATABASE_URL',
85
+ );
86
+ }
87
+ const store = await createPostgresCronStore({ url });
88
+ clock = createClockRuntime({
89
+ instanceId: options.instanceId,
90
+ now,
91
+ store,
92
+ });
82
93
  } else {
83
94
  throw new Error(
84
- `oke boot: unknown clock driver "${clockDriver}" (expected memory · file · frozen)`,
95
+ `oke boot: unknown clock driver "${clockDriver}" (expected memory · file · postgres · frozen)`,
85
96
  );
86
97
  }
87
98
 
@@ -33,15 +33,14 @@ export function resolveGateKvDriverId(
33
33
  return docker ? "redis" : "memory";
34
34
  }
35
35
 
36
- function kvUrlFor(docker: boolean): string | undefined {
36
+ function kvUrlFor(docker: boolean): string {
37
37
  const url = process.env.REDIS_URL ?? process.env.OKE_STORE_KV_URL ?? undefined;
38
38
  if (!url) {
39
- if (docker) {
40
- throw new Error(
41
- "oke boot: gate redis kv needs REDIS_URL (did `oke dev -d` write docker/.env.docker?)",
42
- );
43
- }
44
- return undefined;
39
+ throw new Error(
40
+ docker
41
+ ? "oke boot: gate redis kv needs REDIS_URL (did `oke dev -d` write docker/.env.docker?)"
42
+ : "oke boot: gate redis kv needs REDIS_URL — declare drivers.store.kv memory for local, or set REDIS_URL",
43
+ );
45
44
  }
46
45
  return url;
47
46
  }
@@ -65,19 +64,15 @@ export async function bindGate(
65
64
 
66
65
  let kvNs;
67
66
  if (kvId === "redis") {
67
+ // Fail loud when redis is configured without URL/inject — never soft-fallback
68
+ // to memory (that silently doubles rate budgets under horizontal scale).
68
69
  const url = injected ? undefined : kvUrlFor(docker);
69
- if (!injected && url === undefined) {
70
- // Local without REDIS_URL — soft-fallback to memory (same as missing redis for store would fail;
71
- // gate rates should not brick local apps that only declared policy gates).
72
- kvNs = await memoryDrivers.kv.open({ name: GATE_KV_NAMESPACE, nowMs: now });
73
- } else {
74
- kvNs = await redisDriver.open({
75
- name: GATE_KV_NAMESPACE,
76
- ...(url !== undefined ? { url } : {}),
77
- ...(injected !== undefined ? { client: injected } : {}),
78
- nowMs: now,
79
- });
80
- }
70
+ kvNs = await redisDriver.open({
71
+ name: GATE_KV_NAMESPACE,
72
+ ...(url !== undefined ? { url } : {}),
73
+ ...(injected !== undefined ? { client: injected } : {}),
74
+ nowMs: now,
75
+ });
81
76
  } else {
82
77
  kvNs = await memoryDrivers.kv.open({ name: GATE_KV_NAMESPACE, nowMs: now });
83
78
  }
@@ -15,6 +15,7 @@ import { gate } from "../../elements/gate.ts";
15
15
  import { signal } from "../../elements/signal.ts";
16
16
  import { vault } from "../../elements/vault.ts";
17
17
  import { buildVaultBootChain } from "../../elements/vault/boot-chain.ts";
18
+ import { flow } from "../flow.ts";
18
19
  import { bootApplication } from "../boot.ts";
19
20
 
20
21
  describe("boot binders honour drivers.* config", () => {
@@ -126,19 +127,119 @@ describe("boot binders honour drivers.* config", () => {
126
127
  }
127
128
  });
128
129
 
129
- test("clock: drivers.clock postgres fails loud", async () => {
130
+ test("clock: drivers.clock postgres fails without DATABASE_URL", async () => {
131
+ const prevDb = process.env.DATABASE_URL;
132
+ const prevStore = process.env.OKE_STORE_SQL_URL;
133
+ delete process.env.DATABASE_URL;
134
+ delete process.env.OKE_STORE_SQL_URL;
135
+ try {
136
+ await expect(
137
+ bootApplication({
138
+ env: "local",
139
+ startScheduler: false,
140
+ clocks: [clock("tick", { every: "1h" })],
141
+ config: {
142
+ drivers: {
143
+ clock: { local: "postgres" },
144
+ },
145
+ },
146
+ }),
147
+ ).rejects.toThrow(/clock driver "postgres" needs DATABASE_URL/);
148
+ } finally {
149
+ if (prevDb !== undefined) process.env.DATABASE_URL = prevDb;
150
+ else delete process.env.DATABASE_URL;
151
+ if (prevStore !== undefined) process.env.OKE_STORE_SQL_URL = prevStore;
152
+ else delete process.env.OKE_STORE_SQL_URL;
153
+ }
154
+ });
155
+
156
+ test("journal: durable flow binds memory journal by default", async () => {
157
+ const result = await bootApplication({
158
+ env: "local",
159
+ startScheduler: false,
160
+ flows: [flow({ name: "charge", durable: true, do: () => ({ ok: true }) })],
161
+ });
162
+ try {
163
+ expect(result.journal?.driverId).toBe("memory");
164
+ expect(typeof result.journal?.instanceId).toBe("string");
165
+ expect(result.journal?.leaseMs).toBeGreaterThan(0);
166
+ } finally {
167
+ await result.close();
168
+ }
169
+ });
170
+
171
+ test("journal: no durable flow → no journal runtime", async () => {
172
+ const result = await bootApplication({
173
+ env: "local",
174
+ startScheduler: false,
175
+ flows: [flow({ name: "plain", do: () => ({ ok: true }) })],
176
+ });
177
+ try {
178
+ expect(result.journal).toBeUndefined();
179
+ } finally {
180
+ await result.close();
181
+ }
182
+ });
183
+
184
+ test("journal: drivers.journal file binds file store", async () => {
185
+ tmp = await mkdtemp(join(tmpdir(), "oke-journal-file-"));
186
+ process.chdir(tmp);
187
+ const result = await bootApplication({
188
+ env: "local",
189
+ startScheduler: false,
190
+ flows: [flow({ name: "charge", durable: true, do: () => ({ ok: true }) })],
191
+ config: {
192
+ drivers: {
193
+ journal: { local: "file", docker: "postgres", test: "memory", prod: "postgres" },
194
+ },
195
+ },
196
+ });
197
+ try {
198
+ expect(result.journal?.driverId).toBe("file");
199
+ } finally {
200
+ await result.close();
201
+ }
202
+ });
203
+
204
+ test("journal: drivers.journal postgres fails without DATABASE_URL", async () => {
205
+ const prevDb = process.env.DATABASE_URL;
206
+ const prevStore = process.env.OKE_STORE_SQL_URL;
207
+ delete process.env.DATABASE_URL;
208
+ delete process.env.OKE_STORE_SQL_URL;
209
+ try {
210
+ await expect(
211
+ bootApplication({
212
+ env: "local",
213
+ startScheduler: false,
214
+ flows: [flow({ name: "charge", durable: true, do: () => ({ ok: true }) })],
215
+ config: {
216
+ drivers: {
217
+ journal: { local: "postgres" },
218
+ },
219
+ },
220
+ }),
221
+ ).rejects.toThrow(/journal driver "postgres" needs DATABASE_URL/);
222
+ } finally {
223
+ if (prevDb !== undefined) process.env.DATABASE_URL = prevDb;
224
+ else delete process.env.DATABASE_URL;
225
+ if (prevStore !== undefined) process.env.OKE_STORE_SQL_URL = prevStore;
226
+ else delete process.env.OKE_STORE_SQL_URL;
227
+ }
228
+ });
229
+
230
+ test("journal: unknown driver fails loud", async () => {
130
231
  await expect(
131
232
  bootApplication({
132
233
  env: "local",
133
234
  startScheduler: false,
134
- clocks: [clock("tick", { every: "1h" })],
235
+ flows: [flow({ name: "charge", durable: true, do: () => ({ ok: true }) })],
135
236
  config: {
136
237
  drivers: {
137
- clock: { local: "postgres" },
238
+ journal: { local: "neon" },
138
239
  },
139
240
  },
140
241
  }),
141
- ).rejects.toThrow(/clock driver "postgres" is not implemented/);
242
+ ).rejects.toThrow(/unknown journal driver "neon"/);
142
243
  });
143
244
 
144
245
  test("gate: drivers.store.kv redis opens redis-backed oke:gates", async () => {
@@ -162,6 +263,24 @@ describe("boot binders honour drivers.* config", () => {
162
263
  }
163
264
  });
164
265
 
266
+ test("gate: drivers.store.kv redis without REDIS_URL fails loud (never soft memory)", async () => {
267
+ delete process.env.REDIS_URL;
268
+ delete process.env.OKE_STORE_KV_URL;
269
+ await expect(
270
+ bootApplication({
271
+ env: "local",
272
+ gates: [gate.rate({ max: 10, per: "1m" })],
273
+ config: {
274
+ drivers: {
275
+ store: {
276
+ kv: { local: "redis", docker: "redis", prod: "redis" },
277
+ },
278
+ },
279
+ },
280
+ }),
281
+ ).rejects.toThrow(/gate redis kv needs REDIS_URL/);
282
+ });
283
+
165
284
  test("gate: drivers.store.kv memory keeps memory oke:gates", async () => {
166
285
  const result = await bootApplication({
167
286
  env: "local",
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Lazy journal binder — loaded only when a flow declares `durable: true`.
3
+ */
4
+
5
+ import { mkdirSync } from "node:fs";
6
+ import { dirname, resolve } from "node:path";
7
+ import {
8
+ createFileJournalStore,
9
+ createMemoryJournalStore,
10
+ JOURNAL_DEFAULT_LEASE_MS,
11
+ type JournalStore,
12
+ } from "../journal.ts";
13
+ import { createPostgresJournalStore } from "../../drivers/journal-postgres.ts";
14
+ import { resolveDriverId, type ConfigEnv } from "../../config/index.ts";
15
+ import type { BootOptions } from "../boot.ts";
16
+
17
+ /** Bound journal runtime — store + this instance's lease identity. */
18
+ export interface JournalRuntime {
19
+ readonly store: JournalStore;
20
+ readonly instanceId: string;
21
+ readonly leaseMs: number;
22
+ readonly driverId: string;
23
+ }
24
+
25
+ /** Result of binding a journal runtime. */
26
+ export interface BindJournalResult {
27
+ readonly journal: JournalRuntime;
28
+ }
29
+
30
+ /** Default on-disk journal path (single-host file driver). */
31
+ export const DEFAULT_FILE_JOURNAL_PATH = ".oke/journal.json";
32
+
33
+ /**
34
+ * Resolve `drivers.journal` for the active env (default `memory`).
35
+ *
36
+ * @param options - Boot options
37
+ * @param env - Active environment
38
+ */
39
+ export function resolveJournalDriverId(options: BootOptions, env: ConfigEnv): string {
40
+ const resolved = resolveDriverId(options.config?.drivers?.journal, env);
41
+ if (resolved) return resolved;
42
+ return "memory";
43
+ }
44
+
45
+ /**
46
+ * Construct a journal store. Supported ids: `memory` · `file` · `postgres`.
47
+ *
48
+ * @param options - Boot options
49
+ * @param env - Active environment
50
+ */
51
+ export async function bindJournal(
52
+ options: BootOptions,
53
+ env: ConfigEnv,
54
+ ): Promise<BindJournalResult> {
55
+ const driver = resolveJournalDriverId(options, env);
56
+ const instanceId = options.instanceId ?? `inst-${crypto.randomUUID()}`;
57
+
58
+ let store: JournalStore;
59
+ if (driver === "memory") {
60
+ store = createMemoryJournalStore();
61
+ } else if (driver === "file") {
62
+ const path = resolve(process.cwd(), DEFAULT_FILE_JOURNAL_PATH);
63
+ mkdirSync(dirname(path), { recursive: true });
64
+ store = createFileJournalStore(path);
65
+ } else if (driver === "postgres") {
66
+ const url = process.env.DATABASE_URL ?? process.env.OKE_STORE_SQL_URL ?? undefined;
67
+ if (!url) {
68
+ throw new Error(
69
+ env === "docker"
70
+ ? 'oke boot: journal driver "postgres" needs DATABASE_URL (did `oke dev -d` write docker/.env.docker?)'
71
+ : 'oke boot: journal driver "postgres" needs DATABASE_URL',
72
+ );
73
+ }
74
+ store = await createPostgresJournalStore({ url });
75
+ } else {
76
+ throw new Error(
77
+ `oke boot: unknown journal driver "${driver}" (expected memory · file · postgres)`,
78
+ );
79
+ }
80
+
81
+ return {
82
+ journal: {
83
+ store,
84
+ instanceId,
85
+ leaseMs: options.journalLeaseMs ?? JOURNAL_DEFAULT_LEASE_MS,
86
+ driverId: driver,
87
+ },
88
+ };
89
+ }
@@ -29,6 +29,25 @@ function redisUrlFor(docker: boolean): string | undefined {
29
29
  return url;
30
30
  }
31
31
 
32
+ let signalRedisWarned = false;
33
+
34
+ /** One-shot: redis Signal is emit-relay + process-local outbox consume today. */
35
+ function warnSignalRedisProcessLocal(): void {
36
+ if (signalRedisWarned) return;
37
+ signalRedisWarned = true;
38
+ console.warn(
39
+ "oke boot: drivers.signal redis — emit relays to Redis, but consume/live/drain use a " +
40
+ "process-local outbox (not multi-instance competing consumers). Prefer a shared durable " +
41
+ "outbox path for multi-process tests, or a single consumer instance, until Redis Streams " +
42
+ "consume ships.",
43
+ );
44
+ }
45
+
46
+ /** Test helper — reset the one-shot redis Signal warn. */
47
+ export function resetSignalRedisWarnForTests(): void {
48
+ signalRedisWarned = false;
49
+ }
50
+
32
51
  /**
33
52
  * Construct a Signal runtime, register decls / binding names, start the bus.
34
53
  *
@@ -59,6 +78,7 @@ export async function bindSignal(
59
78
  break;
60
79
  case "redis": {
61
80
  const redis = injectedRedis ?? createBunSignalRedisClient(redisUrlFor(docker));
81
+ warnSignalRedisProcessLocal();
62
82
  signal = createSignalRuntime({
63
83
  driver: redisSignalDriver,
64
84
  now,
@@ -3,8 +3,11 @@
3
3
  */
4
4
 
5
5
  import { afterEach, describe, expect, test } from "bun:test";
6
+ import { files } from "../../elements/store.ts";
6
7
  import {
8
+ bindStore,
7
9
  indexDriverFor,
10
+ resetFilesFsWarnForTests,
8
11
  resolveFilesDriverId,
9
12
  resolveIndexDriverId,
10
13
  resolveKvDriverId,
@@ -77,6 +80,85 @@ describe("bindStore driver resolution", () => {
77
80
  });
78
81
  });
79
82
 
83
+ describe("bindStore files fs multi-instance warn", () => {
84
+ afterEach(() => {
85
+ resetFilesFsWarnForTests();
86
+ });
87
+
88
+ test("warns once when files driver is fs", () => {
89
+ const warnings: string[] = [];
90
+ const prev = console.warn;
91
+ console.warn = (...args: unknown[]) => {
92
+ warnings.push(args.map(String).join(" "));
93
+ };
94
+ try {
95
+ const uploads = files("uploads");
96
+ bindStore(
97
+ {
98
+ stores: [uploads],
99
+ config: {
100
+ drivers: {
101
+ store: {
102
+ files: { local: "fs", docker: "s3", prod: "s3" },
103
+ },
104
+ },
105
+ },
106
+ },
107
+ "local",
108
+ () => Date.now(),
109
+ false,
110
+ );
111
+ bindStore(
112
+ {
113
+ stores: [uploads],
114
+ config: {
115
+ drivers: {
116
+ store: {
117
+ files: { local: "fs", docker: "s3", prod: "s3" },
118
+ },
119
+ },
120
+ },
121
+ },
122
+ "local",
123
+ () => Date.now(),
124
+ false,
125
+ );
126
+ expect(warnings.some((w) => w.includes('drivers.store.files "fs"'))).toBe(true);
127
+ expect(warnings.filter((w) => w.includes('drivers.store.files "fs"'))).toHaveLength(1);
128
+ } finally {
129
+ console.warn = prev;
130
+ }
131
+ });
132
+
133
+ test("does not warn for memory or s3 files", () => {
134
+ const warnings: string[] = [];
135
+ const prev = console.warn;
136
+ console.warn = (...args: unknown[]) => {
137
+ warnings.push(args.map(String).join(" "));
138
+ };
139
+ try {
140
+ bindStore(
141
+ {
142
+ stores: [files("uploads")],
143
+ config: {
144
+ drivers: {
145
+ store: {
146
+ files: { local: "memory", docker: "s3", prod: "s3" },
147
+ },
148
+ },
149
+ },
150
+ },
151
+ "local",
152
+ () => Date.now(),
153
+ false,
154
+ );
155
+ expect(warnings.some((w) => w.includes('drivers.store.files "fs"'))).toBe(false);
156
+ } finally {
157
+ console.warn = prev;
158
+ }
159
+ });
160
+ });
161
+
80
162
  describe("bindStore index driver resolution", () => {
81
163
  const prevIndex = process.env.OKE_INDEX_DRIVER;
82
164
 
@@ -29,6 +29,27 @@ import type { BootOptions } from "../boot.ts"; // type-only — no cycle at runt
29
29
  * @param now - Clock
30
30
  * @param docker - Prefer compose URLs when opening postgres/redis
31
31
  */
32
+ let filesFsWarned = false;
33
+
34
+ /** Test helper — reset the one-shot `fs` multi-instance warn. */
35
+ export function resetFilesFsWarnForTests(): void {
36
+ filesFsWarned = false;
37
+ }
38
+
39
+ /**
40
+ * Warn once when `drivers.store.files` is `fs` — per-process (or per-pod)
41
+ * filesystem visibility under horizontal scale.
42
+ */
43
+ function warnFilesFsMultiInstance(): void {
44
+ if (filesFsWarned) return;
45
+ filesFsWarned = true;
46
+ console.warn(
47
+ 'oke boot: drivers.store.files "fs" is single-host — each instance sees its own ' +
48
+ "filesystem (temp root when unbound). For horizontal scale use drivers.store.files s3 " +
49
+ "(or an explicitly shared volume root, still single-host semantics).",
50
+ );
51
+ }
52
+
32
53
  export function bindStore(
33
54
  options: BootOptions,
34
55
  env: ConfigEnv,
@@ -39,6 +60,7 @@ export function bindStore(
39
60
  const kvId = resolveKvDriverId(options, env, docker);
40
61
  const filesId = resolveFilesDriverId(options, env, docker);
41
62
  const indexId = resolveIndexDriverId(options, env, docker);
63
+ if (filesId === "fs") warnFilesFsMultiInstance();
42
64
  const sqlUrl = sqlUrlFor(sqlId, docker);
43
65
  const kvUrl = kvUrlFor(kvId, docker);
44
66
  const filesRoot = filesRootFor(filesId);
@@ -79,7 +79,7 @@ describe("boot — lazy element needs", () => {
79
79
  expect(needs.signal).toBe(false);
80
80
  });
81
81
 
82
- test("oke() Store-only graph stays under the prior 50 kB baseline", async () => {
82
+ test("oke() Store-only graph stays under the prior 53 kB baseline", async () => {
83
83
  const dir = await mkdtemp(join(tmpdir(), "oke-store-only-"));
84
84
  const entry = join(dir, "entry.ts");
85
85
  const appPath = join(import.meta.dir, "app.ts");
@@ -116,9 +116,11 @@ describe("boot — lazy element needs", () => {
116
116
  if (raw.byteLength === 0) continue;
117
117
  total += Bun.gzipSync(new Uint8Array(raw)).byteLength;
118
118
  }
119
- // Rebased after AI stream/tool-loop + fx.t/i18n on the shared fx surface
120
- // (~49.5 kB gzip). Still far below an eager full-element bind.
121
- expect(total).toBeLessThan(50_000);
119
+ // Rebased after fx.sendOtp/verifyOtp on the shared fx surface (~50.1 kB
120
+ // gzip), durable-journal lease surface (~51.2 kB), then fx.deliverOtp +
121
+ // unified otp() plugin wiring on the shared surface (~50.9 kB → 53 kB
122
+ // cap). Clock/channel/journal drivers stay lazy-bound; far below eager bind.
123
+ expect(total).toBeLessThan(53_000);
122
124
  } finally {
123
125
  await rm(dir, { recursive: true, force: true });
124
126
  }