okengine 0.19.0 → 0.19.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.
Files changed (110) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/ai/meta.json +1 -1
  3. package/site/content/docs/ai/skills.mdx +1 -1
  4. package/site/content/docs/client/index.mdx +1 -2
  5. package/site/content/docs/elements/ai/agents.mdx +5 -2
  6. package/site/content/docs/elements/ai/index.mdx +4 -3
  7. package/site/content/docs/elements/ai/prompts.mdx +3 -2
  8. package/site/content/docs/elements/channel/email.mdx +5 -2
  9. package/site/content/docs/elements/channel/index.mdx +1 -1
  10. package/site/content/docs/elements/clock/index.mdx +23 -12
  11. package/site/content/docs/elements/clock/schedules.mdx +6 -3
  12. package/site/content/docs/elements/clock/sleep.mdx +10 -7
  13. package/site/content/docs/elements/flow/consumers.mdx +96 -58
  14. package/site/content/docs/elements/flow/index.mdx +15 -15
  15. package/site/content/docs/elements/flow/routing.mdx +11 -2
  16. package/site/content/docs/elements/gate/tenancy.mdx +5 -2
  17. package/site/content/docs/elements/signal/broadcast.mdx +6 -4
  18. package/site/content/docs/elements/signal/index.mdx +14 -1
  19. package/site/content/docs/elements/signal/live.mdx +3 -2
  20. package/site/content/docs/elements/signal/once.mdx +3 -2
  21. package/site/content/docs/elements/store/files.mdx +24 -16
  22. package/site/content/docs/elements/store/index.mdx +11 -8
  23. package/site/content/docs/elements/store/kv.mdx +24 -16
  24. package/site/content/docs/elements/store/search.mdx +18 -14
  25. package/site/content/docs/elements/store/sql.mdx +16 -12
  26. package/site/content/docs/elements/vault/config.mdx +5 -2
  27. package/site/content/docs/elements/vault/rotation.mdx +5 -2
  28. package/site/content/docs/elements/vault/secrets.mdx +5 -2
  29. package/site/content/docs/index.mdx +4 -19
  30. package/site/content/docs/plugins/anonymous.mdx +1 -1
  31. package/site/content/docs/plugins/cors.mdx +1 -1
  32. package/site/content/docs/plugins/csrf.mdx +1 -1
  33. package/site/content/docs/plugins/headers.mdx +1 -1
  34. package/site/content/docs/plugins/ip-allowlist.mdx +1 -1
  35. package/site/content/docs/plugins/maintenance-mode.mdx +1 -1
  36. package/site/content/docs/reference/cli.mdx +1 -1
  37. package/site/content/docs/reference/errors.mdx +1 -0
  38. package/site/content/docs/reference/fx.mdx +9 -8
  39. package/site/content/docs/reference/okid.mdx +1 -1
  40. package/site/content/docs/reference/plugins.mdx +1 -1
  41. package/site/content/docs/reference/security.mdx +2 -2
  42. package/site/content/docs/understand/meta.json +1 -1
  43. package/site/content/docs/understand/the-architecture.mdx +264 -0
  44. package/src/bench/REPORT.md +48 -17
  45. package/src/bench/g17-hybrid-search.bench.ts +13 -13
  46. package/src/compiler/extract.test.ts +76 -1
  47. package/src/compiler/extract.ts +106 -7
  48. package/src/compiler/search-writer-isolation.test.ts +0 -1
  49. package/src/console/ui-next/dist/assets/{access-page-DFeymU07.js → access-page-DceEWH9u.js} +1 -1
  50. package/src/console/ui-next/dist/assets/{agent-disclosure-U1rdfblp.js → agent-disclosure-tC9s2VFd.js} +1 -1
  51. package/src/console/ui-next/dist/assets/{cache-glyph-BeFJeqBG.js → cache-glyph-C-naNQSR.js} +1 -1
  52. package/src/console/ui-next/dist/assets/{call-pii-button-CVAONPii.js → call-pii-button-DnZ_MlDn.js} +1 -1
  53. package/src/console/ui-next/dist/assets/{collapsible-D2A6NJ-3.js → collapsible-RekgR6Qz.js} +1 -1
  54. package/src/console/ui-next/dist/assets/{duration-tone-Cgk_h5ja.js → duration-tone-DugtWBS0.js} +1 -1
  55. package/src/console/ui-next/dist/assets/flows-page-DVmn1ZuQ.js +1 -0
  56. package/src/console/ui-next/dist/assets/{highlighted-json-MYZQtRnw.js → highlighted-json-CvBGaiSD.js} +1 -1
  57. package/src/console/ui-next/dist/assets/{http-method-Jrh39p7A.js → http-method-D7_OXbdC.js} +1 -1
  58. package/src/console/ui-next/dist/assets/{index-DXP2dBIF.js → index-DH0K2f6N.js} +3 -3
  59. package/src/console/ui-next/dist/assets/{observability-page-HvolXxTI.js → observability-page-qJzF2nSN.js} +1 -1
  60. package/src/console/ui-next/dist/assets/{replica-lag-CSh2dzrb.js → replica-lag-Vqk0pUBA.js} +1 -1
  61. package/src/console/ui-next/dist/assets/request-meta-BtShi4sG.js +1 -0
  62. package/src/console/ui-next/dist/assets/{store-page-eiKiHnNe.js → store-page-CMsYH_vH.js} +1 -1
  63. package/src/console/ui-next/dist/assets/{trace-detail-sheet-CZkMeKS-.js → trace-detail-sheet-ycFB2uua.js} +1 -1
  64. package/src/console/ui-next/dist/assets/{tree-expand-toggle-CW8y5A2h.js → tree-expand-toggle-iG1jcWgU.js} +1 -1
  65. package/src/console/ui-next/dist/assets/{units-page-B_RWJrEO.js → units-page-Bavchke4.js} +1 -1
  66. package/src/console/ui-next/dist/assets/{vault-page-CWrg-A68.js → vault-page-DxUFhiZI.js} +1 -1
  67. package/src/console/ui-next/dist/index.html +1 -1
  68. package/src/console/ui-next/src/features/flows/graph/element-map.test.ts +1 -1
  69. package/src/console/ui-next/src/features/flows/graph/element-map.ts +3 -3
  70. package/src/console/ui-next/src/features/flows/traces/trace-detail.test.ts +4 -5
  71. package/src/console/ui-next/src/features/flows/traces/trace-gates.ts +3 -6
  72. package/src/elements/gate/declare.ts +1 -1
  73. package/src/elements/gate.ts +1 -1
  74. package/src/elements/store/live-default.test.ts +8 -0
  75. package/src/elements/store/search-embed-flow.ts +2 -2
  76. package/src/elements/store/search-lsh.ts +35 -0
  77. package/src/elements/store/search-runtime.pglite.test.ts +186 -0
  78. package/src/elements/store/search-runtime.ts +30 -16
  79. package/src/elements/store/search.test.ts +83 -1
  80. package/src/elements/store.ts +2 -0
  81. package/src/full.ts +3 -0
  82. package/src/http.ts +3 -0
  83. package/src/index.ts +3 -0
  84. package/src/kernel/app.ts +30 -10
  85. package/src/kernel/boot.ts +1 -1
  86. package/src/kernel/cdc-payload.test.ts +224 -0
  87. package/src/kernel/cdc-payload.ts +146 -0
  88. package/src/kernel/errors-flow-name.ts +17 -0
  89. package/src/kernel/errors.registry.test.ts +6 -4
  90. package/src/kernel/flow-name.test.ts +104 -0
  91. package/src/kernel/flow.ts +3 -2
  92. package/src/kernel/fx-emit-types.test.ts +31 -0
  93. package/src/kernel/fx.test.ts +2 -1
  94. package/src/kernel/fx.ts +13 -3
  95. package/src/kernel/index.ts +2 -0
  96. package/src/kernel/on.ts +5 -0
  97. package/src/kernel/stamp-http.test.ts +13 -0
  98. package/src/kernel/stamp-http.ts +21 -3
  99. package/src/kernel/unit.ts +4 -2
  100. package/src/kernel-entry.ts +3 -0
  101. package/src/mcp/docs-index.ts +3 -3
  102. package/src/mcp/docs-mcp.test.ts +2 -2
  103. package/src/mcp/docs-tools.ts +2 -1
  104. package/site/content/docs/understand/the-anatomy.mdx +0 -132
  105. package/site/content/docs/understand/the-model.mdx +0 -32
  106. package/site/content/docs/understand/the-problem.mdx +0 -74
  107. package/site/content/docs/understand/the-vocabulary.mdx +0 -26
  108. package/src/console/ui-next/dist/assets/flows-page-Dss7941e.js +0 -1
  109. package/src/console/ui-next/dist/assets/request-meta-BatF8KrK.js +0 -1
  110. /package/site/content/docs/{ai → understand}/try-it.mdx +0 -0
@@ -14,7 +14,7 @@ import {
14
14
  deserializePlanes,
15
15
  lshBucket,
16
16
  lshBucketToSql,
17
- neighborBuckets,
17
+ lshHammingSql,
18
18
  } from "./search-lsh.ts";
19
19
  import {
20
20
  embColumn,
@@ -74,8 +74,9 @@ export interface RunSqlSearchDeps {
74
74
  }
75
75
 
76
76
  /**
77
- * Execute hybrid search against PostgreSQL using GIN + LSH B-tree candidates,
78
- * then BM25F / cosine / fusion in-process (correct, testable math).
77
+ * Execute hybrid search against PostgreSQL using GIN lexical candidates UNION
78
+ * SimHash Hamming-rank (K=64 `bit_count` ORDER BY LIMIT), then BM25F / cosine
79
+ * / fusion in-process (correct, testable math).
79
80
  *
80
81
  * @param deps - Connection, table meta, options
81
82
  */
@@ -103,11 +104,9 @@ export async function runSqlSearch(deps: RunSqlSearchDeps): Promise<SqlSearchRes
103
104
  const embedFields = searchable.filter((c) => c.embed);
104
105
  if (embedFields.length > 0) engines.push("lsh");
105
106
 
106
- // Candidate retrieval: GIN tsvector match OR LSH buckets.
107
- const params: unknown[] = [query];
108
- let whereSql = `${OKE_TSV_COL} @@ plainto_tsquery('english', ?)`;
109
107
  let queryVec: readonly number[] | undefined;
110
- const bucketParams: bigint[] = [];
108
+ const hamExprs: string[] = [];
109
+ const hamParams: unknown[] = [];
111
110
 
112
111
  if (embedFields.length > 0 && deps.embedQuery) {
113
112
  const model = embedFields[0]?.embed?.model;
@@ -137,20 +136,18 @@ export async function runSqlSearch(deps: RunSqlSearchDeps): Promise<SqlSearchRes
137
136
  const planesBuf = row["planes"] as Buffer;
138
137
  const planes = deserializePlanes(Buffer.from(planesBuf), k);
139
138
  const bucket = lshBucket(queryVec, planes);
140
- for (const b of neighborBuckets(bucket, k)) {
141
- bucketParams.push(b);
142
- }
143
- whereSql += ` OR ${lshColumn(field.sqlName)} = ANY(?::bigint[])`;
144
- params.push(`{${bucketParams.map((b) => lshBucketToSql(b)).join(",")}}`);
139
+ hamExprs.push(lshHammingSql(lshColumn(field.sqlName), "?"));
140
+ hamParams.push(lshBucketToSql(bucket));
145
141
  }
146
142
  }
147
143
 
148
- // Compose list-grammar filters.
144
+ let filterClause = "";
145
+ const filterParams: unknown[] = [];
149
146
  if (parsed.page.where) {
150
147
  const compiled = compileWhere(parsed.page.where);
151
148
  if (compiled.clause) {
152
- whereSql = `(${whereSql}) AND (${compiled.clause})`;
153
- params.push(...compiled.params);
149
+ filterClause = ` AND (${compiled.clause})`;
150
+ filterParams.push(...compiled.params);
154
151
  }
155
152
  }
156
153
 
@@ -159,10 +156,27 @@ export async function runSqlSearch(deps: RunSqlSearchDeps): Promise<SqlSearchRes
159
156
  ...searchable.map((c) => quoteIdent(c.sqlName)),
160
157
  ...embedFields.map((c) => quoteIdent(embColumn(c.sqlName))),
161
158
  ].join(", ");
159
+ const tableIdent = quoteIdent(tableName);
162
160
 
163
161
  // Oversample candidates for fusion then truncate.
164
162
  const fetchLimit = Math.min(Math.max(limit * 5, 50), 500);
165
- const candidateSql = `SELECT ${selectCols} FROM ${quoteIdent(tableName)} WHERE ${whereSql} LIMIT ${fetchLimit}`;
163
+ const lexicalSql = `SELECT ${selectCols} FROM ${tableIdent} WHERE ${OKE_TSV_COL} @@ plainto_tsquery('english', ?)${filterClause} LIMIT ${fetchLimit}`;
164
+
165
+ // SimHash k-NN: rank by Hamming over the K=64 bit pack, then LIMIT.
166
+ // Hamming-1 equality (`= ANY(65 buckets)`) is not a candidate set — true
167
+ // near-neighbors sit at distance ~5–16, so that probe recovered ~0 rows.
168
+ let candidateSql: string;
169
+ const params: unknown[] = [];
170
+ if (hamExprs.length > 0) {
171
+ const lshPred = embedFields.map((f) => `${lshColumn(f.sqlName)} IS NOT NULL`).join(" OR ");
172
+ const orderExpr = hamExprs.length === 1 ? hamExprs[0]! : `LEAST(${hamExprs.join(", ")})`;
173
+ const lshSql = `SELECT ${selectCols} FROM ${tableIdent} WHERE (${lshPred})${filterClause} ORDER BY ${orderExpr} ASC NULLS LAST LIMIT ${fetchLimit}`;
174
+ candidateSql = `(${lexicalSql}) UNION (${lshSql})`;
175
+ params.push(query, ...filterParams, ...hamParams, ...filterParams);
176
+ } else {
177
+ candidateSql = lexicalSql;
178
+ params.push(query, ...filterParams);
179
+ }
166
180
  const candidates = await conn.query(candidateSql, params);
167
181
 
168
182
  const statsRows = await conn.query(
@@ -3,18 +3,23 @@
3
3
  */
4
4
 
5
5
  import { describe, expect, test } from "bun:test";
6
+ import { createHash } from "node:crypto";
6
7
  import { field, store } from "../store.ts";
7
8
  import { bm25fScore, termFrequencies, tokenize } from "./search-bm25.ts";
8
9
  import { fuseLists, fuseRrf, fuseWeighted } from "./search-fusion.ts";
9
- import { RRF_DEFAULT_K, SearchConfigError } from "./search-errors.ts";
10
+ import { LSH_DEFAULT_K, RRF_DEFAULT_K, SearchConfigError } from "./search-errors.ts";
10
11
  import {
11
12
  cosineSimilarity,
13
+ deserializePlanes,
12
14
  generateHyperplanes,
15
+ hammingDistance,
13
16
  hyperplaneSeed,
14
17
  lshBucket,
15
18
  lshBucketFromSql,
16
19
  lshBucketToSql,
20
+ lshHammingSql,
17
21
  neighborBuckets,
22
+ serializePlanes,
18
23
  } from "./search-lsh.ts";
19
24
 
20
25
  describe("field.searchable / .embed", () => {
@@ -101,8 +106,85 @@ describe("LSH", () => {
101
106
  expect(lshBucketFromSql(sql)).toBe(high);
102
107
  expect(lshBucketToSql(42n)).toBe("42");
103
108
  });
109
+
110
+ test("write-time and query-time buckets are identical for the same vector", () => {
111
+ const seed = hyperplaneSeed("g17_docs", "body", 32, LSH_DEFAULT_K);
112
+ const planes = generateHyperplanes(seed, 32, LSH_DEFAULT_K);
113
+ const reloaded = deserializePlanes(serializePlanes(planes), LSH_DEFAULT_K);
114
+ const vector = synthHashBag(
115
+ "refund policy shipping delay. Document 0 discusses refund policy shipping delay.",
116
+ );
117
+ const write = lshBucket(vector, planes);
118
+ const query = lshBucket(vector, reloaded);
119
+ expect(write).toBe(query);
120
+ expect(lshBucketToSql(write)).toBe(lshBucketToSql(query));
121
+ expect(neighborBuckets(write, LSH_DEFAULT_K)).toHaveLength(LSH_DEFAULT_K + 1);
122
+ });
123
+
124
+ test("K=64 Hamming-1 misses G17-like neighbors; Hamming-rank recovers them", () => {
125
+ // Same deterministic hash-bag generator G17 uses — 40 docs, 8 topics.
126
+ const topics = [
127
+ "refund policy shipping delay",
128
+ "password reset two factor auth",
129
+ "invoice payment stripe webhook",
130
+ "search ranking bm25 hybrid",
131
+ "postgres gin btree index plan",
132
+ "tenant isolation row level security",
133
+ "durable flow journal resume",
134
+ "live query fanout subscriber",
135
+ ];
136
+ const planes = generateHyperplanes(hyperplaneSeed("g17_docs", "body", 32, 64), 32, 64);
137
+ const docs = topics.flatMap((topic, t) =>
138
+ [0, 1, 2, 3, 4].map((n) => {
139
+ const i = t + n * 8;
140
+ const body = `${topic}. Document ${i} discusses ${topic} with enough tokens for BM25.`;
141
+ const vec = synthHashBag(body);
142
+ return { i, vec, bucket: lshBucket(vec, planes) };
143
+ }),
144
+ );
145
+ const qVec = synthHashBag(topics[0]!);
146
+ const qBucket = lshBucket(qVec, planes);
147
+ const scored = docs.map((d) => ({
148
+ i: d.i,
149
+ cos: cosineSimilarity(qVec, d.vec),
150
+ ham: hammingDistance(qBucket, d.bucket),
151
+ }));
152
+ const exact = [...scored].sort((a, b) => b.cos - a.cos).slice(0, 10);
153
+ const exactSet = new Set(exact.map((x) => x.i));
154
+ const ham1 = scored.filter((s) => s.ham <= 1);
155
+ const ham1Hits = ham1.filter((s) => exactSet.has(s.i)).length;
156
+ const ranked = [...scored].sort((a, b) => a.ham - b.ham || b.cos - a.cos).slice(0, 50);
157
+ const rankedTop = [...ranked].sort((a, b) => b.cos - a.cos).slice(0, 10);
158
+ const rankedHits = rankedTop.filter((s) => exactSet.has(s.i)).length;
159
+ // Hamming-1 is the broken probe (G17 P@10 ≈ 0). Hamming-rank of the K=64
160
+ // pack recovers the exact cosine top-10 on this 40-row hand case.
161
+ expect(ham1Hits).toBe(0);
162
+ expect(rankedHits).toBe(10);
163
+ expect(exact.every((e) => e.ham > 1)).toBe(true);
164
+ expect(lshHammingSql("__oke_lsh_body")).toContain("bit_count");
165
+ });
104
166
  });
105
167
 
168
+ /**
169
+ * G17 synthetic embedding — deterministic SHA-256 hash bag, L2-normalized.
170
+ *
171
+ * @param text - Source text
172
+ */
173
+ function synthHashBag(text: string, dims = 32): number[] {
174
+ const v = Array.from({ length: dims }, () => 0);
175
+ const tokens = text.toLowerCase().split(/\W+/).filter(Boolean);
176
+ for (const t of tokens) {
177
+ const h = createHash("sha256").update(t).digest();
178
+ for (let i = 0; i < dims; i++) {
179
+ v[i]! += (h[i % h.length]! / 255) * 2 - 1;
180
+ }
181
+ }
182
+ let norm = 0;
183
+ for (const x of v) norm += x * x;
184
+ norm = Math.sqrt(norm) || 1;
185
+ return v.map((x) => x / norm);
186
+ }
187
+
106
188
  describe("fusion", () => {
107
189
  test("RRF default k=60 matches Cormack hand ranks", () => {
108
190
  expect(RRF_DEFAULT_K).toBe(60);
@@ -197,7 +197,9 @@ export {
197
197
  lshBucketToSql,
198
198
  lshBucketFromSql,
199
199
  cosineSimilarity,
200
+ hammingDistance,
200
201
  neighborBuckets,
202
+ lshHammingSql,
201
203
  serializePlanes,
202
204
  deserializePlanes,
203
205
  } from "./store/search-lsh.ts";
package/src/full.ts CHANGED
@@ -37,6 +37,9 @@ export {
37
37
  type OkeApp,
38
38
  type OkeOptions,
39
39
  type ReadyState,
40
+ type CdcAction,
41
+ type CdcPayload,
42
+ type CdcPayloadInput,
40
43
  type FlowDef,
41
44
  type FlowFailure,
42
45
  type FlowErrorValue,
package/src/http.ts CHANGED
@@ -28,6 +28,9 @@ export { isFlowFailure } from "./kernel/hooks.ts";
28
28
  export { plugin, isPlugin, type PluginDef } from "./kernel/plugin.ts";
29
29
  export { createBunRuntime, APP_PORT, type Runtime, type ServeOptions } from "./runtime/index.ts";
30
30
  export type {
31
+ CdcAction,
32
+ CdcPayload,
33
+ CdcPayloadInput,
31
34
  OkeApp,
32
35
  OkeOptions,
33
36
  ReadyState,
package/src/index.ts CHANGED
@@ -16,6 +16,9 @@
16
16
 
17
17
  export {
18
18
  oke,
19
+ type CdcAction,
20
+ type CdcPayload,
21
+ type CdcPayloadInput,
19
22
  type OkeApp,
20
23
  type OkeOptions,
21
24
  type ReadyState,
package/src/kernel/app.ts CHANGED
@@ -67,7 +67,8 @@ import {
67
67
  import { GATE_PUBLIC_NAME } from "../elements/gate/flatten.ts";
68
68
  import type { JsonCodeAuth } from "../runtime/json-code-block.ts";
69
69
  import { resolveDurationMs } from "./elapsed.ts";
70
- import { fail, throwOke } from "./errors.ts";
70
+ import { fail, throwOke, OkeError } from "./errors.ts";
71
+ import { FLOW_NAME_DUPLICATE } from "./errors-flow-name.ts";
71
72
  import { consumeRegisteredFlowUnits, type FlowUnitBag } from "./flow-units.ts";
72
73
  import {
73
74
  isFlowFailure,
@@ -143,6 +144,14 @@ import { recordObservedEffect } from "./effects.ts";
143
144
  import { cacheDimensionOf, createRunTelemetry } from "./run-telemetry.ts";
144
145
  import type { RunsRuntime } from "../runs/runtime.ts";
145
146
  import type { Effects, ResourceRef } from "../manifest/types.ts";
147
+ import {
148
+ enrichCdcPayload,
149
+ pkColumnByTableFromManifest,
150
+ type CdcPayloadInput,
151
+ type DeclaredPk,
152
+ } from "./cdc-payload.ts";
153
+
154
+ export type { CdcAction, CdcPayload, CdcPayloadInput } from "./cdc-payload.ts";
146
155
 
147
156
  /**
148
157
  * Auto-cache helpers — loaded only when a Store runtime is bound.
@@ -334,12 +343,6 @@ export interface OkeOptions {
334
343
  /** Readiness probe state — see `GET /_/ready`. */
335
344
  export type ReadyState = "booting" | "orphan_scan" | "ready";
336
345
 
337
- /** Payload for a CDC invocation. */
338
- export interface CdcPayload {
339
- readonly before: Record<string, unknown> | null;
340
- readonly after: Record<string, unknown> | null;
341
- }
342
-
343
346
  /** Result of executing a flow. */
344
347
  export interface ExecuteResult {
345
348
  readonly output: unknown;
@@ -582,10 +585,14 @@ export interface OkeApp<D extends Record<string, unknown> = {}, R extends AppRou
582
585
  * Invoke all flows bound to a CDC table change.
583
586
  *
584
587
  * @param tableName - Table name
585
- * @param payload - before/after
588
+ * @param payload - `{ before, after }` (and optional `table` / `action` / `id`)
586
589
  * @param column - Optional column filter
587
590
  */
588
- dispatchCdc(tableName: string, payload: CdcPayload, column?: string): Promise<ExecuteResult[]>;
591
+ dispatchCdc(
592
+ tableName: string,
593
+ payload: CdcPayloadInput,
594
+ column?: string,
595
+ ): Promise<ExecuteResult[]>;
589
596
  /**
590
597
  * Call a flow by name/handle (same path as `fx.call`).
591
598
  *
@@ -937,6 +944,7 @@ export function oke(options: OkeOptions): OkeApp {
937
944
  const flushedPlugins = new WeakMap<AnyFlowDef, Set<PluginDef>>();
938
945
  /** Runtime route table — types accumulate on the returned {@link OkeApp}. */
939
946
  const routes: RuntimeRouteMap = {};
947
+ let cdcPkByTable: ReadonlyMap<string, DeclaredPk> = pkColumnByTableFromManifest(options.manifest);
940
948
 
941
949
  let authMaterialization: AuthHttpMaterialization | undefined;
942
950
  let wiredAuth: WiredGateAuth | undefined;
@@ -1081,6 +1089,10 @@ export function oke(options: OkeOptions): OkeApp {
1081
1089
  }
1082
1090
 
1083
1091
  function registerFlow(flowDef: AnyFlowDef): void {
1092
+ const existing = flowsByName.get(flowDef.name);
1093
+ if (existing && existing !== flowDef && flowDef.name) {
1094
+ throw new OkeError(FLOW_NAME_DUPLICATE, { flow: flowDef.name });
1095
+ }
1084
1096
  flowsByName.set(flowDef.name, flowDef);
1085
1097
  flushFlowPlugins(flowDef);
1086
1098
  }
@@ -1348,6 +1360,8 @@ export function oke(options: OkeOptions): OkeApp {
1348
1360
  const baseChannel = overrides?.channel ?? effectiveChannel;
1349
1361
  const mergedCatalog = mergeTemplateCatalogs(baseChannel?.catalog, ...pluginChannelCatalogs);
1350
1362
 
1363
+ cdcPkByTable = pkColumnByTableFromManifest(overrides?.manifest ?? options.manifest);
1364
+
1351
1365
  const merged: BootOptions = {
1352
1366
  env: bootEnv,
1353
1367
  docker: overrides?.docker ?? options.docker,
@@ -2405,6 +2419,12 @@ export function oke(options: OkeOptions): OkeApp {
2405
2419
  return results;
2406
2420
  },
2407
2421
  async dispatchCdc(tableName, payload, column) {
2422
+ const enriched = enrichCdcPayload(
2423
+ tableName,
2424
+ payload.before ?? null,
2425
+ payload.after ?? null,
2426
+ cdcPkByTable.get(tableName),
2427
+ );
2408
2428
  const results: ExecuteResult[] = [];
2409
2429
  for (const b of adopted) {
2410
2430
  if (b.trigger.kind !== "cdc") continue;
@@ -2413,7 +2433,7 @@ export function oke(options: OkeOptions): OkeApp {
2413
2433
  if (column !== undefined && t.column !== undefined && t.column !== column) {
2414
2434
  continue;
2415
2435
  }
2416
- results.push(await execute(b.flow, payload, t));
2436
+ results.push(await execute(b.flow, enriched, t));
2417
2437
  }
2418
2438
  return results;
2419
2439
  },
@@ -194,7 +194,7 @@ export interface BootOptions {
194
194
  };
195
195
  /**
196
196
  * Process instance id for Clock / Journal / fleet registry.
197
- * When unset, boot mints one `inst-<uuid>` and passes it to every binder.
197
+ * When unset, boot mints one `inst-<okid>` and passes it to every binder.
198
198
  */
199
199
  readonly instanceId?: string;
200
200
  /** Injected fleet store (chaos / tests — activates the registry in `test`). */
@@ -0,0 +1,224 @@
1
+ /**
2
+ * CdcPayload enrichment — action from images, PK from Manifest, additive fields.
3
+ */
4
+
5
+ import { beforeEach, describe, expect, test } from "bun:test";
6
+ import { field, store } from "../elements/store.ts";
7
+ import type { Manifest } from "../manifest/types.ts";
8
+ import { oke } from "./app.ts";
9
+ import {
10
+ cdcActionFromImages,
11
+ cdcIdFromImages,
12
+ declaredPk,
13
+ declaredPkColumn,
14
+ enrichCdcPayload,
15
+ pkColumnByTableFromManifest,
16
+ type CdcPayload,
17
+ } from "./cdc-payload.ts";
18
+ import { flow, resetFlowSeq } from "./flow.ts";
19
+ import { on, resetBindings } from "./on.ts";
20
+
21
+ beforeEach(() => {
22
+ resetBindings();
23
+ resetFlowSeq();
24
+ });
25
+
26
+ const manifest: Manifest = {
27
+ oke: "1.0",
28
+ app: "cdc-enrich",
29
+ stores: {
30
+ db: {
31
+ facet: "sql",
32
+ tables: {
33
+ orders: {
34
+ columns: {
35
+ id: { type: "text", primaryKey: true, sqlName: "id" },
36
+ status: { type: "text", sqlName: "status" },
37
+ },
38
+ },
39
+ accounts: {
40
+ columns: {
41
+ code: { type: "text", primaryKey: true, sqlName: "code" },
42
+ name: { type: "text", sqlName: "name" },
43
+ },
44
+ },
45
+ members: {
46
+ columns: {
47
+ userId: { type: "text", primaryKey: true, sqlName: "user_id" },
48
+ },
49
+ },
50
+ },
51
+ },
52
+ },
53
+ };
54
+
55
+ describe("cdc-payload helpers", () => {
56
+ test("action is created / updated / deleted from before/after presence", () => {
57
+ expect(cdcActionFromImages(null, { id: "1" })).toBe("created");
58
+ expect(cdcActionFromImages({ id: "1" }, { id: "1" })).toBe("updated");
59
+ expect(cdcActionFromImages({ id: "1" }, null)).toBe("deleted");
60
+ });
61
+
62
+ test("id uses after for created/updated and before for deleted", () => {
63
+ expect(cdcIdFromImages(null, { code: "a1" }, "code")).toBe("a1");
64
+ expect(cdcIdFromImages({ code: "a1" }, { code: "a1", name: "x" }, "code")).toBe("a1");
65
+ expect(cdcIdFromImages({ code: "a1" }, null, "code")).toBe("a1");
66
+ });
67
+
68
+ test("id preserves number PKs and stringifies anything else", () => {
69
+ expect(cdcIdFromImages(null, { id: 42 }, "id")).toBe(42);
70
+ expect(cdcIdFromImages(null, { id: true }, "id")).toBe("true");
71
+ expect(cdcIdFromImages(null, {}, "id")).toBe("");
72
+ });
73
+
74
+ test("id tries JS key then SQL name when they differ", () => {
75
+ const pk = { key: "userId", sqlName: "user_id" };
76
+ expect(cdcIdFromImages(null, { userId: "u1" }, pk)).toBe("u1");
77
+ expect(cdcIdFromImages(null, { user_id: "u2" }, pk)).toBe("u2");
78
+ });
79
+
80
+ test("declaredPk reads Manifest primaryKey, else id", () => {
81
+ expect(declaredPk(manifest.stores!.db!.tables!.accounts!.columns)).toEqual({
82
+ key: "code",
83
+ sqlName: "code",
84
+ });
85
+ expect(declaredPkColumn(manifest.stores!.db!.tables!.members!.columns)).toBe("user_id");
86
+ expect(declaredPk(undefined)).toEqual({ key: "id", sqlName: "id" });
87
+ expect(declaredPk({ name: { type: "text" } })).toEqual({ key: "id", sqlName: "id" });
88
+ });
89
+
90
+ test("pkColumnByTableFromManifest maps every SQL table", () => {
91
+ const map = pkColumnByTableFromManifest(manifest);
92
+ expect(map.get("orders")).toEqual({ key: "id", sqlName: "id" });
93
+ expect(map.get("accounts")).toEqual({ key: "code", sqlName: "code" });
94
+ expect(map.get("members")).toEqual({ key: "userId", sqlName: "user_id" });
95
+ expect(pkColumnByTableFromManifest(undefined).size).toBe(0);
96
+ });
97
+
98
+ test("enrichCdcPayload fills table, action, and id", () => {
99
+ expect(enrichCdcPayload("orders", null, { id: "o1", status: "open" }, "id")).toEqual({
100
+ table: "orders",
101
+ action: "created",
102
+ id: "o1",
103
+ before: null,
104
+ after: { id: "o1", status: "open" },
105
+ });
106
+ });
107
+ });
108
+
109
+ describe("dispatchCdc — enriched payload", () => {
110
+ test("insert/update/delete carry table, action, and the declared PK for two tables", async () => {
111
+ const seen: CdcPayload[] = [];
112
+ const db = store.sql("main");
113
+ const orders = store.schema.table("orders", {
114
+ id: field.text().primaryKey(),
115
+ status: field.text(),
116
+ });
117
+ const accounts = store.schema.table("accounts", {
118
+ code: field.text().primaryKey(),
119
+ name: field.text(),
120
+ });
121
+
122
+ on(
123
+ db.table(orders).changed(),
124
+ flow("audit.orders", {
125
+ do: (p: CdcPayload) => {
126
+ seen.push(p);
127
+ },
128
+ }),
129
+ );
130
+ on(
131
+ db.table(accounts).changed(),
132
+ flow("audit.accounts", {
133
+ do: (p: CdcPayload) => {
134
+ seen.push(p);
135
+ },
136
+ }),
137
+ );
138
+
139
+ const app = oke({ autoBoot: false, name: "cdc-enrich", manifest });
140
+
141
+ await app.dispatchCdc("orders", { before: null, after: { id: "o1", status: "open" } });
142
+ await app.dispatchCdc("orders", {
143
+ before: { id: "o1", status: "open" },
144
+ after: { id: "o1", status: "paid" },
145
+ });
146
+ await app.dispatchCdc("orders", { before: { id: "o1", status: "paid" }, after: null });
147
+
148
+ await app.dispatchCdc("accounts", { before: null, after: { code: "acme", name: "Acme" } });
149
+ await app.dispatchCdc("accounts", {
150
+ before: { code: "acme", name: "Acme" },
151
+ after: { code: "acme", name: "Acme Inc" },
152
+ });
153
+ await app.dispatchCdc("accounts", { before: { code: "acme", name: "Acme Inc" }, after: null });
154
+
155
+ expect(seen).toEqual([
156
+ {
157
+ table: "orders",
158
+ action: "created",
159
+ id: "o1",
160
+ before: null,
161
+ after: { id: "o1", status: "open" },
162
+ },
163
+ {
164
+ table: "orders",
165
+ action: "updated",
166
+ id: "o1",
167
+ before: { id: "o1", status: "open" },
168
+ after: { id: "o1", status: "paid" },
169
+ },
170
+ {
171
+ table: "orders",
172
+ action: "deleted",
173
+ id: "o1",
174
+ before: { id: "o1", status: "paid" },
175
+ after: null,
176
+ },
177
+ {
178
+ table: "accounts",
179
+ action: "created",
180
+ id: "acme",
181
+ before: null,
182
+ after: { code: "acme", name: "Acme" },
183
+ },
184
+ {
185
+ table: "accounts",
186
+ action: "updated",
187
+ id: "acme",
188
+ before: { code: "acme", name: "Acme" },
189
+ after: { code: "acme", name: "Acme Inc" },
190
+ },
191
+ {
192
+ table: "accounts",
193
+ action: "deleted",
194
+ id: "acme",
195
+ before: { code: "acme", name: "Acme Inc" },
196
+ after: null,
197
+ },
198
+ ]);
199
+ });
200
+
201
+ test("a handler that only destructures { before, after } still runs", async () => {
202
+ const seen: string[] = [];
203
+ const db = store.sql("main");
204
+ const orders = store.schema.table("orders", {
205
+ id: field.text().primaryKey(),
206
+ status: field.text(),
207
+ });
208
+ on(
209
+ db.table(orders).changed("status"),
210
+ flow("t.cdc.bare", {
211
+ do: (p: { before: { status: string }; after: { status: string } }) => {
212
+ seen.push(`cdc:${p.before.status}->${p.after.status}`);
213
+ },
214
+ }),
215
+ );
216
+ const app = oke({ autoBoot: false, name: "cdc-bare" });
217
+ await app.dispatchCdc(
218
+ "orders",
219
+ { before: { status: "open" }, after: { status: "paid" } },
220
+ "status",
221
+ );
222
+ expect(seen).toEqual(["cdc:open->paid"]);
223
+ });
224
+ });