@reventlessdev/trait-attachments 1.0.0-alpha.4 → 1.0.0-alpha.41

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/README.md CHANGED
@@ -43,12 +43,14 @@ Unlike the geocoding trait this one writes nothing back into its host: the graft
43
43
  ```sh
44
44
  pnpm exec graft-trait @reventlessdev/trait-attachments \
45
45
  --into src/Product --tests tests/Product \
46
- --entity Product --entityId productId --noun Image \
47
- --file productImage --created ProductAdded --view Products
46
+ --entity Product --entityId productId --entityIdType CatalogSpec.ProductId \
47
+ --noun Image --file productImage --created ProductAdded --view Products
48
48
  ```
49
49
 
50
50
  The field is named for the store it draws from, and `--noun` is what this host calls
51
- one attachment — it runs through every name the graft declares.
51
+ one attachment — it runs through every name the graft declares. `--entityIdType` names
52
+ the host's identity module, so the emitted ids are typed; leave it out for a host that
53
+ keeps its ids as strings.
52
54
 
53
55
  2. Fill the `TODO(graft)` markers. They are the host's own policy: the events its
54
56
  refusal turns on, the error it raises, and the `else if` in `decide` that raises it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/trait-attachments",
3
- "version": "1.0.0-alpha.4",
3
+ "version": "1.0.0-alpha.41",
4
4
  "description": "Attachments domain trait: rules, host contract, conformance suite and emitter for an ordered set of stored files on an entity, with a primary and captions",
5
5
  "license": "Apache-2.0",
6
6
  "files": [
@@ -11,16 +11,16 @@
11
11
  "README.md"
12
12
  ],
13
13
  "dependencies": {
14
- "sury": "11.0.0-rc.2",
15
- "sury-ppx": "11.0.0-rc.2",
16
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.202",
17
- "@reventlessdev/reventless-spec": "3.0.0-alpha.128"
14
+ "sury": "11.0.0",
15
+ "sury-ppx": "11.0.0",
16
+ "@reventlessdev/reventless-gwt": "1.0.0-alpha.239",
17
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.161"
18
18
  },
19
19
  "devDependencies": {
20
- "rescript": "12.3.0"
20
+ "rescript": "12.3.1"
21
21
  },
22
22
  "peerDependencies": {
23
- "rescript": "12.3.0"
23
+ "rescript": "^12.3.1"
24
24
  },
25
25
  "publishConfig": {
26
26
  "registry": "https://registry.npmjs.org"
@@ -6,7 +6,6 @@ contract is over that slice. The rules live in `Attachments_Rules` and are asser
6
6
  through a host by `Attachments_Conformance`; the spec surface the host maps onto
7
7
  them is written by `Attachments_Scaffold`.
8
8
  */
9
-
10
9
  /**
11
10
  This trait's own account of itself — see `AddressGeocoding.declaration` for why the
12
11
  version is read rather than written.
@@ -43,13 +42,27 @@ module type Binding = {
43
42
  module Spec: ReventlessGwt.Behavior_GWT.BehaviorSpec
44
43
  module Behavior: ReventlessGwt.Behavior_GWT.Behavior with module Spec = Spec
45
44
 
46
- /** History that brings the entity into existence with an empty set. */
47
- let created: array<Spec.consumedEvent>
48
- /** The set's own facts, as the slice consumes them. */
49
- let attachedC: ref => Spec.consumedEvent
50
- let removedC: ref => Spec.consumedEvent
51
- let primarySetC: ref => Spec.consumedEvent
52
- let altTextSetC: (ref, string) => Spec.consumedEvent
45
+ /** The same facts as the slice **consumes** them, for building history.
46
+
47
+ A slice declares `consumedEvent` and `event` separately and they differ —
48
+ the consumed form carries no entity id, because the partition already
49
+ says which entity it belongs to. So a suite needs both spellings of the
50
+ same fact: one to lay down history, one to assert what was emitted.
51
+
52
+ Grouped rather than suffixed. The pairs used to read `attachedC` against
53
+ `attached`, which said "consumed" only to someone who already knew — and
54
+ in a codebase where every binding also carries commands, a bare `C` reads
55
+ just as naturally as one. */
56
+ module Consumed: {
57
+ /** History that brings the entity into existence with an empty set. */
58
+ let created: array<Spec.consumedEvent>
59
+ let attached: ref => Spec.consumedEvent
60
+ let removed: ref => Spec.consumedEvent
61
+ let primarySet: ref => Spec.consumedEvent
62
+ /** The conclusion — see `Attachments_Rules.fact`. */
63
+ let effectiveChanged: option<ref> => Spec.consumedEvent
64
+ let altTextSet: (ref, string) => Spec.consumedEvent
65
+ }
53
66
 
54
67
  let attach: ref => Spec.command
55
68
  let remove: ref => Spec.command
@@ -60,6 +73,10 @@ module type Binding = {
60
73
  let attached: ref => Spec.event
61
74
  let removed: ref => Spec.event
62
75
  let primarySet: ref => Spec.event
76
+ /** The member a reader should now show, or `None` where the set has none.
77
+ Every graft declares it, whatever its cardinality: a subscriber reading it
78
+ must not have to know how many members the host allows. */
79
+ let effectiveChanged: option<ref> => Spec.event
63
80
  let altTextSet: (ref, string) => Spec.event
64
81
  /** The refusal for a primary or caption on a ref that is not in the set. */
65
82
  let notAttached: Spec.error
@@ -89,13 +106,19 @@ module type SingleBinding = {
89
106
  module Spec: ReventlessGwt.Behavior_GWT.BehaviorSpec
90
107
  module Behavior: ReventlessGwt.Behavior_GWT.Behavior with module Spec = Spec
91
108
 
92
- /** History that brings the entity into existence with an empty set. */
93
- let created: array<Spec.consumedEvent>
94
- /** The set's own facts, as the slice consumes them. No `primarySetC`: nothing
95
- emits one. */
96
- let attachedC: ref => Spec.consumedEvent
97
- let removedC: ref => Spec.consumedEvent
98
- let altTextSetC: (ref, string) => Spec.consumedEvent
109
+ /** The facts as the slice **consumes** them — see `Binding.Consumed`. No
110
+ `primarySet`: at this cardinality nothing emits one. */
111
+ module Consumed: {
112
+ /** History that brings the entity into existence with an empty set. */
113
+ let created: array<Spec.consumedEvent>
114
+ let attached: ref => Spec.consumedEvent
115
+ let removed: ref => Spec.consumedEvent
116
+ let altTextSet: (ref, string) => Spec.consumedEvent
117
+ /** Declared at this cardinality too even though `attached`/`removed` imply
118
+ it here: a subscriber must not have to know how many members a host
119
+ allows to know what to read. */
120
+ let effectiveChanged: option<ref> => Spec.consumedEvent
121
+ }
99
122
 
100
123
  let attach: ref => Spec.command
101
124
  /** Remove whatever is held. Takes no ref — there is only one, and asking the
@@ -111,6 +134,8 @@ module type SingleBinding = {
111
134
  let attached: ref => Spec.event
112
135
  let removed: ref => Spec.event
113
136
  let altTextSet: (ref, string) => Spec.event
137
+ /** The member a reader should now show, or `None` where nothing is held. */
138
+ let effectiveChanged: option<ref> => Spec.event
114
139
  /** The refusal for a caption on a ref that is not held. */
115
140
  let notAttached: Spec.error
116
141
  }
@@ -2,7 +2,6 @@
2
2
  The conformance suite, run by a host against its own graft. `Make(Binding).register()`
3
3
  inside a Jest test file registers one `describe` block over the binding.
4
4
  */
5
-
6
5
  /** The suite's title, composed once and read twice: the suite registers it,
7
6
  and `certify-trait` computes the same string to find the run's assertions in
8
7
  a test report. Exported rather than inlined so neither side parses the
@@ -17,13 +16,15 @@ let suiteName = (host: string) => `${host} conforms to the attachments trait`
17
16
  module Make = (B: Attachments.Binding) => {
18
17
  module G = ReventlessGwt.Behavior_GWT.Make(B.Spec, B.Behavior)
19
18
 
20
- let withA = Array.concat(B.created, [B.attachedC(B.refA)])
21
- let withAB = Array.concat(withA, [B.attachedC(B.refB)])
19
+ let withA = Array.concat(B.Consumed.created, [B.Consumed.attached(B.refA)])
20
+ let withAB = Array.concat(withA, [B.Consumed.attached(B.refB)])
22
21
 
23
22
  let register = () =>
24
23
  G.describe(suiteName(B.Spec.name), () => {
25
24
  G.test("the first attachment is appended", () =>
26
- G.givenEvents(B.created)->G.whenCmd(B.attach(B.refA))->G.thenEvent(B.attached(B.refA))
25
+ G.givenEvents(B.Consumed.created)
26
+ ->G.whenCmd(B.attach(B.refA))
27
+ ->G.thenEvents([B.attached(B.refA), B.effectiveChanged(Some(B.refA))])
27
28
  )
28
29
 
29
30
  G.test("attaching a ref already in the set is a no-op", () =>
@@ -35,7 +36,9 @@ module Make = (B: Attachments.Binding) => {
35
36
  )
36
37
 
37
38
  G.test("removing an attached ref is appended", () =>
38
- G.givenEvents(withA)->G.whenCmd(B.remove(B.refA))->G.thenEvent(B.removed(B.refA))
39
+ G.givenEvents(withA)
40
+ ->G.whenCmd(B.remove(B.refA))
41
+ ->G.thenEvents([B.removed(B.refA), B.effectiveChanged(None)])
39
42
  )
40
43
 
41
44
  G.test("removing a ref not in the set is a no-op", () =>
@@ -43,9 +46,9 @@ module Make = (B: Attachments.Binding) => {
43
46
  )
44
47
 
45
48
  G.test("a removed ref can be attached again", () =>
46
- G.givenEvents(Array.concat(withA, [B.removedC(B.refA)]))
49
+ G.givenEvents(Array.concat(withA, [B.Consumed.removed(B.refA)]))
47
50
  ->G.whenCmd(B.attach(B.refA))
48
- ->G.thenEvent(B.attached(B.refA))
51
+ ->G.thenEvents([B.attached(B.refA), B.effectiveChanged(Some(B.refA))])
49
52
  )
50
53
 
51
54
  G.test("the primary must be in the set", () =>
@@ -57,27 +60,50 @@ module Make = (B: Attachments.Binding) => {
57
60
  )
58
61
 
59
62
  G.test("choosing another primary is appended", () =>
60
- G.givenEvents(withAB)->G.whenCmd(B.setPrimary(B.refB))->G.thenEvent(B.primarySet(B.refB))
63
+ G.givenEvents(withAB)
64
+ ->G.whenCmd(B.setPrimary(B.refB))
65
+ ->G.thenEvents([B.primarySet(B.refB), B.effectiveChanged(Some(B.refB))])
61
66
  )
62
67
 
63
68
  G.test("choosing the current primary is a no-op", () =>
64
- G.givenEvents(Array.concat(withAB, [B.primarySetC(B.refB)]))
69
+ G.givenEvents(Array.concat(withAB, [B.Consumed.primarySet(B.refB)]))
65
70
  ->G.whenCmd(B.setPrimary(B.refB))
66
71
  ->G.thenNoEvent
67
72
  )
68
73
 
69
74
  G.test("removing the chosen primary lets the first remaining stand in", () =>
70
- G.givenEvents(Array.concat(withAB, [B.primarySetC(B.refB), B.removedC(B.refB)]))
75
+ G.givenEvents(
76
+ Array.concat(withAB, [B.Consumed.primarySet(B.refB), B.Consumed.removed(B.refB)]),
77
+ )
71
78
  ->G.whenCmd(B.setPrimary(B.refA))
72
79
  ->G.thenNoEvent
73
80
  )
74
81
 
82
+ // The two moves nobody decides. Neither produces a fact of its own — the
83
+ // set simply resolves differently afterwards — which is why anything
84
+ // outside it could not follow the primary before this was announced.
85
+ G.test("removing the member that stood in promotes the next, and says so", () =>
86
+ G.givenEvents(withAB)
87
+ ->G.whenCmd(B.remove(B.refA))
88
+ ->G.thenEvents([B.removed(B.refA), B.effectiveChanged(Some(B.refB))])
89
+ )
90
+
91
+ G.test("removing the chosen primary announces the one that stands in", () =>
92
+ G.givenEvents(Array.concat(withAB, [B.Consumed.primarySet(B.refB)]))
93
+ ->G.whenCmd(B.remove(B.refB))
94
+ ->G.thenEvents([B.removed(B.refB), B.effectiveChanged(Some(B.refA))])
95
+ )
96
+
75
97
  G.test("a caption needs its ref in the set", () =>
76
98
  G.givenEvents(withA)
77
99
  ->G.whenCmd(B.setAltText(B.refB, "side"))
78
100
  ->G.thenError(B.notAttached)
79
101
  )
80
102
 
103
+ // One fact and no second one: a caption moves no file, so the conclusion
104
+ // is unchanged and must not be announced. This is what stops an
105
+ // implementation appending it after every command rather than after the
106
+ // ones that move it.
81
107
  G.test("a caption is appended", () =>
82
108
  G.givenEvents(withA)
83
109
  ->G.whenCmd(B.setAltText(B.refA, "front"))
@@ -85,7 +111,7 @@ module Make = (B: Attachments.Binding) => {
85
111
  )
86
112
 
87
113
  G.test("repeating the caption is a no-op", () =>
88
- G.givenEvents(Array.concat(withA, [B.altTextSetC(B.refA, "front")]))
114
+ G.givenEvents(Array.concat(withA, [B.Consumed.altTextSet(B.refA, "front")]))
89
115
  ->G.whenCmd(B.setAltText(B.refA, "front"))
90
116
  ->G.thenNoEvent
91
117
  )
@@ -108,12 +134,14 @@ single image wants exactly as much as a gallery member does.
108
134
  module MakeSingle = (B: Attachments.SingleBinding) => {
109
135
  module G = ReventlessGwt.Behavior_GWT.Make(B.Spec, B.Behavior)
110
136
 
111
- let withA = Array.concat(B.created, [B.attachedC(B.refA)])
137
+ let withA = Array.concat(B.Consumed.created, [B.Consumed.attached(B.refA)])
112
138
 
113
139
  let register = () =>
114
140
  G.describe(suiteName(B.Spec.name), () => {
115
141
  G.test("the first attachment is appended", () =>
116
- G.givenEvents(B.created)->G.whenCmd(B.attach(B.refA))->G.thenEvent(B.attached(B.refA))
142
+ G.givenEvents(B.Consumed.created)
143
+ ->G.whenCmd(B.attach(B.refA))
144
+ ->G.thenEvents([B.attached(B.refA), B.effectiveChanged(Some(B.refA))])
117
145
  )
118
146
 
119
147
  G.test("attaching the ref already held is a no-op", () =>
@@ -126,21 +154,25 @@ module MakeSingle = (B: Attachments.SingleBinding) => {
126
154
  G.test("a second ref replaces the first rather than joining it", () =>
127
155
  G.givenEvents(withA)
128
156
  ->G.whenCmd(B.attach(B.refB))
129
- ->G.thenEvents([B.removed(B.refA), B.attached(B.refB)])
157
+ ->G.thenEvents([B.removed(B.refA), B.attached(B.refB), B.effectiveChanged(Some(B.refB))])
130
158
  )
131
159
 
132
160
  G.test("clearing a held set removes what it holds", () =>
133
- G.givenEvents(withA)->G.whenCmd(B.clear)->G.thenEvent(B.removed(B.refA))
161
+ G.givenEvents(withA)
162
+ ->G.whenCmd(B.clear)
163
+ ->G.thenEvents([B.removed(B.refA), B.effectiveChanged(None)])
134
164
  )
135
165
 
136
166
  G.test("clearing an empty set is a no-op", () =>
137
- G.givenEvents(B.created)->G.whenCmd(B.clear)->G.thenNoEvent
167
+ G.givenEvents(B.Consumed.created)->G.whenCmd(B.clear)->G.thenNoEvent
138
168
  )
139
169
 
140
170
  G.test("a replaced ref can be attached again", () =>
141
- G.givenEvents(Array.concat(withA, [B.removedC(B.refA), B.attachedC(B.refB)]))
171
+ G.givenEvents(
172
+ Array.concat(withA, [B.Consumed.removed(B.refA), B.Consumed.attached(B.refB)]),
173
+ )
142
174
  ->G.whenCmd(B.attach(B.refA))
143
- ->G.thenEvents([B.removed(B.refB), B.attached(B.refA)])
175
+ ->G.thenEvents([B.removed(B.refB), B.attached(B.refA), B.effectiveChanged(Some(B.refA))])
144
176
  )
145
177
 
146
178
  // The caption names no ref either, so what it lands on is whatever is
@@ -153,17 +185,21 @@ module MakeSingle = (B: Attachments.SingleBinding) => {
153
185
  )
154
186
 
155
187
  G.test("a caption follows a replacement onto the new ref", () =>
156
- G.givenEvents(Array.concat(withA, [B.removedC(B.refA), B.attachedC(B.refB)]))
188
+ G.givenEvents(
189
+ Array.concat(withA, [B.Consumed.removed(B.refA), B.Consumed.attached(B.refB)]),
190
+ )
157
191
  ->G.whenCmd(B.setAltText("side"))
158
192
  ->G.thenEvent(B.altTextSet(B.refB, "side"))
159
193
  )
160
194
 
161
195
  G.test("captioning an empty set is refused", () =>
162
- G.givenEvents(B.created)->G.whenCmd(B.setAltText("front"))->G.thenError(B.notAttached)
196
+ G.givenEvents(B.Consumed.created)
197
+ ->G.whenCmd(B.setAltText("front"))
198
+ ->G.thenError(B.notAttached)
163
199
  )
164
200
 
165
201
  G.test("repeating the caption is a no-op", () =>
166
- G.givenEvents(Array.concat(withA, [B.altTextSetC(B.refA, "front")]))
202
+ G.givenEvents(Array.concat(withA, [B.Consumed.altTextSet(B.refA, "front")]))
167
203
  ->G.whenCmd(B.setAltText("front"))
168
204
  ->G.thenNoEvent
169
205
  )
@@ -1,5 +1,6 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
3
4
  import * as Behavior_GWT$ReventlessGwt from "@reventlessdev/reventless-gwt/src/Behavior_GWT.res.mjs";
4
5
 
5
6
  function suiteName(host) {
@@ -13,26 +14,46 @@ function Make(B) {
13
14
  evolve: $$let.evolve,
14
15
  decide: $$let.decide
15
16
  });
16
- let withA = B.created.concat([B.attachedC(B.refA)]);
17
- let withAB = withA.concat([B.attachedC(B.refB)]);
17
+ let withA = B.Consumed.created.concat([B.Consumed.attached(B.refA)]);
18
+ let withAB = withA.concat([B.Consumed.attached(B.refB)]);
18
19
  let register = () => G.describe(suiteName(B.Spec.name), () => {
19
- G.test("the first attachment is appended", () => G.thenEvent(G.whenCmd(G.givenEvents(B.created), B.attach(B.refA)), B.attached(B.refA)));
20
+ G.test("the first attachment is appended", () => G.thenEvents(G.whenCmd(G.givenEvents(B.Consumed.created), B.attach(B.refA)), [
21
+ B.attached(B.refA),
22
+ B.effectiveChanged(Primitive_option.some(B.refA))
23
+ ]));
20
24
  G.test("attaching a ref already in the set is a no-op", () => G.thenNoEvent(G.whenCmd(G.givenEvents(withA), B.attach(B.refA))));
21
25
  G.test("a second ref extends the set rather than replacing it", () => G.thenEvent(G.whenCmd(G.givenEvents(withA), B.attach(B.refB)), B.attached(B.refB)));
22
- G.test("removing an attached ref is appended", () => G.thenEvent(G.whenCmd(G.givenEvents(withA), B.remove(B.refA)), B.removed(B.refA)));
26
+ G.test("removing an attached ref is appended", () => G.thenEvents(G.whenCmd(G.givenEvents(withA), B.remove(B.refA)), [
27
+ B.removed(B.refA),
28
+ B.effectiveChanged(undefined)
29
+ ]));
23
30
  G.test("removing a ref not in the set is a no-op", () => G.thenNoEvent(G.whenCmd(G.givenEvents(withA), B.remove(B.refB))));
24
- G.test("a removed ref can be attached again", () => G.thenEvent(G.whenCmd(G.givenEvents(withA.concat([B.removedC(B.refA)])), B.attach(B.refA)), B.attached(B.refA)));
31
+ G.test("a removed ref can be attached again", () => G.thenEvents(G.whenCmd(G.givenEvents(withA.concat([B.Consumed.removed(B.refA)])), B.attach(B.refA)), [
32
+ B.attached(B.refA),
33
+ B.effectiveChanged(Primitive_option.some(B.refA))
34
+ ]));
25
35
  G.test("the primary must be in the set", () => G.thenError(G.whenCmd(G.givenEvents(withA), B.setPrimary(B.refB)), B.notAttached));
26
36
  G.test("the first attached is the primary until one is chosen", () => G.thenNoEvent(G.whenCmd(G.givenEvents(withAB), B.setPrimary(B.refA))));
27
- G.test("choosing another primary is appended", () => G.thenEvent(G.whenCmd(G.givenEvents(withAB), B.setPrimary(B.refB)), B.primarySet(B.refB)));
28
- G.test("choosing the current primary is a no-op", () => G.thenNoEvent(G.whenCmd(G.givenEvents(withAB.concat([B.primarySetC(B.refB)])), B.setPrimary(B.refB))));
37
+ G.test("choosing another primary is appended", () => G.thenEvents(G.whenCmd(G.givenEvents(withAB), B.setPrimary(B.refB)), [
38
+ B.primarySet(B.refB),
39
+ B.effectiveChanged(Primitive_option.some(B.refB))
40
+ ]));
41
+ G.test("choosing the current primary is a no-op", () => G.thenNoEvent(G.whenCmd(G.givenEvents(withAB.concat([B.Consumed.primarySet(B.refB)])), B.setPrimary(B.refB))));
29
42
  G.test("removing the chosen primary lets the first remaining stand in", () => G.thenNoEvent(G.whenCmd(G.givenEvents(withAB.concat([
30
- B.primarySetC(B.refB),
31
- B.removedC(B.refB)
43
+ B.Consumed.primarySet(B.refB),
44
+ B.Consumed.removed(B.refB)
32
45
  ])), B.setPrimary(B.refA))));
46
+ G.test("removing the member that stood in promotes the next, and says so", () => G.thenEvents(G.whenCmd(G.givenEvents(withAB), B.remove(B.refA)), [
47
+ B.removed(B.refA),
48
+ B.effectiveChanged(Primitive_option.some(B.refB))
49
+ ]));
50
+ G.test("removing the chosen primary announces the one that stands in", () => G.thenEvents(G.whenCmd(G.givenEvents(withAB.concat([B.Consumed.primarySet(B.refB)])), B.remove(B.refB)), [
51
+ B.removed(B.refB),
52
+ B.effectiveChanged(Primitive_option.some(B.refA))
53
+ ]));
33
54
  G.test("a caption needs its ref in the set", () => G.thenError(G.whenCmd(G.givenEvents(withA), B.setAltText(B.refB, "side")), B.notAttached));
34
55
  G.test("a caption is appended", () => G.thenEvent(G.whenCmd(G.givenEvents(withA), B.setAltText(B.refA, "front")), B.altTextSet(B.refA, "front")));
35
- G.test("repeating the caption is a no-op", () => G.thenNoEvent(G.whenCmd(G.givenEvents(withA.concat([B.altTextSetC(B.refA, "front")])), B.setAltText(B.refA, "front"))));
56
+ G.test("repeating the caption is a no-op", () => G.thenNoEvent(G.whenCmd(G.givenEvents(withA.concat([B.Consumed.altTextSet(B.refA, "front")])), B.setAltText(B.refA, "front"))));
36
57
  });
37
58
  return {
38
59
  G: G,
@@ -49,30 +70,38 @@ function MakeSingle(B) {
49
70
  evolve: $$let.evolve,
50
71
  decide: $$let.decide
51
72
  });
52
- let withA = B.created.concat([B.attachedC(B.refA)]);
73
+ let withA = B.Consumed.created.concat([B.Consumed.attached(B.refA)]);
53
74
  let register = () => G.describe(suiteName(B.Spec.name), () => {
54
- G.test("the first attachment is appended", () => G.thenEvent(G.whenCmd(G.givenEvents(B.created), B.attach(B.refA)), B.attached(B.refA)));
75
+ G.test("the first attachment is appended", () => G.thenEvents(G.whenCmd(G.givenEvents(B.Consumed.created), B.attach(B.refA)), [
76
+ B.attached(B.refA),
77
+ B.effectiveChanged(Primitive_option.some(B.refA))
78
+ ]));
55
79
  G.test("attaching the ref already held is a no-op", () => G.thenNoEvent(G.whenCmd(G.givenEvents(withA), B.attach(B.refA))));
56
80
  G.test("a second ref replaces the first rather than joining it", () => G.thenEvents(G.whenCmd(G.givenEvents(withA), B.attach(B.refB)), [
57
81
  B.removed(B.refA),
58
- B.attached(B.refB)
82
+ B.attached(B.refB),
83
+ B.effectiveChanged(Primitive_option.some(B.refB))
84
+ ]));
85
+ G.test("clearing a held set removes what it holds", () => G.thenEvents(G.whenCmd(G.givenEvents(withA), B.clear), [
86
+ B.removed(B.refA),
87
+ B.effectiveChanged(undefined)
59
88
  ]));
60
- G.test("clearing a held set removes what it holds", () => G.thenEvent(G.whenCmd(G.givenEvents(withA), B.clear), B.removed(B.refA)));
61
- G.test("clearing an empty set is a no-op", () => G.thenNoEvent(G.whenCmd(G.givenEvents(B.created), B.clear)));
89
+ G.test("clearing an empty set is a no-op", () => G.thenNoEvent(G.whenCmd(G.givenEvents(B.Consumed.created), B.clear)));
62
90
  G.test("a replaced ref can be attached again", () => G.thenEvents(G.whenCmd(G.givenEvents(withA.concat([
63
- B.removedC(B.refA),
64
- B.attachedC(B.refB)
91
+ B.Consumed.removed(B.refA),
92
+ B.Consumed.attached(B.refB)
65
93
  ])), B.attach(B.refA)), [
66
94
  B.removed(B.refB),
67
- B.attached(B.refA)
95
+ B.attached(B.refA),
96
+ B.effectiveChanged(Primitive_option.some(B.refA))
68
97
  ]));
69
98
  G.test("a caption lands on the ref that is held", () => G.thenEvent(G.whenCmd(G.givenEvents(withA), B.setAltText("front")), B.altTextSet(B.refA, "front")));
70
99
  G.test("a caption follows a replacement onto the new ref", () => G.thenEvent(G.whenCmd(G.givenEvents(withA.concat([
71
- B.removedC(B.refA),
72
- B.attachedC(B.refB)
100
+ B.Consumed.removed(B.refA),
101
+ B.Consumed.attached(B.refB)
73
102
  ])), B.setAltText("side")), B.altTextSet(B.refB, "side")));
74
- G.test("captioning an empty set is refused", () => G.thenError(G.whenCmd(G.givenEvents(B.created), B.setAltText("front")), B.notAttached));
75
- G.test("repeating the caption is a no-op", () => G.thenNoEvent(G.whenCmd(G.givenEvents(withA.concat([B.altTextSetC(B.refA, "front")])), B.setAltText("front"))));
103
+ G.test("captioning an empty set is refused", () => G.thenError(G.whenCmd(G.givenEvents(B.Consumed.created), B.setAltText("front")), B.notAttached));
104
+ G.test("repeating the caption is a no-op", () => G.thenNoEvent(G.whenCmd(G.givenEvents(withA.concat([B.Consumed.altTextSet(B.refA, "front")])), B.setAltText("front"))));
76
105
  });
77
106
  return {
78
107
  G: G,
@@ -7,11 +7,15 @@ A host maps its own constructors onto `op` and `fact` and keeps the spec surface
7
7
  the variants, their annotations, its own refusals. Nothing here knows what an
8
8
  entity is. `Attachments_Conformance` asserts these rules through a host.
9
9
  */
10
-
11
10
  /** The stored file's reference. A `StorageRef` path today, so `string` carries it
12
11
  without a wrapper the host would have to unwrap on every arm. */
13
12
  type ref = string
14
13
 
14
+ // `@schema` so `Attachments_Scaffold`'s config can take this very type rather
15
+ // than a second spelling of it. A graft's config and the rule it selects being
16
+ // the same value is the point: a config that could say `"single"` where the
17
+ // rules say `Single` is a config that can be misspelled.
18
+
15
19
  /**
16
20
  How many members a host's set may hold.
17
21
 
@@ -27,14 +31,12 @@ resolves to a no-op through the rules it already has (the only member is already
27
31
  the effective primary), which is why `Single` needs no branch for it and why the
28
32
  scaffold simply declines to emit the command rather than the rules refusing it.
29
33
  */
30
- // `@schema` so `Attachments_Scaffold`'s config can take this very type rather
31
- // than a second spelling of it. A graft's config and the rule it selects being
32
- // the same value is the point: a config that could say `"single"` where the
33
- // rules say `Single` is a config that can be misspelled.
34
34
  @schema
35
35
  type cardinality =
36
- | /** An unbounded, ordered set: a gallery. */ Many
37
- | /** At most one member, replaced rather than added to. */ Single
36
+ /** An unbounded, ordered set: a gallery. */
37
+ | Many
38
+ /** At most one member, replaced rather than added to. */
39
+ | Single
38
40
 
39
41
  /** Refolded per decision, never stored — a StateChangeSlice's state is. */
40
42
  type t = {
@@ -48,20 +50,20 @@ let empty = {attached: [], primary: None, altTexts: []}
48
50
 
49
51
  /** What a host asks the set to do. */
50
52
  type op =
51
- | /** `altText` is the caption a host may supply with the file itself. */
52
- Attach({ref: ref, altText: option<string>})
53
+ /** `altText` is the caption a host may supply with the file itself. */
54
+ | Attach({ref: ref, altText: option<string>})
53
55
  | Remove({ref: ref})
54
- | /** Empty the set, whatever it holds.
56
+ /** Empty the set, whatever it holds.
55
57
 
56
58
  The op a bounded set's remove command maps onto: with one member there is
57
59
  no ref for the caller to name, and naming it would be asking them to
58
60
  repeat what the row already says. Well defined at either cardinality —
59
61
  an unbounded host that wants a "remove them all" command gets it here
60
62
  rather than by looping its own remove. */
61
- Clear
63
+ | Clear
62
64
  | SetPrimary({ref: ref})
63
65
  | SetAltText({ref: ref, altText: string})
64
- | /** Caption whichever member is the primary, without naming it.
66
+ /** Caption whichever member is the primary, without naming it.
65
67
 
66
68
  The counterpart of `Clear`, and the op a bounded set's caption command
67
69
  maps onto — with one member there is nothing to name. It is not
@@ -69,7 +71,7 @@ type op =
69
71
  sees, so "caption the hero" is a command an unbounded host wants too,
70
72
  and resolving it here through `effectivePrimary` is what keeps the
71
73
  answer the same as the member a projection puts first. */
72
- SetPrimaryAltText({altText: string})
74
+ | SetPrimaryAltText({altText: string})
73
75
 
74
76
  /** What the set decided, for the host to name in its own event. */
75
77
  type fact =
@@ -77,6 +79,25 @@ type fact =
77
79
  | Removed({ref: ref})
78
80
  | PrimarySet({ref: ref})
79
81
  | AltTextSet({ref: ref, altText: string})
82
+ /** The member a reader should now show, or `None` where the set has none.
83
+
84
+ **A conclusion, not a decision.** `PrimarySet` records that somebody
85
+ chose; this records what the set resolved to, however it got there —
86
+ which is a different fact, and the one anything outside the set can act
87
+ on. The two coincide on an explicit choice and diverge everywhere else:
88
+ the first attachment stands in with nobody choosing, and removing the
89
+ one that stood in promotes the next.
90
+
91
+ Emitted at **both** cardinalities even though a `Single` host could
92
+ derive it from its own `Attached`/`Removed` pair, for the reason
93
+ `decide` gives below for not collapsing those two: a fact that only some
94
+ grafts emit makes a subscriber ask how many members the host allows,
95
+ which is not its business.
96
+
97
+ Optional because "there is no longer one to show" is as much a change as
98
+ any other, and a subscriber that cannot be told it keeps showing a file
99
+ the set no longer holds. */
100
+ | EffectiveChanged({ref: option<ref>})
80
101
 
81
102
  /** The primary a reader should show: the one chosen, else the first attached, so
82
103
  a set never shows no file while it holds one. The read model applies the same
@@ -137,6 +158,10 @@ let evolve = (t, fact) =>
137
158
  altTexts: t.altTexts->Array.filter(((r, _)) => r != ref),
138
159
  }
139
160
  | PrimarySet({ref}) => {...t, primary: Some(ref)}
161
+ // Carries no state of its own: it is what `effectivePrimary` already answers,
162
+ // said out loud. Folding it would be storing a derivation beside the values it
163
+ // derives from, which is the one way the two could disagree.
164
+ | EffectiveChanged(_) => t
140
165
  | AltTextSet({ref, altText}) => {
141
166
  ...t,
142
167
  altTexts: t.altTexts->Array.filter(((r, _)) => r != ref)->Array.concat([(ref, altText)]),
@@ -167,7 +192,7 @@ and an event log records both. Collapsing them into a single "replaced" fact
167
192
  would make a host declare an event that only bounded hosts have, which is one
168
193
  more way the two cardinalities' emitted surfaces could diverge.
169
194
  */
170
- let decide = (t, ~cardinality: cardinality=Many, op): result<array<fact>, [#NotAttached]> =>
195
+ let decideFacts = (t, ~cardinality: cardinality=Many, op): result<array<fact>, [#NotAttached]> =>
171
196
  switch op {
172
197
  | Attach({ref, altText}) =>
173
198
  if t.attached->Array.includes(ref) {
@@ -179,12 +204,7 @@ let decide = (t, ~cardinality: cardinality=Many, op): result<array<fact>, [#NotA
179
204
  // individually rather than through `Clear` so the facts read the same
180
205
  // whether one was there or (through some history nothing produces) more.
181
206
  | Single =>
182
- Ok(
183
- Array.concat(
184
- t.attached->Array.map(r => Removed({ref: r})),
185
- [Attached({ref, altText})],
186
- ),
187
- )
207
+ Ok(Array.concat(t.attached->Array.map(r => Removed({ref: r})), [Attached({ref, altText})]))
188
208
  }
189
209
  }
190
210
  | Remove({ref}) => t.attached->Array.includes(ref) ? Ok([Removed({ref: ref})]) : Ok([])
@@ -206,3 +226,26 @@ let decide = (t, ~cardinality: cardinality=Many, op): result<array<fact>, [#NotA
206
226
  | None => Error(#NotAttached)
207
227
  }
208
228
  }
229
+
230
+ /**
231
+ The set's own facts, followed by `EffectiveChanged` when those facts moved which
232
+ member a reader should show.
233
+
234
+ Appended rather than woven into each arm because the question is the same one
235
+ after every op: fold what was decided, ask `effectivePrimary` again, and say so
236
+ if the answer differs. An arm that had to remember to announce would eventually
237
+ be an arm that forgot — and the ops where it moves without anybody choosing (a
238
+ first attachment, a removal that promotes the next) are exactly the ones where
239
+ forgetting is easiest.
240
+ */
241
+ let decide = (t, ~cardinality: cardinality=Many, op): result<array<fact>, [#NotAttached]> =>
242
+ switch decideFacts(t, ~cardinality, op) {
243
+ | Error(_) as e => e
244
+ | Ok(facts) =>
245
+ let after = facts->Array.reduce(t, evolve)
246
+ Ok(
247
+ effectivePrimary(t) == effectivePrimary(after)
248
+ ? facts
249
+ : facts->Array.concat([EffectiveChanged({ref: effectivePrimary(after)})]),
250
+ )
251
+ }
@@ -1,6 +1,7 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as Sury from "sury";
4
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
4
5
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
6
  import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
6
7
  import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
@@ -84,6 +85,8 @@ function evolve(t, fact) {
84
85
  fact.altText
85
86
  ]])
86
87
  };
88
+ case "EffectiveChanged" :
89
+ return t;
87
90
  }
88
91
  }
89
92
 
@@ -112,7 +115,7 @@ function setAltText(t, ref, altText) {
112
115
  }
113
116
  }
114
117
 
115
- function decide(t, cardinalityOpt, op) {
118
+ function decideFacts(t, cardinalityOpt, op) {
116
119
  let cardinality = cardinalityOpt !== undefined ? cardinalityOpt : "Many";
117
120
  if (typeof op !== "object") {
118
121
  return {
@@ -209,6 +212,23 @@ function decide(t, cardinalityOpt, op) {
209
212
  }
210
213
  }
211
214
 
215
+ function decide(t, cardinalityOpt, op) {
216
+ let cardinality = cardinalityOpt !== undefined ? cardinalityOpt : "Many";
217
+ let e = decideFacts(t, cardinality, op);
218
+ if (e.TAG !== "Ok") {
219
+ return e;
220
+ }
221
+ let facts = e._0;
222
+ let after = Stdlib_Array.reduce(facts, t, evolve);
223
+ return {
224
+ TAG: "Ok",
225
+ _0: Primitive_object.equal(effectivePrimary(t), effectivePrimary(after)) ? facts : facts.concat([{
226
+ TAG: "EffectiveChanged",
227
+ ref: effectivePrimary(after)
228
+ }])
229
+ };
230
+ }
231
+
212
232
  export {
213
233
  cardinalitySchema,
214
234
  empty,
@@ -218,6 +238,7 @@ export {
218
238
  primaryFirst,
219
239
  evolve,
220
240
  setAltText,
241
+ decideFacts,
221
242
  decide,
222
243
  }
223
244
  /* cardinalitySchema Not a pure module */