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.
Files changed (68) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/elements/clock/index.mdx +162 -11
  3. package/site/content/docs/elements/clock/schedules.mdx +6 -3
  4. package/site/content/docs/elements/flow/consumers.mdx +137 -79
  5. package/site/content/docs/elements/flow/index.mdx +14 -14
  6. package/site/content/docs/elements/flow/routing.mdx +18 -2
  7. package/site/content/docs/elements/signal/broadcast.mdx +9 -13
  8. package/site/content/docs/elements/signal/index.mdx +245 -13
  9. package/site/content/docs/elements/signal/live.mdx +5 -13
  10. package/site/content/docs/elements/signal/once.mdx +83 -28
  11. package/site/content/docs/elements/store/sql.mdx +2 -2
  12. package/site/content/docs/plugins/anonymous.mdx +1 -1
  13. package/site/content/docs/plugins/cors.mdx +1 -1
  14. package/site/content/docs/plugins/csrf.mdx +1 -1
  15. package/site/content/docs/plugins/headers.mdx +1 -1
  16. package/site/content/docs/plugins/ip-allowlist.mdx +1 -1
  17. package/site/content/docs/plugins/maintenance-mode.mdx +1 -1
  18. package/site/content/docs/reference/errors.mdx +34 -26
  19. package/site/content/docs/reference/fx.mdx +6 -6
  20. package/site/content/docs/reference/okid.mdx +1 -1
  21. package/site/content/docs/reference/plugins.mdx +1 -1
  22. package/site/content/docs/understand/the-architecture.mdx +1 -1
  23. package/src/compiler/extract.test.ts +123 -1
  24. package/src/compiler/extract.ts +134 -7
  25. package/src/compiler/search-writer-isolation.test.ts +0 -1
  26. package/src/console/ui-next/dist/assets/{access-page-C_qLDhTq.js → access-page-BpugjHHY.js} +1 -1
  27. package/src/console/ui-next/dist/assets/{agent-disclosure-BHVqr3TN.js → agent-disclosure-qnsmtJhf.js} +1 -1
  28. package/src/console/ui-next/dist/assets/{cache-glyph-CKe92lRQ.js → cache-glyph-BcpWUM98.js} +1 -1
  29. package/src/console/ui-next/dist/assets/{call-pii-button-DEwTl8ZX.js → call-pii-button-Bz5xiNmR.js} +1 -1
  30. package/src/console/ui-next/dist/assets/{collapsible-BCBtDrCt.js → collapsible-Cuxn2WH8.js} +1 -1
  31. package/src/console/ui-next/dist/assets/{duration-tone-JroqeuCp.js → duration-tone-Bjnl3EaM.js} +1 -1
  32. package/src/console/ui-next/dist/assets/{flows-page-CVHa0RTt.js → flows-page-DVujp-T2.js} +1 -1
  33. package/src/console/ui-next/dist/assets/{highlighted-json-DjJW6hqe.js → highlighted-json-Bql0qlqW.js} +1 -1
  34. package/src/console/ui-next/dist/assets/{http-method-DC5HBdLU.js → http-method-BljvrfRg.js} +1 -1
  35. package/src/console/ui-next/dist/assets/{index-CYjiZ3WO.js → index-D1vE656k.js} +3 -3
  36. package/src/console/ui-next/dist/assets/{observability-page-CAYMyKb3.js → observability-page-Bdi0bLJl.js} +1 -1
  37. package/src/console/ui-next/dist/assets/{replica-lag-yAQYLv75.js → replica-lag-CpkPfITG.js} +1 -1
  38. package/src/console/ui-next/dist/assets/{request-meta-D0yusGxJ.js → request-meta-C4ZVNFVt.js} +1 -1
  39. package/src/console/ui-next/dist/assets/{store-page-BTKJeJ02.js → store-page-DAHtnesC.js} +1 -1
  40. package/src/console/ui-next/dist/assets/{trace-detail-sheet-Bp-Yygs5.js → trace-detail-sheet-B2c9QRBw.js} +1 -1
  41. package/src/console/ui-next/dist/assets/{tree-expand-toggle-DoaVDfAM.js → tree-expand-toggle-BP4tRC98.js} +1 -1
  42. package/src/console/ui-next/dist/assets/{units-page-BRz7xyYL.js → units-page-Oi6--n09.js} +1 -1
  43. package/src/console/ui-next/dist/assets/{vault-page-3jQt-bOJ.js → vault-page-Dt-cPkiU.js} +1 -1
  44. package/src/console/ui-next/dist/index.html +1 -1
  45. package/src/elements/store/live-default.test.ts +8 -0
  46. package/src/elements/store/search-embed-flow.ts +2 -2
  47. package/src/full.ts +3 -0
  48. package/src/http.ts +3 -0
  49. package/src/index.ts +3 -0
  50. package/src/kernel/app.ts +94 -10
  51. package/src/kernel/boot.ts +1 -1
  52. package/src/kernel/cdc-payload.test.ts +224 -0
  53. package/src/kernel/cdc-payload.ts +146 -0
  54. package/src/kernel/errors-flow-name.ts +17 -0
  55. package/src/kernel/errors-once-signal.ts +25 -0
  56. package/src/kernel/errors.registry.test.ts +14 -4
  57. package/src/kernel/flow-name.test.ts +104 -0
  58. package/src/kernel/flow.ts +3 -2
  59. package/src/kernel/fx-emit-types.test.ts +31 -0
  60. package/src/kernel/fx.test.ts +2 -1
  61. package/src/kernel/fx.ts +13 -3
  62. package/src/kernel/index.ts +2 -0
  63. package/src/kernel/on.ts +5 -0
  64. package/src/kernel/once-signal.test.ts +71 -0
  65. package/src/kernel/stamp-http.test.ts +13 -0
  66. package/src/kernel/stamp-http.ts +21 -3
  67. package/src/kernel/unit.ts +4 -2
  68. 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("lookupOkeError finds UNDECLARED_EMBED at 1009 (not tenant)", () => {
168
- expect(lookupOkeError(1009)).toEqual(OKE_ERRORS.UNDECLARED_EMBED);
169
- expect(OKE_ERRORS.UNDECLARED_EMBED.code).toBe(1009);
170
- expect(TENANT_REQUIRED.code).toBe(1810);
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
+ });
@@ -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()`. Explicit `flow("notes.get", {…})`
244
- * still wins.
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
+ });
@@ -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: NamedRef, payload?: unknown, options?: SignalEmitOptions): Promise<void>;
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 (UUID). */
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) {
@@ -4,7 +4,9 @@
4
4
 
5
5
  export {
6
6
  oke,
7
+ type CdcAction,
7
8
  type CdcPayload,
9
+ type CdcPayloadInput,
8
10
  type ExecuteResult,
9
11
  type OkeApp,
10
12
  type OkeOptions,
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;