@reventlessdev/trait-attachments 1.0.0-alpha.1 → 1.0.0-alpha.11

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.
@@ -25,7 +25,6 @@ is how a scaffolder acquires a policy language nobody asked for. They are left
25
25
  as `TODO(graft)` markers, and the developer writes ReScript, which is better at
26
26
  this than any config could be.
27
27
  */
28
-
29
28
  /**
30
29
  The names a graft needs, and nothing else.
31
30
 
@@ -51,6 +50,18 @@ type config = {
51
50
  noun: string,
52
51
  /** The attachment field, named for the store it draws from: `"productImage"`. */
53
52
  file: string,
53
+ /**
54
+ How many attachments this host's entity may hold. Absent ⇒ `Many`, so a graft
55
+ written before this key existed emits exactly what it emitted before.
56
+
57
+ It is not one rule among the emitted text: it changes which commands exist.
58
+ `Single` emits `Set{Entity}{Noun}` (which replaces), a `Remove{Entity}{Noun}`
59
+ that names no reference — there is only one, and asking a caller to name it is
60
+ asking them to repeat what the row already says — and no primary command at
61
+ all, because a set of one has nothing to choose between. The view field
62
+ changes with them: one captioned image, not an array of them.
63
+ */
64
+ cardinality?: Attachments_Rules.cardinality,
54
65
  /** The host event that brings the entity into existence: `"ProductAdded"`. */
55
66
  created: string,
56
67
  /** Whether that event carries the entity id. `false` for a payload-less
@@ -58,6 +69,17 @@ type config = {
58
69
  createdCarriesEntityId?: bool,
59
70
  /** The view whose lifecycle states `transition` names: `"Products"`. */
60
71
  view: string,
72
+ /**
73
+ The name of that view's lifecycle enum *type*: `"shelfStatus"`. Only read when
74
+ `transition` is given, because only then is a `lifecycleState` binding emitted.
75
+
76
+ Carried rather than derived: the enum is a type on the host's view and nothing
77
+ in the other names reaches it — `Products` has a `shelfStatus`, but a host may
78
+ call its own `state`, `stage` or `phase`. Absent leaves a `TODO(graft)` marker
79
+ in its place, which does not compile; that is deliberate, and better than a
80
+ guess that compiles against the wrong type.
81
+ */
82
+ lifecycleType?: string,
61
83
  /** The semantic type of the reference. `Reventless.UploadableImage.t` unless
62
84
  the host attaches something other than pictures. */
63
85
  refType?: string,
@@ -103,22 +125,34 @@ type names = {
103
125
  attached: string,
104
126
  removed: string,
105
127
  primarySet: string,
128
+ effectiveChanged: string,
106
129
  altTextSet: string,
107
130
  notFound: string,
108
131
  notAttached: string,
109
132
  }
110
133
 
134
+ let cardinalityOf = (c: config): Attachments_Rules.cardinality => c.cardinality->Option.getOr(Many)
135
+
136
+ let isSingle = (c: config): bool => cardinalityOf(c) == Single
137
+
111
138
  let namesOf = (c: config): names => {
112
139
  let subject = c.entity ++ c.noun
113
140
  {
114
141
  slice: subject ++ "s",
115
- attachCmd: "Attach" ++ subject,
142
+ // `Set` rather than `Attach` for a bounded set: the command replaces what is
143
+ // there, and `Attach` would name the wrong half of what it does. The *event*
144
+ // stays `Attached` at both cardinalities — an image was attached is the fact,
145
+ // and a replacement is that fact preceded by a removal.
146
+ attachCmd: (isSingle(c) ? "Set" : "Attach") ++ subject,
116
147
  removeCmd: "Remove" ++ subject,
117
148
  setPrimaryCmd: "SetPrimary" ++ subject,
118
149
  setAltTextCmd: "Set" ++ subject ++ "AltText",
119
150
  attached: subject ++ "Attached",
120
151
  removed: subject ++ "Removed",
121
152
  primarySet: c.entity ++ "Primary" ++ c.noun ++ "Set",
153
+ // Named for what it states — the one that now stands — rather than for the
154
+ // command that moved it, because most of the time no command did.
155
+ effectiveChanged: c.entity ++ "Effective" ++ c.noun ++ "Changed",
122
156
  altTextSet: subject ++ "AltTextSet",
123
157
  notFound: c.entity ++ "NotFound",
124
158
  notAttached: subject ++ "NotAttached",
@@ -127,15 +161,71 @@ let namesOf = (c: config): names => {
127
161
 
128
162
  let refTypeOf = (c: config) => c.refType->Option.getOr("Reventless.UploadableImage.t")
129
163
 
164
+ // What the VIEW holds, as against what an event's reference field holds. An
165
+ // event names a file; a view holds that file together with the text that goes
166
+ // with it, because a cell renderer is handed a field and a value and never the
167
+ // row — so a caption in a sibling field is a caption no cell can draw.
168
+ //
169
+ // Read off the reference type by name, as `contentArgOf` is. A host attaching
170
+ // something the vocabulary has no composite for keeps the bare reference, which
171
+ // is the shape it has today rather than a guess at one it does not.
172
+ let viewTypeOf = (c: config): string =>
173
+ refTypeOf(c)->String.includes("Image") ? "Reventless.CaptionedImage.t" : refTypeOf(c)
174
+
175
+ // The collection field a member-selecting command picks out of. Named for the
176
+ // plural of the attachment field, which is what the view calls it.
177
+ let setFieldOf = (c: config): string => c.file ++ "s"
178
+
179
+ /**
180
+ The type a command field takes when it means *select one of the ones I have*
181
+ rather than *here is a new file*.
182
+
183
+ This is the distinction the whole graft used to be unable to make. Remove,
184
+ choose-primary and caption all name a reference the row already holds, and all
185
+ three were typed as the uploadable they select among — so a UI reading the
186
+ declaration bound an upload input to each of them, which on a remove command
187
+ offers the caller the one thing it cannot do.
188
+
189
+ `Single` declares no such field at all, so this is only ever reached for the
190
+ commands where a choice genuinely exists.
191
+
192
+ Spelled as a reference to a binding rather than inline: an `@s.matches(…)`
193
+ attribute has to fit on one line to parse, and the call with both names in it
194
+ does not — which is also the better shape, since the collection is one answer
195
+ and three inline copies are three chances for one to name a field that has moved.
196
+ */
197
+ let selectionBinding = "selected"
198
+
199
+ let selectionTypeOf = (_c: config): string => `@s.matches(${selectionBinding}) string`
200
+
201
+ // What the members are, as the selection's own declaration states it — so a form
202
+ // drawing a picker shows thumbnails rather than a list of paths, without having
203
+ // to reach the collection field's element type.
204
+ //
205
+ // Read off the reference type by name, which is the only thing this module ever
206
+ // does. A host attaching something the vocabulary has no word for gets no
207
+ // content stated, and a reader falls back to its own rules — which is the honest
208
+ // answer rather than a guess with a default in it.
209
+ let contentArgOf = (c: config): string => {
210
+ let ref = refTypeOf(c)
211
+ if ref->String.includes("Image") {
212
+ `~content=Reventless.Semantic.Id.imageRef, `
213
+ } else if ref->String.includes("File") {
214
+ `~content=Reventless.Semantic.Id.fileRef, `
215
+ } else {
216
+ ""
217
+ }
218
+ }
219
+
130
220
  // `@authorize` is the host's policy, so an absent one emits nothing at all rather
131
221
  // than a permissive default — a graft that silently declared "anyone" would be
132
222
  // worse than one that declares nothing.
133
223
  //
134
224
  // The rule survives being emitted as an annotation because the PPX lowers it
135
225
  // into a `switch` whose arms are ordinary expressions: `AllowGroupz` does not
136
- // compile. `@transition` has no such second chance — it is stripped before the
137
- // typechecker runs so the states go out through `commandTransition` below
138
- // instead, where the compiler resolves them.
226
+ // compile. A stripped-before-the-typechecker attribute had no such second
227
+ // chance, which is why the states go out through `commandTransition` below,
228
+ // where the compiler resolves them.
139
229
  let commandAttributes = (c: config): string =>
140
230
  switch c.authorize {
141
231
  | Some(a) => ` | @authorize(${a})\n `
@@ -146,18 +236,35 @@ let commandAttributes = (c: config): string =>
146
236
  // slice is a file the trait writes.
147
237
  //
148
238
  // The states arrive as config either way; what changes is where they land. In
149
- // `@transition([Products.Listed])` they are stripped before the typechecker and
150
- // matched as strings at plugin assembly; in `Guards([Products.Listed])` they are
239
+ // the removed `@transition([Products.Listed])` they were stripped before the
240
+ // typechecker and matched as strings at assembly; in `Guards([Products.Listed])` they are
151
241
  // constructor references the compiler resolves, so a config typo is a build
152
242
  // error naming it. Same input, and the difference is only who checks it.
153
- let commandTransitionBinding = (c: config): array<string> => {
243
+ // Every command this graft declares, in declaration order. `SetPrimary` is
244
+ // absent for a bounded set — the one thing that changes the *shape* of the
245
+ // emitted surface rather than a rule inside it — and every emission below reads
246
+ // this list rather than repeating the condition.
247
+ let commandNames = (c: config): array<string> => {
154
248
  let n = namesOf(c)
155
- let arms = [n.attachCmd, n.removeCmd, n.setPrimaryCmd, n.setAltTextCmd]
156
- ->Array.map(cmd => ` | ${cmd}(_)`)
157
- ->Array.join("\n")
249
+ isSingle(c)
250
+ ? [n.attachCmd, n.removeCmd, n.setAltTextCmd]
251
+ : [n.attachCmd, n.removeCmd, n.setPrimaryCmd, n.setAltTextCmd]
252
+ }
253
+
254
+ let commandTransitionBinding = (c: config): array<string> => {
255
+ let arms = commandNames(c)->Array.map(cmd => ` | ${cmd}(_)`)->Array.join("\n")
256
+ // The enum's *type*, which `lifecycleType` names. Absent leaves the marker,
257
+ // and the marker does not compile — which is the honest outcome for a graft
258
+ // that declared the states its commands are legal in and not the type they
259
+ // are constructors of. A guess here would compile against whatever enum the
260
+ // view happens to have first.
261
+ let lifecycleType = switch c.lifecycleType {
262
+ | Some(t) => `${c.view}.${t}`
263
+ | None => `${c.view}.<lifecycle> // TODO(graft): the enum's name`
264
+ }
158
265
  switch c.transition {
159
266
  | Some(states) => [
160
- `type lifecycleState = ${c.view}.<lifecycle> // TODO(graft): the enum's name`,
267
+ `type lifecycleState = ${lifecycleType}`,
161
268
  `let commandTransition = (command: command): Reventless.Transition.t<lifecycleState> => {`,
162
269
  ` open Reventless.Transition`,
163
270
  ` switch command {`,
@@ -192,133 +299,235 @@ let lines = (ls: array<string>) => ls->Array.join("\n")
192
299
  let sliceSpec = (c: config): string => {
193
300
  let n = namesOf(c)
194
301
  let ref = refTypeOf(c)
302
+ let sel = selectionTypeOf(c)
303
+ let single = isSingle(c)
195
304
  let attrs = commandAttributes(c)
196
305
  let createdArm =
197
306
  c.createdCarriesEntityId->Option.getOr(true)
198
307
  ? ` | ${c.created}({ ${c.entityId}: string})`
199
308
  : ` | ${c.created}`
200
- lines([
201
- `// ${n.slice} StateChangeSlice: ${c.entity}'s attachment setattach, remove,`,
202
- `// choose the primary, caption. A graft of the Attachments trait; the set's rules`,
203
- `// are the trait's and are asserted by its conformance suite, bound in the tests.`,
204
- `//`,
205
- `// Emitted by the trait. Everything below is this host's own vocabulary, so it is`,
206
- `// ordinary source from here on — edit it freely.`,
207
- ``,
208
- `@@reventless.spec`,
209
- ``,
210
- `@schema`,
211
- `type consumedEvent =`,
212
- createdArm,
213
- ` | ${n.attached}({ ${c.file}: string})`,
214
- ` | ${n.removed}({ ${c.file}: string})`,
215
- ` | ${n.primarySet}({ ${c.file}: string})`,
216
- ` | ${n.altTextSet}({ ${c.file}: string, altText: string})`,
217
- ` // TODO(graft): add the events this host's own refusal turns on whatever`,
218
- ` // moves it into a state where attachments may not be changed.`,
219
- ``,
220
- `@schema`,
221
- `type command =`,
222
- `${attrs}${n.attachCmd}({ ${c.entityId}: string, ${c.file}: ${ref}, altText?: string})`,
223
- `${attrs}${n.removeCmd}({ ${c.entityId}: string, ${c.file}: ${ref}})`,
224
- `${attrs}${n.setPrimaryCmd}({ ${c.entityId}: string, ${c.file}: ${ref}})`,
225
- `${attrs}${n.setAltTextCmd}({ ${c.entityId}: string, ${c.file}: ${ref}, altText: string})`,
226
- ``,
227
- `@schema`,
228
- `type error =`,
229
- ` | ${n.notFound}`,
230
- ` | ${n.notAttached}`,
231
- ` // TODO(graft): add this host's own refusal.`,
232
- ``,
233
- `@schema`,
234
- `type event =`,
235
- ` | ${n.attached}({ ${c.entityId}: string, ${c.file}: ${ref}, altText?: string})`,
236
- ` | ${n.removed}({ ${c.entityId}: string, ${c.file}: ${ref}})`,
237
- ` | ${n.primarySet}({ ${c.entityId}: string, ${c.file}: ${ref}})`,
238
- ` | ${n.altTextSet}({ ${c.entityId}: string, ${c.file}: ${ref}, altText: string})`,
239
- ``,
240
- ...commandTransitionBinding(c),
241
- `// The graft's own record of itself. Nothing else survives into a deployed`,
242
- `// plugin — the dependency and the rules alias are source-side — so without`,
243
- `// this a running estate cannot say where this slice came from.`,
244
- `let traits = [TraitAttachments.Attachments.declaration]`,
245
- ``,
246
- ])
309
+ // Present only where a choice exists. A bounded set has one member, so
310
+ // nothing consumes or emits a primary and the trait's rules never produce
311
+ // the fact, so listing the event would declare one nothing can write.
312
+ let primaryArm = (prefix: string) =>
313
+ single ? [] : [` | ${n.primarySet}({ ${prefix}${c.file}: ${ref}})`]
314
+ let headline = single
315
+ ? [
316
+ `// ${n.slice} StateChangeSlice: ${c.entity}'s single attachment — set it, remove`,
317
+ `// it, caption it. A graft of the Attachments trait; the set's rules are the`,
318
+ ]
319
+ : [
320
+ `// ${n.slice} StateChangeSlice: ${c.entity}'s attachment set — attach, remove,`,
321
+ `// choose the primary, caption. A graft of the Attachments trait; the rules are`,
322
+ ]
323
+ lines(
324
+ [
325
+ ...headline,
326
+ `// trait's and are asserted by its conformance suite, bound in the tests.`,
327
+ `//`,
328
+ `// Emitted by the trait. Everything below is this host's own vocabulary, so it is`,
329
+ `// ordinary source from here on — edit it freely.`,
330
+ ``,
331
+ `@@reventless.spec`,
332
+ ``,
333
+ `@schema`,
334
+ `type consumedEvent =`,
335
+ createdArm,
336
+ ` | ${n.attached}({ ${c.file}: ${ref}})`,
337
+ ` | ${n.removed}({ ${c.file}: ${ref}})`,
338
+ ...primaryArm(""),
339
+ ` | ${n.altTextSet}({ ${c.file}: ${ref}, altText: string})`,
340
+ ` | ${n.effectiveChanged}({ ${c.file}?: ${ref}})`,
341
+ ` // TODO(graft): add the events this host's own refusal turns on — whatever`,
342
+ ` // moves it into a state where attachments may not be changed.`,
343
+ ``,
344
+ ...single
345
+ ? [
346
+ `// One reference field, on ${n.attachCmd}, and it accepts a new file — so`,
347
+ `// it is typed as the uploadable it is and a form binds an upload input to it.`,
348
+ `// Neither other command names a reference: with one attachment there is`,
349
+ `// nothing to choose between, and asking a caller to name it would be asking`,
350
+ `// them to repeat what the row already says.`,
351
+ ]
352
+ : [
353
+ `// The reference fields divide into two kinds, and the division is the point.`,
354
+ `// The one on ${n.attachCmd} accepts a new file, so it is typed as the`,
355
+ `// uploadable it is and a form binds an upload input to it. The others name a`,
356
+ `// file the row ALREADY holds, so they are typed as selections out of`,
357
+ `// \`${setFieldOf(c)}\` and a form offers those instead of an uploader.`,
358
+ `//`,
359
+ `// Bound once rather than spelled three times: the collection is one answer,`,
360
+ `// and three copies are three chances for one to name a field that has moved.`,
361
+ `let ${selectionBinding} = Reventless.MemberRef.of_(~view="${c.view}", ${contentArgOf(
362
+ c,
363
+ )}~field="${setFieldOf(c)}")`,
364
+ ``,
365
+ ],
366
+ `@schema`,
367
+ `type command =`,
368
+ `${attrs}${n.attachCmd}({ ${c.entityId}: string, ${c.file}: ${ref}, altText?: string})`,
369
+ // The bounded set's remove names nothing. This is the reported defect in its
370
+ // purest form — the old command asked for an upload in order to delete.
371
+ single
372
+ ? `${attrs}${n.removeCmd}({ ${c.entityId}: string})`
373
+ : `${attrs}${n.removeCmd}({ ${c.entityId}: string, ${c.file}: ${sel}})`,
374
+ ...single ? [] : [`${attrs}${n.setPrimaryCmd}({ ${c.entityId}: string, ${c.file}: ${sel}})`],
375
+ single
376
+ ? `${attrs}${n.setAltTextCmd}({ ${c.entityId}: string, altText: string})`
377
+ : `${attrs}${n.setAltTextCmd}({ ${c.entityId}: string, ${c.file}: ${sel}, altText: string})`,
378
+ ``,
379
+ `@schema`,
380
+ `type error =`,
381
+ ` | ${n.notFound}`,
382
+ ` | ${n.notAttached}`,
383
+ ` // TODO(graft): add this host's own refusal.`,
384
+ ``,
385
+ `@schema`,
386
+ `type event =`,
387
+ ` | ${n.attached}({ ${c.entityId}: string, ${c.file}: ${ref}, altText?: string})`,
388
+ ` | ${n.removed}({ ${c.entityId}: string, ${c.file}: ${ref}})`,
389
+ ...primaryArm(`${c.entityId}: string, `),
390
+ ` | ${n.altTextSet}({ ${c.entityId}: string, ${c.file}: ${ref}, altText: string})`,
391
+ ` // The member a reader should now show, or none. A conclusion rather than a`,
392
+ ` // decision: most of the moves that change it — a first attachment, a removal`,
393
+ ` // promoting the next — are nobody's choice, so nothing else announces them.`,
394
+ ` | ${n.effectiveChanged}({ ${c.entityId}: string, ${c.file}?: ${ref}})`,
395
+ ``,
396
+ ...commandTransitionBinding(c),
397
+ `// The graft's own record of itself. Nothing else survives into a deployed`,
398
+ `// plugin — the dependency and the rules alias are source-side — so without`,
399
+ `// this a running estate cannot say where this slice came from.`,
400
+ `let traits = [TraitAttachments.Attachments.declaration]`,
401
+ ``,
402
+ ],
403
+ )
247
404
  }
248
405
 
249
406
  // ── The slice body ───────────────────────────────────────────────────────────
250
407
 
251
408
  let sliceBehavior = (c: config): string => {
252
409
  let n = namesOf(c)
253
- lines([
254
- `@@reventless.behavior`,
255
- ``,
256
- `// The set's rules are the trait's. What is left here is this host's own refusal`,
257
- `// and the mapping between its constructors and the trait's ops and facts.`,
258
- `module Attachments = TraitAttachments.Attachments_Rules`,
259
- ``,
260
- `type state = {exists: bool, attachments: Attachments.t}`,
261
- ``,
262
- `let initialState = {exists: false, attachments: Attachments.empty}`,
263
- ``,
264
- `let evolve = (state, event) => {`,
265
- ` let fold = fact => {...state, attachments: state.attachments->Attachments.evolve(fact)}`,
266
- ` switch event {`,
267
- ` | ${c.created}(_) => {...state, exists: true}`,
268
- ` | ${n.attached}({${c.file}}) => fold(Attached({ref: ${c.file}, altText: None}))`,
269
- ` | ${n.removed}({${c.file}}) => fold(Removed({ref: ${c.file}}))`,
270
- ` | ${n.primarySet}({${c.file}}) => fold(PrimarySet({ref: ${c.file}}))`,
271
- ` | ${n.altTextSet}({${c.file}, altText}) => fold(AltTextSet({ref: ${c.file}, altText}))`,
272
- ` // TODO(graft): fold this host's own events into its own state.`,
273
- ` }`,
274
- `}`,
275
- ``,
276
- `let toOp = command =>`,
277
- ` switch command {`,
278
- ` | ${n.attachCmd}({${c.entityId}, ${c.file}, altText: ?altText}) => (`,
279
- ` ${c.entityId},`,
280
- ` Attachments.Attach({ref: ${c.file}, altText}),`,
281
- ` )`,
282
- ` | ${n.removeCmd}({${c.entityId}, ${c.file}}) => (`,
283
- ` ${c.entityId},`,
284
- ` Attachments.Remove({ref: ${c.file}}),`,
285
- ` )`,
286
- ` | ${n.setPrimaryCmd}({${c.entityId}, ${c.file}}) => (`,
287
- ` ${c.entityId},`,
288
- ` Attachments.SetPrimary({ref: ${c.file}}),`,
289
- ` )`,
290
- ` | ${n.setAltTextCmd}({${c.entityId}, ${c.file}, altText}) => (`,
291
- ` ${c.entityId},`,
292
- ` Attachments.SetAltText({ref: ${c.file}, altText}),`,
293
- ` )`,
294
- ` }`,
295
- ``,
296
- `let toEvent = (${c.entityId}, fact) =>`,
297
- ` switch fact {`,
298
- ` | Attachments.Attached({ref, altText}) =>`,
299
- ` ${n.attached}({${c.entityId}, ${c.file}: ref, altText: ?altText})`,
300
- ` | Attachments.Removed({ref}) => ${n.removed}({${c.entityId}, ${c.file}: ref})`,
301
- ` | Attachments.PrimarySet({ref}) => ${n.primarySet}({${c.entityId}, ${c.file}: ref})`,
302
- ` | Attachments.AltTextSet({ref, altText}) =>`,
303
- ` ${n.altTextSet}({${c.entityId}, ${c.file}: ref, altText})`,
304
- ` }`,
305
- ``,
306
- `let decide = (state, command) =>`,
307
- ` if !state.exists {`,
308
- ` Error(${n.notFound})`,
309
- ` } else {`,
310
- ` // TODO(graft): this host's own refusal goes here, ahead of the set's rules —`,
311
- ` // an \`else if\` returning the error added above. A graft with no extra refusal`,
312
- ` // is a complete graft, so leaving this is legitimate.`,
313
- ` let (${c.entityId}, op) = toOp(command)`,
314
- ` switch state.attachments->Attachments.decide(op) {`,
315
- ` | Error(#NotAttached) => Error(${n.notAttached})`,
316
- ` | Ok(None) => Ok([])`,
317
- ` | Ok(Some(fact)) => Ok([toEvent(${c.entityId}, fact)])`,
318
- ` }`,
319
- ` }`,
320
- ``,
321
- ])
410
+ let single = isSingle(c)
411
+ lines(
412
+ [
413
+ `@@reventless.behavior`,
414
+ ``,
415
+ `// The set's rules are the trait's. What is left here is this host's own refusal`,
416
+ `// and the mapping between its constructors and the trait's ops and facts.`,
417
+ `module Attachments = TraitAttachments.Attachments_Rules`,
418
+ ``,
419
+ `type state = {exists: bool, attachments: Attachments.t}`,
420
+ ``,
421
+ `let initialState = {exists: false, attachments: Attachments.empty}`,
422
+ ``,
423
+ `let evolve = (state, event) => {`,
424
+ ` let fold = fact => {...state, attachments: state.attachments->Attachments.evolve(fact)}`,
425
+ ` switch event {`,
426
+ // A creation event that carries no id is a bare constructor, so a wildcard
427
+ // payload does not compile against it. The spec above already branches on
428
+ // this; the fold has to branch with it.
429
+ c.createdCarriesEntityId->Option.getOr(true)
430
+ ? ` | ${c.created}(_) => {...state, exists: true}`
431
+ : ` | ${c.created} => {...state, exists: true}`,
432
+ ` | ${n.attached}({${c.file}}) => fold(Attached({ref: ${c.file}, altText: None}))`,
433
+ ` | ${n.removed}({${c.file}}) => fold(Removed({ref: ${c.file}}))`,
434
+ ...single ? [] : [` | ${n.primarySet}({${c.file}}) => fold(PrimarySet({ref: ${c.file}}))`],
435
+ ` | ${n.altTextSet}({${c.file}, altText}) => fold(AltTextSet({ref: ${c.file}, altText}))`,
436
+ ` | ${n.effectiveChanged}({${c.file}: ?ref}) => fold(EffectiveChanged({ref: ref}))`,
437
+ ` // TODO(graft): fold this host's own events into its own state.`,
438
+ ` }`,
439
+ `}`,
440
+ ``,
441
+ `let toOp = command =>`,
442
+ ` switch command {`,
443
+ ` | ${n.attachCmd}({${c.entityId}, ${c.file}, altText: ?altText}) => (`,
444
+ ` ${c.entityId},`,
445
+ ` Attachments.Attach({ref: ${c.file}, altText}),`,
446
+ ` )`,
447
+ // `Clear` is what a ref-less remove maps onto: the op that empties the set,
448
+ // whatever it holds. Nothing here has to look the member up.
449
+ ...single
450
+ ? [` | ${n.removeCmd}({${c.entityId}}) => (${c.entityId}, Attachments.Clear)`]
451
+ : [
452
+ ` | ${n.removeCmd}({${c.entityId}, ${c.file}}) => (`,
453
+ ` ${c.entityId},`,
454
+ ` Attachments.Remove({ref: ${c.file}}),`,
455
+ ` )`,
456
+ ` | ${n.setPrimaryCmd}({${c.entityId}, ${c.file}}) => (`,
457
+ ` ${c.entityId},`,
458
+ ` Attachments.SetPrimary({ref: ${c.file}}),`,
459
+ ` )`,
460
+ ],
461
+ ...single
462
+ ? [
463
+ ` | ${n.setAltTextCmd}({${c.entityId}, altText}) => (`,
464
+ ` ${c.entityId},`,
465
+ // Not punned: a single-field inline record whose field shares its name
466
+ // with the variable filling it is read as a record copy, and the
467
+ // anonymous type then escapes its constructor.
468
+ ` Attachments.SetPrimaryAltText({altText: altText}),`,
469
+ ` )`,
470
+ ]
471
+ : [
472
+ ` | ${n.setAltTextCmd}({${c.entityId}, ${c.file}, altText}) => (`,
473
+ ` ${c.entityId},`,
474
+ ` Attachments.SetAltText({ref: ${c.file}, altText}),`,
475
+ ` )`,
476
+ ],
477
+ ` }`,
478
+ ``,
479
+ // `Some`/`None` for a bounded set only. `fact` is the trait's type and so
480
+ // lists a primary at both cardinalities, but a graft with no primary command
481
+ // can never decide one — and an arm that fabricated some other event to keep
482
+ // the switch total would be writing a fact nothing happened.
483
+ `let toEvent = (${c.entityId}, fact) =>`,
484
+ ` switch fact {`,
485
+ ` | Attachments.Attached({ref, altText}) =>`,
486
+ ` ${single
487
+ ? "Some("
488
+ : ""}${n.attached}({${c.entityId}, ${c.file}: ref, altText: ?altText})${single
489
+ ? ")"
490
+ : ""}`,
491
+ ` | Attachments.Removed({ref}) => ${single
492
+ ? `Some(${n.removed}({${c.entityId}, ${c.file}: ref}))`
493
+ : `${n.removed}({${c.entityId}, ${c.file}: ref})`}`,
494
+ ...single
495
+ ? [
496
+ ` // Unreachable: no command of this graft chooses a primary, because a set`,
497
+ ` // of one has nothing to choose between. It contributes no event.`,
498
+ ` | Attachments.PrimarySet(_) => None`,
499
+ ]
500
+ : [` | Attachments.PrimarySet({ref}) => ${n.primarySet}({${c.entityId}, ${c.file}: ref})`],
501
+ ` | Attachments.AltTextSet({ref, altText}) =>`,
502
+ ` ${single ? "Some(" : ""}${n.altTextSet}({${c.entityId}, ${c.file}: ref, altText})${single
503
+ ? ")"
504
+ : ""}`,
505
+ ` | Attachments.EffectiveChanged({ref}) =>`,
506
+ ` ${single ? "Some(" : ""}${n.effectiveChanged}({${c.entityId}, ${c.file}: ?ref})${single
507
+ ? ")"
508
+ : ""}`,
509
+ ` }`,
510
+ ``,
511
+ `let decide = (state, command) =>`,
512
+ ` if !state.exists {`,
513
+ ` Error(${n.notFound})`,
514
+ ` } else {`,
515
+ ` // TODO(graft): this host's own refusal goes here, ahead of the set's rules —`,
516
+ ` // an \`else if\` returning the error added above. A graft with no extra refusal`,
517
+ ` // is a complete graft, so leaving this is legitimate.`,
518
+ ` let (${c.entityId}, op) = toOp(command)`,
519
+ single
520
+ ? ` switch state.attachments->Attachments.decide(~cardinality=Single, op) {`
521
+ : ` switch state.attachments->Attachments.decide(op) {`,
522
+ ` | Error(#NotAttached) => Error(${n.notAttached})`,
523
+ single
524
+ ? ` | Ok(facts) => Ok(facts->Array.filterMap(toEvent(${c.entityId}, _)))`
525
+ : ` | Ok(facts) => Ok(facts->Array.map(toEvent(${c.entityId}, _)))`,
526
+ ` }`,
527
+ ` }`,
528
+ ``,
529
+ ],
530
+ )
322
531
  }
323
532
 
324
533
  // ── The conformance binding ──────────────────────────────────────────────────
@@ -329,6 +538,7 @@ let sliceBehavior = (c: config): string => {
329
538
 
330
539
  let conformanceBinding = (c: config): string => {
331
540
  let n = namesOf(c)
541
+ let single = isSingle(c)
332
542
  let id = "e1"
333
543
  let refA = c.refA->Option.getOr(`/uploads/00000000-0000-4000-8000-000000000001/a`)
334
544
  let refB = c.refB->Option.getOr(`/uploads/00000000-0000-4000-8000-000000000002/b`)
@@ -336,47 +546,72 @@ let conformanceBinding = (c: config): string => {
336
546
  c.createdCarriesEntityId->Option.getOr(true)
337
547
  ? `${c.created}({ ${c.entityId}: "${id}"})`
338
548
  : c.created
339
- lines([
340
- `// The Attachments trait's conformance suite, bound to \`${n.slice}\`.`,
341
- `// Emitted whole: every name here is one the graft already declared.`,
342
- ``,
343
- `module Binding = {`,
344
- ` type ref = string`,
345
- ` let refA = "${refA}"`,
346
- ` let refB = "${refB}"`,
347
- ``,
348
- ` module Spec = ${n.slice}`,
349
- ` module Behavior = ${n.slice}_Behavior`,
350
- ``,
351
- ` // Annotated: the slice consumes and emits same-named constructors.`,
352
- ` let created: array<${n.slice}.consumedEvent> = [${createdValue}]`,
353
- ` let attachedC = (ref): ${n.slice}.consumedEvent => ${n.attached}({ ${c.file}: ref})`,
354
- ` let removedC = (ref): ${n.slice}.consumedEvent => ${n.removed}({ ${c.file}: ref})`,
355
- ` let primarySetC = (ref): ${n.slice}.consumedEvent => ${n.primarySet}({ ${c.file}: ref})`,
356
- ` let altTextSetC = (ref, altText): ${n.slice}.consumedEvent =>`,
357
- ` ${n.altTextSet}({ ${c.file}: ref, altText})`,
358
- ``,
359
- ` let attach = ref => ${n.slice}.${n.attachCmd}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
360
- ` let remove = ref => ${n.slice}.${n.removeCmd}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
361
- ` let setPrimary = ref =>`,
362
- ` ${n.slice}.${n.setPrimaryCmd}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
363
- ` let setAltText = (ref, altText) =>`,
364
- ` ${n.slice}.${n.setAltTextCmd}({ ${c.entityId}: "${id}", ${c.file}: ref, altText})`,
365
- ``,
366
- ` let attached = ref => ${n.slice}.${n.attached}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
367
- ` let removed = ref => ${n.slice}.${n.removed}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
368
- ` let primarySet = ref =>`,
369
- ` ${n.slice}.${n.primarySet}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
370
- ` let altTextSet = (ref, altText) =>`,
371
- ` ${n.slice}.${n.altTextSet}({ ${c.entityId}: "${id}", ${c.file}: ref, altText})`,
372
- ` let notAttached = ${n.slice}.${n.notAttached}`,
373
- `}`,
374
- ``,
375
- `module Conformance = TraitAttachments.Attachments_Conformance.Make(Binding)`,
376
- ``,
377
- `Conformance.register()`,
378
- ``,
379
- ])
549
+ lines(
550
+ [
551
+ `// The Attachments trait's conformance suite, bound to \`${n.slice}\`.`,
552
+ `// Emitted whole: every name here is one the graft already declared.`,
553
+ ``,
554
+ `module Binding = {`,
555
+ ` type ref = string`,
556
+ ` let refA = "${refA}"`,
557
+ ` let refB = "${refB}"`,
558
+ ``,
559
+ ` module Spec = ${n.slice}`,
560
+ ` module Behavior = ${n.slice}_Behavior`,
561
+ ``,
562
+ ` // Annotated: the slice consumes and emits same-named constructors.`,
563
+ ` // The same facts as the slice consumes them: no ${c.entityId}, because the`,
564
+ ` // partition already says which entity they belong to.`,
565
+ ` module Consumed = {`,
566
+ ` let created: array<${n.slice}.consumedEvent> = [${createdValue}]`,
567
+ ` let attached = (ref): ${n.slice}.consumedEvent => ${n.attached}({ ${c.file}: ref})`,
568
+ ` let removed = (ref): ${n.slice}.consumedEvent => ${n.removed}({ ${c.file}: ref})`,
569
+ ...single
570
+ ? []
571
+ : [
572
+ ` let primarySet = (ref): ${n.slice}.consumedEvent => ${n.primarySet}({ ${c.file}: ref})`,
573
+ ],
574
+ ` let altTextSet = (ref, altText): ${n.slice}.consumedEvent =>`,
575
+ ` ${n.altTextSet}({ ${c.file}: ref, altText})`,
576
+ ` let effectiveChanged = (ref): ${n.slice}.consumedEvent =>`,
577
+ ` ${n.effectiveChanged}({ ${c.file}: ?ref})`,
578
+ ` }`,
579
+ ``,
580
+ ` let attach = ref => ${n.slice}.${n.attachCmd}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
581
+ ...single
582
+ ? [` let clear = ${n.slice}.${n.removeCmd}({ ${c.entityId}: "${id}"})`]
583
+ : [
584
+ ` let remove = ref => ${n.slice}.${n.removeCmd}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
585
+ ` let setPrimary = ref =>`,
586
+ ` ${n.slice}.${n.setPrimaryCmd}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
587
+ ],
588
+ single
589
+ ? ` let setAltText = altText => ${n.slice}.${n.setAltTextCmd}({ ${c.entityId}: "${id}", altText})`
590
+ : ` let setAltText = (ref, altText) =>\n ${n.slice}.${n.setAltTextCmd}({ ${c.entityId}: "${id}", ${c.file}: ref, altText})`,
591
+ ``,
592
+ ` let attached = ref => ${n.slice}.${n.attached}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
593
+ ` let removed = ref => ${n.slice}.${n.removed}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
594
+ ...single
595
+ ? []
596
+ : [
597
+ ` let primarySet = ref =>`,
598
+ ` ${n.slice}.${n.primarySet}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
599
+ ],
600
+ ` let altTextSet = (ref, altText) =>`,
601
+ ` ${n.slice}.${n.altTextSet}({ ${c.entityId}: "${id}", ${c.file}: ref, altText})`,
602
+ ` let effectiveChanged = ref =>`,
603
+ ` ${n.slice}.${n.effectiveChanged}({ ${c.entityId}: "${id}", ${c.file}: ?ref})`,
604
+ ` let notAttached = ${n.slice}.${n.notAttached}`,
605
+ `}`,
606
+ ``,
607
+ single
608
+ ? `module Conformance = TraitAttachments.Attachments_Conformance.MakeSingle(Binding)`
609
+ : `module Conformance = TraitAttachments.Attachments_Conformance.Make(Binding)`,
610
+ ``,
611
+ `Conformance.register()`,
612
+ ``,
613
+ ],
614
+ )
380
615
  }
381
616
 
382
617
  // ── The projection patch ─────────────────────────────────────────────────────
@@ -385,50 +620,117 @@ let conformanceBinding = (c: config): string => {
385
620
  // `switch` the host wrote. Placing an arm in it is the one part of a graft this
386
621
  // module deliberately does not automate.
387
622
 
388
- let projectionPatch = (c: config): patch => {
623
+ // The bounded set's projection, which is a different patch rather than the same
624
+ // one with a branch in it: with one attachment the view carries the value itself
625
+ // and no collection at all, so there is no set to fold over and no primary to
626
+ // put first. Three assignments.
627
+ //
628
+ // The removal and the caption both guard on the reference they name. A
629
+ // replacement decides two facts — the old leaves, then the new arrives — and the
630
+ // guard is what makes each arm depend only on the row it finds rather than on
631
+ // those two reaching the projection in the order they were decided.
632
+ let singleProjectionPatch = (c: config): patch => {
389
633
  let n = namesOf(c)
390
634
  {
391
- into: `StateViewSliceStream/${c.view}_Projection.res`,
392
- at: `the projection's \`switch\`, and two fields on \`${c.view}\`'s state`,
635
+ into: `StateViewStream/${c.view}_Projection.res`,
636
+ at: `the projection's \`switch\`, and one field on \`${c.view}\`'s state`,
393
637
  contents: lines([
394
- `// On the view's state, two fields — the set, and its primary as one string.`,
395
- `// The second is not redundancy: a card, a gallery tile and a reference cell`,
396
- `// each read one image-semantic string per row, so without it every tile is blank.`,
638
+ `// On the view's state, one field — the reference and its text, in one value.`,
639
+ `// No collection: this entity holds one attachment, so the field a card, a`,
640
+ `// gallery tile and a list cell read IS the whole of what it has.`,
397
641
  `//`,
398
- `// ${c.file}s: array<{${c.file}: string, altText?: string}>,`,
399
- `// ${c.file}?: string,`,
642
+ `// ${c.file}?: ${viewTypeOf(c)},`,
400
643
  ``,
401
644
  `| ${n.attached}({${c.entityId}, ${c.file}, altText: ?altText}) =>`,
402
645
  ` Update(${c.entityId}, state => {`,
403
- ` let ${c.file}s = Array.concat(state.${c.file}s, [{${c.file}: ${c.file}, altText: ?altText}])`,
404
- ` {...state, ${c.file}s, ${c.file}: ?withPrimary(${c.file}s, state.primaryChosen)}`,
646
+ ` ...state,`,
647
+ ` ${c.file}: {ref: ${c.file}, altText: ?altText},`,
405
648
  ` })`,
406
649
  `| ${n.removed}({${c.entityId}, ${c.file}}) =>`,
650
+ ` Update(${c.entityId}, state =>`,
651
+ ` // Guarded on the reference: a removal that names something this row no`,
652
+ ` // longer holds — the first half of a replacement, arriving late — must not`,
653
+ ` // blank the one it does.`,
654
+ ` heldRef(state) == Some(${c.file}) ? {...state, ${c.file}: ?None} : state`,
655
+ ` )`,
656
+ `| ${n.altTextSet}({${c.entityId}, ${c.file}, altText}) =>`,
657
+ ` Update(${c.entityId}, state =>`,
658
+ ` switch state.${c.file} {`,
659
+ ` | Some(held) if held.ref == ${c.file} => {...state, ${c.file}: {...held, altText}}`,
660
+ ` | _ => state`,
661
+ ` }`,
662
+ ` )`,
663
+ ``,
664
+ `// The reference this row holds, if it holds one. Named because both guards`,
665
+ `// above ask the same question of a value that is no longer the reference itself.`,
666
+ `let heldRef = (state: ${c.view}.state) => state.${c.file}->Option.map(held => held.ref)`,
667
+ ]),
668
+ }
669
+ }
670
+
671
+ let manyProjectionPatch = (c: config): patch => {
672
+ let n = namesOf(c)
673
+ let set = setFieldOf(c)
674
+ {
675
+ into: `StateViewStream/${c.view}_Projection.res`,
676
+ at: `the projection's \`switch\`, and one field on \`${c.view}\`'s state`,
677
+ contents: lines([
678
+ `// On the view's state, one field — the set, primary first.`,
679
+ `//`,
680
+ `// The primary is the FIRST member rather than a scalar beside the set. That is`,
681
+ `// what a card, a gallery tile and a list cell read, so there is no second field`,
682
+ `// to keep in step with the set and no arm that can forget to. The text rides`,
683
+ `// inside each member for the same reason: a cell renderer is handed a field and`,
684
+ `// a value and never the row, so a caption in a sibling field is one no cell can`,
685
+ `// draw.`,
686
+ `//`,
687
+ `// What it costs, stated plainly: attachment order stops being readable off the`,
688
+ `// view. The log still has it.`,
689
+ `//`,
690
+ `// ${set}: array<${viewTypeOf(c)}>,`,
691
+ ``,
692
+ `// Appended, so the first attached is the primary until one is chosen.`,
693
+ `| ${n.attached}({${c.entityId}, ${c.file}, altText: ?altText}) =>`,
694
+ ` Update(${c.entityId}, state =>`,
695
+ ` state.${set}->Array.some(m => m.ref == ${c.file})`,
696
+ ` ? state`,
697
+ ` : {`,
698
+ ` ...state,`,
699
+ ` ${set}: state.${set}->Array.concat([{ref: ${c.file}, altText: ?altText}]),`,
700
+ ` }`,
701
+ ` )`,
702
+ `// Removing the head promotes the next member with no arm to say so.`,
703
+ `| ${n.removed}({${c.entityId}, ${c.file}}) =>`,
407
704
  ` Update(${c.entityId}, state => {`,
408
- ` let ${c.file}s = state.${c.file}s->Array.filter(m => m.${c.file} != ${c.file})`,
409
- ` {...state, ${c.file}s, ${c.file}: ?withPrimary(${c.file}s, state.primaryChosen)}`,
705
+ ` ...state,`,
706
+ ` ${set}: state.${set}->Array.filter(m => m.ref != ${c.file}),`,
410
707
  ` })`,
411
708
  `| ${n.primarySet}({${c.entityId}, ${c.file}}) =>`,
412
- ` Update(${c.entityId}, state => {...state, ${c.file}: Some(${c.file})})`,
709
+ ` Update(${c.entityId}, state => primaryFirst(state, ${c.file}))`,
413
710
  `| ${n.altTextSet}({${c.entityId}, ${c.file}, altText}) =>`,
414
711
  ` Update(${c.entityId}, state => {`,
415
712
  ` ...state,`,
416
- ` ${c.file}s: state.${c.file}s->Array.map(m =>`,
417
- ` m.${c.file} == ${c.file} ? {...m, altText} : m`,
418
- ` ),`,
713
+ ` ${set}: state.${set}->Array.map(m => m.ref == ${c.file} ? {...m, altText} : m),`,
419
714
  ` })`,
420
715
  ``,
421
- `// The primary a reader should show: the one chosen, else the first attached —`,
422
- `// the same rule the trait applies, over the view's own rows.`,
423
- `let withPrimary = (members, chosen) =>`,
424
- ` TraitAttachments.Attachments_Rules.primaryOf(`,
716
+ `// Choosing the primary is moving it to the front the trait's rule, applied`,
717
+ `// over the view's rows. The only arm that reorders; the rest leave the head`,
718
+ `// where they found it.`,
719
+ `let primaryFirst = (state: ${c.view}.state, chosen) => {`,
720
+ ` ...state,`,
721
+ ` ${set}: TraitAttachments.Attachments_Rules.primaryFirst(`,
425
722
  ` ~chosen,`,
426
- ` ~attached=members->Array.map(m => m.${c.file}),`,
427
- ` )`,
723
+ ` ~members=state.${set},`,
724
+ ` ~ref=m => m.ref,`,
725
+ ` ),`,
726
+ `}`,
428
727
  ]),
429
728
  }
430
729
  }
431
730
 
731
+ let projectionPatch = (c: config): patch =>
732
+ isSingle(c) ? singleProjectionPatch(c) : manyProjectionPatch(c)
733
+
432
734
  /**
433
735
  Emit a graft.
434
736
 
@@ -439,9 +741,9 @@ let emit = (~config: config, ~into: string, ~tests: string): output => {
439
741
  let n = namesOf(config)
440
742
  {
441
743
  files: [
442
- {path: `${into}/StateChangeSlice/${n.slice}.res`, contents: sliceSpec(config)},
744
+ {path: `${into}/StateChange/${n.slice}.res`, contents: sliceSpec(config)},
443
745
  {
444
- path: `${into}/StateChangeSlice/${n.slice}_Behavior.res`,
746
+ path: `${into}/StateChange/${n.slice}_Behavior.res`,
445
747
  contents: sliceBehavior(config),
446
748
  },
447
749
  {