@reventlessdev/reventless-gwt 1.0.0-alpha.74 → 1.0.0-alpha.76

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 (40) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/package.json +8 -8
  3. package/src/Bind.res.mjs +2 -2
  4. package/src/Cancellation.res +6 -0
  5. package/src/Cancellation.res.mjs +3 -2
  6. package/src/ChildProcess.res +19 -1
  7. package/src/ChildProcess.res.mjs +16 -1
  8. package/src/Cli.res +115 -9
  9. package/src/Cli.res.mjs +101 -17
  10. package/src/Collector.res +11 -0
  11. package/src/Collector.res.mjs +6 -3
  12. package/src/DomainGraph.res +0 -0
  13. package/src/DomainGraph.res.mjs +0 -0
  14. package/src/Filter.res +11 -2
  15. package/src/Filter.res.mjs +10 -3
  16. package/src/FormatterVsCode.res +1 -1
  17. package/src/FormatterVsCode.res.mjs +2 -1
  18. package/src/JestBind.res +1 -1
  19. package/src/JestBind.res.mjs +2 -2
  20. package/src/PlatformRunner.res +27 -6
  21. package/src/PlatformRunner.res.mjs +27 -6
  22. package/src/ProcessManager.res +51 -7
  23. package/src/ProcessManager.res.mjs +43 -4
  24. package/src/RunWorker.res +24 -0
  25. package/src/RunWorker.res.mjs +16 -0
  26. package/src/Watch.res +46 -30
  27. package/src/Watch.res.mjs +34 -32
  28. package/src/WatcherProbe.res +11 -3
  29. package/src/WatcherProbe.res.mjs +15 -2
  30. package/src/Worker.res +24 -0
  31. package/src/Worker.res.mjs +9 -0
  32. package/tests/CliRunEntryTest.res +57 -0
  33. package/tests/CliRunEntryTest.res.mjs +65 -0
  34. package/tests/DomainGraphTest.res +79 -2
  35. package/tests/DomainGraphTest.res.mjs +87 -6
  36. package/tests/FlowGwtTest.res.mjs +2 -1
  37. package/tests/InboundTranslationGwtTest.res.mjs +2 -1
  38. package/tests/OutboundTranslationGwtTest.res.mjs +2 -1
  39. package/tests/WatchDebounceTest.res +35 -7
  40. package/tests/WatchDebounceTest.res.mjs +37 -6
@@ -3,13 +3,25 @@
3
3
  import * as Nodefs from "node:fs";
4
4
  import * as Nodepath from "node:path";
5
5
  import * as Stdlib_Int from "@rescript/runtime/lib/es6/Stdlib_Int.js";
6
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
7
+ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
8
+ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
9
+ import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
10
+
11
+ let _errCode = ((e) => (e && typeof e.code === "string") ? e.code : undefined);
6
12
 
7
13
  function isAlive(pid) {
8
14
  try {
9
15
  process.kill(pid, 0);
10
16
  return true;
11
- } catch (exn) {
12
- return false;
17
+ } catch (raw_exn) {
18
+ let exn = Primitive_exceptions.internalToException(raw_exn);
19
+ let e = Stdlib_JsExn.fromException(exn);
20
+ if (e !== undefined) {
21
+ return Primitive_object.equal(_errCode(Primitive_option.valFromOption(e)), "EPERM");
22
+ } else {
23
+ return false;
24
+ }
13
25
  }
14
26
  }
15
27
 
@@ -38,6 +50,7 @@ function hasLiveWatcher(dir) {
38
50
  }
39
51
 
40
52
  export {
53
+ _errCode,
41
54
  isAlive,
42
55
  libLock,
43
56
  lockHasLivePid,
package/src/Worker.res ADDED
@@ -0,0 +1,24 @@
1
+ // Minimal `node:worker_threads` binding for gwt watch re-runs.
2
+ //
3
+ // Node's ESM module registry is keyed by URL and never evicts, so re-importing a
4
+ // recompiled test file in the same process replays tests that close over the
5
+ // *old* instances of their statically-imported implementation modules (the
6
+ // Loader can only cache-bust the test file's own URL, not its transitive
7
+ // imports). Running each watch re-run in a short-lived Worker gives it a fresh
8
+ // registry — the recompiled modules are re-imported — and reclaims the whole
9
+ // imported graph on worker exit (fixing the monotonic-buster memory leak).
10
+
11
+ type t
12
+ type workerOptions<'a> = {workerData: 'a}
13
+ type url
14
+
15
+ @new @module("node:worker_threads")
16
+ external make: (url, workerOptions<'a>) => t = "Worker"
17
+
18
+ // A worker's stdout is piped to the parent's process.stdout by default, so the
19
+ // NDJSON the formatters write inside the worker reaches the client unchanged.
20
+ @send external on: (t, string, 'cb) => unit = "on"
21
+ @send external terminate: t => promise<int> = "terminate"
22
+
23
+ // The compiled worker entry lives next to this module in the in-source build.
24
+ let runWorkerUrl: url = %raw(`new URL("./RunWorker.res.mjs", import.meta.url)`)
@@ -0,0 +1,9 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+
4
+ let runWorkerUrl = (new URL("./RunWorker.res.mjs", import.meta.url));
5
+
6
+ export {
7
+ runWorkerUrl,
8
+ }
9
+ /* runWorkerUrl Not a pure module */
@@ -0,0 +1,57 @@
1
+ // Regression tests for the CLI runner's per-test deadline (A2) and the
2
+ // Collector's skip-depth reset (A4). Before A2 a hung test body wedged the whole
3
+ // run — `runEntry` awaited it with no deadline. Before A4 a throwing `xdescribe`
4
+ // left `skipDepth` > 0, silently skipping every subsequently loaded file; the
5
+ // reset lives in `Collector.activate`.
6
+
7
+ open JestGlobals
8
+
9
+ @val external setTimeout: (unit => unit, int) => unit = "setTimeout"
10
+
11
+ let hangingEntry = (~timeout): Collector.entry => {
12
+ id: "hangs",
13
+ name: "hangs forever",
14
+ describePath: [],
15
+ slice: None,
16
+ // A body that never resolves — the pathological hung test.
17
+ body: () => Promise.make((_resolve, _reject) => ()),
18
+ status: Collector.Runnable,
19
+ location: None,
20
+ timeout,
21
+ }
22
+
23
+ describe("Cli.runEntry deadline", () => {
24
+ testPromise("a hung body is reported as a timeout, not a wedge", async () => {
25
+ let r = await Cli.runEntry(hangingEntry(~timeout=Some(50)))
26
+ expect(r.status)->toEqual(RunnerTypes.Fail)
27
+ switch r.mismatch {
28
+ | Some(Outcome.Throw({error})) => expect(error->String.includes("timed out"))->toEqual(true)
29
+ | _ => JsError.throwWithMessage("expected a timeout Throw mismatch")
30
+ }
31
+ })
32
+
33
+ testPromise("a body that resolves within the deadline passes", async () => {
34
+ let entry: Collector.entry = {
35
+ id: "ok",
36
+ name: "resolves fast",
37
+ describePath: [],
38
+ slice: None,
39
+ body: () => Promise.resolve(Outcome.pass),
40
+ status: Collector.Runnable,
41
+ location: None,
42
+ timeout: Some(1000),
43
+ }
44
+ let r = await Cli.runEntry(entry)
45
+ expect(r.status)->toEqual(RunnerTypes.Pass)
46
+ })
47
+ })
48
+
49
+ describe("Collector.activate", () => {
50
+ testSync("resets a leaked skipDepth to zero", () => {
51
+ // Simulate the leak a throwing xdescribe body would leave behind.
52
+ Collector.skipDepth := 3
53
+ Collector.activate()
54
+ expect(Collector.skipDepth.contents)->toEqual(0)
55
+ Collector.deactivate()
56
+ })
57
+ })
@@ -0,0 +1,65 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
4
+ import * as Cli$ReventlessGwt from "../src/Cli.res.mjs";
5
+ import * as Outcome$ReventlessGwt from "../src/Outcome.res.mjs";
6
+ import * as Collector$ReventlessGwt from "../src/Collector.res.mjs";
7
+
8
+ function hangingEntry(timeout) {
9
+ return {
10
+ id: "hangs",
11
+ name: "hangs forever",
12
+ describePath: [],
13
+ slice: undefined,
14
+ body: () => new Promise((_resolve, _reject) => {}),
15
+ status: "Runnable",
16
+ location: undefined,
17
+ timeout: timeout
18
+ };
19
+ }
20
+
21
+ globalThis.describe("Cli.runEntry deadline", () => {
22
+ globalThis.test("a hung body is reported as a timeout, not a wedge", async () => {
23
+ let r = await Cli$ReventlessGwt.runEntry(hangingEntry(50));
24
+ globalThis.expect(r.status).toEqual("Fail");
25
+ let match = r.mismatch;
26
+ if (match === undefined) {
27
+ return Stdlib_JsError.throwWithMessage("expected a timeout Throw mismatch");
28
+ }
29
+ if (match.TAG !== "Throw") {
30
+ return Stdlib_JsError.throwWithMessage("expected a timeout Throw mismatch");
31
+ }
32
+ globalThis.expect(match.error.includes("timed out")).toEqual(true);
33
+ });
34
+ globalThis.test("a body that resolves within the deadline passes", async () => {
35
+ let entry_describePath = [];
36
+ let entry_body = () => Promise.resolve(Outcome$ReventlessGwt.pass);
37
+ let entry_timeout = 1000;
38
+ let entry = {
39
+ id: "ok",
40
+ name: "resolves fast",
41
+ describePath: entry_describePath,
42
+ slice: undefined,
43
+ body: entry_body,
44
+ status: "Runnable",
45
+ location: undefined,
46
+ timeout: entry_timeout
47
+ };
48
+ let r = await Cli$ReventlessGwt.runEntry(entry);
49
+ globalThis.expect(r.status).toEqual("Pass");
50
+ });
51
+ });
52
+
53
+ globalThis.describe("Collector.activate", () => {
54
+ globalThis.test("resets a leaked skipDepth to zero", () => {
55
+ Collector$ReventlessGwt.skipDepth.contents = 3;
56
+ Collector$ReventlessGwt.activate();
57
+ globalThis.expect(Collector$ReventlessGwt.skipDepth.contents).toEqual(0);
58
+ Collector$ReventlessGwt.deactivate();
59
+ });
60
+ });
61
+
62
+ export {
63
+ hangingEntry,
64
+ }
65
+ /* Not a pure module */
@@ -47,17 +47,19 @@ let automation = (~name, ~consumes=[], ~produces=[], ~target=""): Reventless.Plu
47
47
  targetName: target,
48
48
  }
49
49
 
50
- let inbound = (~name, ~produces=[], ~target=""): Reventless.Plugin.inboundTranslationSliceDef => {
50
+ let inbound = (~name, ~produces=[], ~target="", ~ext=None): Reventless.Plugin.inboundTranslationSliceDef => {
51
51
  name,
52
52
  commandTypes: produces,
53
53
  targetName: target,
54
+ externalSystem: ext,
54
55
  }
55
56
 
56
- let outbound = (~name, ~consumes=[], ~produces=[], ~target=None): Reventless.Plugin.outboundTranslationSliceDef => {
57
+ let outbound = (~name, ~consumes=[], ~produces=[], ~target=None, ~ext=None): Reventless.Plugin.outboundTranslationSliceDef => {
57
58
  name,
58
59
  consumedEventTypes: consumes,
59
60
  inboundCommandTypes: produces,
60
61
  targetName: target,
62
+ externalSystem: ext,
61
63
  }
62
64
 
63
65
  let structure = (
@@ -83,6 +85,8 @@ let structure = (
83
85
 
84
86
  let hasEdge = (g: DomainGraph.graph, from, to, kind) =>
85
87
  g.edges->Array.some(e => e.from == from && e.to == to && e.kind == kind)
88
+ let edgeLabel = (g: DomainGraph.graph, from, to, kind) =>
89
+ g.edges->Array.find(e => e.from == from && e.to == to && e.kind == kind)->Option.flatMap(e => e.label)
86
90
  let nodeKind = (g: DomainGraph.graph, id) =>
87
91
  g.nodes->Array.find(n => n.id == id)->Option.map(n => n.kind)
88
92
 
@@ -128,6 +132,25 @@ describe("DomainGraph.build", () => {
128
132
  expect(hasEdge(g, "Catalog:Category", "Catalog.Archived", "emits"))->toBe(true)
129
133
  })
130
134
 
135
+ testPromise("a payload-less event also projects into the aggregate's linked view", async () => {
136
+ // Classic aggregate → linked view: the projection drew only producedEventTypes, which
137
+ // drops payload-less events — so a bare `| Archived` emitted but never reached the view.
138
+ // It must now project too (matching the emit), just like the payloaded `Added`.
139
+ let shop = structure(
140
+ ~aggregates=[
141
+ writable(
142
+ ~name="Category",
143
+ ~produces=["Catalog.Added"],
144
+ ~events=[evt(~name="Added"), evt(~name="Archived")],
145
+ ~linkedViews=["Categories"],
146
+ ),
147
+ ],
148
+ )
149
+ let g = DomainGraph.build(~structures=[("Catalog", shop)])
150
+ expect(hasEdge(g, "Catalog.Added", "Catalog:Categories", "projects"))->toBe(true)
151
+ expect(hasEdge(g, "Catalog.Archived", "Catalog:Categories", "projects"))->toBe(true)
152
+ })
153
+
131
154
  testPromise("an Internal StateViewSlice still gets a node + projects edge (dev graph shows it)", async () => {
132
155
  // Internal components are hidden from the deployed AutoUI but carried in pluginStructure
133
156
  // so the developer-facing domain graph can render them — see Plugin_Structure.res /
@@ -217,6 +240,60 @@ describe("DomainGraph.build", () => {
217
240
  expect(hasEdge(g, "Shop:ExportProduct", "Shop_Product_Sync", "delegatesTo"))->toBe(true)
218
241
  })
219
242
 
243
+ testPromise("an inbound translation naming an external system draws a box feeding it", async () => {
244
+ let shop = structure(
245
+ ~inboundTranslationSlices=[
246
+ inbound(~name="ImportProduct", ~produces=["ImportProduct.Add"], ~target="Product", ~ext=Some("SupplierFeed")),
247
+ ],
248
+ )
249
+ let g = DomainGraph.build(~structures=[("Shop", shop)])
250
+ // External box: drawn OUTSIDE the plugin (plugin: "") with a stable "ext:<name>" id.
251
+ expect(nodeKind(g, "ext:SupplierFeed"))->toEqual(Some("ExternalSystem"))
252
+ expect(g.nodes->Array.find(n => n.id == "ext:SupplierFeed")->Option.map(n => n.plugin))->toEqual(Some(""))
253
+ // Inbound direction: external → slice (unlabelled — the arrowhead carries the meaning).
254
+ expect(hasEdge(g, "ext:SupplierFeed", "Shop:ImportProduct", "translatesIn"))->toBe(true)
255
+ expect(edgeLabel(g, "ext:SupplierFeed", "Shop:ImportProduct", "translatesIn"))->toEqual(None)
256
+ })
257
+
258
+ testPromise("an outbound translation naming an external system feeds the box", async () => {
259
+ let shop = structure(
260
+ ~outboundTranslationSlices=[
261
+ outbound(~name="SendEmail", ~consumes=["Shop.Placed"], ~target=None, ~ext=Some("EmailService")),
262
+ ],
263
+ )
264
+ let g = DomainGraph.build(~structures=[("Shop", shop)])
265
+ expect(nodeKind(g, "ext:EmailService"))->toEqual(Some("ExternalSystem"))
266
+ // Outbound direction: slice → external (unlabelled — the arrowhead carries the meaning).
267
+ expect(hasEdge(g, "Shop:SendEmail", "ext:EmailService", "translatesOut"))->toBe(true)
268
+ expect(edgeLabel(g, "Shop:SendEmail", "ext:EmailService", "translatesOut"))->toEqual(None)
269
+ })
270
+
271
+ testPromise("translation slices with no external system draw no box (opt-in)", async () => {
272
+ let shop = structure(
273
+ ~inboundTranslationSlices=[inbound(~name="ImportProduct", ~target="Product")],
274
+ )
275
+ let g = DomainGraph.build(~structures=[("Shop", shop)])
276
+ expect(g.nodes->Array.some(n => n.kind == "ExternalSystem"))->toBe(false)
277
+ })
278
+
279
+ testPromise("two slices naming the same external system share one deduped box", async () => {
280
+ let a = structure(
281
+ ~outboundTranslationSlices=[
282
+ outbound(~name="SendEmailA", ~consumes=["A.X"], ~target=None, ~ext=Some("EmailService")),
283
+ ],
284
+ )
285
+ let b = structure(
286
+ ~inboundTranslationSlices=[
287
+ inbound(~name="FromEmail", ~produces=["FromEmail.Record"], ~target="Inbox", ~ext=Some("EmailService")),
288
+ ],
289
+ )
290
+ let g = DomainGraph.build(~structures=[("PluginA", a), ("PluginB", b)])
291
+ // One shared box (per-platform dedup by name) with both an out- and an in-edge.
292
+ expect(g.nodes->Array.filter(n => n.id == "ext:EmailService")->Array.length)->toBe(1)
293
+ expect(hasEdge(g, "PluginA:SendEmailA", "ext:EmailService", "translatesOut"))->toBe(true)
294
+ expect(hasEdge(g, "ext:EmailService", "PluginB:FromEmail", "translatesIn"))->toBe(true)
295
+ })
296
+
220
297
  testPromise("an event produced by two write-sides is one node with two emit edges", async () => {
221
298
  let shop = structure(
222
299
  ~aggregates=[
@@ -71,25 +71,29 @@ function automation(name, consumesOpt, producesOpt, targetOpt) {
71
71
  };
72
72
  }
73
73
 
74
- function inbound(name, producesOpt, targetOpt) {
74
+ function inbound(name, producesOpt, targetOpt, extOpt) {
75
75
  let produces = producesOpt !== undefined ? producesOpt : [];
76
76
  let target = targetOpt !== undefined ? targetOpt : "";
77
+ let ext = extOpt !== undefined ? Primitive_option.valFromOption(extOpt) : undefined;
77
78
  return {
78
79
  name: name,
79
80
  commandTypes: produces,
80
- targetName: target
81
+ targetName: target,
82
+ externalSystem: ext
81
83
  };
82
84
  }
83
85
 
84
- function outbound(name, consumesOpt, producesOpt, targetOpt) {
86
+ function outbound(name, consumesOpt, producesOpt, targetOpt, extOpt) {
85
87
  let consumes = consumesOpt !== undefined ? consumesOpt : [];
86
88
  let produces = producesOpt !== undefined ? producesOpt : [];
87
89
  let target = targetOpt !== undefined ? Primitive_option.valFromOption(targetOpt) : undefined;
90
+ let ext = extOpt !== undefined ? Primitive_option.valFromOption(extOpt) : undefined;
88
91
  return {
89
92
  name: name,
90
93
  consumedEventTypes: consumes,
91
94
  inboundCommandTypes: produces,
92
- targetName: target
95
+ targetName: target,
96
+ externalSystem: ext
93
97
  };
94
98
  }
95
99
 
@@ -125,6 +129,16 @@ function hasEdge(g, from, to, kind) {
125
129
  });
126
130
  }
127
131
 
132
+ function edgeLabel(g, from, to, kind) {
133
+ return Stdlib_Option.flatMap(g.edges.find(e => {
134
+ if (e.from === from && e.to === to) {
135
+ return e.kind === kind;
136
+ } else {
137
+ return false;
138
+ }
139
+ }), e => e.label);
140
+ }
141
+
128
142
  function nodeKind(g, id) {
129
143
  return Stdlib_Option.map(g.nodes.find(n => n.id === id), n => n.kind);
130
144
  }
@@ -166,6 +180,26 @@ globalThis.describe("DomainGraph.build", () => {
166
180
  globalThis.expect(hasEdge(g, "Catalog:Category", "Catalog.Added", "emits")).toBe(true);
167
181
  globalThis.expect(hasEdge(g, "Catalog:Category", "Catalog.Archived", "emits")).toBe(true);
168
182
  });
183
+ globalThis.test("a payload-less event also projects into the aggregate's linked view", async () => {
184
+ let shop = structure([writable("Category", undefined, ["Catalog.Added"], undefined, ["Categories"], [
185
+ {
186
+ name: "Added",
187
+ schema: "",
188
+ references: []
189
+ },
190
+ {
191
+ name: "Archived",
192
+ schema: "",
193
+ references: []
194
+ }
195
+ ])], undefined, undefined, undefined, undefined, undefined, undefined, undefined);
196
+ let g = DomainGraph$ReventlessGwt.build([[
197
+ "Catalog",
198
+ shop
199
+ ]]);
200
+ globalThis.expect(hasEdge(g, "Catalog.Added", "Catalog:Categories", "projects")).toBe(true);
201
+ globalThis.expect(hasEdge(g, "Catalog.Archived", "Catalog:Categories", "projects")).toBe(true);
202
+ });
169
203
  globalThis.test("an Internal StateViewSlice still gets a node + projects edge (dev graph shows it)", async () => {
170
204
  let shop = structure([writable("Order", undefined, ["Shop.Placed"], undefined, undefined, undefined)], undefined, [queryable("AvailableView", ["Shop.Placed"], "Internal")], undefined, undefined, undefined, undefined, undefined);
171
205
  let g = DomainGraph$ReventlessGwt.build([[
@@ -209,7 +243,7 @@ globalThis.describe("DomainGraph.build", () => {
209
243
  globalThis.expect(hasEdge(g, "Shop:AutoShip", "Shop_Ship_Place", "delegatesTo")).toBe(false);
210
244
  });
211
245
  globalThis.test("an inbound translation routes to the command it raises on its target", async () => {
212
- let shop = structure(undefined, undefined, undefined, [writable("Product", [command("Add", "Shop_Product_Add", undefined)], undefined, undefined, undefined, undefined)], undefined, undefined, [inbound("ImportProduct", ["ImportProduct.Add"], "Product")], undefined);
246
+ let shop = structure(undefined, undefined, undefined, [writable("Product", [command("Add", "Shop_Product_Add", undefined)], undefined, undefined, undefined, undefined)], undefined, undefined, [inbound("ImportProduct", ["ImportProduct.Add"], "Product", undefined)], undefined);
213
247
  let g = DomainGraph$ReventlessGwt.build([[
214
248
  "Shop",
215
249
  shop
@@ -218,13 +252,59 @@ globalThis.describe("DomainGraph.build", () => {
218
252
  globalThis.expect(hasEdge(g, "Shop:ImportProduct", "Shop_Product_Add", "delegatesTo")).toBe(true);
219
253
  });
220
254
  globalThis.test("an outbound translation with a target routes to the command it raises", async () => {
221
- let shop = structure(undefined, undefined, undefined, [writable("Product", [command("Sync", "Shop_Product_Sync", undefined)], undefined, undefined, undefined, undefined)], undefined, [outbound("ExportProduct", ["Shop.Added"], ["ExportProduct.Sync"], "Product")], undefined, undefined);
255
+ let shop = structure(undefined, undefined, undefined, [writable("Product", [command("Sync", "Shop_Product_Sync", undefined)], undefined, undefined, undefined, undefined)], undefined, [outbound("ExportProduct", ["Shop.Added"], ["ExportProduct.Sync"], "Product", undefined)], undefined, undefined);
222
256
  let g = DomainGraph$ReventlessGwt.build([[
223
257
  "Shop",
224
258
  shop
225
259
  ]]);
226
260
  globalThis.expect(hasEdge(g, "Shop:ExportProduct", "Shop_Product_Sync", "delegatesTo")).toBe(true);
227
261
  });
262
+ globalThis.test("an inbound translation naming an external system draws a box feeding it", async () => {
263
+ let shop = structure(undefined, undefined, undefined, undefined, undefined, undefined, [inbound("ImportProduct", ["ImportProduct.Add"], "Product", "SupplierFeed")], undefined);
264
+ let g = DomainGraph$ReventlessGwt.build([[
265
+ "Shop",
266
+ shop
267
+ ]]);
268
+ globalThis.expect(nodeKind(g, "ext:SupplierFeed")).toEqual("ExternalSystem");
269
+ globalThis.expect(Stdlib_Option.map(g.nodes.find(n => n.id === "ext:SupplierFeed"), n => n.plugin)).toEqual("");
270
+ globalThis.expect(hasEdge(g, "ext:SupplierFeed", "Shop:ImportProduct", "translatesIn")).toBe(true);
271
+ globalThis.expect(edgeLabel(g, "ext:SupplierFeed", "Shop:ImportProduct", "translatesIn")).toEqual(undefined);
272
+ });
273
+ globalThis.test("an outbound translation naming an external system feeds the box", async () => {
274
+ let shop = structure(undefined, undefined, undefined, undefined, undefined, [outbound("SendEmail", ["Shop.Placed"], undefined, Primitive_option.some(undefined), "EmailService")], undefined, undefined);
275
+ let g = DomainGraph$ReventlessGwt.build([[
276
+ "Shop",
277
+ shop
278
+ ]]);
279
+ globalThis.expect(nodeKind(g, "ext:EmailService")).toEqual("ExternalSystem");
280
+ globalThis.expect(hasEdge(g, "Shop:SendEmail", "ext:EmailService", "translatesOut")).toBe(true);
281
+ globalThis.expect(edgeLabel(g, "Shop:SendEmail", "ext:EmailService", "translatesOut")).toEqual(undefined);
282
+ });
283
+ globalThis.test("translation slices with no external system draw no box (opt-in)", async () => {
284
+ let shop = structure(undefined, undefined, undefined, undefined, undefined, undefined, [inbound("ImportProduct", undefined, "Product", undefined)], undefined);
285
+ let g = DomainGraph$ReventlessGwt.build([[
286
+ "Shop",
287
+ shop
288
+ ]]);
289
+ globalThis.expect(g.nodes.some(n => n.kind === "ExternalSystem")).toBe(false);
290
+ });
291
+ globalThis.test("two slices naming the same external system share one deduped box", async () => {
292
+ let a = structure(undefined, undefined, undefined, undefined, undefined, [outbound("SendEmailA", ["A.X"], undefined, Primitive_option.some(undefined), "EmailService")], undefined, undefined);
293
+ let b = structure(undefined, undefined, undefined, undefined, undefined, undefined, [inbound("FromEmail", ["FromEmail.Record"], "Inbox", "EmailService")], undefined);
294
+ let g = DomainGraph$ReventlessGwt.build([
295
+ [
296
+ "PluginA",
297
+ a
298
+ ],
299
+ [
300
+ "PluginB",
301
+ b
302
+ ]
303
+ ]);
304
+ globalThis.expect(g.nodes.filter(n => n.id === "ext:EmailService").length).toBe(1);
305
+ globalThis.expect(hasEdge(g, "PluginA:SendEmailA", "ext:EmailService", "translatesOut")).toBe(true);
306
+ globalThis.expect(hasEdge(g, "ext:EmailService", "PluginB:FromEmail", "translatesIn")).toBe(true);
307
+ });
228
308
  globalThis.test("an event produced by two write-sides is one node with two emit edges", async () => {
229
309
  let shop = structure([
230
310
  writable("A", undefined, ["Shop.Touched"], undefined, undefined, undefined),
@@ -408,6 +488,7 @@ export {
408
488
  outbound,
409
489
  structure,
410
490
  hasEdge,
491
+ edgeLabel,
411
492
  nodeKind,
412
493
  }
413
494
  /* Not a pure module */
@@ -307,7 +307,8 @@ let ConfirmSlice = {
307
307
  consumedEventSchema: consumedEventSchema$4,
308
308
  outboundItemSchema: outboundItemSchema,
309
309
  inboundCommandSchema: S.unit,
310
- collect: collect$1
310
+ collect: collect$1,
311
+ externalSystem: undefined
311
312
  };
312
313
 
313
314
  let Place = Flow_GWT$ReventlessGwt.CommandStep(PlaceOrderSlice)({
@@ -46,7 +46,8 @@ let PaymentWebhookSlice = {
46
46
  externalInputSchema: externalInputSchema,
47
47
  commandSchema: commandSchema,
48
48
  translate: translate,
49
- commandAuthorization: commandAuthorization
49
+ commandAuthorization: commandAuthorization,
50
+ externalSystem: undefined
50
51
  };
51
52
 
52
53
  let include = InboundTranslation_GWT$ReventlessGwt.Make(PaymentWebhookSlice);
@@ -32,7 +32,8 @@ let SendTrackingEmailSlice = {
32
32
  consumedEventSchema: consumedEventSchema,
33
33
  outboundItemSchema: outboundItemSchema,
34
34
  inboundCommandSchema: inboundCommandSchema,
35
- collect: collect
35
+ collect: collect,
36
+ externalSystem: undefined
36
37
  };
37
38
 
38
39
  let include = OutboundTranslation_GWT$ReventlessGwt.Make(SendTrackingEmailSlice);
@@ -49,10 +49,11 @@ describe("Watch.debounce coalescing", () => {
49
49
  expect(calls.contents)->toEqual([Watch.Unlink])
50
50
  })
51
51
 
52
- testPromise("interleaved .res.mjs unlinks don't steal the representative path", async () => {
52
+ testPromise("interleaved .res.mjs unlinks never surface; each distinct .res source does", async () => {
53
53
  // The real directory-mv burst chokidar emits: source AND generated files
54
- // unlink interleaved. The coalesced path must be a `.res` source, not the
55
- // last-seen `.res.mjs` (which would misclassify as a non-structural change).
54
+ // unlink interleaved. Only `.res` sources are emitted (never a `.res.mjs`),
55
+ // and every *distinct* source is emitted two moved modules must both drive
56
+ // a rebuild, not collapse to one representative.
56
57
  let calls = ref([])
57
58
  let fire = Watch.debounce(40, (e, p) => calls := Array.concat(calls.contents, [(e, p)]))
58
59
  fire(Watch.Unlink, "/p/src/old/A.res")
@@ -62,10 +63,37 @@ describe("Watch.debounce coalescing", () => {
62
63
  fire(Watch.Add, "/p/src/new/A.res")
63
64
  fire(Watch.Add, "/p/src/new/A.res.mjs")
64
65
  await delay(120)
65
- expect(calls.contents->Array.length)->toEqual(1)
66
- let (e, p) = calls.contents->Array.getUnsafe(0)
67
- expect(e)->toEqual(Watch.Unlink)
68
- expect(Watch.isStructuralSource(e, p))->toEqual(true)
66
+ let paths = calls.contents->Array.map(((_, p)) => p)
67
+ expect(calls.contents->Array.every(((e, p)) => Watch.isStructuralSource(e, p)))->toEqual(true)
68
+ expect(paths->Array.includes("/p/src/old/A.res"))->toEqual(true)
69
+ expect(paths->Array.includes("/p/src/old/A_Behavior.res"))->toEqual(true)
70
+ expect(paths->Array.some(p => p->String.endsWith(".mjs")))->toEqual(false)
71
+ expect(calls.contents->Array.length)->toEqual(2)
72
+ })
73
+
74
+ testPromise("a burst spanning two packages emits an unlink per package (A4.1)", async () => {
75
+ // Regression: the old single-`bestPath` debounce clean-rebuilt only one
76
+ // package on a multi-package burst, stranding the other with stale .res.mjs.
77
+ let calls = ref([])
78
+ let fire = Watch.debounce(40, (e, p) => calls := Array.concat(calls.contents, [(e, p)]))
79
+ fire(Watch.Unlink, "/pkgA/src/Foo.res")
80
+ fire(Watch.Unlink, "/pkgB/src/Bar.res")
81
+ await delay(120)
82
+ let paths = calls.contents->Array.map(((_, p)) => p)
83
+ expect(paths->Array.includes("/pkgA/src/Foo.res"))->toEqual(true)
84
+ expect(paths->Array.includes("/pkgB/src/Bar.res"))->toEqual(true)
85
+ expect(calls.contents->Array.length)->toEqual(2)
86
+ })
87
+
88
+ testPromise("a structural signal suppresses a co-occurring plain change", async () => {
89
+ // A structural rebuild does its own re-run, so a plain edit in the same
90
+ // window must not also emit a separate Change (which would double-run).
91
+ let calls = ref([])
92
+ let fire = Watch.debounce(40, (e, _p) => calls := Array.concat(calls.contents, [e]))
93
+ fire(Watch.Change, "/p/src/Edited.res")
94
+ fire(Watch.Unlink, "/p/src/old/Moved.res")
95
+ await delay(120)
96
+ expect(calls.contents)->toEqual([Watch.Unlink])
69
97
  })
70
98
 
71
99
  testPromise("a lone change stays a change (incremental re-run)", async () => {
@@ -55,7 +55,7 @@ globalThis.describe("Watch.debounce coalescing", () => {
55
55
  await delay(120);
56
56
  globalThis.expect(calls.contents).toEqual(["Unlink"]);
57
57
  });
58
- globalThis.test("interleaved .res.mjs unlinks don't steal the representative path", async () => {
58
+ globalThis.test("interleaved .res.mjs unlinks never surface; each distinct .res source does", async () => {
59
59
  let calls = {
60
60
  contents: []
61
61
  };
@@ -72,11 +72,42 @@ globalThis.describe("Watch.debounce coalescing", () => {
72
72
  fire("Add", "/p/src/new/A.res");
73
73
  fire("Add", "/p/src/new/A.res.mjs");
74
74
  await delay(120);
75
- globalThis.expect(calls.contents.length).toEqual(1);
76
- let match = calls.contents[0];
77
- let e = match[0];
78
- globalThis.expect(e).toEqual("Unlink");
79
- globalThis.expect(Watch$ReventlessGwt.isStructuralSource(e, match[1])).toEqual(true);
75
+ let paths = calls.contents.map(param => param[1]);
76
+ globalThis.expect(calls.contents.every(param => Watch$ReventlessGwt.isStructuralSource(param[0], param[1]))).toEqual(true);
77
+ globalThis.expect(paths.includes("/p/src/old/A.res")).toEqual(true);
78
+ globalThis.expect(paths.includes("/p/src/old/A_Behavior.res")).toEqual(true);
79
+ globalThis.expect(paths.some(p => p.endsWith(".mjs"))).toEqual(false);
80
+ globalThis.expect(calls.contents.length).toEqual(2);
81
+ });
82
+ globalThis.test("a burst spanning two packages emits an unlink per package (A4.1)", async () => {
83
+ let calls = {
84
+ contents: []
85
+ };
86
+ let fire = Watch$ReventlessGwt.debounce(40, (e, p) => {
87
+ calls.contents = calls.contents.concat([[
88
+ e,
89
+ p
90
+ ]]);
91
+ });
92
+ fire("Unlink", "/pkgA/src/Foo.res");
93
+ fire("Unlink", "/pkgB/src/Bar.res");
94
+ await delay(120);
95
+ let paths = calls.contents.map(param => param[1]);
96
+ globalThis.expect(paths.includes("/pkgA/src/Foo.res")).toEqual(true);
97
+ globalThis.expect(paths.includes("/pkgB/src/Bar.res")).toEqual(true);
98
+ globalThis.expect(calls.contents.length).toEqual(2);
99
+ });
100
+ globalThis.test("a structural signal suppresses a co-occurring plain change", async () => {
101
+ let calls = {
102
+ contents: []
103
+ };
104
+ let fire = Watch$ReventlessGwt.debounce(40, (e, _p) => {
105
+ calls.contents = calls.contents.concat([e]);
106
+ });
107
+ fire("Change", "/p/src/Edited.res");
108
+ fire("Unlink", "/p/src/old/Moved.res");
109
+ await delay(120);
110
+ globalThis.expect(calls.contents).toEqual(["Unlink"]);
80
111
  });
81
112
  globalThis.test("a lone change stays a change (incremental re-run)", async () => {
82
113
  let calls = {