okengine 0.12.0 → 0.13.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 (33) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/elements/store.mdx +20 -0
  3. package/site/content/docs/providers/index.mdx +1 -0
  4. package/site/content/docs/recipes/dragonfly.mdx +4 -3
  5. package/site/content/docs/recipes/redis.mdx +4 -5
  6. package/site/content/docs/recipes/valkey.mdx +5 -3
  7. package/site/content/docs/reference/configuration.mdx +3 -1
  8. package/site/content/docs/reference/environment-variables.mdx +5 -5
  9. package/src/cli/load-config.images.test.ts +1 -0
  10. package/src/compiler/extract.test.ts +14 -0
  11. package/src/compiler/extract.ts +1 -0
  12. package/src/console/ui-next/dist/assets/{flows-page-C_Tas1E1.js → flows-page-Dg8CTE29.js} +1 -1
  13. package/src/console/ui-next/dist/assets/{index-DX248G39.js → index-Bp-R7jtM.js} +3 -3
  14. package/src/console/ui-next/dist/assets/{observability-page-BwVS5Jvm.js → observability-page-DAnpEaq1.js} +1 -1
  15. package/src/console/ui-next/dist/assets/{units-page-C5KOI7qG.js → units-page-BtQ0bqMe.js} +1 -1
  16. package/src/console/ui-next/dist/assets/{vault-page-CSOYMHhO.js → vault-page-Ca-MvcmJ.js} +1 -1
  17. package/src/console/ui-next/dist/index.html +1 -1
  18. package/src/docker/helpers.ts +28 -0
  19. package/src/docker/recipes/dragonfly.ts +3 -6
  20. package/src/docker/recipes/redis.ts +2 -6
  21. package/src/docker/recipes/valkey.ts +2 -6
  22. package/src/drivers/types.ts +1 -1
  23. package/src/elements/gate/runtime.ts +3 -3
  24. package/src/elements/store/declare.ts +7 -0
  25. package/src/elements/store/kv-sql.test.ts +86 -0
  26. package/src/elements/store/kv-sql.ts +178 -0
  27. package/src/elements/store/runtime.ts +35 -9
  28. package/src/elements/store.test.ts +2 -0
  29. package/src/kernel/boot-bind/store.test.ts +150 -1
  30. package/src/kernel/boot-bind/store.ts +66 -1
  31. package/src/manifest/diff.test.ts +13 -0
  32. package/src/manifest/diff.ts +15 -0
  33. package/src/manifest/types.ts +5 -0
@@ -5,7 +5,7 @@
5
5
  * `redis://` URL, different binaries. Pin via `images["store.kv"]`.
6
6
  */
7
7
 
8
- import { credEnv } from "../helpers.ts";
8
+ import { credEnv, kvServerCommand } from "../helpers.ts";
9
9
  import type { ImageRecipe } from "../types.ts";
10
10
 
11
11
  /** Redis Open Source — Redis protocol on 6379. */
@@ -14,11 +14,7 @@ export const redis: ImageRecipe = {
14
14
  port: 6379,
15
15
  match: (i) => /(?:^|\/)redis(?:[:@/]|$)/i.test(i) || /keydb/i.test(i),
16
16
  apply: (s) => ({
17
- command: [
18
- "sh",
19
- "-c",
20
- 'exec redis-server --requirepass "$$OKE_STORE_KV_PASSWORD" --maxmemory "$${OKE_STORE_KV_MAXMEMORY:-0}" --maxmemory-policy "$${OKE_STORE_KV_MAXMEMORY_POLICY:-noeviction}"',
21
- ],
17
+ command: ["sh", "-c", kvServerCommand(s, "redis-server")],
22
18
  healthcheck: {
23
19
  test: ["CMD", "redis-cli", "-a", credEnv(s, "PASSWORD"), "ping"],
24
20
  interval: "5s",
@@ -5,7 +5,7 @@
5
5
  * `valkey/valkey`.
6
6
  */
7
7
 
8
- import { credEnv } from "../helpers.ts";
8
+ import { credEnv, kvServerCommand } from "../helpers.ts";
9
9
  import type { ImageRecipe } from "../types.ts";
10
10
 
11
11
  /** Valkey — Redis protocol on 6379. */
@@ -14,11 +14,7 @@ export const valkey: ImageRecipe = {
14
14
  port: 6379,
15
15
  match: (i) => /valkey/i.test(i),
16
16
  apply: (s) => ({
17
- command: [
18
- "sh",
19
- "-c",
20
- 'exec valkey-server --requirepass "$$OKE_STORE_KV_PASSWORD" --maxmemory "$${OKE_STORE_KV_MAXMEMORY:-0}" --maxmemory-policy "$${OKE_STORE_KV_MAXMEMORY_POLICY:-noeviction}"',
21
- ],
17
+ command: ["sh", "-c", kvServerCommand(s, "valkey-server")],
22
18
  healthcheck: {
23
19
  test: ["CMD", "valkey-cli", "-a", credEnv(s, "PASSWORD"), "ping"],
24
20
  interval: "5s",
@@ -83,7 +83,7 @@ export interface SqlDriver {
83
83
  /** Key-value namespace handle. */
84
84
  export interface KvNamespace {
85
85
  /** Protocol driver id. */
86
- readonly driverId: "memory" | "redis";
86
+ readonly driverId: "memory" | "redis" | "postgres" | "pglite";
87
87
  /**
88
88
  * Read a key.
89
89
  *
@@ -16,8 +16,8 @@ import { takeRate } from "./strategies.ts";
16
16
 
17
17
  /** KV surface required for rate gates. */
18
18
  export interface GateKv {
19
- /** Driver id when known (`memory` · `redis`). */
20
- readonly driverId?: "memory" | "redis";
19
+ /** Driver id when known (`memory` · `redis`; SQL ids unused on rate gates). */
20
+ readonly driverId?: "memory" | "redis" | "postgres" | "pglite";
21
21
  eval<T = unknown>(script: string, keys: readonly string[], args?: readonly string[]): Promise<T>;
22
22
  }
23
23
 
@@ -46,7 +46,7 @@ export interface GateRuntime {
46
46
  /** Registered declarations by name. */
47
47
  readonly gates: ReadonlyMap<string, GateDecl>;
48
48
  /** KV backend used for rate limits (`memory` · `redis`, or unset). */
49
- readonly kvDriverId: "memory" | "redis" | undefined;
49
+ readonly kvDriverId: "memory" | "redis" | "postgres" | "pglite" | undefined;
50
50
  /**
51
51
  * Evaluate a gate chain in order; stop on first denial.
52
52
  *
@@ -74,6 +74,11 @@ export interface SqlStoreDecl extends StoreDeclBase {
74
74
  export interface KvStoreOptions {
75
75
  /** Optional description. */
76
76
  readonly description?: string;
77
+ /**
78
+ * Persist this namespace in SQL (`oke_kv` JSONB on `DATABASE_URL`),
79
+ * not the cache Redis (`REDIS_URL`). Distinct from Flow `durable`.
80
+ */
81
+ readonly durable?: boolean;
77
82
  }
78
83
 
79
84
  /** KV store declaration. */
@@ -81,6 +86,7 @@ export interface KvStoreDecl extends StoreDeclBase {
81
86
  readonly facet: "kv";
82
87
  readonly ref: `kv:${string}`;
83
88
  readonly description?: string;
89
+ readonly durable?: boolean;
84
90
  }
85
91
 
86
92
  /** Options for {@link store.files}. */
@@ -179,6 +185,7 @@ export function kv(name: string, options: KvStoreOptions = {}): KvStoreDecl {
179
185
  name,
180
186
  ref: `kv:${name}`,
181
187
  ...(options.description !== undefined ? { description: options.description } : {}),
188
+ ...(options.durable === true ? { durable: true } : {}),
182
189
  };
183
190
  }
184
191
 
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Durable KV on `oke_kv` JSONB — round-trip, TTL purge, cache Redis unchanged.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { pgliteDriver } from "../../drivers/pglite.ts";
7
+ import { createRedisFakeClient, redisDriver } from "../../drivers/redis.ts";
8
+ import { memoryDrivers } from "../../drivers/memory.ts";
9
+ import { store } from "./declare.ts";
10
+ import { openSqlKvNamespace } from "./kv-sql.ts";
11
+ import { createStoreRuntime, type KvStoreFxHandle } from "./runtime.ts";
12
+
13
+ const ctx = { effects: {} };
14
+
15
+ describe("oke_kv JSONB durable namespace", () => {
16
+ test("set/get/delete round-trips a JSON object", async () => {
17
+ const conn = await pgliteDriver.connect({ url: "memory://" });
18
+ try {
19
+ const ns = await openSqlKvNamespace({
20
+ conn,
21
+ namespace: "ledger",
22
+ now: () => 1_000_000,
23
+ });
24
+ expect(ns.driverId).toBe("pglite");
25
+ await ns.set("idemp", { orderId: "o1", n: 2 });
26
+ expect(await ns.get("idemp")).toEqual({ orderId: "o1", n: 2 });
27
+ expect(await ns.list("id")).toEqual(["idemp"]);
28
+ expect(await ns.delete("idemp")).toBe(true);
29
+ expect(await ns.get("idemp")).toBeUndefined();
30
+ } finally {
31
+ await conn.close();
32
+ }
33
+ });
34
+
35
+ test("expired keys are invisible and purgeExpired removes the row", async () => {
36
+ const conn = await pgliteDriver.connect({ url: "memory://" });
37
+ let t = 1_000_000;
38
+ try {
39
+ const ns = await openSqlKvNamespace({
40
+ conn,
41
+ namespace: "ledger",
42
+ now: () => t,
43
+ });
44
+ await ns.set("fresh", { ok: true }, "5s");
45
+ await ns.set("stale", { ok: false }, "1s");
46
+ t += 1_500;
47
+ expect(await ns.get("stale")).toBeUndefined();
48
+ expect(await ns.get("fresh")).toEqual({ ok: true });
49
+ const removed = await ns.purgeExpired();
50
+ expect(removed).toBe(1);
51
+ const leftover = await conn.query(`SELECT key FROM oke_kv WHERE key = ?`, ["stale"]);
52
+ expect(leftover).toEqual([]);
53
+ } finally {
54
+ await conn.close();
55
+ }
56
+ });
57
+
58
+ test("non-durable namespace stays on redis; durable uses pglite", async () => {
59
+ const fake = createRedisFakeClient();
60
+ const sessions = store.kv("sessions");
61
+ const ledger = store.kv("ledger", { durable: true });
62
+ const runtime = createStoreRuntime({
63
+ drivers: {
64
+ sql: pgliteDriver,
65
+ kv: redisDriver,
66
+ files: memoryDrivers.files,
67
+ index: memoryDrivers.index,
68
+ },
69
+ sqlUrl: "memory://",
70
+ kv: { sessions: { url: "redis://cache", client: fake } },
71
+ now: () => 1_000_000,
72
+ });
73
+ runtime.register(sessions);
74
+ runtime.register(ledger);
75
+ const cache = (await runtime.open(sessions, ctx)) as KvStoreFxHandle;
76
+ const durable = (await runtime.open(ledger, ctx)) as KvStoreFxHandle;
77
+ expect(cache.driverId).toBe("redis");
78
+ expect(durable.driverId).toBe("pglite");
79
+ await cache.set("sid", { user: "a" });
80
+ await durable.set("idemp", { orderId: "o1" });
81
+ expect(await cache.get("sid")).toEqual({ user: "a" });
82
+ expect(await durable.get("idemp")).toEqual({ orderId: "o1" });
83
+ expect([...fake.data.keys()].some((k) => k.includes("idemp"))).toBe(false);
84
+ await runtime.close();
85
+ });
86
+ });
@@ -0,0 +1,178 @@
1
+ /**
2
+ * SQL-backed durable KV — `oke_kv` JSONB on the shared store.sql connection.
3
+ *
4
+ * Not a catalog `drivers.store.kv` id. Cache namespaces stay redis/memory.
5
+ */
6
+
7
+ import type { KvNamespace, SqlConnection } from "../../drivers/types.ts";
8
+ import { parseTtlMs } from "./cache.ts";
9
+
10
+ /** Engine-owned durable KV table — boot DDL, excluded from `oke db push`. */
11
+ export const OKE_KV_TABLE = "oke_kv";
12
+
13
+ /** Options for {@link openSqlKvNamespace}. */
14
+ export interface OpenSqlKvOptions {
15
+ /** Shared SQL connection (`sharedSqlConn`). */
16
+ readonly conn: SqlConnection;
17
+ /** `store.kv` namespace name. */
18
+ readonly namespace: string;
19
+ /** Injectable clock (epoch ms). */
20
+ readonly now: () => number;
21
+ }
22
+
23
+ /** SQL KV handle — {@link KvNamespace} plus {@link SqlKvNamespace.purgeExpired}. */
24
+ export interface SqlKvNamespace extends KvNamespace {
25
+ readonly driverId: "postgres" | "pglite" | "memory";
26
+ /**
27
+ * Delete rows whose `expires_at` has passed.
28
+ *
29
+ * @returns Rows removed
30
+ */
31
+ purgeExpired(): Promise<number>;
32
+ }
33
+
34
+ const ensured = new WeakSet<SqlConnection>();
35
+
36
+ /**
37
+ * Create `oke_kv` + the expires partial index (idempotent).
38
+ *
39
+ * @param conn - Shared SQL connection
40
+ */
41
+ export async function ensureOkeKvSchema(conn: SqlConnection): Promise<void> {
42
+ if (ensured.has(conn)) return;
43
+ await conn.exec(`CREATE TABLE IF NOT EXISTS ${OKE_KV_TABLE} (
44
+ namespace TEXT NOT NULL,
45
+ key TEXT NOT NULL,
46
+ value JSONB NOT NULL,
47
+ expires_at BIGINT,
48
+ updated_at BIGINT NOT NULL,
49
+ PRIMARY KEY (namespace, key)
50
+ )`);
51
+ await conn.exec(
52
+ `CREATE INDEX IF NOT EXISTS oke_kv_expires ON ${OKE_KV_TABLE} (expires_at) WHERE expires_at IS NOT NULL`,
53
+ );
54
+ ensured.add(conn);
55
+ }
56
+
57
+ /**
58
+ * Open a durable KV namespace on an already-open SQL connection.
59
+ *
60
+ * @param options - Connection / namespace / clock
61
+ */
62
+ export async function openSqlKvNamespace(options: OpenSqlKvOptions): Promise<SqlKvNamespace> {
63
+ const { conn, namespace, now } = options;
64
+ await ensureOkeKvSchema(conn);
65
+ await purgeExpiredOn(conn, now());
66
+
67
+ return {
68
+ driverId: conn.driverId,
69
+ async get(key) {
70
+ const rows = await conn.query(
71
+ `SELECT value FROM ${OKE_KV_TABLE}
72
+ WHERE namespace = ? AND key = ?
73
+ AND (expires_at IS NULL OR expires_at > ?)`,
74
+ [namespace, key, now()],
75
+ );
76
+ const raw = rows[0]?.value;
77
+ if (raw === undefined) return undefined;
78
+ return decodeJsonb(raw);
79
+ },
80
+ async set(key, value, ttl) {
81
+ await purgeExpiredOn(conn, now());
82
+ const t = now();
83
+ const expiresAt = ttlExpiresAt(ttl, t);
84
+ await conn.exec(
85
+ `INSERT INTO ${OKE_KV_TABLE} (namespace, key, value, expires_at, updated_at)
86
+ VALUES (?, ?, CAST(? AS JSONB), ?, ?)
87
+ ON CONFLICT (namespace, key) DO UPDATE SET
88
+ value = EXCLUDED.value,
89
+ expires_at = EXCLUDED.expires_at,
90
+ updated_at = EXCLUDED.updated_at`,
91
+ [namespace, key, JSON.stringify(value), expiresAt, t],
92
+ );
93
+ },
94
+ async delete(key) {
95
+ const result = await conn.exec(
96
+ `DELETE FROM ${OKE_KV_TABLE} WHERE namespace = ? AND key = ?`,
97
+ [namespace, key],
98
+ );
99
+ return result.changes > 0;
100
+ },
101
+ async list(prefix = "") {
102
+ const rows = await conn.query(
103
+ `SELECT key FROM ${OKE_KV_TABLE}
104
+ WHERE namespace = ?
105
+ AND (expires_at IS NULL OR expires_at > ?)
106
+ AND key LIKE ?
107
+ ORDER BY key`,
108
+ [namespace, now(), `${prefix}%`],
109
+ );
110
+ return rows.map((row) => String(row.key ?? ""));
111
+ },
112
+ async ttlMs(key) {
113
+ const rows = await conn.query(
114
+ `SELECT expires_at FROM ${OKE_KV_TABLE}
115
+ WHERE namespace = ? AND key = ?
116
+ AND (expires_at IS NULL OR expires_at > ?)`,
117
+ [namespace, key, now()],
118
+ );
119
+ const raw = rows[0]?.expires_at;
120
+ if (raw === undefined || raw === null) return null;
121
+ const at = Number(raw);
122
+ if (!Number.isFinite(at)) return null;
123
+ return Math.max(0, at - now());
124
+ },
125
+ async eval() {
126
+ throw new Error("oke store: durable store.kv does not support eval");
127
+ },
128
+ async close() {
129
+ /* Shared connection — StoreRuntime.close owns it. */
130
+ },
131
+ async purgeExpired() {
132
+ return purgeExpiredOn(conn, now());
133
+ },
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Decode a JSONB cell — PGlite/Postgres may return an object or a string.
139
+ *
140
+ * @param raw - Driver cell
141
+ */
142
+ function decodeJsonb(raw: unknown): unknown {
143
+ if (typeof raw === "string") {
144
+ try {
145
+ return JSON.parse(raw) as unknown;
146
+ } catch {
147
+ return raw;
148
+ }
149
+ }
150
+ return raw;
151
+ }
152
+
153
+ /**
154
+ * Absolute expiry from a TTL string, or `null` when omitted / unparsed.
155
+ *
156
+ * @param ttl - Duration like `"7d"`
157
+ * @param t - Now (epoch ms)
158
+ */
159
+ function ttlExpiresAt(ttl: string | undefined, t: number): number | null {
160
+ if (ttl === undefined) return null;
161
+ const ms = parseTtlMs(ttl);
162
+ if (ms <= 0) return null;
163
+ return t + ms;
164
+ }
165
+
166
+ /**
167
+ * Delete expired rows across every namespace on this connection.
168
+ *
169
+ * @param conn - Shared SQL connection
170
+ * @param t - Now (epoch ms)
171
+ */
172
+ async function purgeExpiredOn(conn: SqlConnection, t: number): Promise<number> {
173
+ const result = await conn.exec(
174
+ `DELETE FROM ${OKE_KV_TABLE} WHERE expires_at IS NOT NULL AND expires_at <= ?`,
175
+ [t],
176
+ );
177
+ return result.changes;
178
+ }
@@ -42,6 +42,7 @@ import type {
42
42
  SqlStoreDecl,
43
43
  StoreDecl,
44
44
  } from "./declare.ts";
45
+ import { openSqlKvNamespace } from "./kv-sql.ts";
45
46
  import { createGatedFilesStoreHandle, type GatedFilesFxBridge } from "./files-fx.ts";
46
47
  import {
47
48
  createFilesImagePipeline,
@@ -98,6 +99,11 @@ export interface CreateStoreRuntimeOptions {
98
99
  * Boot sets `off` for docker/prod and local+autoPush.
99
100
  */
100
101
  readonly domainDdl?: DomainDdlMode;
102
+ /**
103
+ * Shared SQL URL for durable KV (and apps with no `store.sql()` decl).
104
+ * Durable namespaces open via `sharedSqlConn`, never a second Redis.
105
+ */
106
+ readonly sqlUrl?: string;
101
107
  }
102
108
 
103
109
  /** Per-invocation context when opening a handle through the runtime. */
@@ -118,7 +124,7 @@ export type StoreHandle =
118
124
  /** KV handle on `fx.store`. */
119
125
  export interface KvStoreFxHandle {
120
126
  readonly ref: `kv:${string}`;
121
- readonly driverId: "memory" | "redis";
127
+ readonly driverId: "memory" | "redis" | "postgres" | "pglite";
122
128
  get(key: string): Promise<unknown>;
123
129
  set(key: string, value: unknown, ttl?: string): Promise<void>;
124
130
  delete(key: string): Promise<boolean>;
@@ -383,17 +389,37 @@ export function createStoreRuntime(options: CreateStoreRuntimeOptions): StoreRun
383
389
  return sharedSqlConn(sqlDriver, "primary", { url });
384
390
  }
385
391
 
392
+ /**
393
+ * Durable KV — JSONB table on the shared SQL connection.
394
+ *
395
+ * @param name - Namespace
396
+ */
397
+ async function openDurableKv(name: string): Promise<KvNamespace> {
398
+ const sqlDriver = options.drivers.sql;
399
+ if (!sqlDriver) {
400
+ throw new Error("oke store: durable store.kv needs a configured sql driver");
401
+ }
402
+ const firstSql = Object.values(options.sql ?? [])[0];
403
+ const url = options.sqlUrl ?? firstSql?.primary.url;
404
+ const conn = await sharedSqlConn(sqlDriver, "primary", { url });
405
+ return openSqlKvNamespace({ conn, namespace: name, now });
406
+ }
407
+
386
408
  async function openKv(decl: KvStoreDecl): Promise<KvStoreFxHandle> {
387
- const driver = options.drivers.kv;
388
- if (!driver) throw new Error("No kv driver configured");
389
409
  let ns = kvNs.get(decl.name);
390
410
  if (!ns) {
391
- const binding = options.kv?.[decl.name] ?? {};
392
- ns = await driver.open({
393
- name: decl.name,
394
- url: binding.url,
395
- client: binding.client as never,
396
- });
411
+ if (decl.durable === true) {
412
+ ns = await openDurableKv(decl.name);
413
+ } else {
414
+ const driver = options.drivers.kv;
415
+ if (!driver) throw new Error("No kv driver configured");
416
+ const binding = options.kv?.[decl.name] ?? {};
417
+ ns = await driver.open({
418
+ name: decl.name,
419
+ url: binding.url,
420
+ client: binding.client as never,
421
+ });
422
+ }
397
423
  kvNs.set(decl.name, ns);
398
424
  }
399
425
  const driverId = ns.driverId;
@@ -63,6 +63,8 @@ describe("store facets", () => {
63
63
  test("declares sql · kv · files · index with resource refs", () => {
64
64
  expect(store.sql("notes").ref).toBe("sql:notes");
65
65
  expect(store.kv("sessions").ref).toBe("kv:sessions");
66
+ expect(store.kv("ledger", { durable: true }).durable).toBe(true);
67
+ expect(store.kv("sessions").durable).toBeUndefined();
66
68
  expect(store.files("uploads").ref).toBe("files:uploads");
67
69
  expect(store.index("kb", { dims: 8 }).ref).toBe("index:kb");
68
70
  });
@@ -3,11 +3,12 @@
3
3
  */
4
4
 
5
5
  import { afterEach, describe, expect, test } from "bun:test";
6
- import { files } from "../../elements/store.ts";
6
+ import { files, store } from "../../elements/store.ts";
7
7
  import {
8
8
  bindStore,
9
9
  indexDriverFor,
10
10
  resetFilesFsWarnForTests,
11
+ resetKvDurableWarnsForTests,
11
12
  resolveFilesDriverId,
12
13
  resolveIndexDriverId,
13
14
  resolveKvDriverId,
@@ -98,6 +99,154 @@ describe("bindStore driver resolution", () => {
98
99
  });
99
100
  });
100
101
 
102
+ describe("bindStore durable KV routing", () => {
103
+ const prev = {
104
+ redis: process.env.REDIS_URL,
105
+ kv: process.env.OKE_STORE_KV_URL,
106
+ database: process.env.DATABASE_URL,
107
+ sql: process.env.OKE_STORE_SQL_URL,
108
+ };
109
+
110
+ afterEach(() => {
111
+ resetKvDurableWarnsForTests();
112
+ if (prev.redis === undefined) delete process.env.REDIS_URL;
113
+ else process.env.REDIS_URL = prev.redis;
114
+ if (prev.kv === undefined) delete process.env.OKE_STORE_KV_URL;
115
+ else process.env.OKE_STORE_KV_URL = prev.kv;
116
+ if (prev.database === undefined) delete process.env.DATABASE_URL;
117
+ else process.env.DATABASE_URL = prev.database;
118
+ if (prev.sql === undefined) delete process.env.OKE_STORE_SQL_URL;
119
+ else process.env.OKE_STORE_SQL_URL = prev.sql;
120
+ });
121
+
122
+ test("pglite + durable in test does not throw or warn", () => {
123
+ const warnings: string[] = [];
124
+ const prevWarn = console.warn;
125
+ console.warn = (...args: unknown[]) => {
126
+ warnings.push(args.map(String).join(" "));
127
+ };
128
+ try {
129
+ bindStore(
130
+ { stores: [store.kv("ledger", { durable: true })] },
131
+ "test",
132
+ () => Date.now(),
133
+ false,
134
+ );
135
+ expect(warnings.some((w) => w.includes("durable"))).toBe(false);
136
+ } finally {
137
+ console.warn = prevWarn;
138
+ }
139
+ });
140
+
141
+ test("postgres + durable without DATABASE_URL throws outside test", () => {
142
+ delete process.env.DATABASE_URL;
143
+ delete process.env.OKE_STORE_SQL_URL;
144
+ process.env.REDIS_URL = "redis://cache";
145
+ expect(() =>
146
+ bindStore(
147
+ {
148
+ stores: [store.kv("ledger", { durable: true })],
149
+ config: {
150
+ drivers: {
151
+ store: {
152
+ sql: { dev: "postgres", test: "pglite", prod: "postgres" },
153
+ kv: { dev: "redis", test: "memory", prod: "redis" },
154
+ },
155
+ },
156
+ },
157
+ },
158
+ "dev",
159
+ () => Date.now(),
160
+ false,
161
+ ),
162
+ ).toThrow(/DATABASE_URL/);
163
+ });
164
+
165
+ test("redis + cache-shaped KV warns once outside test", () => {
166
+ process.env.REDIS_URL = "redis://cache";
167
+ const warnings: string[] = [];
168
+ const prevWarn = console.warn;
169
+ console.warn = (...args: unknown[]) => {
170
+ warnings.push(args.map(String).join(" "));
171
+ };
172
+ try {
173
+ const options = {
174
+ stores: [store.kv("sessions")],
175
+ config: {
176
+ drivers: {
177
+ store: {
178
+ sql: { dev: "memory", test: "memory", prod: "memory" },
179
+ kv: { dev: "redis", test: "memory", prod: "redis" },
180
+ },
181
+ },
182
+ },
183
+ };
184
+ bindStore(options, "dev", () => Date.now(), false);
185
+ bindStore(options, "dev", () => Date.now(), false);
186
+ expect(warnings.filter((w) => w.includes("cache-shaped"))).toHaveLength(1);
187
+ } finally {
188
+ console.warn = prevWarn;
189
+ }
190
+ });
191
+
192
+ test("non-durable KV keeps REDIS_URL; durable does not", async () => {
193
+ process.env.REDIS_URL = "redis://cache";
194
+ const sessions = store.kv("sessions");
195
+ const ledger = store.kv("ledger", { durable: true });
196
+ const runtime = bindStore(
197
+ {
198
+ stores: [sessions, ledger],
199
+ config: {
200
+ drivers: {
201
+ store: {
202
+ sql: { test: "pglite" },
203
+ kv: { test: "memory" },
204
+ },
205
+ },
206
+ },
207
+ },
208
+ "test",
209
+ () => Date.now(),
210
+ false,
211
+ );
212
+ const ctx = { effects: {} };
213
+ const cache = await runtime.open(sessions, ctx);
214
+ const durable = await runtime.open(ledger, ctx);
215
+ expect(cache.driverId).toBe("memory");
216
+ expect(durable.driverId).toBe("pglite");
217
+ await runtime.close();
218
+ });
219
+
220
+ test("memory SQL + durable in dev warns that memory cannot honor durability", () => {
221
+ const warnings: string[] = [];
222
+ const prevWarn = console.warn;
223
+ console.warn = (...args: unknown[]) => {
224
+ warnings.push(args.map(String).join(" "));
225
+ };
226
+ try {
227
+ bindStore(
228
+ {
229
+ stores: [store.kv("ledger", { durable: true })],
230
+ config: {
231
+ drivers: {
232
+ store: {
233
+ sql: { dev: "memory", test: "memory", prod: "memory" },
234
+ kv: { dev: "memory", test: "memory", prod: "redis" },
235
+ },
236
+ },
237
+ },
238
+ },
239
+ "dev",
240
+ () => Date.now(),
241
+ false,
242
+ );
243
+ expect(warnings.some((w) => w.includes("memory") && w.includes("durable"))).toBe(true);
244
+ } finally {
245
+ console.warn = prevWarn;
246
+ }
247
+ });
248
+ });
249
+
101
250
  describe("bindStore files fs multi-instance warn", () => {
102
251
  afterEach(() => {
103
252
  resetFilesFsWarnForTests();