okengine 0.17.2 → 0.18.3
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/README.md +9 -5
- package/manifest.v1.schema.json +61 -2
- package/package.json +18 -4
- package/site/content/docs/elements/clock.mdx +1 -1
- package/site/content/docs/elements/flow.mdx +25 -1
- package/site/content/docs/elements/store.mdx +287 -341
- package/site/content/docs/elements/vault.mdx +5 -5
- package/site/content/docs/get-started/installation.mdx +1 -2
- package/site/content/docs/get-started/introduction.mdx +54 -110
- package/site/content/docs/get-started/meta.json +9 -1
- package/site/content/docs/get-started/testing.mdx +328 -0
- package/site/content/docs/get-started/why.mdx +94 -70
- package/site/content/docs/index.mdx +1 -1
- package/site/content/docs/plugins/apple.mdx +151 -0
- package/site/content/docs/plugins/discord.mdx +139 -0
- package/site/content/docs/plugins/facebook.mdx +134 -0
- package/site/content/docs/plugins/figma.mdx +138 -0
- package/site/content/docs/plugins/github.mdx +138 -0
- package/site/content/docs/plugins/google.mdx +153 -0
- package/site/content/docs/plugins/index.mdx +47 -1
- package/site/content/docs/plugins/meta.json +10 -0
- package/site/content/docs/plugins/microsoft.mdx +151 -0
- package/site/content/docs/plugins/oauth.mdx +188 -0
- package/site/content/docs/plugins/x.mdx +125 -0
- package/site/content/docs/reference/cli.md +3 -2
- package/site/content/docs/reference/client.mdx +58 -1
- package/site/content/docs/reference/configuration.mdx +2 -4
- package/site/content/docs/reference/fx.mdx +3 -1
- package/site/content/docs/reference/index.mdx +0 -5
- package/site/content/docs/reference/meta.json +2 -2
- package/site/content/docs/reference/okid.mdx +137 -0
- package/src/auth/bindings.ts +1 -1
- package/src/auth/config.ts +9 -0
- package/src/auth/identity-sql.ts +314 -0
- package/src/auth/identity.ts +140 -2
- package/src/auth/index.ts +17 -1
- package/src/auth/method-context.ts +3 -0
- package/src/auth/oauth-as/cimd.ts +132 -0
- package/src/auth/oauth-as/crypto.test.ts +101 -0
- package/src/auth/oauth-as/crypto.ts +393 -0
- package/src/auth/oauth-as/errors.ts +68 -0
- package/src/auth/oauth-as/http.test.ts +419 -0
- package/src/auth/oauth-as/http.ts +842 -0
- package/src/auth/oauth-as/stores.ts +61 -0
- package/src/auth/oauth-as/tables.ts +142 -0
- package/src/auth/tables.ts +0 -11
- package/src/bench/README.md +83 -0
- package/src/bench/REPORT.md +176 -0
- package/src/bench/g01-rls-stamp.bench.ts +194 -0
- package/src/bench/g02-clock-per-tenant.bench.ts +158 -0
- package/src/bench/g03-signal-once.bench.ts +157 -0
- package/src/bench/g03-signal-reconnect.bench.ts +254 -0
- package/src/bench/g03-signal-sse-memory.bench.ts +191 -0
- package/src/bench/g04-auth-vault-hotpath.bench.ts +170 -0
- package/src/bench/g05-sustained-full.bench.ts +265 -0
- package/src/bench/g06-mixed-load.bench.ts +260 -0
- package/src/bench/g07-vault-crypto.bench.ts +100 -0
- package/src/bench/g07-vault-rotate-under-read.bench.ts +285 -0
- package/src/bench/g08-conn-oversubscribe.bench.ts +194 -0
- package/src/bench/g08-store-kv-durable.bench.ts +133 -0
- package/src/bench/g08-store-sql.bench.ts +178 -0
- package/src/bench/g09-journal-sustained.bench.ts +203 -0
- package/src/bench/g10-observability-contention.bench.ts +246 -0
- package/src/bench/g11-cold-start-cycle.bench.ts +164 -0
- package/src/bench/g13-elements.bench.ts +427 -0
- package/src/bench/g14-graceful-shutdown.bench.ts +244 -0
- package/src/bench/g15-postgres-degradation.bench.ts +264 -0
- package/src/bench/g16-live-query-fanout.bench.ts +206 -0
- package/src/bench/lib/event-loop-lag.ts +26 -0
- package/src/bench/lib/infra.ts +60 -0
- package/src/bench/lib/report.ts +52 -0
- package/src/bench/lib/rss-sampler.ts +61 -0
- package/src/bench/lib/signal-pg.ts +88 -0
- package/src/bench/load-app.ts +337 -0
- package/src/bench/load-child.ts +108 -0
- package/src/bench/smoke.bench.ts +43 -0
- package/src/cli/competitor-mention-removal.test.ts +28 -0
- package/src/cli/doctor-fd.ts +117 -0
- package/src/cli/doctor.test.ts +192 -0
- package/src/cli/doctor.ts +129 -1
- package/src/client/create.ts +95 -1
- package/src/client/index.ts +9 -2
- package/src/client/transport.ts +11 -4
- package/src/client/use-live-query.ts +154 -0
- package/src/client-react/index.ts +15 -1
- package/src/client-react/live-resource.ts +246 -0
- package/src/client-react/use-live-query.test.ts +475 -0
- package/src/client-react/use-live-query.ts +530 -0
- package/src/compiler/extract.test.ts +518 -0
- package/src/compiler/extract.ts +386 -19
- package/src/console/server/invoke-user-flow.ts +2 -1
- package/src/console/ui-next/dist/assets/{access-page-DnWbnGzq.js → access-page-DY4N6nnk.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-BiZ4-6yQ.js → flows-page-CsPDMrVM.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-C8NRK2R-.js → index-CcTDXHuz.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CrB6vd1T.js → observability-page-CKR595wP.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-CS5-aETQ.js → store-page-02xOiqIK.js} +3 -3
- package/src/console/ui-next/dist/assets/{units-page-CjtdlW8l.js → units-page-CpPFFKyE.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-C6Xxm9SA.js → vault-page-BsMf-9_W.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/src/features/store/lib/fields-from-table.ts +36 -2
- package/src/drivers/cdc-outbox.ts +389 -0
- package/src/drivers/memory.ts +20 -0
- package/src/drivers/oauth-apple.ts +156 -0
- package/src/drivers/oauth-discord.ts +79 -0
- package/src/drivers/oauth-facebook.ts +80 -0
- package/src/drivers/oauth-figma.ts +116 -0
- package/src/drivers/oauth-github.ts +92 -0
- package/src/drivers/oauth-google.ts +142 -0
- package/src/drivers/oauth-microsoft.ts +174 -0
- package/src/drivers/oauth-oidc.ts +293 -0
- package/src/drivers/oauth-shared.ts +326 -0
- package/src/drivers/oauth-types.ts +159 -0
- package/src/drivers/oauth-x.ts +77 -0
- package/src/drivers/oauth2-common.ts +95 -0
- package/src/drivers/oauth2-token.ts +61 -0
- package/src/drivers/pg-rls-row-passes.ts +251 -0
- package/src/drivers/pg-rls.ts +2 -0
- package/src/drivers/postgres.ts +45 -2
- package/src/drivers/signal-postgres.ts +2 -1
- package/src/elements/channel/runtime.ts +29 -2
- package/src/elements/channel.test.ts +52 -0
- package/src/elements/gate/boot.ts +29 -2
- package/src/elements/store/emit-drizzle.ts +147 -14
- package/src/elements/store/field-ddl.test.ts +118 -0
- package/src/elements/store/field-types.test.ts +455 -0
- package/src/elements/store/list-query.golden.json +777 -0
- package/src/elements/store/list-query.parity.test.ts +396 -0
- package/src/elements/store/list-query.ts +792 -0
- package/src/elements/store/live-default.test.ts +136 -0
- package/src/elements/store/live-http.test.ts +160 -0
- package/src/elements/store/live-isolation.test.ts +291 -0
- package/src/elements/store/live-query-runtime.test.ts +323 -0
- package/src/elements/store/live-query-runtime.ts +403 -0
- package/src/elements/store/live-query-server.test.ts +377 -0
- package/src/elements/store/live-query-server.ts +102 -0
- package/src/elements/store/live-query.ts +97 -0
- package/src/elements/store/resource.ts +189 -680
- package/src/elements/store/rls-row-passes-policies.parity.test.ts +665 -0
- package/src/elements/store/schema-decl.ts +539 -41
- package/src/elements/store/sql-rls-stamp.test.ts +27 -0
- package/src/elements/store/sql-session.ts +297 -35
- package/src/elements/store/table.ts +102 -21
- package/src/elements/store.test.ts +3 -1
- package/src/elements/store.ts +12 -1
- package/src/elements/vault/chaos-child.ts +74 -1
- package/src/elements/vault/chaos.test.ts +4 -2
- package/src/elements/vault/storage.ts +4 -2
- package/src/index.ts +4 -1
- package/src/kernel/app-auth.ts +1 -0
- package/src/kernel/app.ts +116 -2
- package/src/kernel/auth-sharing.test.ts +196 -0
- package/src/kernel/boot.test.ts +3 -3
- package/src/kernel/errors.ts +8 -0
- package/src/kernel/fx.test.ts +1 -0
- package/src/kernel/fx.ts +14 -2
- package/src/kernel/horizontal-child.ts +2 -1
- package/src/kernel/http-resource.ts +33 -7
- package/src/kernel/identity-host-persist.test.ts +119 -0
- package/src/kernel/instance-id.ts +4 -2
- package/src/kernel/journal.ts +2 -1
- package/src/kernel/mcp-tool.test.ts +95 -0
- package/src/kernel/on.ts +9 -0
- package/src/kernel/realtime-bind.ts +326 -0
- package/src/kernel/resource-live.ts +117 -0
- package/src/kernel/triggers.ts +86 -4
- package/src/manifest/diff.ts +37 -0
- package/src/manifest/types.ts +64 -2
- package/src/okid.bench.test.ts +64 -0
- package/src/okid.test.ts +338 -0
- package/src/okid.ts +245 -0
- package/src/plugins/anonymous.ts +19 -1
- package/src/plugins/auth/shared.ts +15 -0
- package/src/plugins/index.ts +2 -0
- package/src/plugins/magic-link.ts +10 -8
- package/src/plugins/mcp-oauth.ts +208 -0
- package/src/plugins/oauth/flow-store.ts +117 -0
- package/src/plugins/oauth/link.ts +69 -0
- package/src/plugins/oauth/shared.ts +108 -0
- package/src/plugins/oauth/token-vault.ts +100 -0
- package/src/plugins/oauth.security.test.ts +535 -0
- package/src/plugins/oauth.ts +532 -0
- package/src/plugins/otp.ts +48 -6
- package/src/plugins/passkey.ts +20 -1
- package/src/plugins/two-factor.ts +11 -0
- package/src/plugins/username.ts +40 -7
- package/src/release/build-lib.ts +7 -1
- package/src/release/measure.ts +1 -0
- package/src/release/official-plugins.ts +4 -1
- package/src/runs/collect.ts +2 -1
- package/src/runs/drivers/files.ts +2 -1
- package/src/test/create-test-app.ts +114 -5
- package/src/test/export-bundle.test.ts +33 -0
- package/src/test/live-signals.test.ts +83 -0
- package/src/test/tenant-isolation.test.ts +175 -0
- package/src/testing.ts +26 -0
- package/src/upgrade/codemods.ts +1 -1
- package/site/content/docs/reference/migrating-environments.mdx +0 -158
package/src/drivers/postgres.ts
CHANGED
|
@@ -122,8 +122,25 @@ export function toPostgresParams(sql: string, values: readonly unknown[] = []):
|
|
|
122
122
|
export async function connectPostgres(options: SqlConnectOptions = {}): Promise<SqlConnection> {
|
|
123
123
|
const role = options.role ?? "primary";
|
|
124
124
|
const injected = options.client as PostgresClientLike | undefined;
|
|
125
|
-
|
|
126
|
-
|
|
125
|
+
// Dedicated single-connection client (pool.max === 1): workloads that hold
|
|
126
|
+
// manual transaction state across many statements (e.g. vault rotateMaster)
|
|
127
|
+
// must not share a pooled client — Bun raises ERR_POSTGRES_UNSAFE_TRANSACTION
|
|
128
|
+
// when raw statements interleave with open transaction state on the shared
|
|
129
|
+
// pool. Opt in explicitly; default behavior is unchanged.
|
|
130
|
+
const dedicated = !injected && options.pool?.max === 1;
|
|
131
|
+
const client =
|
|
132
|
+
injected ??
|
|
133
|
+
(dedicated
|
|
134
|
+
? dedicatedPostgresClient(resolvePostgresUrl(options.url))
|
|
135
|
+
: sharedPostgresClient(options.url));
|
|
136
|
+
return wrapPostgresClient(client, role, {
|
|
137
|
+
shared: injected === undefined && !dedicated,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Standalone one-connection Bun.SQL client — closed with its SqlConnection. */
|
|
142
|
+
function dedicatedPostgresClient(url: string): PostgresClientLike {
|
|
143
|
+
return new Bun.SQL(url, { max: 1 }) as unknown as PostgresClientLike;
|
|
127
144
|
}
|
|
128
145
|
|
|
129
146
|
/**
|
|
@@ -266,6 +283,32 @@ export function createPostgresFakeClient(): PostgresClientLike & {
|
|
|
266
283
|
return [{ [retCol]: next }];
|
|
267
284
|
}
|
|
268
285
|
|
|
286
|
+
// CTE-shaped atomic increment with jsonb image capture:
|
|
287
|
+
// WITH __oke_old AS (SELECT "pk" FROM t WHERE "pk" = ? FOR UPDATE)
|
|
288
|
+
// UPDATE t SET "col" = "col" + ? FROM __oke_old
|
|
289
|
+
// WHERE t."pk" = __oke_old."pk"
|
|
290
|
+
// RETURNING row_to_json(t) AS __oke_after_data, "col"
|
|
291
|
+
const cteIncrement =
|
|
292
|
+
/^WITH\s+__oke_old\s+AS\s+\(SELECT\s+("?[\w]+"?)\s+FROM\s+("?[\w]+"?)\s+WHERE\s+"?[\w]+"?\s*=\s*\?\s+FOR\s+UPDATE\)\s+UPDATE\s+("?[\w]+"?)\s+SET\s+("?[\w]+"?)\s*=\s*("?[\w]+"?)\s*\+\s*\?\s+FROM\s+__oke_old\s+WHERE\s+("?[\w]+")\."?[\w]+"?\s*=\s*__oke_old\."?[\w]+"?\s+RETURNING\s+row_to_json\(("?[\w]+")\)\s+AS\s+__oke_after_data,\s*("?[\w]+"?)\s*$/i.exec(
|
|
293
|
+
normalised,
|
|
294
|
+
);
|
|
295
|
+
if (cteIncrement) {
|
|
296
|
+
const whereTable = parseIdent(cteIncrement[2]!);
|
|
297
|
+
const updateTable = parseIdent(cteIncrement[3]!);
|
|
298
|
+
const setCol = parseIdent(cteIncrement[4]!);
|
|
299
|
+
const addCol = parseIdent(cteIncrement[5]!);
|
|
300
|
+
const retCol = parseIdent(cteIncrement[8]!);
|
|
301
|
+
if (whereTable !== updateTable || setCol !== addCol || setCol !== retCol) {
|
|
302
|
+
throw new Error(`postgres fake: unsupported SQL: ${sql}`);
|
|
303
|
+
}
|
|
304
|
+
const list = tables.get(updateTable) ?? [];
|
|
305
|
+
const row = list.find((r) => r[parseIdent(cteIncrement[1]!)] === values[0]);
|
|
306
|
+
if (!row) return [];
|
|
307
|
+
const next = Number(row[setCol] ?? 0) + Number(values[1]);
|
|
308
|
+
row[setCol] = next;
|
|
309
|
+
return [{ __oke_after_data: { ...row }, [retCol]: next }];
|
|
310
|
+
}
|
|
311
|
+
|
|
269
312
|
const insertReturning =
|
|
270
313
|
/^INSERT\s+INTO\s+("?[a-zA-Z_][a-zA-Z0-9_]*"?)\s*\(([^)]+)\)\s*VALUES\s*\(([^)]+)\)\s*RETURNING\s+\*\s*$/i.exec(
|
|
271
314
|
normalised,
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { SignalDecl } from "../elements/signal/declare.ts";
|
|
9
9
|
import type { SignalDelivery } from "../manifest/types.ts";
|
|
10
|
+
import { okid } from "../okid.ts";
|
|
10
11
|
import { DryRunWriteIsolationError, setDryRunMessageId, withDryRun } from "../kernel/dry-run.ts";
|
|
11
12
|
import { OkeError, OKE_ERRORS } from "../kernel/errors.ts";
|
|
12
13
|
import { LIVE_RESUME_GAP } from "../kernel/errors-live-resume.ts";
|
|
@@ -607,7 +608,7 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
|
|
|
607
608
|
await tx.exec(
|
|
608
609
|
`INSERT INTO oke_signal_messages (id, signal, payload, ordering_key, delivery, attempts, failures, created_at, available_at, status, locked_by, lease_expires_at, delivered_to, parent_run_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
609
610
|
[
|
|
610
|
-
|
|
611
|
+
okid(),
|
|
611
612
|
signal,
|
|
612
613
|
JSON.stringify(payload ?? null),
|
|
613
614
|
orderingKey,
|
|
@@ -175,7 +175,32 @@ export function createChannelRuntime(options: CreateChannelRuntimeOptions = {}):
|
|
|
175
175
|
const catalog = options.catalog ?? {};
|
|
176
176
|
const defaultLocale = options.defaultLocale ?? "en";
|
|
177
177
|
const now = options.now ?? (() => Date.now());
|
|
178
|
-
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* One wire conversation per transport instance — sently transports are not
|
|
181
|
+
* safe for concurrent `send()` on the shared socket (interleaved SMTP DATA
|
|
182
|
+
* → "Unexpected SMTP response for DATA end"). Every driver handed to this
|
|
183
|
+
* runtime gets a serializing wrapper so concurrent flows queue instead of
|
|
184
|
+
* corrupting the connection.
|
|
185
|
+
*/
|
|
186
|
+
function serializeTransport(t: ChannelDriver["transport"] & object): Transport {
|
|
187
|
+
let tail: Promise<unknown> = Promise.resolve();
|
|
188
|
+
return {
|
|
189
|
+
...(t.provider !== undefined ? { provider: t.provider } : {}),
|
|
190
|
+
send(mail: Parameters<Transport["send"]>[0]): ReturnType<Transport["send"]> {
|
|
191
|
+
const run = tail.then(
|
|
192
|
+
() => t.send(mail),
|
|
193
|
+
() => t.send(mail),
|
|
194
|
+
);
|
|
195
|
+
tail = run.catch(() => undefined);
|
|
196
|
+
return run as ReturnType<Transport["send"]>;
|
|
197
|
+
},
|
|
198
|
+
} as Transport;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const drivers = [...(options.drivers ?? [])].map((d) =>
|
|
202
|
+
d.transport ? { ...d, transport: serializeTransport(d.transport) } : d,
|
|
203
|
+
);
|
|
179
204
|
|
|
180
205
|
function resolveBody(
|
|
181
206
|
template: string,
|
|
@@ -543,7 +568,9 @@ export function createChannelRuntime(options: CreateChannelRuntimeOptions = {}):
|
|
|
543
568
|
let attempts: ChannelAttempt[];
|
|
544
569
|
|
|
545
570
|
if (medium === "email" || (medium === "any" && chain.some((d) => d.transport))) {
|
|
546
|
-
|
|
571
|
+
// Default sender must pass RFC/sently validation — `oke@localhost`
|
|
572
|
+
// (dotless) was rejected by every real SMTP transport after retries.
|
|
573
|
+
const from = decl.from ?? "oke@localhost.test";
|
|
547
574
|
const sendResult = await sendViaEmailChain(chain, {
|
|
548
575
|
from,
|
|
549
576
|
to: opts.to,
|
|
@@ -70,6 +70,58 @@ describe("channel declaration", () => {
|
|
|
70
70
|
});
|
|
71
71
|
|
|
72
72
|
describe("fallback chain records both attempts", () => {
|
|
73
|
+
test("default sender passes SMTP address validation (G13d fix)", async () => {
|
|
74
|
+
const seen: Array<string | undefined> = [];
|
|
75
|
+
const capturing = okTransport("smtp");
|
|
76
|
+
const base = capturing.send.bind(capturing);
|
|
77
|
+
const runtime = createChannelRuntime({
|
|
78
|
+
templates: [channel.email().template("no-from-declared")],
|
|
79
|
+
drivers: [
|
|
80
|
+
driverFromTransport("smtp", {
|
|
81
|
+
provider: "smtp",
|
|
82
|
+
async send(options: MailOptions) {
|
|
83
|
+
seen.push(addressToString(options.from));
|
|
84
|
+
return base(options);
|
|
85
|
+
},
|
|
86
|
+
}),
|
|
87
|
+
],
|
|
88
|
+
catalog: { "no-from-declared": { en: { subject: "s", text: "t" } } },
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const result = await runtime.send("no-from-declared", { to: "user@example.com" });
|
|
92
|
+
expect(result.ok).toBe(true);
|
|
93
|
+
// Dotless `oke@localhost` was rejected by sently address validation.
|
|
94
|
+
expect(seen[0]).toBe("oke@localhost.test");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("concurrent sends serialize per transport — no wire interleave (G13d fix)", async () => {
|
|
98
|
+
let inside = 0;
|
|
99
|
+
let overlapped = false;
|
|
100
|
+
const serialTransport: Transport = {
|
|
101
|
+
provider: "smtp",
|
|
102
|
+
async send(options: MailOptions): Promise<SendResult> {
|
|
103
|
+
if (inside > 0) overlapped = true;
|
|
104
|
+
inside += 1;
|
|
105
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
106
|
+
inside -= 1;
|
|
107
|
+
return okTransport("smtp").send(options);
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
const runtime = createChannelRuntime({
|
|
111
|
+
templates: [channel.email().template("bulk")],
|
|
112
|
+
drivers: [driverFromTransport("smtp", serialTransport)],
|
|
113
|
+
catalog: { bulk: { en: { subject: "s", text: "t" } } },
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const results = await Promise.all(
|
|
117
|
+
Array.from({ length: 8 }, (_, i) =>
|
|
118
|
+
runtime.send("bulk", { to: `user${i}@example.com` }).then((r) => r.ok),
|
|
119
|
+
),
|
|
120
|
+
);
|
|
121
|
+
expect(results.every(Boolean)).toBe(true);
|
|
122
|
+
expect(overlapped).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
|
|
73
125
|
test("failed then succeeded — both attempts on the receipt", async () => {
|
|
74
126
|
const inbox = createChannelInbox();
|
|
75
127
|
const runtime = createChannelRuntime({
|
|
@@ -28,7 +28,7 @@ export class GateBootError extends Error {
|
|
|
28
28
|
constructor(gaps: readonly GatePostureGap[]) {
|
|
29
29
|
const lines = gaps.map((g) => ` - ${g.flowId} ${g.method} ${g.path}`);
|
|
30
30
|
super(
|
|
31
|
-
`gate boot failed — ${gaps.length}
|
|
31
|
+
`gate boot failed — ${gaps.length} trigger(s) missing auth posture (attach a gate or .public()):\n${lines.join("\n")}`,
|
|
32
32
|
);
|
|
33
33
|
this.name = "GateBootError";
|
|
34
34
|
this.gaps = gaps;
|
|
@@ -43,6 +43,13 @@ export interface HttpTriggerPosture {
|
|
|
43
43
|
readonly gates: readonly (string | { readonly name: string })[];
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/** Minimal MCP tool trigger shape for posture audit. */
|
|
47
|
+
export interface McpTriggerPosture {
|
|
48
|
+
readonly kind: "mcp";
|
|
49
|
+
readonly name: string;
|
|
50
|
+
readonly gates: readonly (string | { readonly name: string })[];
|
|
51
|
+
}
|
|
52
|
+
|
|
46
53
|
/** Binding shape consumed by the audit. */
|
|
47
54
|
export interface BindingPosture {
|
|
48
55
|
readonly trigger: { readonly kind: string } & Omit<Partial<HttpTriggerPosture>, "kind">;
|
|
@@ -103,6 +110,26 @@ export function collectUnguardedHttpGaps(bindings: readonly BindingPosture[]): G
|
|
|
103
110
|
return gaps;
|
|
104
111
|
}
|
|
105
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Collect MCP tool triggers with neither a gate nor `.public()`.
|
|
115
|
+
*
|
|
116
|
+
* @param bindings - Adopted / registered bindings
|
|
117
|
+
*/
|
|
118
|
+
export function collectUnguardedMcpGaps(bindings: readonly BindingPosture[]): GatePostureGap[] {
|
|
119
|
+
const gaps: GatePostureGap[] = [];
|
|
120
|
+
for (const b of bindings) {
|
|
121
|
+
if (b.trigger.kind !== "mcp") continue;
|
|
122
|
+
const t = b.trigger as McpTriggerPosture;
|
|
123
|
+
if (hasHttpGatePosture(t.gates)) continue;
|
|
124
|
+
gaps.push({
|
|
125
|
+
flowId: b.flow.name,
|
|
126
|
+
method: "mcp",
|
|
127
|
+
path: t.name,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return gaps;
|
|
131
|
+
}
|
|
132
|
+
|
|
106
133
|
/**
|
|
107
134
|
* Whether `unguardedHttp: "allow"` may skip the audit for this boot.
|
|
108
135
|
* Hard-coded: only `env === "test"`.
|
|
@@ -131,6 +158,6 @@ export function assertHttpGatePosture(
|
|
|
131
158
|
const opts: AssertHttpGatePostureOptions =
|
|
132
159
|
typeof options === "string" ? { unguardedHttp: options } : options;
|
|
133
160
|
if (unguardedHttpAllowActive(opts.unguardedHttp, opts.env)) return;
|
|
134
|
-
const gaps = collectUnguardedHttpGaps(bindings);
|
|
161
|
+
const gaps = [...collectUnguardedHttpGaps(bindings), ...collectUnguardedMcpGaps(bindings)];
|
|
135
162
|
if (gaps.length > 0) throw new GateBootError(gaps);
|
|
136
163
|
}
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
schemaTable,
|
|
18
18
|
schemaTableSqlName,
|
|
19
19
|
tablesFromExports,
|
|
20
|
+
type FieldTypeOptions,
|
|
20
21
|
type RelationColumnRef,
|
|
21
22
|
type SchemaColumnDecl,
|
|
22
23
|
type SchemaColumnInput,
|
|
@@ -68,17 +69,21 @@ export function dialectFromDriverId(driverId: string | undefined): SqlDialect {
|
|
|
68
69
|
/**
|
|
69
70
|
* Serialize one column to a Drizzle builder chain.
|
|
70
71
|
*
|
|
72
|
+
* Emits drizzle-exact calls for every {@link FieldSqlType}, carrying options
|
|
73
|
+
* (`length`, `precision`, `scale`, `withTimezone`, `mode`, `fields`,
|
|
74
|
+
* `enumValues`). Temporal modes are always pinned explicitly — rc.5 library
|
|
75
|
+
* defaults differ from OKE's (bare `date()` = string, bare `timestamp()` =
|
|
76
|
+
* Date).
|
|
77
|
+
*
|
|
78
|
+
* Postgres INTEGER is 32-bit; abstract `integer` (often ms clocks) → bigint.
|
|
79
|
+
* drizzle-orm's `bigint()` requires `{ mode }` — omitting it throws on
|
|
80
|
+
* `config.mode` when the generated schema is loaded by drizzle-kit push.
|
|
81
|
+
*
|
|
71
82
|
* @param col - Column decl
|
|
72
83
|
* @param dialect - Target dialect (affects import names only at file level)
|
|
73
84
|
*/
|
|
74
85
|
export function emitColumnSource(col: SchemaColumnDecl, _dialect: SqlDialect = "postgres"): string {
|
|
75
|
-
|
|
76
|
-
// drizzle-orm's `bigint()` requires `{ mode }` — omitting it throws on
|
|
77
|
-
// `config.mode` when the generated schema is loaded by drizzle-kit push.
|
|
78
|
-
let expr =
|
|
79
|
-
col.sqlType === "integer"
|
|
80
|
-
? `bigint(${JSON.stringify(col.sqlName)}, { mode: "number" })`
|
|
81
|
-
: `text(${JSON.stringify(col.sqlName)})`;
|
|
86
|
+
let expr = `${columnFactoryCall(col)}`;
|
|
82
87
|
if (col.primaryKey) expr += ".primaryKey()";
|
|
83
88
|
else if (col.notNull) expr += ".notNull()";
|
|
84
89
|
if (col.unique) expr += ".unique()";
|
|
@@ -87,6 +92,8 @@ export function emitColumnSource(col: SchemaColumnDecl, _dialect: SqlDialect = "
|
|
|
87
92
|
}
|
|
88
93
|
if (col.defaultFnKind === "id") expr += ".$defaultFn(id)";
|
|
89
94
|
else if (col.defaultFnKind === "now") expr += ".$defaultFn(now)";
|
|
95
|
+
else if (col.defaultFnKind === "nowIso") expr += ".$defaultFn(nowIso)";
|
|
96
|
+
else if (col.defaultFnKind === "nowDate") expr += ".$defaultFn(nowDate)";
|
|
90
97
|
if (col.references) {
|
|
91
98
|
const target = col.references.ref();
|
|
92
99
|
const targetTable = exportNameForTable(target.tableName ?? "unknown");
|
|
@@ -104,8 +111,119 @@ export function emitColumnSource(col: SchemaColumnDecl, _dialect: SqlDialect = "
|
|
|
104
111
|
return expr;
|
|
105
112
|
}
|
|
106
113
|
|
|
107
|
-
|
|
114
|
+
/** Factory call for a column type with its options, e.g. `varchar("x", { length: 256 })`. */
|
|
115
|
+
function columnFactoryCall(col: SchemaColumnDecl): string {
|
|
116
|
+
const name = JSON.stringify(col.sqlName);
|
|
117
|
+
const o = col.typeOptions ?? {};
|
|
118
|
+
switch (col.sqlType) {
|
|
119
|
+
case "text":
|
|
120
|
+
return enumConfig(o) !== undefined
|
|
121
|
+
? `text(${name}, { enum: ${JSON.stringify(o.enumValues)} })`
|
|
122
|
+
: `text(${name})`;
|
|
123
|
+
case "varchar":
|
|
124
|
+
case "char": {
|
|
125
|
+
const factory = col.sqlType;
|
|
126
|
+
const configParts: string[] = [];
|
|
127
|
+
if (o.length !== undefined) configParts.push(`length: ${o.length}`);
|
|
128
|
+
const enumValues = enumConfig(o);
|
|
129
|
+
if (enumValues !== undefined) configParts.push(`enum: ${enumValues}`);
|
|
130
|
+
return configParts.length > 0
|
|
131
|
+
? `${factory}(${name}, { ${configParts.join(", ")} })`
|
|
132
|
+
: `${factory}(${name})`;
|
|
133
|
+
}
|
|
134
|
+
case "boolean":
|
|
135
|
+
return `boolean(${name})`;
|
|
136
|
+
case "smallint":
|
|
137
|
+
return `smallint(${name})`;
|
|
138
|
+
case "integer":
|
|
139
|
+
// Postgres INTEGER is 32-bit — abstract integer (ms clocks) widens.
|
|
140
|
+
return `bigint(${name}, { mode: "number" })`;
|
|
141
|
+
case "bigint":
|
|
142
|
+
return `bigint(${name}, { mode: ${JSON.stringify(o.mode ?? "number")} })`;
|
|
143
|
+
case "serial":
|
|
144
|
+
return `serial(${name})`;
|
|
145
|
+
case "smallserial":
|
|
146
|
+
return `smallserial(${name})`;
|
|
147
|
+
case "bigserial":
|
|
148
|
+
return `bigserial(${name}, { mode: ${JSON.stringify(o.mode ?? "number")} })`;
|
|
149
|
+
case "numeric": {
|
|
150
|
+
const configParts: string[] = [];
|
|
151
|
+
if (o.precision !== undefined) configParts.push(`precision: ${o.precision}`);
|
|
152
|
+
if (o.scale !== undefined) configParts.push(`scale: ${o.scale}`);
|
|
153
|
+
if (o.mode !== undefined && o.mode !== "string") {
|
|
154
|
+
configParts.push(`mode: ${JSON.stringify(o.mode)}`);
|
|
155
|
+
}
|
|
156
|
+
// rc.5 factory shape: numeric(name, config?) where the config is a
|
|
157
|
+
// discriminated union requiring at least one of precision/scale/mode.
|
|
158
|
+
return configParts.length > 0
|
|
159
|
+
? `numeric(${name}, { ${configParts.join(", ")} })`
|
|
160
|
+
: `numeric(${name})`;
|
|
161
|
+
}
|
|
162
|
+
case "real":
|
|
163
|
+
return `real(${name})`;
|
|
164
|
+
case "doublePrecision":
|
|
165
|
+
return `doublePrecision(${name})`;
|
|
166
|
+
case "json":
|
|
167
|
+
return `json(${name})`;
|
|
168
|
+
case "jsonb":
|
|
169
|
+
return `jsonb(${name})`;
|
|
170
|
+
case "uuid":
|
|
171
|
+
return `uuid(${name})`;
|
|
172
|
+
case "time": {
|
|
173
|
+
const configParts: string[] = [];
|
|
174
|
+
if (o.withTimezone !== undefined) configParts.push(`withTimezone: ${o.withTimezone}`);
|
|
175
|
+
if (o.precision !== undefined) configParts.push(`precision: ${o.precision}`);
|
|
176
|
+
return configParts.length > 0
|
|
177
|
+
? `time(${name}, { ${configParts.join(", ")} })`
|
|
178
|
+
: `time(${name})`;
|
|
179
|
+
}
|
|
180
|
+
case "timestamp": {
|
|
181
|
+
const configParts: string[] = [`mode: ${JSON.stringify(o.mode ?? "date")}`];
|
|
182
|
+
if (o.precision !== undefined) configParts.push(`precision: ${o.precision}`);
|
|
183
|
+
if (o.withTimezone !== undefined) configParts.push(`withTimezone: ${o.withTimezone}`);
|
|
184
|
+
return `timestamp(${name}, { ${configParts.join(", ")} })`;
|
|
185
|
+
}
|
|
186
|
+
case "date":
|
|
187
|
+
return `date(${name}, { mode: ${JSON.stringify(o.mode ?? "date")} })`;
|
|
188
|
+
case "interval": {
|
|
189
|
+
const configParts: string[] = [];
|
|
190
|
+
if (o.fields !== undefined) configParts.push(`fields: ${JSON.stringify(o.fields)}`);
|
|
191
|
+
if (o.precision !== undefined) configParts.push(`precision: ${o.precision}`);
|
|
192
|
+
return configParts.length > 0
|
|
193
|
+
? `interval(${name}, { ${configParts.join(", ")} })`
|
|
194
|
+
: `interval(${name})`;
|
|
195
|
+
}
|
|
196
|
+
case "point":
|
|
197
|
+
return o.mode !== undefined && o.mode !== "tuple"
|
|
198
|
+
? `point(${name}, { mode: ${JSON.stringify(o.mode)} })`
|
|
199
|
+
: `point(${name})`;
|
|
200
|
+
case "line":
|
|
201
|
+
return o.mode !== undefined && o.mode !== "tuple"
|
|
202
|
+
? `line(${name}, { mode: ${JSON.stringify(o.mode)} })`
|
|
203
|
+
: `line(${name})`;
|
|
204
|
+
case "bytea":
|
|
205
|
+
return `bytea(${name})`;
|
|
206
|
+
case "inet":
|
|
207
|
+
return `inet(${name})`;
|
|
208
|
+
case "cidr":
|
|
209
|
+
return `cidr(${name})`;
|
|
210
|
+
case "macaddr":
|
|
211
|
+
return `macaddr(${name})`;
|
|
212
|
+
case "macaddr8":
|
|
213
|
+
return `macaddr8(${name})`;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function enumConfig(o: FieldTypeOptions): string | undefined {
|
|
218
|
+
if (!o.enumValues || o.enumValues.length === 0) return undefined;
|
|
219
|
+
return JSON.stringify(o.enumValues);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function literalSource(value: string | number | boolean | Date | Buffer | null): string {
|
|
108
223
|
if (value === null) return "null";
|
|
224
|
+
if (value instanceof Date) return `new Date(${JSON.stringify(value.toISOString())})`;
|
|
225
|
+
if (Buffer.isBuffer(value))
|
|
226
|
+
return `Buffer.from(${JSON.stringify(value.toString("base64"))}, "base64")`;
|
|
109
227
|
if (typeof value === "string") return JSON.stringify(value);
|
|
110
228
|
if (typeof value === "boolean") return value ? "true" : "false";
|
|
111
229
|
return String(value);
|
|
@@ -184,12 +302,18 @@ export function emitDrizzleSource(
|
|
|
184
302
|
options: EmitDrizzleSourceOptions = {},
|
|
185
303
|
): string {
|
|
186
304
|
void dialect;
|
|
187
|
-
const
|
|
188
|
-
|
|
305
|
+
const usedFactories = new Set<string>();
|
|
306
|
+
for (const table of tables) {
|
|
307
|
+
for (const col of Object.values(table.columns)) {
|
|
308
|
+
// Abstract `integer` emits as drizzle `bigint` (32-bit widening).
|
|
309
|
+
usedFactories.add(col.sqlType === "integer" ? "bigint" : col.sqlType);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
const pgIntImport = [...usedFactories].filter(
|
|
313
|
+
(name) => name !== "pgTable" && name !== "pgPolicy",
|
|
189
314
|
);
|
|
190
|
-
const pgIntImport = needsInteger ? "bigint" : null;
|
|
191
315
|
const needsPolicy = tables.some((t) => (t.policies?.length ?? 0) > 0);
|
|
192
|
-
const coreNames = ["pgTable",
|
|
316
|
+
const coreNames = ["pgTable", ...pgIntImport, needsPolicy ? "pgPolicy" : null].filter(
|
|
193
317
|
(name): name is string => name !== null,
|
|
194
318
|
);
|
|
195
319
|
const coreImport = `import { ${coreNames.join(", ")} } from "drizzle-orm/pg-core";`;
|
|
@@ -201,9 +325,18 @@ export function emitDrizzleSource(
|
|
|
201
325
|
const needsNow = tables.some((t) =>
|
|
202
326
|
Object.values(t.columns).some((c) => c.defaultFnKind === "now"),
|
|
203
327
|
);
|
|
204
|
-
const
|
|
205
|
-
(
|
|
328
|
+
const needsNowIso = tables.some((t) =>
|
|
329
|
+
Object.values(t.columns).some((c) => c.defaultFnKind === "nowIso"),
|
|
330
|
+
);
|
|
331
|
+
const needsNowDate = tables.some((t) =>
|
|
332
|
+
Object.values(t.columns).some((c) => c.defaultFnKind === "nowDate"),
|
|
206
333
|
);
|
|
334
|
+
const helperNames = [
|
|
335
|
+
needsId ? "id" : null,
|
|
336
|
+
needsNow ? "now" : null,
|
|
337
|
+
needsNowIso ? "nowIso" : null,
|
|
338
|
+
needsNowDate ? "nowDate" : null,
|
|
339
|
+
].filter((x): x is string => x !== null);
|
|
207
340
|
const helperImport =
|
|
208
341
|
helperNames.length > 0 ? `import { ${helperNames.join(", ")} } from "okengine/store";\n` : "";
|
|
209
342
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime auto-DDL across the widened field surface — `ensureFromMeta`
|
|
3
|
+
* creates tables with the new column types on memory + pglite drivers.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { memorySqlDriver } from "../../drivers/memory.ts";
|
|
8
|
+
import { createSqlStoreHandle } from "./sql-session.ts";
|
|
9
|
+
import { defineTable } from "./table.ts";
|
|
10
|
+
import { resolveColumns } from "./table.ts";
|
|
11
|
+
|
|
12
|
+
/** TableHandle carrying sqlType metadata for DDL (declare-site shape). */
|
|
13
|
+
function typedTable(
|
|
14
|
+
name: string,
|
|
15
|
+
cols: Readonly<Record<string, string>>,
|
|
16
|
+
): ReturnType<typeof defineTable> {
|
|
17
|
+
const base = defineTable(name, Object.fromEntries(Object.keys(cols).map((k) => [k, true])));
|
|
18
|
+
return {
|
|
19
|
+
...base,
|
|
20
|
+
columns: Object.fromEntries(
|
|
21
|
+
Object.entries(base.columns).map(([k, v]) => [k, { ...v, sqlType: cols[k] }]),
|
|
22
|
+
),
|
|
23
|
+
} as ReturnType<typeof defineTable>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("ensureFromMeta — widened types", () => {
|
|
27
|
+
test("memory driver CREATEs boolean/jsonb/uuid/timestamp columns", async () => {
|
|
28
|
+
const conn = await memorySqlDriver.connect({ role: "primary" });
|
|
29
|
+
const handle = createSqlStoreHandle("sql:app", {
|
|
30
|
+
connection: conn,
|
|
31
|
+
classifications: new Map(),
|
|
32
|
+
routedRole: "primary",
|
|
33
|
+
domainDdl: "ensure",
|
|
34
|
+
});
|
|
35
|
+
const events = typedTable("events_wide", {
|
|
36
|
+
id: "uuid",
|
|
37
|
+
active: "boolean",
|
|
38
|
+
doc: "jsonb",
|
|
39
|
+
at: "timestamp",
|
|
40
|
+
day: "date",
|
|
41
|
+
amount: "numeric",
|
|
42
|
+
ratio: "doublePrecision",
|
|
43
|
+
big: "bigserial",
|
|
44
|
+
ip: "inet",
|
|
45
|
+
bin: "bytea",
|
|
46
|
+
loc: "point",
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const cols = resolveColumns(events);
|
|
50
|
+
expect(cols.find((c) => c.sqlName === "active")!.sqlType).toBe("BOOLEAN");
|
|
51
|
+
expect(cols.find((c) => c.sqlName === "doc")!.sqlType).toBe("JSONB");
|
|
52
|
+
|
|
53
|
+
await handle.insert(events).values({
|
|
54
|
+
id: "e1",
|
|
55
|
+
active: true,
|
|
56
|
+
doc: JSON.stringify({ ok: 1 }),
|
|
57
|
+
at: "2026-01-01T00:00:00Z",
|
|
58
|
+
day: "2026-01-01",
|
|
59
|
+
amount: "10.50",
|
|
60
|
+
ratio: 0.25,
|
|
61
|
+
big: 7,
|
|
62
|
+
ip: "10.0.0.1",
|
|
63
|
+
bin: Buffer.from("x"),
|
|
64
|
+
loc: "[1,2]",
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const rows = await handle.select().from(events);
|
|
68
|
+
expect(rows).toHaveLength(1);
|
|
69
|
+
expect(rows[0]).toMatchObject({ id: "e1", active: true });
|
|
70
|
+
await conn.close();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("pglite driver accepts unparameterized BIGINT / BOOLEAN / JSONB DDL", async () => {
|
|
74
|
+
let pglite: typeof import("../../drivers/pglite.ts");
|
|
75
|
+
try {
|
|
76
|
+
pglite = await import("../../drivers/pglite.ts");
|
|
77
|
+
} catch {
|
|
78
|
+
return; // driver optional in some environments
|
|
79
|
+
}
|
|
80
|
+
const conn = await pglite.pgliteDriver.connect({ role: "primary" }).catch(() => null);
|
|
81
|
+
if (!conn) return;
|
|
82
|
+
const handle = createSqlStoreHandle("sql:app", {
|
|
83
|
+
connection: conn,
|
|
84
|
+
classifications: new Map(),
|
|
85
|
+
routedRole: "primary",
|
|
86
|
+
domainDdl: "ensure",
|
|
87
|
+
});
|
|
88
|
+
const wide = typedTable("wide_pglite", {
|
|
89
|
+
id: "text",
|
|
90
|
+
n: "integer",
|
|
91
|
+
flag: "boolean",
|
|
92
|
+
payload: "jsonb",
|
|
93
|
+
});
|
|
94
|
+
// pglite data persists in-repo across runs — start from a clean table.
|
|
95
|
+
await conn.exec(`DROP TABLE IF EXISTS "wide_pglite"`);
|
|
96
|
+
await handle.insert(wide).values({ id: "r1", n: 5, flag: false, payload: '{"a":2}' });
|
|
97
|
+
const rows = await handle.select().from(wide);
|
|
98
|
+
expect(rows).toHaveLength(1);
|
|
99
|
+
await conn.close();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("memory parser survives comma-free numeric DDL", async () => {
|
|
103
|
+
const conn = await memorySqlDriver.connect({ role: "primary" });
|
|
104
|
+
const handle = createSqlStoreHandle("sql:app", {
|
|
105
|
+
connection: conn,
|
|
106
|
+
classifications: new Map(),
|
|
107
|
+
routedRole: "primary",
|
|
108
|
+
domainDdl: "ensure",
|
|
109
|
+
});
|
|
110
|
+
// ddlTypeOf emits NUMERIC without parens — the memory CREATE TABLE parser
|
|
111
|
+
// comma-splits column defs, so numeric(p,s) would break it.
|
|
112
|
+
const rates = typedTable("rates_ddl", { id: "text", rate: "numeric" });
|
|
113
|
+
await handle.insert(rates).values({ id: "1", rate: "3.14" });
|
|
114
|
+
const rows = await handle.select().from(rates);
|
|
115
|
+
expect(rows[0]).toMatchObject({ id: "1", rate: "3.14" });
|
|
116
|
+
await conn.close();
|
|
117
|
+
});
|
|
118
|
+
});
|