okengine 0.11.1 → 0.11.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.
- package/package.json +1 -1
- package/site/content/docs/elements/ai.mdx +22 -1
- package/site/content/docs/elements/store.mdx +3 -1
- package/site/content/docs/elements/vault.mdx +19 -11
- package/site/content/docs/get-started/installation.mdx +7 -1
- package/site/content/docs/recipes/llama-cpp.mdx +10 -9
- package/site/content/docs/reference/cli.md +6 -2
- package/site/content/docs/reference/environment-variables.mdx +9 -9
- package/src/cli/ai-setup/ai-setup.test.ts +3 -1
- package/src/cli/ai-setup/apply.ts +61 -1
- package/src/cli/ask-seed.test.ts +4 -3
- package/src/cli/ask-seed.ts +5 -6
- package/src/cli/client-add.test.ts +2 -1
- package/src/cli/dev.test.ts +116 -0
- package/src/cli/dev.ts +107 -18
- package/src/cli/project-state.test.ts +50 -0
- package/src/cli/project-state.ts +123 -0
- package/src/cli/vault-cmd.test.ts +47 -18
- package/src/cli/vault-cmd.ts +2 -1
- package/src/compiler/extract.ts +12 -1
- package/src/console/server/console.test.ts +3 -1
- package/src/console/server/operator-db.test.ts +48 -17
- package/src/console/server/operator-db.ts +5 -1
- package/src/docker/derive.ts +24 -3
- package/src/docker/docker.test.ts +4 -2
- package/src/docker/index.ts +1 -0
- package/src/docker/recipes/index.ts +1 -0
- package/src/docker/recipes/llama-cpp.ts +20 -4
- package/src/drivers/ai-openai-compatible.ts +15 -3
- package/src/drivers/vault-builtin.test.ts +50 -42
- package/src/elements/ai/declare.ts +73 -3
- package/src/elements/ai/errors.test.ts +35 -0
- package/src/elements/ai/errors.ts +139 -0
- package/src/elements/ai/eval.ts +26 -1
- package/src/elements/ai/runtime.ts +140 -80
- package/src/elements/ai/tools.test.ts +1 -1
- package/src/elements/ai.test.ts +99 -2
- package/src/elements/ai.ts +11 -1
- package/src/elements/index.ts +2 -0
- package/src/elements/store/index-boot.test.ts +23 -6
- package/src/elements/store/resource.test.ts +38 -19
- package/src/elements/store/sql-session.test.ts +55 -58
- package/src/elements/vault/builtin-adapter.test.ts +115 -58
- package/src/elements/vault/builtin-adapter.ts +241 -47
- package/src/elements/vault/chaos-child.ts +424 -0
- package/src/elements/vault/chaos.test.ts +651 -0
- package/src/elements/vault/resilience.ts +6 -1
- package/src/elements/vault/security-checklist.test.ts +10 -8
- package/src/elements/vault/storage.ts +130 -27
- package/src/elements/vault/test-helpers.ts +368 -0
- package/src/elements/vault.ts +6 -0
- package/src/index.ts +2 -0
- package/src/kernel/app.ts +83 -10
- package/src/kernel/auto-registry.test.ts +52 -1
- package/src/kernel/element-registries.ts +19 -4
- package/src/kernel/errors.ts +3 -3
- package/src/kernel/fx.test.ts +25 -0
- package/src/kernel/fx.ts +4 -1
- package/src/manifest/types.ts +4 -0
- package/src/test/create-test-app.ts +16 -11
- package/src/test/reset-element-registries.ts +17 -9
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* native ANN index — no JS-side full scan survives.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { afterEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
10
10
|
import { connectPglite } from "../../drivers/pglite.ts";
|
|
11
11
|
import { openPgvectorIndex } from "../../drivers/pgvector.ts";
|
|
12
12
|
import { connectPostgres } from "../../drivers/postgres.ts";
|
|
@@ -22,6 +22,17 @@ const prev = {
|
|
|
22
22
|
pglite: process.env.OKE_PGLITE_URL,
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
+
/** Warmed PGlite for direct openPgvectorIndex cases (boot path opens its own). */
|
|
26
|
+
let sharedPglite: SqlConnection | undefined;
|
|
27
|
+
|
|
28
|
+
beforeAll(async () => {
|
|
29
|
+
sharedPglite = await connectPglite({ url: "memory://index-boot-shared" });
|
|
30
|
+
}, 15_000);
|
|
31
|
+
|
|
32
|
+
afterAll(async () => {
|
|
33
|
+
await sharedPglite?.close();
|
|
34
|
+
});
|
|
35
|
+
|
|
25
36
|
afterEach(() => {
|
|
26
37
|
if (prev.pglite === undefined) delete process.env.OKE_PGLITE_URL;
|
|
27
38
|
else process.env.OKE_PGLITE_URL = prev.pglite;
|
|
@@ -43,7 +54,8 @@ function recordingConn(conn: SqlConnection): { conn: SqlConnection; statements:
|
|
|
43
54
|
statements.push(sql);
|
|
44
55
|
return conn.exec(sql, params);
|
|
45
56
|
},
|
|
46
|
-
close
|
|
57
|
+
// Never close the underlying shared PGlite — afterAll owns that.
|
|
58
|
+
close: async () => {},
|
|
47
59
|
},
|
|
48
60
|
};
|
|
49
61
|
}
|
|
@@ -63,12 +75,16 @@ describe("store.index boot wiring — memory", () => {
|
|
|
63
75
|
});
|
|
64
76
|
|
|
65
77
|
describe("store.index boot wiring — pglite + pgvector", () => {
|
|
78
|
+
// bindStore opens its own PGlite — cannot inject the shared instance; allow
|
|
79
|
+
// cold WASM under suite contention (same budget as sql conformance).
|
|
66
80
|
test("boot: sql=pglite + index=pgvector resolves end to end", async () => {
|
|
67
81
|
process.env.OKE_PGLITE_URL = "memory://";
|
|
68
82
|
const kb = declareIndex("kb", { dims: 3 });
|
|
69
83
|
const runtime = bindStore(
|
|
70
84
|
{
|
|
71
|
-
config: {
|
|
85
|
+
config: {
|
|
86
|
+
drivers: { store: { sql: { test: "pglite" }, index: { test: "pgvector" } } },
|
|
87
|
+
},
|
|
72
88
|
stores: [kb],
|
|
73
89
|
},
|
|
74
90
|
"test",
|
|
@@ -86,10 +102,11 @@ describe("store.index boot wiring — pglite + pgvector", () => {
|
|
|
86
102
|
expect(hits[0]!.meta).toEqual({ label: "near" });
|
|
87
103
|
expect(await handle.delete("farther")).toBe(true);
|
|
88
104
|
await runtime.close();
|
|
89
|
-
});
|
|
105
|
+
}, 15_000);
|
|
90
106
|
|
|
91
107
|
test("pglite runs the shared pgvector HNSW path — real ANN, not a scan", async () => {
|
|
92
|
-
const real =
|
|
108
|
+
const real = sharedPglite!;
|
|
109
|
+
await real.exec(`DROP TABLE IF EXISTS oke_idx_ann CASCADE`);
|
|
93
110
|
const { conn, statements } = recordingConn(real);
|
|
94
111
|
const idx = await openPgvectorIndex({ name: "ann", dims: 3, sql: conn });
|
|
95
112
|
await idx.upsert("a", [1, 0, 0]);
|
|
@@ -106,7 +123,7 @@ describe("store.index boot wiring — pglite + pgvector", () => {
|
|
|
106
123
|
);
|
|
107
124
|
expect(JSON.stringify(indexes)).toContain("USING hnsw");
|
|
108
125
|
await idx.close();
|
|
109
|
-
|
|
126
|
+
// shared connection — closed in afterAll
|
|
110
127
|
});
|
|
111
128
|
});
|
|
112
129
|
|
|
@@ -5,18 +5,19 @@
|
|
|
5
5
|
* client-facing key.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { afterEach, describe, expect, test } from "bun:test";
|
|
8
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
9
9
|
import { integer, pgTable, text } from "drizzle-orm/pg-core";
|
|
10
10
|
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
|
|
11
11
|
import { z } from "zod";
|
|
12
12
|
import { pgliteDriver } from "../../drivers/pglite.ts";
|
|
13
|
+
import type { SqlConnection } from "../../drivers/types.ts";
|
|
13
14
|
import { oke } from "../../kernel/app.ts";
|
|
14
15
|
import { resetFlowSeq } from "../../kernel/flow.ts";
|
|
15
16
|
import { on, resetBindings } from "../../kernel/on.ts";
|
|
16
17
|
import { http } from "../../kernel/triggers.ts";
|
|
17
18
|
import { createTestApp } from "../../test/create-test-app.ts";
|
|
18
19
|
import { classify, field, id, now, PII_MASK, store } from "../store.ts";
|
|
19
|
-
import { createSqlStoreHandle } from "./sql-session.ts";
|
|
20
|
+
import { createSqlStoreHandle, type SqlStoreHandle } from "./sql-session.ts";
|
|
20
21
|
import { mapRowToJs, resolveColumns } from "./table.ts";
|
|
21
22
|
|
|
22
23
|
afterEach(() => {
|
|
@@ -24,6 +25,18 @@ afterEach(() => {
|
|
|
24
25
|
resetFlowSeq();
|
|
25
26
|
});
|
|
26
27
|
|
|
28
|
+
/** Posts table for the JS-key exit lock (needs real Postgres dialect via PGlite). */
|
|
29
|
+
const jsKeyPosts = pgTable("posts", {
|
|
30
|
+
id: text("id").primaryKey(),
|
|
31
|
+
title: text("title").notNull(),
|
|
32
|
+
createdAt: integer("created_at").notNull(),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/** File-scoped warmed PGlite for the SqlStoreHandle exit describe. */
|
|
36
|
+
let sharedConn: SqlConnection;
|
|
37
|
+
/** Handle over {@link sharedConn}. */
|
|
38
|
+
let sharedHandle: SqlStoreHandle;
|
|
39
|
+
|
|
27
40
|
describe("mapRowToJs — declared keys only", () => {
|
|
28
41
|
const posts = pgTable("posts", {
|
|
29
42
|
id: text("id").primaryKey(),
|
|
@@ -47,40 +60,46 @@ describe("mapRowToJs — declared keys only", () => {
|
|
|
47
60
|
});
|
|
48
61
|
|
|
49
62
|
describe("SqlStoreHandle exit — list/get/create return JS keys only", () => {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test("select / findById / insert.returning never emit created_at", async () => {
|
|
57
|
-
const conn = await pgliteDriver.connect({
|
|
58
|
-
url: "memory://",
|
|
63
|
+
// Real Postgres dialect (PGlite) — share one warmed instance; TRUNCATE between tests.
|
|
64
|
+
beforeAll(async () => {
|
|
65
|
+
sharedConn = await pgliteDriver.connect({
|
|
66
|
+
url: "memory://resource-js-keys-shared",
|
|
59
67
|
role: "primary",
|
|
60
68
|
});
|
|
61
|
-
|
|
62
|
-
connection:
|
|
69
|
+
sharedHandle = createSqlStoreHandle("sql:app", {
|
|
70
|
+
connection: sharedConn,
|
|
63
71
|
classifications: new Map(),
|
|
64
72
|
routedRole: "primary",
|
|
65
73
|
domainDdl: "ensure",
|
|
66
74
|
});
|
|
75
|
+
// Warm DDL via the insert path (pgTable is not a TableHandle for ensureTable).
|
|
76
|
+
await sharedHandle.insert(jsKeyPosts).values({ id: "_warmup", title: "x", createdAt: 0 });
|
|
77
|
+
await sharedConn.exec(`TRUNCATE "posts" RESTART IDENTITY CASCADE`);
|
|
78
|
+
}, 15_000);
|
|
79
|
+
|
|
80
|
+
afterAll(async () => {
|
|
81
|
+
await sharedConn.close();
|
|
82
|
+
});
|
|
67
83
|
|
|
68
|
-
|
|
69
|
-
|
|
84
|
+
beforeEach(async () => {
|
|
85
|
+
await sharedConn.exec(`TRUNCATE "posts" RESTART IDENTITY CASCADE`);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("select / findById / insert.returning never emit created_at", async () => {
|
|
89
|
+
const [created] = await sharedHandle
|
|
90
|
+
.insert(jsKeyPosts)
|
|
70
91
|
.values({ id: "p1", title: "one", createdAt: 100 })
|
|
71
92
|
.returning();
|
|
72
93
|
expect(Object.keys(created!).sort()).toEqual(["createdAt", "id", "title"]);
|
|
73
94
|
expect("created_at" in created!).toBe(false);
|
|
74
95
|
|
|
75
|
-
const listed = await
|
|
96
|
+
const listed = await sharedHandle.select().from(jsKeyPosts);
|
|
76
97
|
expect(Object.keys(listed[0]!).sort()).toEqual(["createdAt", "id", "title"]);
|
|
77
98
|
expect("created_at" in listed[0]!).toBe(false);
|
|
78
99
|
|
|
79
|
-
const got = await
|
|
100
|
+
const got = await sharedHandle.findById(jsKeyPosts, "p1");
|
|
80
101
|
expect(Object.keys(got!).sort()).toEqual(["createdAt", "id", "title"]);
|
|
81
102
|
expect("created_at" in got!).toBe(false);
|
|
82
|
-
|
|
83
|
-
await conn.close();
|
|
84
103
|
});
|
|
85
104
|
});
|
|
86
105
|
|
|
@@ -4,9 +4,13 @@
|
|
|
4
4
|
* Relational `with:` / Drizzle RQB (`db.query.*.findMany({ with })`) is a
|
|
5
5
|
* documented limitation: effects, cache keys, and PII masking assume one
|
|
6
6
|
* table per call, so no relational query surface may appear on the handle.
|
|
7
|
+
*
|
|
8
|
+
* Upsert / orderBy / like need the real Postgres dialect (PGlite). One warmed
|
|
9
|
+
* in-memory PGlite is shared for the whole file (cold WASM once); TRUNCATE
|
|
10
|
+
* isolates each test without a fresh `PGlite.create`.
|
|
7
11
|
*/
|
|
8
12
|
|
|
9
|
-
import { describe, expect, test } from "bun:test";
|
|
13
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
10
14
|
import { and, desc, eq, like, lt, or } from "drizzle-orm";
|
|
11
15
|
import { integer, pgTable, text } from "drizzle-orm/pg-core";
|
|
12
16
|
import { pgliteDriver } from "../../drivers/pglite.ts";
|
|
@@ -19,31 +23,38 @@ const posts = pgTable("posts", {
|
|
|
19
23
|
createdAt: integer("created_at").notNull(),
|
|
20
24
|
});
|
|
21
25
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
26
|
+
/** File-scoped warmed PGlite — opened in {@link beforeAll}. */
|
|
27
|
+
let sharedConn: SqlConnection;
|
|
28
|
+
/** Handle over {@link sharedConn}. */
|
|
29
|
+
let sharedHandle: SqlStoreHandle;
|
|
30
|
+
|
|
31
|
+
beforeAll(async () => {
|
|
32
|
+
sharedConn = await pgliteDriver.connect({
|
|
33
|
+
url: "memory://sql-session-shared",
|
|
25
34
|
role: "primary",
|
|
26
35
|
});
|
|
27
|
-
|
|
28
|
-
connection:
|
|
36
|
+
sharedHandle = createSqlStoreHandle("sql:app", {
|
|
37
|
+
connection: sharedConn,
|
|
29
38
|
classifications: new Map(),
|
|
30
39
|
routedRole: "primary",
|
|
31
40
|
domainDdl: "ensure",
|
|
32
41
|
});
|
|
33
|
-
|
|
34
|
-
}
|
|
42
|
+
// Warm DDL via the insert path (pgTable is not a TableHandle for ensureTable).
|
|
43
|
+
await sharedHandle.insert(posts).values({ id: "_warmup", title: "x", createdAt: 0 });
|
|
44
|
+
await sharedConn.exec(`TRUNCATE "posts" RESTART IDENTITY CASCADE`);
|
|
45
|
+
}, 15_000);
|
|
46
|
+
|
|
47
|
+
afterAll(async () => {
|
|
48
|
+
await sharedConn.close();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
beforeEach(async () => {
|
|
52
|
+
await sharedConn.exec(`TRUNCATE "posts" RESTART IDENTITY CASCADE`);
|
|
53
|
+
});
|
|
35
54
|
|
|
36
55
|
describe("SqlStoreHandle — no relational query surface (path b)", () => {
|
|
37
|
-
test("created handle exposes exactly the single-table surface",
|
|
38
|
-
|
|
39
|
-
const handle = createSqlStoreHandle("sql:app", {
|
|
40
|
-
connection: conn,
|
|
41
|
-
classifications: new Map(),
|
|
42
|
-
routedRole: "primary",
|
|
43
|
-
domainDdl: "ensure",
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
expect(Object.keys(handle).sort()).toEqual(
|
|
56
|
+
test("created handle exposes exactly the single-table surface", () => {
|
|
57
|
+
expect(Object.keys(sharedHandle).sort()).toEqual(
|
|
47
58
|
[
|
|
48
59
|
"ref",
|
|
49
60
|
"routedRole",
|
|
@@ -64,47 +75,40 @@ describe("SqlStoreHandle — no relational query surface (path b)", () => {
|
|
|
64
75
|
);
|
|
65
76
|
|
|
66
77
|
// No Drizzle RQB / with: surface — ever.
|
|
67
|
-
expect("query" in
|
|
68
|
-
expect("findMany" in
|
|
69
|
-
expect("findFirst" in
|
|
70
|
-
expect("with" in
|
|
71
|
-
|
|
72
|
-
await conn.close();
|
|
78
|
+
expect("query" in sharedHandle).toBe(false);
|
|
79
|
+
expect("findMany" in sharedHandle).toBe(false);
|
|
80
|
+
expect("findFirst" in sharedHandle).toBe(false);
|
|
81
|
+
expect("with" in sharedHandle).toBe(false);
|
|
73
82
|
});
|
|
74
83
|
});
|
|
75
84
|
|
|
76
85
|
describe("SqlStoreHandle — orderBy / limit select chain", () => {
|
|
77
86
|
test("orderBy + limit without where", async () => {
|
|
78
|
-
|
|
79
|
-
await
|
|
80
|
-
await
|
|
81
|
-
await handle.insert(posts).values({ id: "p3", title: "three", createdAt: 200 });
|
|
87
|
+
await sharedHandle.insert(posts).values({ id: "p1", title: "one", createdAt: 100 });
|
|
88
|
+
await sharedHandle.insert(posts).values({ id: "p2", title: "two", createdAt: 300 });
|
|
89
|
+
await sharedHandle.insert(posts).values({ id: "p3", title: "three", createdAt: 200 });
|
|
82
90
|
|
|
83
|
-
const rows = await
|
|
91
|
+
const rows = await sharedHandle.select().from(posts).orderBy(desc(posts.createdAt)).limit(2);
|
|
84
92
|
expect(rows.map((r) => r.id)).toEqual(["p2", "p3"]);
|
|
85
|
-
await conn.close();
|
|
86
93
|
});
|
|
87
94
|
|
|
88
95
|
test("limit directly on from() — no where required", async () => {
|
|
89
|
-
|
|
90
|
-
await
|
|
91
|
-
await handle.insert(posts).values({ id: "p2", title: "two", createdAt: 300 });
|
|
96
|
+
await sharedHandle.insert(posts).values({ id: "p1", title: "one", createdAt: 100 });
|
|
97
|
+
await sharedHandle.insert(posts).values({ id: "p2", title: "two", createdAt: 300 });
|
|
92
98
|
|
|
93
|
-
const rows = await
|
|
99
|
+
const rows = await sharedHandle.select().from(posts).limit(1);
|
|
94
100
|
expect(rows).toHaveLength(1);
|
|
95
|
-
await conn.close();
|
|
96
101
|
});
|
|
97
102
|
|
|
98
103
|
test("composite cursor predicate: where → orderBy → limit", async () => {
|
|
99
|
-
|
|
100
|
-
await
|
|
101
|
-
await
|
|
102
|
-
await handle.insert(posts).values({ id: "p3", title: "three", createdAt: 200 });
|
|
104
|
+
await sharedHandle.insert(posts).values({ id: "p1", title: "one", createdAt: 100 });
|
|
105
|
+
await sharedHandle.insert(posts).values({ id: "p2", title: "two", createdAt: 300 });
|
|
106
|
+
await sharedHandle.insert(posts).values({ id: "p3", title: "three", createdAt: 200 });
|
|
103
107
|
// Tie on createdAt=200 with an id that sorts after the cursor id.
|
|
104
|
-
await
|
|
108
|
+
await sharedHandle.insert(posts).values({ id: "p0", title: "four", createdAt: 200 });
|
|
105
109
|
|
|
106
110
|
const order = [desc(posts.createdAt), desc(posts.id)] as const;
|
|
107
|
-
const page1 = await
|
|
111
|
+
const page1 = await sharedHandle
|
|
108
112
|
.select()
|
|
109
113
|
.from(posts)
|
|
110
114
|
.orderBy(...order)
|
|
@@ -112,61 +116,54 @@ describe("SqlStoreHandle — orderBy / limit select chain", () => {
|
|
|
112
116
|
expect(page1.map((r) => r.id)).toEqual(["p2", "p3"]);
|
|
113
117
|
|
|
114
118
|
// (createdAt, id) < (200, "p3") — the second page of a keyset cursor.
|
|
115
|
-
const page2 = await
|
|
119
|
+
const page2 = await sharedHandle
|
|
116
120
|
.select()
|
|
117
121
|
.from(posts)
|
|
118
122
|
.where(or(lt(posts.createdAt, 200), and(eq(posts.createdAt, 200), lt(posts.id, "p3"))))
|
|
119
123
|
.orderBy(...order)
|
|
120
124
|
.limit(2);
|
|
121
125
|
expect(page2.map((r) => r.id)).toEqual(["p0", "p1"]);
|
|
122
|
-
await conn.close();
|
|
123
126
|
});
|
|
124
127
|
|
|
125
128
|
test("like filters rows through the session (postgres case-sensitive)", async () => {
|
|
126
|
-
|
|
127
|
-
await
|
|
128
|
-
await handle.insert(posts).values({ id: "p2", title: "bravo", createdAt: 200 });
|
|
129
|
+
await sharedHandle.insert(posts).values({ id: "p1", title: "alpha", createdAt: 100 });
|
|
130
|
+
await sharedHandle.insert(posts).values({ id: "p2", title: "bravo", createdAt: 200 });
|
|
129
131
|
|
|
130
|
-
const rows = await
|
|
132
|
+
const rows = await sharedHandle.select().from(posts).where(like(posts.title, "a%"));
|
|
131
133
|
expect(rows.map((r) => r.id)).toEqual(["p1"]);
|
|
132
|
-
await conn.close();
|
|
133
134
|
});
|
|
134
135
|
});
|
|
135
136
|
|
|
136
137
|
describe("SqlStoreHandle — upsert", () => {
|
|
137
138
|
test("default inserts once then already-existed without touching the row", async () => {
|
|
138
|
-
const
|
|
139
|
-
const first = await handle.upsert(
|
|
139
|
+
const first = await sharedHandle.upsert(
|
|
140
140
|
posts,
|
|
141
141
|
{ id: "welcome" },
|
|
142
142
|
{ id: "welcome", title: "Hello", createdAt: 1 },
|
|
143
143
|
);
|
|
144
144
|
expect(first.status).toBe("upserted");
|
|
145
145
|
|
|
146
|
-
const second = await
|
|
146
|
+
const second = await sharedHandle.upsert(
|
|
147
147
|
posts,
|
|
148
148
|
{ id: "welcome" },
|
|
149
149
|
{ id: "welcome", title: "Changed", createdAt: 2 },
|
|
150
150
|
);
|
|
151
151
|
expect(second.status).toBe("already-existed");
|
|
152
152
|
|
|
153
|
-
const row = await
|
|
153
|
+
const row = await sharedHandle.findById(posts, "welcome");
|
|
154
154
|
expect(row).toEqual({ id: "welcome", title: "Hello", createdAt: 1 });
|
|
155
|
-
await conn.close();
|
|
156
155
|
});
|
|
157
156
|
|
|
158
157
|
test("onExisting update changes matched columns", async () => {
|
|
159
|
-
|
|
160
|
-
await
|
|
161
|
-
const updated = await handle.upsert(
|
|
158
|
+
await sharedHandle.upsert(posts, { id: "n1" }, { id: "n1", title: "one", createdAt: 10 });
|
|
159
|
+
const updated = await sharedHandle.upsert(
|
|
162
160
|
posts,
|
|
163
161
|
{ id: "n1" },
|
|
164
162
|
{ id: "n1", title: "two", createdAt: 20 },
|
|
165
163
|
{ onExisting: "update" },
|
|
166
164
|
);
|
|
167
165
|
expect(updated.status).toBe("changed");
|
|
168
|
-
const row = await
|
|
166
|
+
const row = await sharedHandle.findById(posts, "n1");
|
|
169
167
|
expect(row).toEqual({ id: "n1", title: "two", createdAt: 20 });
|
|
170
|
-
await conn.close();
|
|
171
168
|
});
|
|
172
169
|
});
|
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Runs against a real PGlite instance so the SQL (DDL, `bytea` round-trip,
|
|
5
5
|
* `DISTINCT ON`, `jsonb`) is exercised rather than mocked.
|
|
6
|
+
*
|
|
7
|
+
* One warmed in-memory PGlite is shared for the whole file (cold WASM once);
|
|
8
|
+
* {@link resetVaultTables} isolates each test without a fresh `PGlite.create`.
|
|
6
9
|
*/
|
|
7
10
|
|
|
8
|
-
import { describe, expect, test } from "bun:test";
|
|
11
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
9
12
|
import { connectPglite } from "../../drivers/pglite.ts";
|
|
10
13
|
import type { SqlConnection } from "../../drivers/types.ts";
|
|
11
14
|
import {
|
|
@@ -16,10 +19,22 @@ import {
|
|
|
16
19
|
import { generateMasterKey, masterKeyToBase64 } from "./crypto.ts";
|
|
17
20
|
import { isVaultError, VaultError } from "./errors.ts";
|
|
18
21
|
import type { SqlExec } from "./storage.ts";
|
|
22
|
+
import { resetVaultTables } from "./test-helpers.ts";
|
|
19
23
|
|
|
20
24
|
const PATH = "prod/api/stripe";
|
|
21
25
|
const VALUE = "sk_live_do_not_log_me";
|
|
22
26
|
|
|
27
|
+
/** File-scoped warmed PGlite — opened in {@link beforeAll}. */
|
|
28
|
+
let sharedConn: SqlConnection;
|
|
29
|
+
|
|
30
|
+
beforeAll(async () => {
|
|
31
|
+
sharedConn = await connectPglite({ url: "memory://vault-adapter-shared" });
|
|
32
|
+
}, 15_000);
|
|
33
|
+
|
|
34
|
+
afterAll(async () => {
|
|
35
|
+
await sharedConn.close();
|
|
36
|
+
});
|
|
37
|
+
|
|
23
38
|
/**
|
|
24
39
|
* Adapt a driver connection to the Vault's {@link SqlExec} surface.
|
|
25
40
|
*
|
|
@@ -29,7 +44,7 @@ function asExec(conn: SqlConnection): SqlExec {
|
|
|
29
44
|
return sqlConnectionAsExec(conn);
|
|
30
45
|
}
|
|
31
46
|
|
|
32
|
-
/** An adapter over
|
|
47
|
+
/** An initialized, unsealed adapter over the shared PGlite, plus a no-op close. */
|
|
33
48
|
interface Harness {
|
|
34
49
|
readonly adapter: BuiltinVaultAdapter;
|
|
35
50
|
readonly db: SqlExec;
|
|
@@ -38,13 +53,13 @@ interface Harness {
|
|
|
38
53
|
}
|
|
39
54
|
|
|
40
55
|
/**
|
|
41
|
-
* Boot an initialized, unsealed adapter over
|
|
56
|
+
* Boot an initialized, unsealed adapter over the shared PGlite (tables reset).
|
|
42
57
|
*
|
|
43
58
|
* @param options - Rewrap batch size for rotation tests
|
|
44
59
|
*/
|
|
45
60
|
async function harness(options: { batchSize?: number } = {}): Promise<Harness> {
|
|
46
|
-
|
|
47
|
-
const db = asExec(
|
|
61
|
+
await resetVaultTables(sharedConn);
|
|
62
|
+
const db = asExec(sharedConn);
|
|
48
63
|
const adapter = createBuiltinVaultAdapter({
|
|
49
64
|
db,
|
|
50
65
|
...(options.batchSize === undefined ? {} : { kekRewrapBatchSize: options.batchSize }),
|
|
@@ -55,31 +70,29 @@ async function harness(options: { batchSize?: number } = {}): Promise<Harness> {
|
|
|
55
70
|
adapter,
|
|
56
71
|
db,
|
|
57
72
|
masterKey: init.masterKey,
|
|
58
|
-
close
|
|
73
|
+
async close() {
|
|
74
|
+
/* shared connection — closed in afterAll */
|
|
75
|
+
},
|
|
59
76
|
};
|
|
60
77
|
}
|
|
61
78
|
|
|
62
79
|
describe("builtin vault adapter — lifecycle", () => {
|
|
63
80
|
test("initialize returns the master key once and refuses a second run", async () => {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const init = await adapter.initialize();
|
|
81
|
+
await resetVaultTables(sharedConn);
|
|
82
|
+
const adapter = createBuiltinVaultAdapter({ db: asExec(sharedConn) });
|
|
83
|
+
const init = await adapter.initialize();
|
|
68
84
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
85
|
+
expect(init.masterKey).toMatch(/^[A-Za-z0-9+/]+=*$/);
|
|
86
|
+
expect(init.verifyHash).toMatch(/^[0-9a-f]{64}$/);
|
|
87
|
+
expect(init.kekVersion).toBe(1);
|
|
72
88
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
89
|
+
const status = await adapter.status();
|
|
90
|
+
expect(status.initialized).toBe(true);
|
|
91
|
+
expect(status.sealed).toBe(true);
|
|
92
|
+
expect(status.masterKeyPresent).toBe(true);
|
|
77
93
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
} finally {
|
|
81
|
-
await conn.close();
|
|
82
|
-
}
|
|
94
|
+
const failure = await adapter.initialize().catch((e: unknown) => e);
|
|
95
|
+
expect(isVaultError(failure, "ALREADY_INITIALIZED")).toBe(true);
|
|
83
96
|
});
|
|
84
97
|
|
|
85
98
|
test("unseal + set/get round-trips a secret", async () => {
|
|
@@ -303,51 +316,94 @@ describe("builtin vault adapter — master rotation", () => {
|
|
|
303
316
|
}
|
|
304
317
|
});
|
|
305
318
|
|
|
306
|
-
test("
|
|
307
|
-
const
|
|
308
|
-
const db = asExec(conn);
|
|
319
|
+
test("rotateMaster: concurrent callers — exactly one wins, loser fails before any DEK rewrite", async () => {
|
|
320
|
+
const h = await harness({ batchSize: 2 });
|
|
309
321
|
try {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
await first.unseal(init.masterKey);
|
|
313
|
-
for (let i = 0; i < 5; i += 1) {
|
|
314
|
-
await first.set(`prod/api/key-${i}`, `value-${i}`);
|
|
322
|
+
for (let i = 0; i < 6; i += 1) {
|
|
323
|
+
await h.adapter.set(`prod/race/${i}`, `v-${i}`);
|
|
315
324
|
}
|
|
325
|
+
const other = createBuiltinVaultAdapter({
|
|
326
|
+
db: h.db,
|
|
327
|
+
kekRewrapBatchSize: 2,
|
|
328
|
+
rotateLeaseMs: 5_000,
|
|
329
|
+
});
|
|
330
|
+
await other.unseal(h.masterKey);
|
|
316
331
|
|
|
317
|
-
const
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
332
|
+
const keyA = generateMasterKey();
|
|
333
|
+
const keyB = generateMasterKey();
|
|
334
|
+
const before = await h.db.query<{ n: string }>(
|
|
335
|
+
`SELECT COUNT(*)::text AS n FROM oke_vault_keys WHERE kek_version = 2`,
|
|
336
|
+
);
|
|
337
|
+
expect(before[0]?.n).toBe("0");
|
|
322
338
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
339
|
+
const [ra, rb] = await Promise.allSettled([
|
|
340
|
+
h.adapter.rotateMaster(keyA),
|
|
341
|
+
other.rotateMaster(keyB),
|
|
342
|
+
]);
|
|
343
|
+
expect([ra, rb].filter((r) => r.status === "fulfilled")).toHaveLength(1);
|
|
344
|
+
expect([ra, rb].filter((r) => r.status === "rejected")).toHaveLength(1);
|
|
327
345
|
|
|
328
|
-
const
|
|
329
|
-
expect(
|
|
346
|
+
const loser = ra.status === "rejected" ? ra : rb;
|
|
347
|
+
expect(
|
|
348
|
+
loser.status === "rejected" && loser.reason instanceof Error ? loser.reason.message : "",
|
|
349
|
+
).toMatch(/lease held|already in progress/i);
|
|
330
350
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
expect((
|
|
337
|
-
|
|
351
|
+
// Loser must not have wrapped any DEK under its key: at most the winner's batch.
|
|
352
|
+
const mid = await h.db.query<{ kek_version: string; n: string }>(
|
|
353
|
+
`SELECT kek_version::text, COUNT(*)::text AS n FROM oke_vault_keys GROUP BY kek_version ORDER BY 1`,
|
|
354
|
+
);
|
|
355
|
+
const v2 = mid.find((r) => r.kek_version === "2");
|
|
356
|
+
expect(Number(v2?.n ?? "0")).toBeLessThanOrEqual(2);
|
|
357
|
+
} finally {
|
|
358
|
+
await h.close();
|
|
359
|
+
}
|
|
360
|
+
});
|
|
338
361
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
362
|
+
test("continueRotateMaster cold-resumes from rewrap_key_hash after process death", async () => {
|
|
363
|
+
await resetVaultTables(sharedConn);
|
|
364
|
+
const db = asExec(sharedConn);
|
|
365
|
+
const first = createBuiltinVaultAdapter({ db, kekRewrapBatchSize: 2 });
|
|
366
|
+
const init = await first.initialize();
|
|
367
|
+
await first.unseal(init.masterKey);
|
|
368
|
+
for (let i = 0; i < 5; i += 1) {
|
|
369
|
+
await first.set(`prod/api/key-${i}`, `value-${i}`);
|
|
370
|
+
}
|
|
342
371
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
372
|
+
const next = generateMasterKey();
|
|
373
|
+
const nextB64 = masterKeyToBase64(next);
|
|
374
|
+
let progress = await first.rotateMaster(next);
|
|
375
|
+
expect(progress.remaining).toBe(3);
|
|
376
|
+
expect((await first.status()).rewrapTargetKekVersion).toBe(2);
|
|
377
|
+
|
|
378
|
+
// Simulate process death: drop in-memory pending, expire the rotate lease
|
|
379
|
+
// (lazy reclaim — same physics as SIGKILL + lease TTL).
|
|
380
|
+
await db.execute(`UPDATE oke_vault_status SET rotate_lease_expires_at = $1 WHERE id = 1`, [
|
|
381
|
+
Date.now() - 1,
|
|
382
|
+
]);
|
|
383
|
+
const second = createBuiltinVaultAdapter({ db, kekRewrapBatchSize: 2 });
|
|
384
|
+
await second.unseal(init.masterKey);
|
|
385
|
+
expect((await second.status()).rewrapTargetKekVersion).toBe(2);
|
|
386
|
+
|
|
387
|
+
const wrong = await second.continueRotateMaster().catch((e: unknown) => e);
|
|
388
|
+
expect(isVaultError(wrong, "INVALID_KEY")).toBe(true);
|
|
389
|
+
|
|
390
|
+
progress = await second.continueRotateMaster(nextB64);
|
|
391
|
+
while (progress.remaining > 0) {
|
|
392
|
+
progress = await second.continueRotateMaster(nextB64);
|
|
393
|
+
}
|
|
394
|
+
expect(progress.remaining).toBe(0);
|
|
395
|
+
expect((await second.status()).kekVersion).toBe(2);
|
|
396
|
+
expect((await second.status()).rewrapTargetKekVersion).toBeUndefined();
|
|
397
|
+
|
|
398
|
+
for (let i = 0; i < 5; i += 1) {
|
|
399
|
+
expect((await second.get(`prod/api/key-${i}`))?.value).toBe(`value-${i}`);
|
|
350
400
|
}
|
|
401
|
+
|
|
402
|
+
await second.seal();
|
|
403
|
+
const stale = await second.unseal(init.masterKey).catch((e: unknown) => e);
|
|
404
|
+
expect(isVaultError(stale, "INVALID_KEY")).toBe(true);
|
|
405
|
+
await second.unseal(nextB64);
|
|
406
|
+
expect((await second.get("prod/api/key-0"))?.value).toBe("value-0");
|
|
351
407
|
});
|
|
352
408
|
});
|
|
353
409
|
|
|
@@ -458,6 +514,7 @@ describe("builtin vault adapter — audit and backup", () => {
|
|
|
458
514
|
|
|
459
515
|
const blob = await h.adapter.exportBackup();
|
|
460
516
|
expect(Buffer.from(blob).toString("utf8")).toStartWith("oke-vault-backup-v1\n");
|
|
517
|
+
expect(Buffer.from(blob).toString("utf8")).toEndWith("oke-vault-backup-end\n");
|
|
461
518
|
expect(Buffer.from(blob).toString("utf8")).not.toContain("prod/db/main");
|
|
462
519
|
|
|
463
520
|
await h.adapter.delete("prod/api/stripe");
|