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.
- package/package.json +1 -1
- package/site/content/docs/ai/meta.json +1 -1
- package/site/content/docs/ai/skills.mdx +1 -1
- package/site/content/docs/client/index.mdx +1 -2
- package/site/content/docs/elements/ai/agents.mdx +5 -2
- package/site/content/docs/elements/ai/index.mdx +4 -3
- package/site/content/docs/elements/ai/prompts.mdx +3 -2
- package/site/content/docs/elements/channel/email.mdx +5 -2
- package/site/content/docs/elements/channel/index.mdx +1 -1
- package/site/content/docs/elements/clock/index.mdx +23 -12
- package/site/content/docs/elements/clock/schedules.mdx +6 -3
- package/site/content/docs/elements/clock/sleep.mdx +10 -7
- package/site/content/docs/elements/flow/consumers.mdx +96 -58
- package/site/content/docs/elements/flow/index.mdx +15 -15
- package/site/content/docs/elements/flow/routing.mdx +11 -2
- package/site/content/docs/elements/gate/tenancy.mdx +5 -2
- package/site/content/docs/elements/signal/broadcast.mdx +6 -4
- package/site/content/docs/elements/signal/index.mdx +14 -1
- package/site/content/docs/elements/signal/live.mdx +3 -2
- package/site/content/docs/elements/signal/once.mdx +3 -2
- package/site/content/docs/elements/store/files.mdx +24 -16
- package/site/content/docs/elements/store/index.mdx +11 -8
- package/site/content/docs/elements/store/kv.mdx +24 -16
- package/site/content/docs/elements/store/search.mdx +18 -14
- package/site/content/docs/elements/store/sql.mdx +16 -12
- package/site/content/docs/elements/vault/config.mdx +5 -2
- package/site/content/docs/elements/vault/rotation.mdx +5 -2
- package/site/content/docs/elements/vault/secrets.mdx +5 -2
- package/site/content/docs/index.mdx +4 -19
- package/site/content/docs/plugins/anonymous.mdx +1 -1
- package/site/content/docs/plugins/cors.mdx +1 -1
- package/site/content/docs/plugins/csrf.mdx +1 -1
- package/site/content/docs/plugins/headers.mdx +1 -1
- package/site/content/docs/plugins/ip-allowlist.mdx +1 -1
- package/site/content/docs/plugins/maintenance-mode.mdx +1 -1
- package/site/content/docs/reference/cli.mdx +1 -1
- package/site/content/docs/reference/errors.mdx +1 -0
- package/site/content/docs/reference/fx.mdx +9 -8
- package/site/content/docs/reference/okid.mdx +1 -1
- package/site/content/docs/reference/plugins.mdx +1 -1
- package/site/content/docs/reference/security.mdx +2 -2
- package/site/content/docs/understand/meta.json +1 -1
- package/site/content/docs/understand/the-architecture.mdx +264 -0
- package/src/bench/REPORT.md +48 -17
- package/src/bench/g17-hybrid-search.bench.ts +13 -13
- package/src/compiler/extract.test.ts +76 -1
- package/src/compiler/extract.ts +106 -7
- package/src/compiler/search-writer-isolation.test.ts +0 -1
- package/src/console/ui-next/dist/assets/{access-page-DFeymU07.js → access-page-DceEWH9u.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-U1rdfblp.js → agent-disclosure-tC9s2VFd.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-BeFJeqBG.js → cache-glyph-C-naNQSR.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-CVAONPii.js → call-pii-button-DnZ_MlDn.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-D2A6NJ-3.js → collapsible-RekgR6Qz.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-Cgk_h5ja.js → duration-tone-DugtWBS0.js} +1 -1
- package/src/console/ui-next/dist/assets/flows-page-DVmn1ZuQ.js +1 -0
- package/src/console/ui-next/dist/assets/{highlighted-json-MYZQtRnw.js → highlighted-json-CvBGaiSD.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-Jrh39p7A.js → http-method-D7_OXbdC.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-DXP2dBIF.js → index-DH0K2f6N.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-HvolXxTI.js → observability-page-qJzF2nSN.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-CSh2dzrb.js → replica-lag-Vqk0pUBA.js} +1 -1
- package/src/console/ui-next/dist/assets/request-meta-BtShi4sG.js +1 -0
- package/src/console/ui-next/dist/assets/{store-page-eiKiHnNe.js → store-page-CMsYH_vH.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-CZkMeKS-.js → trace-detail-sheet-ycFB2uua.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-CW8y5A2h.js → tree-expand-toggle-iG1jcWgU.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-B_RWJrEO.js → units-page-Bavchke4.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-CWrg-A68.js → vault-page-DxUFhiZI.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/src/features/flows/graph/element-map.test.ts +1 -1
- package/src/console/ui-next/src/features/flows/graph/element-map.ts +3 -3
- package/src/console/ui-next/src/features/flows/traces/trace-detail.test.ts +4 -5
- package/src/console/ui-next/src/features/flows/traces/trace-gates.ts +3 -6
- package/src/elements/gate/declare.ts +1 -1
- package/src/elements/gate.ts +1 -1
- package/src/elements/store/live-default.test.ts +8 -0
- package/src/elements/store/search-embed-flow.ts +2 -2
- package/src/elements/store/search-lsh.ts +35 -0
- package/src/elements/store/search-runtime.pglite.test.ts +186 -0
- package/src/elements/store/search-runtime.ts +30 -16
- package/src/elements/store/search.test.ts +83 -1
- package/src/elements/store.ts +2 -0
- package/src/full.ts +3 -0
- package/src/http.ts +3 -0
- package/src/index.ts +3 -0
- package/src/kernel/app.ts +30 -10
- package/src/kernel/boot.ts +1 -1
- package/src/kernel/cdc-payload.test.ts +224 -0
- package/src/kernel/cdc-payload.ts +146 -0
- package/src/kernel/errors-flow-name.ts +17 -0
- package/src/kernel/errors.registry.test.ts +6 -4
- package/src/kernel/flow-name.test.ts +104 -0
- package/src/kernel/flow.ts +3 -2
- package/src/kernel/fx-emit-types.test.ts +31 -0
- package/src/kernel/fx.test.ts +2 -1
- package/src/kernel/fx.ts +13 -3
- package/src/kernel/index.ts +2 -0
- package/src/kernel/on.ts +5 -0
- package/src/kernel/stamp-http.test.ts +13 -0
- package/src/kernel/stamp-http.ts +21 -3
- package/src/kernel/unit.ts +4 -2
- package/src/kernel-entry.ts +3 -0
- package/src/mcp/docs-index.ts +3 -3
- package/src/mcp/docs-mcp.test.ts +2 -2
- package/src/mcp/docs-tools.ts +2 -1
- package/site/content/docs/understand/the-anatomy.mdx +0 -132
- package/site/content/docs/understand/the-model.mdx +0 -32
- package/site/content/docs/understand/the-problem.mdx +0 -74
- package/site/content/docs/understand/the-vocabulary.mdx +0 -26
- package/src/console/ui-next/dist/assets/flows-page-Dss7941e.js +0 -1
- package/src/console/ui-next/dist/assets/request-meta-BatF8KrK.js +0 -1
- /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
|
-
|
|
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
|
|
78
|
-
*
|
|
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
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
|
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);
|
package/src/elements/store.ts
CHANGED
package/src/full.ts
CHANGED
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
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/
|
|
588
|
+
* @param payload - `{ before, after }` (and optional `table` / `action` / `id`)
|
|
586
589
|
* @param column - Optional column filter
|
|
587
590
|
*/
|
|
588
|
-
dispatchCdc(
|
|
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,
|
|
2436
|
+
results.push(await execute(b.flow, enriched, t));
|
|
2417
2437
|
}
|
|
2418
2438
|
return results;
|
|
2419
2439
|
},
|
package/src/kernel/boot.ts
CHANGED
|
@@ -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-<
|
|
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
|
+
});
|