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.
- package/AGENTS.md +17 -15
- package/README.md +57 -29
- package/package.json +11 -16
- package/site/content/docs/ai/llms-txt.mdx +54 -0
- package/site/content/docs/ai/mcp.mdx +123 -0
- package/site/content/docs/ai/meta.json +5 -0
- package/site/content/docs/ai/skills.mdx +53 -0
- package/site/content/docs/console/access.mdx +29 -0
- package/site/content/docs/console/ai.mdx +35 -0
- package/site/content/docs/console/architecture.mdx +35 -0
- package/site/content/docs/console/channels.mdx +37 -0
- package/site/content/docs/console/clock.mdx +31 -0
- package/site/content/docs/console/flows.mdx +31 -0
- package/site/content/docs/console/gates.mdx +35 -0
- package/site/content/docs/console/manifest-diff.mdx +34 -0
- package/site/content/docs/console/meta.json +23 -0
- package/site/content/docs/console/overview.mdx +40 -0
- package/site/content/docs/console/plugins.mdx +41 -0
- package/site/content/docs/console/privacy.mdx +32 -0
- package/site/content/docs/console/runs.mdx +40 -0
- package/site/content/docs/console/signals.mdx +31 -0
- package/site/content/docs/console/store.mdx +32 -0
- package/site/content/docs/console/tenancy.mdx +32 -0
- package/site/content/docs/console/traces.mdx +34 -0
- package/site/content/docs/console/vault.mdx +37 -0
- package/site/content/docs/elements/ai.mdx +180 -0
- package/site/content/docs/elements/channel.mdx +167 -0
- package/site/content/docs/elements/clock.mdx +182 -0
- package/site/content/docs/elements/flow.mdx +288 -0
- package/site/content/docs/elements/gate.mdx +171 -0
- package/site/content/docs/elements/meta.json +5 -0
- package/site/content/docs/elements/signal.mdx +171 -0
- package/site/content/docs/elements/store.mdx +320 -0
- package/site/content/docs/elements/vault.mdx +263 -0
- package/site/content/docs/get-started/basic-usage.mdx +124 -0
- package/site/content/docs/get-started/comparison.mdx +65 -0
- package/site/content/docs/get-started/installation.mdx +113 -0
- package/site/content/docs/get-started/introduction.mdx +123 -0
- package/site/content/docs/get-started/meta.json +5 -0
- package/site/content/docs/index.mdx +63 -0
- package/site/content/docs/meta.json +5 -0
- package/site/content/docs/plugins/compression.mdx +60 -0
- package/site/content/docs/plugins/cors.mdx +92 -0
- package/site/content/docs/plugins/csrf.mdx +96 -0
- package/site/content/docs/plugins/ip-allowlist.mdx +92 -0
- package/site/content/docs/plugins/maintenance-mode.mdx +101 -0
- package/site/content/docs/plugins/meta.json +15 -0
- package/site/content/docs/plugins/security-headers.mdx +136 -0
- package/site/content/docs/reference/cli.md +101 -0
- package/site/content/docs/reference/configuration.mdx +159 -0
- package/site/content/docs/reference/environment-variables.mdx +87 -0
- package/site/content/docs/reference/errors.mdx +80 -0
- package/site/content/docs/reference/fx.mdx +117 -0
- package/site/content/docs/reference/meta.json +5 -0
- package/site/content/docs/reference/plugins.mdx +249 -0
- package/site/content/docs/reference/security.md +63 -0
- package/src/auth/auth.test.ts +3 -0
- package/src/cli/ask-dev-mode.ts +1 -1
- package/src/cli/db.ts +87 -17
- package/src/cli/dev-db-push.test.ts +32 -2
- package/src/cli/dev-schema-sync.test.ts +66 -0
- package/src/cli/dev-schema-sync.ts +139 -0
- package/src/cli/dev.test.ts +123 -1
- package/src/cli/dev.ts +150 -8
- package/src/cli/doc-staleness.test.ts +4 -4
- package/src/cli/docker-cli.test.ts +20 -0
- package/src/cli/docker.ts +10 -0
- package/src/cli/drizzle-env.test.ts +67 -0
- package/src/cli/drizzle-env.ts +78 -0
- package/src/cli/ensure-drizzle-config.ts +50 -0
- package/src/cli/hero-meta.test.ts +1 -1
- package/src/cli/load-config.ts +6 -0
- package/src/cli/mode.ts +24 -4
- package/src/cli/openbao-bootstrap.test.ts +147 -0
- package/src/cli/openbao-bootstrap.ts +280 -0
- package/src/cli/openbao-restart.integration.test.ts +136 -0
- package/src/cli/ports.test.ts +7 -5
- package/src/cli/ports.ts +6 -2
- package/src/cli/resolve-dev-sql-env.test.ts +48 -0
- package/src/cli/resolve-dev-sql-env.ts +42 -0
- package/src/cli/stack.ts +7 -4
- package/src/cli/vault-cmd.ts +63 -0
- package/src/client/types.ts +7 -1
- package/src/compiler/extract.test.ts +40 -0
- package/src/compiler/extract.ts +123 -1
- package/src/compiler/fixtures/skyport/oke.config.ts +2 -2
- package/src/compiler/fixtures/skyport.expected.json +1 -1
- package/src/compiler/response.ts +12 -0
- package/src/config/define-config.test.ts +4 -6
- package/src/config/index.ts +2 -15
- package/src/console/server/app.ts +2 -0
- package/src/console/server/vault.ts +21 -6
- package/src/docker/compose.ts +112 -16
- package/src/docker/derive.ts +7 -1
- package/src/docker/docker.test.ts +103 -0
- package/src/docker/index.ts +11 -1
- package/src/docker/recipes/index.ts +3 -2
- package/src/docker/recipes/openbao.ts +47 -0
- package/src/docker/recipes/redis.ts +5 -1
- package/src/docker/recipes/rustfs.ts +2 -3
- package/src/docker/stack-id.test.ts +43 -8
- package/src/docker/stack-id.ts +99 -20
- package/src/docker/stack.ts +36 -4
- package/src/docker/types.ts +3 -0
- package/src/docs-origin.ts +4 -4
- package/src/drivers/drizzle-dialect.test.ts +20 -0
- package/src/drivers/drizzle-dialect.ts +37 -0
- package/src/drivers/index.ts +1 -2
- package/src/drivers/memory.ts +278 -39
- package/src/drivers/s3.ts +10 -1
- package/src/drivers/vault-driver-removal.test.ts +55 -0
- package/src/drivers/vault-openbao.test.ts +97 -0
- package/src/drivers/vault-openbao.ts +102 -35
- package/src/drivers/vault-types.ts +3 -10
- package/src/elements/store/declare.ts +4 -1
- package/src/elements/store/resource-list-docs.fixture.ts +56 -0
- package/src/elements/store/resource-list-docs.test.ts +79 -0
- package/src/elements/store/resource.test.ts +253 -0
- package/src/elements/store/resource.ts +786 -0
- package/src/elements/store/sql-condition.test.ts +132 -0
- package/src/elements/store/sql-condition.ts +284 -46
- package/src/elements/store/sql-session.test.ts +86 -1
- package/src/elements/store/sql-session.ts +187 -27
- package/src/elements/store/table.ts +34 -4
- package/src/elements/store.ts +16 -0
- package/src/elements/vault/runtime.ts +1 -1
- package/src/elements/vault.test.ts +1 -28
- package/src/elements/vault.ts +1 -1
- package/src/kernel/app.ts +59 -25
- package/src/kernel/boot-bind/channel.test.ts +60 -0
- package/src/kernel/boot-bind/channel.ts +64 -2
- package/src/kernel/boot-bind/store.test.ts +10 -1
- package/src/kernel/boot-bind/store.ts +49 -2
- package/src/kernel/boot.test.ts +0 -1
- package/src/kernel/boot.ts +1 -1
- package/src/kernel/edge.test.ts +68 -0
- package/src/kernel/errors.registry.test.ts +1 -1
- package/src/kernel/flow.ts +8 -0
- package/src/kernel/fx.test.ts +23 -3
- package/src/kernel/fx.ts +115 -18
- package/src/kernel/hooks.test.ts +33 -0
- package/src/kernel/hooks.ts +22 -0
- package/src/kernel/index.ts +7 -0
- package/src/kernel/on.ts +44 -3
- package/src/kernel/plugin.ts +33 -3
- package/src/kernel/registry-isolation.test.ts +74 -0
- package/src/kernel/registry.ts +22 -1
- package/src/kernel/triggers.ts +59 -0
- package/src/manifest/fixtures/skyport.excerpt.json +1 -1
- package/src/manifest/fixtures/skyport.manifest.json +1 -1
- package/src/manifest/index.ts +1 -1
- package/src/manifest/types.ts +1 -1
- package/src/manifest/validate.ts +2 -2
- package/src/plugins/compression.test.ts +127 -0
- package/src/plugins/compression.ts +94 -0
- package/src/plugins/config-source.test.ts +204 -0
- package/src/plugins/config-source.ts +209 -0
- package/src/plugins/cors.test.ts +138 -0
- package/src/plugins/cors.ts +129 -0
- package/src/plugins/csrf.test.ts +102 -0
- package/src/plugins/csrf.ts +86 -0
- package/src/plugins/headers.ts +54 -0
- package/src/plugins/index.ts +26 -0
- package/src/plugins/ip-allowlist.test.ts +105 -0
- package/src/plugins/ip-allowlist.ts +76 -0
- package/src/plugins/maintenance-mode.test.ts +91 -0
- package/src/plugins/maintenance-mode.ts +85 -0
- package/src/plugins/security-headers.test.ts +243 -0
- package/src/plugins/security-headers.ts +255 -0
- package/src/release/measure.ts +1 -2
- package/src/test/create-test-app.ts +14 -2
- package/docs/spec/console.md +0 -762
- package/docs/spec/example.md +0 -1374
- package/docs/spec/four-applications.md +0 -1376
- package/docs/spec/unified-theory.md +0 -498
- package/src/cli/doc-drift.test.ts +0 -147
- package/src/cli/doc-drift.ts +0 -401
- package/src/cli/doctor-diff-examples.ts +0 -90
- package/src/drivers/vault-sops.ts +0 -246
- /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(
|
|
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 ?? [
|
|
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:
|
|
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
|
+
}
|
package/src/kernel/boot.test.ts
CHANGED
package/src/kernel/boot.ts
CHANGED
|
@@ -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://
|
|
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("→");
|
package/src/kernel/flow.ts
CHANGED
|
@@ -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,
|
package/src/kernel/fx.test.ts
CHANGED
|
@@ -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 {
|
|
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://
|
|
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://
|
|
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 {
|
|
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
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
538
|
-
|
|
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
|
|
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);
|
package/src/kernel/hooks.test.ts
CHANGED
|
@@ -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
|
|