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.
Files changed (197) hide show
  1. package/README.md +9 -5
  2. package/manifest.v1.schema.json +61 -2
  3. package/package.json +18 -4
  4. package/site/content/docs/elements/clock.mdx +1 -1
  5. package/site/content/docs/elements/flow.mdx +25 -1
  6. package/site/content/docs/elements/store.mdx +287 -341
  7. package/site/content/docs/elements/vault.mdx +5 -5
  8. package/site/content/docs/get-started/installation.mdx +1 -2
  9. package/site/content/docs/get-started/introduction.mdx +54 -110
  10. package/site/content/docs/get-started/meta.json +9 -1
  11. package/site/content/docs/get-started/testing.mdx +328 -0
  12. package/site/content/docs/get-started/why.mdx +94 -70
  13. package/site/content/docs/index.mdx +1 -1
  14. package/site/content/docs/plugins/apple.mdx +151 -0
  15. package/site/content/docs/plugins/discord.mdx +139 -0
  16. package/site/content/docs/plugins/facebook.mdx +134 -0
  17. package/site/content/docs/plugins/figma.mdx +138 -0
  18. package/site/content/docs/plugins/github.mdx +138 -0
  19. package/site/content/docs/plugins/google.mdx +153 -0
  20. package/site/content/docs/plugins/index.mdx +47 -1
  21. package/site/content/docs/plugins/meta.json +10 -0
  22. package/site/content/docs/plugins/microsoft.mdx +151 -0
  23. package/site/content/docs/plugins/oauth.mdx +188 -0
  24. package/site/content/docs/plugins/x.mdx +125 -0
  25. package/site/content/docs/reference/cli.md +3 -2
  26. package/site/content/docs/reference/client.mdx +58 -1
  27. package/site/content/docs/reference/configuration.mdx +2 -4
  28. package/site/content/docs/reference/fx.mdx +3 -1
  29. package/site/content/docs/reference/index.mdx +0 -5
  30. package/site/content/docs/reference/meta.json +2 -2
  31. package/site/content/docs/reference/okid.mdx +137 -0
  32. package/src/auth/bindings.ts +1 -1
  33. package/src/auth/config.ts +9 -0
  34. package/src/auth/identity-sql.ts +314 -0
  35. package/src/auth/identity.ts +140 -2
  36. package/src/auth/index.ts +17 -1
  37. package/src/auth/method-context.ts +3 -0
  38. package/src/auth/oauth-as/cimd.ts +132 -0
  39. package/src/auth/oauth-as/crypto.test.ts +101 -0
  40. package/src/auth/oauth-as/crypto.ts +393 -0
  41. package/src/auth/oauth-as/errors.ts +68 -0
  42. package/src/auth/oauth-as/http.test.ts +419 -0
  43. package/src/auth/oauth-as/http.ts +842 -0
  44. package/src/auth/oauth-as/stores.ts +61 -0
  45. package/src/auth/oauth-as/tables.ts +142 -0
  46. package/src/auth/tables.ts +0 -11
  47. package/src/bench/README.md +83 -0
  48. package/src/bench/REPORT.md +176 -0
  49. package/src/bench/g01-rls-stamp.bench.ts +194 -0
  50. package/src/bench/g02-clock-per-tenant.bench.ts +158 -0
  51. package/src/bench/g03-signal-once.bench.ts +157 -0
  52. package/src/bench/g03-signal-reconnect.bench.ts +254 -0
  53. package/src/bench/g03-signal-sse-memory.bench.ts +191 -0
  54. package/src/bench/g04-auth-vault-hotpath.bench.ts +170 -0
  55. package/src/bench/g05-sustained-full.bench.ts +265 -0
  56. package/src/bench/g06-mixed-load.bench.ts +260 -0
  57. package/src/bench/g07-vault-crypto.bench.ts +100 -0
  58. package/src/bench/g07-vault-rotate-under-read.bench.ts +285 -0
  59. package/src/bench/g08-conn-oversubscribe.bench.ts +194 -0
  60. package/src/bench/g08-store-kv-durable.bench.ts +133 -0
  61. package/src/bench/g08-store-sql.bench.ts +178 -0
  62. package/src/bench/g09-journal-sustained.bench.ts +203 -0
  63. package/src/bench/g10-observability-contention.bench.ts +246 -0
  64. package/src/bench/g11-cold-start-cycle.bench.ts +164 -0
  65. package/src/bench/g13-elements.bench.ts +427 -0
  66. package/src/bench/g14-graceful-shutdown.bench.ts +244 -0
  67. package/src/bench/g15-postgres-degradation.bench.ts +264 -0
  68. package/src/bench/g16-live-query-fanout.bench.ts +206 -0
  69. package/src/bench/lib/event-loop-lag.ts +26 -0
  70. package/src/bench/lib/infra.ts +60 -0
  71. package/src/bench/lib/report.ts +52 -0
  72. package/src/bench/lib/rss-sampler.ts +61 -0
  73. package/src/bench/lib/signal-pg.ts +88 -0
  74. package/src/bench/load-app.ts +337 -0
  75. package/src/bench/load-child.ts +108 -0
  76. package/src/bench/smoke.bench.ts +43 -0
  77. package/src/cli/competitor-mention-removal.test.ts +28 -0
  78. package/src/cli/doctor-fd.ts +117 -0
  79. package/src/cli/doctor.test.ts +192 -0
  80. package/src/cli/doctor.ts +129 -1
  81. package/src/client/create.ts +95 -1
  82. package/src/client/index.ts +9 -2
  83. package/src/client/transport.ts +11 -4
  84. package/src/client/use-live-query.ts +154 -0
  85. package/src/client-react/index.ts +15 -1
  86. package/src/client-react/live-resource.ts +246 -0
  87. package/src/client-react/use-live-query.test.ts +475 -0
  88. package/src/client-react/use-live-query.ts +530 -0
  89. package/src/compiler/extract.test.ts +518 -0
  90. package/src/compiler/extract.ts +386 -19
  91. package/src/console/server/invoke-user-flow.ts +2 -1
  92. package/src/console/ui-next/dist/assets/{access-page-DnWbnGzq.js → access-page-DY4N6nnk.js} +1 -1
  93. package/src/console/ui-next/dist/assets/{flows-page-BiZ4-6yQ.js → flows-page-CsPDMrVM.js} +1 -1
  94. package/src/console/ui-next/dist/assets/{index-C8NRK2R-.js → index-CcTDXHuz.js} +3 -3
  95. package/src/console/ui-next/dist/assets/{observability-page-CrB6vd1T.js → observability-page-CKR595wP.js} +1 -1
  96. package/src/console/ui-next/dist/assets/{store-page-CS5-aETQ.js → store-page-02xOiqIK.js} +3 -3
  97. package/src/console/ui-next/dist/assets/{units-page-CjtdlW8l.js → units-page-CpPFFKyE.js} +1 -1
  98. package/src/console/ui-next/dist/assets/{vault-page-C6Xxm9SA.js → vault-page-BsMf-9_W.js} +1 -1
  99. package/src/console/ui-next/dist/index.html +1 -1
  100. package/src/console/ui-next/src/features/store/lib/fields-from-table.ts +36 -2
  101. package/src/drivers/cdc-outbox.ts +389 -0
  102. package/src/drivers/memory.ts +20 -0
  103. package/src/drivers/oauth-apple.ts +156 -0
  104. package/src/drivers/oauth-discord.ts +79 -0
  105. package/src/drivers/oauth-facebook.ts +80 -0
  106. package/src/drivers/oauth-figma.ts +116 -0
  107. package/src/drivers/oauth-github.ts +92 -0
  108. package/src/drivers/oauth-google.ts +142 -0
  109. package/src/drivers/oauth-microsoft.ts +174 -0
  110. package/src/drivers/oauth-oidc.ts +293 -0
  111. package/src/drivers/oauth-shared.ts +326 -0
  112. package/src/drivers/oauth-types.ts +159 -0
  113. package/src/drivers/oauth-x.ts +77 -0
  114. package/src/drivers/oauth2-common.ts +95 -0
  115. package/src/drivers/oauth2-token.ts +61 -0
  116. package/src/drivers/pg-rls-row-passes.ts +251 -0
  117. package/src/drivers/pg-rls.ts +2 -0
  118. package/src/drivers/postgres.ts +45 -2
  119. package/src/drivers/signal-postgres.ts +2 -1
  120. package/src/elements/channel/runtime.ts +29 -2
  121. package/src/elements/channel.test.ts +52 -0
  122. package/src/elements/gate/boot.ts +29 -2
  123. package/src/elements/store/emit-drizzle.ts +147 -14
  124. package/src/elements/store/field-ddl.test.ts +118 -0
  125. package/src/elements/store/field-types.test.ts +455 -0
  126. package/src/elements/store/list-query.golden.json +777 -0
  127. package/src/elements/store/list-query.parity.test.ts +396 -0
  128. package/src/elements/store/list-query.ts +792 -0
  129. package/src/elements/store/live-default.test.ts +136 -0
  130. package/src/elements/store/live-http.test.ts +160 -0
  131. package/src/elements/store/live-isolation.test.ts +291 -0
  132. package/src/elements/store/live-query-runtime.test.ts +323 -0
  133. package/src/elements/store/live-query-runtime.ts +403 -0
  134. package/src/elements/store/live-query-server.test.ts +377 -0
  135. package/src/elements/store/live-query-server.ts +102 -0
  136. package/src/elements/store/live-query.ts +97 -0
  137. package/src/elements/store/resource.ts +189 -680
  138. package/src/elements/store/rls-row-passes-policies.parity.test.ts +665 -0
  139. package/src/elements/store/schema-decl.ts +539 -41
  140. package/src/elements/store/sql-rls-stamp.test.ts +27 -0
  141. package/src/elements/store/sql-session.ts +297 -35
  142. package/src/elements/store/table.ts +102 -21
  143. package/src/elements/store.test.ts +3 -1
  144. package/src/elements/store.ts +12 -1
  145. package/src/elements/vault/chaos-child.ts +74 -1
  146. package/src/elements/vault/chaos.test.ts +4 -2
  147. package/src/elements/vault/storage.ts +4 -2
  148. package/src/index.ts +4 -1
  149. package/src/kernel/app-auth.ts +1 -0
  150. package/src/kernel/app.ts +116 -2
  151. package/src/kernel/auth-sharing.test.ts +196 -0
  152. package/src/kernel/boot.test.ts +3 -3
  153. package/src/kernel/errors.ts +8 -0
  154. package/src/kernel/fx.test.ts +1 -0
  155. package/src/kernel/fx.ts +14 -2
  156. package/src/kernel/horizontal-child.ts +2 -1
  157. package/src/kernel/http-resource.ts +33 -7
  158. package/src/kernel/identity-host-persist.test.ts +119 -0
  159. package/src/kernel/instance-id.ts +4 -2
  160. package/src/kernel/journal.ts +2 -1
  161. package/src/kernel/mcp-tool.test.ts +95 -0
  162. package/src/kernel/on.ts +9 -0
  163. package/src/kernel/realtime-bind.ts +326 -0
  164. package/src/kernel/resource-live.ts +117 -0
  165. package/src/kernel/triggers.ts +86 -4
  166. package/src/manifest/diff.ts +37 -0
  167. package/src/manifest/types.ts +64 -2
  168. package/src/okid.bench.test.ts +64 -0
  169. package/src/okid.test.ts +338 -0
  170. package/src/okid.ts +245 -0
  171. package/src/plugins/anonymous.ts +19 -1
  172. package/src/plugins/auth/shared.ts +15 -0
  173. package/src/plugins/index.ts +2 -0
  174. package/src/plugins/magic-link.ts +10 -8
  175. package/src/plugins/mcp-oauth.ts +208 -0
  176. package/src/plugins/oauth/flow-store.ts +117 -0
  177. package/src/plugins/oauth/link.ts +69 -0
  178. package/src/plugins/oauth/shared.ts +108 -0
  179. package/src/plugins/oauth/token-vault.ts +100 -0
  180. package/src/plugins/oauth.security.test.ts +535 -0
  181. package/src/plugins/oauth.ts +532 -0
  182. package/src/plugins/otp.ts +48 -6
  183. package/src/plugins/passkey.ts +20 -1
  184. package/src/plugins/two-factor.ts +11 -0
  185. package/src/plugins/username.ts +40 -7
  186. package/src/release/build-lib.ts +7 -1
  187. package/src/release/measure.ts +1 -0
  188. package/src/release/official-plugins.ts +4 -1
  189. package/src/runs/collect.ts +2 -1
  190. package/src/runs/drivers/files.ts +2 -1
  191. package/src/test/create-test-app.ts +114 -5
  192. package/src/test/export-bundle.test.ts +33 -0
  193. package/src/test/live-signals.test.ts +83 -0
  194. package/src/test/tenant-isolation.test.ts +175 -0
  195. package/src/testing.ts +26 -0
  196. package/src/upgrade/codemods.ts +1 -1
  197. package/site/content/docs/reference/migrating-environments.mdx +0 -158
@@ -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
- const client = injected ?? sharedPostgresClient(options.url);
126
- return wrapPostgresClient(client, role, { shared: injected === undefined });
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
- crypto.randomUUID(),
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
- const drivers = [...(options.drivers ?? [])];
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
- const from = decl.from ?? "oke@localhost";
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} HTTP trigger(s) missing auth posture (attach a gate or .public()):\n${lines.join("\n")}`,
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
- // Postgres INTEGER is 32-bit; abstract `integer` (often ms clocks) → bigint.
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
- function literalSource(value: string | number | boolean | null): string {
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 needsInteger = tables.some((t) =>
188
- Object.values(t.columns).some((c) => c.sqlType === "integer"),
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", "text", pgIntImport, needsPolicy ? "pgPolicy" : null].filter(
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 helperNames = [needsId ? "id" : null, needsNow ? "now" : null].filter(
205
- (x): x is string => x !== null,
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
+ });