okengine 0.19.1 → 0.19.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/site/content/docs/elements/clock/index.mdx +162 -11
- package/site/content/docs/elements/clock/schedules.mdx +6 -3
- package/site/content/docs/elements/flow/consumers.mdx +137 -79
- package/site/content/docs/elements/flow/index.mdx +14 -14
- package/site/content/docs/elements/flow/routing.mdx +18 -2
- package/site/content/docs/elements/signal/broadcast.mdx +9 -13
- package/site/content/docs/elements/signal/index.mdx +245 -13
- package/site/content/docs/elements/signal/live.mdx +5 -13
- package/site/content/docs/elements/signal/once.mdx +83 -28
- package/site/content/docs/elements/store/sql.mdx +2 -2
- 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/errors.mdx +34 -26
- package/site/content/docs/reference/fx.mdx +6 -6
- package/site/content/docs/reference/okid.mdx +1 -1
- package/site/content/docs/reference/plugins.mdx +1 -1
- package/site/content/docs/understand/the-architecture.mdx +1 -1
- package/src/compiler/extract.test.ts +123 -1
- package/src/compiler/extract.ts +134 -7
- package/src/compiler/search-writer-isolation.test.ts +0 -1
- package/src/console/ui-next/dist/assets/{access-page-C_qLDhTq.js → access-page-BpugjHHY.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-BHVqr3TN.js → agent-disclosure-qnsmtJhf.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-CKe92lRQ.js → cache-glyph-BcpWUM98.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-DEwTl8ZX.js → call-pii-button-Bz5xiNmR.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-BCBtDrCt.js → collapsible-Cuxn2WH8.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-JroqeuCp.js → duration-tone-Bjnl3EaM.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-CVHa0RTt.js → flows-page-DVujp-T2.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-DjJW6hqe.js → highlighted-json-Bql0qlqW.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-DC5HBdLU.js → http-method-BljvrfRg.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-CYjiZ3WO.js → index-D1vE656k.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CAYMyKb3.js → observability-page-Bdi0bLJl.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-yAQYLv75.js → replica-lag-CpkPfITG.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-D0yusGxJ.js → request-meta-C4ZVNFVt.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-BTKJeJ02.js → store-page-DAHtnesC.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-Bp-Yygs5.js → trace-detail-sheet-B2c9QRBw.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-DoaVDfAM.js → tree-expand-toggle-BP4tRC98.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-BRz7xyYL.js → units-page-Oi6--n09.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-3jQt-bOJ.js → vault-page-Dt-cPkiU.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/elements/store/live-default.test.ts +8 -0
- package/src/elements/store/search-embed-flow.ts +2 -2
- package/src/full.ts +3 -0
- package/src/http.ts +3 -0
- package/src/index.ts +3 -0
- package/src/kernel/app.ts +94 -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-once-signal.ts +25 -0
- package/src/kernel/errors.registry.test.ts +14 -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/once-signal.test.ts +71 -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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDC flow payload — `{ before, after }` plus additive `table` / `action` / `id`.
|
|
3
|
+
*
|
|
4
|
+
* PK lookup reuses the Manifest `DeclaredColumn.primaryKey` walk already proven
|
|
5
|
+
* by search-embed (not `resolvePkColumn`'s `"id"`/`"code"` heuristics).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { DeclaredColumn, Manifest, Table } from "../manifest/types.ts";
|
|
9
|
+
|
|
10
|
+
/** Insert / update / delete derived from before/after presence. */
|
|
11
|
+
export type CdcAction = "created" | "updated" | "deleted";
|
|
12
|
+
|
|
13
|
+
/** Payload for a CDC invocation. */
|
|
14
|
+
export interface CdcPayload {
|
|
15
|
+
readonly before: Record<string, unknown> | null;
|
|
16
|
+
readonly after: Record<string, unknown> | null;
|
|
17
|
+
readonly table: string;
|
|
18
|
+
readonly action: CdcAction;
|
|
19
|
+
readonly id: string | number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Images-only (or partially enriched) input accepted by {@link dispatchCdc}.
|
|
24
|
+
* Missing `table` / `action` / `id` are filled in before the Flow runs.
|
|
25
|
+
*/
|
|
26
|
+
export type CdcPayloadInput = Pick<CdcPayload, "before" | "after"> &
|
|
27
|
+
Partial<Pick<CdcPayload, "table" | "action" | "id">>;
|
|
28
|
+
|
|
29
|
+
/** JS key + SQL name for a declared primary-key column. */
|
|
30
|
+
export interface DeclaredPk {
|
|
31
|
+
readonly key: string;
|
|
32
|
+
readonly sqlName: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const DEFAULT_PK: DeclaredPk = { key: "id", sqlName: "id" };
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Declared PK from a Manifest table column map.
|
|
39
|
+
* `primaryKey: true` wins; otherwise `"id"` (CDC does not require a PK).
|
|
40
|
+
*
|
|
41
|
+
* @param columns - Manifest `tables.*.columns`
|
|
42
|
+
*/
|
|
43
|
+
export function declaredPk(columns: Table["columns"] | undefined): DeclaredPk {
|
|
44
|
+
for (const [key, col] of Object.entries(columns ?? {})) {
|
|
45
|
+
if (!col || typeof col !== "object") continue;
|
|
46
|
+
const c = col as DeclaredColumn;
|
|
47
|
+
if (c.primaryKey === true) return { key, sqlName: c.sqlName ?? key };
|
|
48
|
+
}
|
|
49
|
+
return DEFAULT_PK;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* SQL name of the declared PK (search-embed SQL `WHERE` uses this).
|
|
54
|
+
*
|
|
55
|
+
* @param columns - Manifest `tables.*.columns`
|
|
56
|
+
*/
|
|
57
|
+
export function declaredPkColumn(columns: Table["columns"] | undefined): string {
|
|
58
|
+
return declaredPk(columns).sqlName;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Table name → declared PK for every SQL table in the Manifest.
|
|
63
|
+
*
|
|
64
|
+
* @param manifest - Compiled Manifest (optional)
|
|
65
|
+
*/
|
|
66
|
+
export function pkColumnByTableFromManifest(
|
|
67
|
+
manifest: Pick<Manifest, "stores"> | undefined,
|
|
68
|
+
): ReadonlyMap<string, DeclaredPk> {
|
|
69
|
+
const out = new Map<string, DeclaredPk>();
|
|
70
|
+
for (const store of Object.values(manifest?.stores ?? {})) {
|
|
71
|
+
if (store.facet !== "sql" || !store.tables) continue;
|
|
72
|
+
for (const [tableName, table] of Object.entries(store.tables)) {
|
|
73
|
+
out.set(tableName, declaredPk(table.columns));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Derive action from row images. `before === null` → created; `after === null`
|
|
81
|
+
* → deleted; otherwise updated. Not `event.op`.
|
|
82
|
+
*
|
|
83
|
+
* @param before - Prior row image
|
|
84
|
+
* @param after - New row image
|
|
85
|
+
*/
|
|
86
|
+
export function cdcActionFromImages(
|
|
87
|
+
before: Record<string, unknown> | null,
|
|
88
|
+
after: Record<string, unknown> | null,
|
|
89
|
+
): CdcAction {
|
|
90
|
+
if (before === null) return "created";
|
|
91
|
+
if (after === null) return "deleted";
|
|
92
|
+
return "updated";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function pkNames(pkColumn: string | DeclaredPk): DeclaredPk {
|
|
96
|
+
return typeof pkColumn === "string" ? { key: pkColumn, sqlName: pkColumn } : pkColumn;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function asCdcId(raw: unknown): string | number {
|
|
100
|
+
if (typeof raw === "number" && Number.isFinite(raw)) return raw;
|
|
101
|
+
if (typeof raw === "string") return raw;
|
|
102
|
+
return String(raw ?? "");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Extract the PK value from the surviving image (`after` for created/updated,
|
|
107
|
+
* `before` for deleted). Tries the JS key then the SQL name.
|
|
108
|
+
*
|
|
109
|
+
* @param before - Prior row image
|
|
110
|
+
* @param after - New row image
|
|
111
|
+
* @param pkColumn - Declared PK key and/or SQL name
|
|
112
|
+
*/
|
|
113
|
+
export function cdcIdFromImages(
|
|
114
|
+
before: Record<string, unknown> | null,
|
|
115
|
+
after: Record<string, unknown> | null,
|
|
116
|
+
pkColumn: string | DeclaredPk,
|
|
117
|
+
): string | number {
|
|
118
|
+
const { key, sqlName } = pkNames(pkColumn);
|
|
119
|
+
const row = after ?? before;
|
|
120
|
+
if (!row) return "";
|
|
121
|
+
const raw = key in row ? row[key] : row[sqlName];
|
|
122
|
+
return asCdcId(raw);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Build the full CDC payload from table name, images, and the declared PK.
|
|
127
|
+
*
|
|
128
|
+
* @param tableName - Physical table name (`resolveTableName` / `dispatchCdc` arg)
|
|
129
|
+
* @param before - Prior row image
|
|
130
|
+
* @param after - New row image
|
|
131
|
+
* @param pkColumn - Declared PK (Manifest) or a column name string
|
|
132
|
+
*/
|
|
133
|
+
export function enrichCdcPayload(
|
|
134
|
+
tableName: string,
|
|
135
|
+
before: Record<string, unknown> | null,
|
|
136
|
+
after: Record<string, unknown> | null,
|
|
137
|
+
pkColumn: string | DeclaredPk = DEFAULT_PK,
|
|
138
|
+
): CdcPayload {
|
|
139
|
+
return {
|
|
140
|
+
before,
|
|
141
|
+
after,
|
|
142
|
+
table: tableName,
|
|
143
|
+
action: cdcActionFromImages(before, after),
|
|
144
|
+
id: cdcIdFromImages(before, after, pkColumn),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OKE1070 — kept off the kernel edge profile.
|
|
3
|
+
*
|
|
4
|
+
* Duplicate Flow names throw this at `oke()` construction. `oke()` imports
|
|
5
|
+
* it directly (that graph is not in `budget-entry`). Registry discovery
|
|
6
|
+
* includes this file; the edge `lookupOkeError` path stays off this def.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { OkeErrorDefinition } from "./errors.ts";
|
|
10
|
+
|
|
11
|
+
/** Two Flow definitions share the same Manifest / `fx.call` name. */
|
|
12
|
+
export const FLOW_NAME_DUPLICATE: OkeErrorDefinition = {
|
|
13
|
+
code: 1070,
|
|
14
|
+
domain: "kernel",
|
|
15
|
+
cause: 'Flow "{flow}" is defined twice.',
|
|
16
|
+
fix: 'Give at least one Flow an explicit flow("…") or a distinct tree export.',
|
|
17
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OKE1071 — kept off the kernel edge profile.
|
|
3
|
+
*
|
|
4
|
+
* Two different Flows bound to the same `signal.once` throw this at `oke()`
|
|
5
|
+
* construction. Extract fails the same collision with a plain `Error`
|
|
6
|
+
* (`OKE1071` in the message) so `okengine/compiler` stays off this module.
|
|
7
|
+
* `oke()` imports it directly (that graph is not in `budget-entry`).
|
|
8
|
+
* Registry discovery includes this file; the edge `lookupOkeError` path
|
|
9
|
+
* stays off this def.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { OkeErrorDefinition } from "./errors.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Two different Flow definitions share one `signal.once` consumer slot.
|
|
16
|
+
*
|
|
17
|
+
* Horizontal replicas of the same Flow are one `on()` in source — this only
|
|
18
|
+
* fires when the Manifest lists two or more Flow names on that signal.
|
|
19
|
+
*/
|
|
20
|
+
export const ONCE_SIGNAL_MULTI_FLOW: OkeErrorDefinition = {
|
|
21
|
+
code: 1071,
|
|
22
|
+
domain: "kernel",
|
|
23
|
+
cause: 'Once signal "{signal}" is bound to more than one Flow ({flows}).',
|
|
24
|
+
fix: "Use signal.broadcast if each flow should independently receive this event, or bind only one flow if these should compete for the same work.",
|
|
25
|
+
};
|
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
} from "./errors.ts";
|
|
18
18
|
import { LIVE_RESUME_GAP } from "./errors-live-resume.ts";
|
|
19
19
|
import { CHANNEL_SCHEMA } from "./errors-channel.ts";
|
|
20
|
+
import { FLOW_NAME_DUPLICATE } from "./errors-flow-name.ts";
|
|
21
|
+
import { ONCE_SIGNAL_MULTI_FLOW } from "./errors-once-signal.ts";
|
|
20
22
|
import { TENANT_NOT_MEMBER, TENANT_REQUIRED, TENANT_UNKNOWN_SCOPE } from "./errors-tenant.ts";
|
|
21
23
|
import {
|
|
22
24
|
assertCodesInDomainRanges,
|
|
@@ -164,10 +166,18 @@ describe("OKE error-code registry", () => {
|
|
|
164
166
|
expect(lookupOkeError(1830)).toEqual(TENANT_UNKNOWN_SCOPE);
|
|
165
167
|
});
|
|
166
168
|
|
|
167
|
-
test("
|
|
168
|
-
|
|
169
|
-
expect(
|
|
170
|
-
expect(
|
|
169
|
+
test("lazy discovery finds FLOW_NAME_DUPLICATE at 1070", async () => {
|
|
170
|
+
const { files, defs } = await discoverLazyErrorDefs(KERNEL_DIR);
|
|
171
|
+
expect(files).toContain("errors-flow-name.ts");
|
|
172
|
+
expect(defs.some((d) => d.code === 1070)).toBe(true);
|
|
173
|
+
expect(FLOW_NAME_DUPLICATE.code).toBe(1070);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("lazy discovery finds ONCE_SIGNAL_MULTI_FLOW at 1071", async () => {
|
|
177
|
+
const { files, defs } = await discoverLazyErrorDefs(KERNEL_DIR);
|
|
178
|
+
expect(files).toContain("errors-once-signal.ts");
|
|
179
|
+
expect(defs.some((d) => d.code === 1071)).toBe(true);
|
|
180
|
+
expect(ONCE_SIGNAL_MULTI_FLOW.code).toBe(1071);
|
|
171
181
|
});
|
|
172
182
|
|
|
173
183
|
test("NO_EFFECTS_DECLARED owns 1020 after renumber (was UNDECLARED_EMBED)", () => {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nameless Flow inherits Signal/Clock trigger names; duplicates fail OKE1070.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
6
|
+
import { clock, resetClocks } from "../elements/clock/declare.ts";
|
|
7
|
+
import { resetSignals, signal } from "../elements/signal/declare.ts";
|
|
8
|
+
import { oke } from "./app.ts";
|
|
9
|
+
import { OkeError } from "./errors.ts";
|
|
10
|
+
import { FLOW_NAME_DUPLICATE } from "./errors-flow-name.ts";
|
|
11
|
+
import { flow, resetFlowSeq } from "./flow.ts";
|
|
12
|
+
import { on, resetBindings } from "./on.ts";
|
|
13
|
+
import { stampFlowName } from "./stamp-http.ts";
|
|
14
|
+
import { http } from "./triggers.ts";
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
resetBindings();
|
|
18
|
+
resetFlowSeq();
|
|
19
|
+
resetSignals();
|
|
20
|
+
resetClocks();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("on() — Signal/Clock name inheritance", () => {
|
|
24
|
+
test("nameless flow inherits inline signal.once name", () => {
|
|
25
|
+
const bound = on(
|
|
26
|
+
signal.once("link-clicked"),
|
|
27
|
+
flow({
|
|
28
|
+
do: () => ({ ok: true }),
|
|
29
|
+
}),
|
|
30
|
+
);
|
|
31
|
+
expect(bound.name).toBe("link-clicked");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("nameless flow inherits clock.every name", () => {
|
|
35
|
+
const bound = on(
|
|
36
|
+
clock.every("cleanup", "10m"),
|
|
37
|
+
flow({
|
|
38
|
+
do: () => ({ ok: true }),
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
expect(bound.name).toBe("cleanup");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("explicit flow name wins over the trigger name", () => {
|
|
45
|
+
const bound = on(
|
|
46
|
+
signal.once("order-placed"),
|
|
47
|
+
flow("orders.fulfill", { do: () => ({ ok: true }) }),
|
|
48
|
+
);
|
|
49
|
+
expect(bound.name).toBe("orders.fulfill");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("HTTP does not inherit a name from the trigger", () => {
|
|
53
|
+
const bound = on(
|
|
54
|
+
http.post("/notes"),
|
|
55
|
+
flow({
|
|
56
|
+
do: () => ({ ok: true }),
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
expect(bound.name).toBe("");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("file-tree stamp overwrites a trigger-inherited name", () => {
|
|
63
|
+
const bound = on(
|
|
64
|
+
signal.once("note-created"),
|
|
65
|
+
flow({
|
|
66
|
+
do: () => ({ ok: true }),
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
expect(bound.name).toBe("note-created");
|
|
70
|
+
stampFlowName(bound, "notes.onCreated");
|
|
71
|
+
expect(bound.name).toBe("notes.onCreated");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("oke — FLOW_NAME_DUPLICATE OKE1070", () => {
|
|
76
|
+
test("two nameless inheritances of the same trigger name fail at construction", () => {
|
|
77
|
+
const ping = signal.once("health.ping");
|
|
78
|
+
on(ping, flow({ do: () => ({ a: true }) }));
|
|
79
|
+
on(ping, flow({ do: () => ({ b: true }) }));
|
|
80
|
+
try {
|
|
81
|
+
oke({ name: "t", autoBoot: false });
|
|
82
|
+
expect.unreachable("oke() should throw OKE1070");
|
|
83
|
+
} catch (err) {
|
|
84
|
+
expect(err).toBeInstanceOf(OkeError);
|
|
85
|
+
const okeErr = err as OkeError;
|
|
86
|
+
expect(okeErr.code).toBe(1070);
|
|
87
|
+
expect(okeErr.code).toBe(FLOW_NAME_DUPLICATE.code);
|
|
88
|
+
expect(okeErr.message).toMatch(/OKE1070/);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("two explicit flows with the same name fail OKE1070", () => {
|
|
93
|
+
on(signal.once("a"), flow("shared", { do: () => 1 }));
|
|
94
|
+
on(clock.every("b", "1h"), flow("shared", { do: () => 2 }));
|
|
95
|
+
expect(() => oke({ name: "t", autoBoot: false })).toThrow(/OKE1070/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("the same flow object bound twice is not a duplicate", () => {
|
|
99
|
+
const shared = flow("shared.work", { do: (input: { n: number }) => input.n });
|
|
100
|
+
on(http.post("/work"), shared);
|
|
101
|
+
on(signal.once("order-placed"), shared);
|
|
102
|
+
expect(() => oke({ name: "t", autoBoot: false })).not.toThrow();
|
|
103
|
+
});
|
|
104
|
+
});
|
package/src/kernel/flow.ts
CHANGED
|
@@ -240,8 +240,9 @@ export const flowBrand: unique symbol = Symbol("oke.flow");
|
|
|
240
240
|
* {@link call} / `mcp.tool`, not here.
|
|
241
241
|
*
|
|
242
242
|
* A nameless `flow({ do })` is stamped `unit.export` by the file-tree
|
|
243
|
-
* generator, {@link unit}, or `.adopt()`.
|
|
244
|
-
*
|
|
243
|
+
* generator, {@link unit}, or `.adopt()`. Bound to a named Signal or Clock
|
|
244
|
+
* with no explicit / tree / export name, it inherits the trigger's name.
|
|
245
|
+
* Explicit `flow("notes.get", {…})` still wins.
|
|
245
246
|
*
|
|
246
247
|
* @param options - Runtime options and handler (name stamped later)
|
|
247
248
|
*/
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed `fx.emit(SignalDecl<T>, payload)` vs untyped string-name emits.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { signal } from "../elements/signal/declare.ts";
|
|
7
|
+
import { createFxContext, type Fx } from "./fx.ts";
|
|
8
|
+
|
|
9
|
+
describe("fx.emit — SignalDecl payload inference", () => {
|
|
10
|
+
test("exported handle type-checks a matching payload; string names stay unknown", () => {
|
|
11
|
+
const orderPlaced = signal.once<{ orderId: string }>("order-placed");
|
|
12
|
+
const { fx } = createFxContext({
|
|
13
|
+
flow: "orders.create",
|
|
14
|
+
effects: { emits: ["order-placed"] },
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const typed: (s: typeof orderPlaced, p: { orderId: string }) => Promise<void> = (s, p) =>
|
|
18
|
+
fx.emit(s, p);
|
|
19
|
+
const untyped: (name: string, p: { extra: boolean }) => Promise<void> = (name, p) =>
|
|
20
|
+
fx.emit(name, p);
|
|
21
|
+
|
|
22
|
+
expect(typeof typed).toBe("function");
|
|
23
|
+
expect(typeof untyped).toBe("function");
|
|
24
|
+
|
|
25
|
+
function _wrongPayload(fxArg: Fx): void {
|
|
26
|
+
// @ts-expect-error payload must match SignalDecl
|
|
27
|
+
void fxArg.emit(orderPlaced, { orderId: 1 });
|
|
28
|
+
}
|
|
29
|
+
expect(typeof _wrongPayload).toBe("function");
|
|
30
|
+
});
|
|
31
|
+
});
|
package/src/kernel/fx.test.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
jsonResultBrand,
|
|
9
9
|
type Fx,
|
|
10
10
|
type FxStubStoreHandle,
|
|
11
|
+
type NamedRef,
|
|
11
12
|
} from "./fx.ts";
|
|
12
13
|
import { Redacted } from "./redacted.ts";
|
|
13
14
|
|
|
@@ -426,7 +427,7 @@ describe("fx — wholesale swap", () => {
|
|
|
426
427
|
throw new Error("runs must not be used");
|
|
427
428
|
},
|
|
428
429
|
},
|
|
429
|
-
async emit(signal) {
|
|
430
|
+
async emit(signal: NamedRef) {
|
|
430
431
|
calls.push(`emit:${typeof signal === "string" ? signal : signal.name}`);
|
|
431
432
|
},
|
|
432
433
|
async deadLetters() {
|
package/src/kernel/fx.ts
CHANGED
|
@@ -686,11 +686,21 @@ export interface Fx {
|
|
|
686
686
|
/**
|
|
687
687
|
* Emit a signal (records `emit`).
|
|
688
688
|
*
|
|
689
|
+
* Pass a {@link SignalDecl} handle for compile-time payload checking.
|
|
690
|
+
* A string name accepts `unknown` (runtime `schema` still applies).
|
|
691
|
+
*
|
|
689
692
|
* @param signal - Signal name or handle
|
|
690
693
|
* @param payload - Payload
|
|
691
694
|
* @param options - Optional emit options (`key` for per-key once ordering)
|
|
692
695
|
*/
|
|
693
|
-
emit(signal:
|
|
696
|
+
emit<T>(signal: SignalDecl<T>, payload?: T, options?: SignalEmitOptions): Promise<void>;
|
|
697
|
+
emit(
|
|
698
|
+
signal:
|
|
699
|
+
| string
|
|
700
|
+
| { readonly name: string; readonly version?: number; readonly delivery?: never },
|
|
701
|
+
payload?: unknown,
|
|
702
|
+
options?: SignalEmitOptions,
|
|
703
|
+
): Promise<void>;
|
|
694
704
|
/**
|
|
695
705
|
* Query dead-lettered messages for one signal (records `read` on `signal:<name>`).
|
|
696
706
|
*
|
|
@@ -847,7 +857,7 @@ export interface Fx {
|
|
|
847
857
|
t(key: AppMessageKey, values?: MessageValues): string;
|
|
848
858
|
/** Active locale for {@link Fx.t} and default channel sends. */
|
|
849
859
|
readonly locale: string;
|
|
850
|
-
/** Generate a unique id (
|
|
860
|
+
/** Generate a unique id (OKID). */
|
|
851
861
|
id(): string;
|
|
852
862
|
/** User-plane auth principal. */
|
|
853
863
|
readonly auth: FxAuth;
|
|
@@ -1928,7 +1938,7 @@ export function createFxContext(options: CreateFxOptions): FxContext {
|
|
|
1928
1938
|
const fx: Fx = {
|
|
1929
1939
|
store: storeHandle,
|
|
1930
1940
|
runs: runsSurface,
|
|
1931
|
-
emit(signal, payload, emitOptions) {
|
|
1941
|
+
emit(signal: NamedRef, payload?: unknown, emitOptions?: SignalEmitOptions) {
|
|
1932
1942
|
const name = resolveName(signal);
|
|
1933
1943
|
return gated("emit", name, async () => {
|
|
1934
1944
|
if (options.signalRuntime) {
|
package/src/kernel/index.ts
CHANGED
package/src/kernel/on.ts
CHANGED
|
@@ -203,6 +203,11 @@ export function on(
|
|
|
203
203
|
list.push(normalized);
|
|
204
204
|
// Stamp runtime carrier for the first bound trigger (type follows BoundTriggerOf).
|
|
205
205
|
(flowDef as { $trigger: Trigger }).$trigger = normalized;
|
|
206
|
+
// `$n` = name inherited from this Signal/Clock (file-tree / unit() may overwrite).
|
|
207
|
+
if ((normalized.kind === "signal" || normalized.kind === "clock") && !flowDef.name) {
|
|
208
|
+
(flowDef as { name: string; $n?: boolean }).name = normalized.name;
|
|
209
|
+
(flowDef as { $n?: boolean }).$n = true;
|
|
210
|
+
}
|
|
206
211
|
stampExposureContract(flowDef as AnyFlowDef, normalized, triggerOrMount);
|
|
207
212
|
bindings.push({ trigger: normalized, flow: flowDef as AnyFlowDef });
|
|
208
213
|
return flowDef;
|