@reventlessdev/trait-attachments 1.0.0-alpha.5 → 1.0.0-alpha.51

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
 
@@ -39,6 +38,9 @@ type config = {
39
38
  entity: string,
40
39
  /** Its id field: `"productId"`. */
41
40
  entityId: string,
41
+ /** The host's identity module for that id: `"ProductId"`. Absent ⇒ `string`,
42
+ for a host that has not declared one. */
43
+ entityIdType?: string,
42
44
  /**
43
45
  What this host calls one attachment, capitalised and singular: `"Image"`,
44
46
  `"Document"`, `"Attachment"`.
@@ -126,13 +128,13 @@ type names = {
126
128
  attached: string,
127
129
  removed: string,
128
130
  primarySet: string,
131
+ effectiveChanged: string,
129
132
  altTextSet: string,
130
133
  notFound: string,
131
134
  notAttached: string,
132
135
  }
133
136
 
134
- let cardinalityOf = (c: config): Attachments_Rules.cardinality =>
135
- c.cardinality->Option.getOr(Many)
137
+ let cardinalityOf = (c: config): Attachments_Rules.cardinality => c.cardinality->Option.getOr(Many)
136
138
 
137
139
  let isSingle = (c: config): bool => cardinalityOf(c) == Single
138
140
 
@@ -151,6 +153,9 @@ let namesOf = (c: config): names => {
151
153
  attached: subject ++ "Attached",
152
154
  removed: subject ++ "Removed",
153
155
  primarySet: c.entity ++ "Primary" ++ c.noun ++ "Set",
156
+ // Named for what it states — the one that now stands — rather than for the
157
+ // command that moved it, because most of the time no command did.
158
+ effectiveChanged: c.entity ++ "Effective" ++ c.noun ++ "Changed",
154
159
  altTextSet: subject ++ "AltTextSet",
155
160
  notFound: c.entity ++ "NotFound",
156
161
  notAttached: subject ++ "NotAttached",
@@ -159,6 +164,15 @@ let namesOf = (c: config): names => {
159
164
 
160
165
  let refTypeOf = (c: config) => c.refType->Option.getOr("Reventless.UploadableImage.t")
161
166
 
167
+ // The entity id's type, and a fixture value of it.
168
+ let idTypeOf = (c: config) => c.entityIdType->Option.mapOr("string", t => t ++ ".t")
169
+
170
+ let idValueOf = (c: config, literal: string) =>
171
+ switch c.entityIdType {
172
+ | Some(t) => `${t}.make("${literal}")`
173
+ | None => `"${literal}"`
174
+ }
175
+
162
176
  // What the VIEW holds, as against what an event's reference field holds. An
163
177
  // event names a file; a view holds that file together with the text that goes
164
178
  // with it, because a cell renderer is handed a field and a value and never the
@@ -302,217 +316,236 @@ let sliceSpec = (c: config): string => {
302
316
  let attrs = commandAttributes(c)
303
317
  let createdArm =
304
318
  c.createdCarriesEntityId->Option.getOr(true)
305
- ? ` | ${c.created}({ ${c.entityId}: string})`
319
+ ? ` | ${c.created}({ ${c.entityId}: ${idTypeOf(c)}})`
306
320
  : ` | ${c.created}`
307
321
  // Present only where a choice exists. A bounded set has one member, so
308
322
  // nothing consumes or emits a primary — and the trait's rules never produce
309
323
  // the fact, so listing the event would declare one nothing can write.
310
324
  let primaryArm = (prefix: string) =>
311
325
  single ? [] : [` | ${n.primarySet}({ ${prefix}${c.file}: ${ref}})`]
312
- let headline =
313
- single
314
- ? [
315
- `// ${n.slice} StateChangeSlice: ${c.entity}'s single attachment — set it, remove`,
316
- `// it, caption it. A graft of the Attachments trait; the set's rules are the`,
317
- ]
318
- : [
319
- `// ${n.slice} StateChangeSlice: ${c.entity}'s attachment set — attach, remove,`,
320
- `// choose the primary, caption. A graft of the Attachments trait; the rules are`,
321
- ]
322
- lines([
323
- ...headline,
324
- `// trait's and are asserted by its conformance suite, bound in the tests.`,
325
- `//`,
326
- `// Emitted by the trait. Everything below is this host's own vocabulary, so it is`,
327
- `// ordinary source from here on — edit it freely.`,
328
- ``,
329
- `@@reventless.spec`,
330
- ``,
331
- `@schema`,
332
- `type consumedEvent =`,
333
- createdArm,
334
- ` | ${n.attached}({ ${c.file}: ${ref}})`,
335
- ` | ${n.removed}({ ${c.file}: ${ref}})`,
336
- ...primaryArm(""),
337
- ` | ${n.altTextSet}({ ${c.file}: ${ref}, altText: string})`,
338
- ` // TODO(graft): add the events this host's own refusal turns on — whatever`,
339
- ` // moves it into a state where attachments may not be changed.`,
340
- ``,
341
- ...(single
342
- ? [
343
- `// One reference field, on ${n.attachCmd}, and it accepts a new file — so`,
344
- `// it is typed as the uploadable it is and a form binds an upload input to it.`,
345
- `// Neither other command names a reference: with one attachment there is`,
346
- `// nothing to choose between, and asking a caller to name it would be asking`,
347
- `// them to repeat what the row already says.`,
348
- ]
349
- : [
350
- `// The reference fields divide into two kinds, and the division is the point.`,
351
- `// The one on ${n.attachCmd} accepts a new file, so it is typed as the`,
352
- `// uploadable it is and a form binds an upload input to it. The others name a`,
353
- `// file the row ALREADY holds, so they are typed as selections out of`,
354
- `// \`${setFieldOf(c)}\` and a form offers those instead of an uploader.`,
355
- `//`,
356
- `// Bound once rather than spelled three times: the collection is one answer,`,
357
- `// and three copies are three chances for one to name a field that has moved.`,
358
- `let ${selectionBinding} = Reventless.MemberRef.of_(~view="${c.view}", ${contentArgOf(
326
+ let headline = single
327
+ ? [
328
+ `// ${n.slice} StateChangeSlice: ${c.entity}'s single attachment — set it, remove`,
329
+ `// it, caption it. A graft of the Attachments trait; the set's rules are the`,
330
+ ]
331
+ : [
332
+ `// ${n.slice} StateChangeSlice: ${c.entity}'s attachment set — attach, remove,`,
333
+ `// choose the primary, caption. A graft of the Attachments trait; the rules are`,
334
+ ]
335
+ lines(
336
+ [
337
+ ...headline,
338
+ `// trait's and are asserted by its conformance suite, bound in the tests.`,
339
+ `//`,
340
+ `// Emitted by the trait. Everything below is this host's own vocabulary, so it is`,
341
+ `// ordinary source from here on — edit it freely.`,
342
+ ``,
343
+ `@@reventless.spec`,
344
+ ``,
345
+ `@schema`,
346
+ `type consumedEvent =`,
347
+ createdArm,
348
+ ` | ${n.attached}({ ${c.file}: ${ref}})`,
349
+ ` | ${n.removed}({ ${c.file}: ${ref}})`,
350
+ ...primaryArm(""),
351
+ ` | ${n.altTextSet}({ ${c.file}: ${ref}, altText: string})`,
352
+ ` | ${n.effectiveChanged}({ ${c.file}?: ${ref}})`,
353
+ ` // TODO(graft): add the events this host's own refusal turns on — whatever`,
354
+ ` // moves it into a state where attachments may not be changed.`,
355
+ ``,
356
+ ...single
357
+ ? [
358
+ `// One reference field, on ${n.attachCmd}, and it accepts a new file — so`,
359
+ `// it is typed as the uploadable it is and a form binds an upload input to it.`,
360
+ `// Neither other command names a reference: with one attachment there is`,
361
+ `// nothing to choose between, and asking a caller to name it would be asking`,
362
+ `// them to repeat what the row already says.`,
363
+ ]
364
+ : [
365
+ `// The reference fields divide into two kinds, and the division is the point.`,
366
+ `// The one on ${n.attachCmd} accepts a new file, so it is typed as the`,
367
+ `// uploadable it is and a form binds an upload input to it. The others name a`,
368
+ `// file the row ALREADY holds, so they are typed as selections out of`,
369
+ `// \`${setFieldOf(c)}\` and a form offers those instead of an uploader.`,
370
+ `//`,
371
+ `// Bound once rather than spelled three times: the collection is one answer,`,
372
+ `// and three copies are three chances for one to name a field that has moved.`,
373
+ `let ${selectionBinding} = Reventless.MemberRef.of_(~view="${c.view}", ${contentArgOf(
374
+ c,
375
+ )}~field="${setFieldOf(c)}")`,
376
+ ``,
377
+ ],
378
+ `@schema`,
379
+ `type command =`,
380
+ `${attrs}${n.attachCmd}({ ${c.entityId}: ${idTypeOf(
381
+ c,
382
+ )}, ${c.file}: ${ref}, altText?: string})`,
383
+ // The bounded set's remove names nothing. This is the reported defect in its
384
+ // purest form — the old command asked for an upload in order to delete.
385
+ single
386
+ ? `${attrs}${n.removeCmd}({ ${c.entityId}: ${idTypeOf(c)}})`
387
+ : `${attrs}${n.removeCmd}({ ${c.entityId}: ${idTypeOf(c)}, ${c.file}: ${sel}})`,
388
+ ...single
389
+ ? []
390
+ : [`${attrs}${n.setPrimaryCmd}({ ${c.entityId}: ${idTypeOf(c)}, ${c.file}: ${sel}})`],
391
+ single
392
+ ? `${attrs}${n.setAltTextCmd}({ ${c.entityId}: ${idTypeOf(c)}, altText: string})`
393
+ : `${attrs}${n.setAltTextCmd}({ ${c.entityId}: ${idTypeOf(
359
394
  c,
360
- )}~field="${setFieldOf(c)}")`,
361
- ``,
362
- ]),
363
- `@schema`,
364
- `type command =`,
365
- `${attrs}${n.attachCmd}({ ${c.entityId}: string, ${c.file}: ${ref}, altText?: string})`,
366
- // The bounded set's remove names nothing. This is the reported defect in its
367
- // purest form — the old command asked for an upload in order to delete.
368
- single
369
- ? `${attrs}${n.removeCmd}({ ${c.entityId}: string})`
370
- : `${attrs}${n.removeCmd}({ ${c.entityId}: string, ${c.file}: ${sel}})`,
371
- ...(single ? [] : [`${attrs}${n.setPrimaryCmd}({ ${c.entityId}: string, ${c.file}: ${sel}})`]),
372
- single
373
- ? `${attrs}${n.setAltTextCmd}({ ${c.entityId}: string, altText: string})`
374
- : `${attrs}${n.setAltTextCmd}({ ${c.entityId}: string, ${c.file}: ${sel}, altText: string})`,
375
- ``,
376
- `@schema`,
377
- `type error =`,
378
- ` | ${n.notFound}`,
379
- ` | ${n.notAttached}`,
380
- ` // TODO(graft): add this host's own refusal.`,
381
- ``,
382
- `@schema`,
383
- `type event =`,
384
- ` | ${n.attached}({ ${c.entityId}: string, ${c.file}: ${ref}, altText?: string})`,
385
- ` | ${n.removed}({ ${c.entityId}: string, ${c.file}: ${ref}})`,
386
- ...primaryArm(`${c.entityId}: string, `),
387
- ` | ${n.altTextSet}({ ${c.entityId}: string, ${c.file}: ${ref}, altText: string})`,
388
- ``,
389
- ...commandTransitionBinding(c),
390
- `// The graft's own record of itself. Nothing else survives into a deployed`,
391
- `// plugin — the dependency and the rules alias are source-side — so without`,
392
- `// this a running estate cannot say where this slice came from.`,
393
- `let traits = [TraitAttachments.Attachments.declaration]`,
394
- ``,
395
- ])
395
+ )}, ${c.file}: ${sel}, altText: string})`,
396
+ ``,
397
+ `@schema`,
398
+ `type error =`,
399
+ ` | ${n.notFound}`,
400
+ ` | ${n.notAttached}`,
401
+ ` // TODO(graft): add this host's own refusal.`,
402
+ ``,
403
+ `@schema`,
404
+ `type event =`,
405
+ ` | ${n.attached}({ ${c.entityId}: ${idTypeOf(c)}, ${c.file}: ${ref}, altText?: string})`,
406
+ ` | ${n.removed}({ ${c.entityId}: ${idTypeOf(c)}, ${c.file}: ${ref}})`,
407
+ ...primaryArm(`${c.entityId}: ${idTypeOf(c)}, `),
408
+ ` | ${n.altTextSet}({ ${c.entityId}: ${idTypeOf(c)}, ${c.file}: ${ref}, altText: string})`,
409
+ ` // The member a reader should now show, or none. A conclusion rather than a`,
410
+ ` // decision: most of the moves that change it — a first attachment, a removal`,
411
+ ` // promoting the next — are nobody's choice, so nothing else announces them.`,
412
+ ` | ${n.effectiveChanged}({ ${c.entityId}: ${idTypeOf(c)}, ${c.file}?: ${ref}})`,
413
+ ``,
414
+ ...commandTransitionBinding(c),
415
+ `// The graft's own record of itself. Nothing else survives into a deployed`,
416
+ `// plugin — the dependency and the rules alias are source-side — so without`,
417
+ `// this a running estate cannot say where this slice came from.`,
418
+ `let traits = [TraitAttachments.Attachments.declaration]`,
419
+ ``,
420
+ ],
421
+ )
396
422
  }
397
423
 
398
424
  // ── The slice body ───────────────────────────────────────────────────────────
399
425
 
400
- let sliceBehavior = (c: config): string => {
426
+ let sliceDecision = (c: config): string => {
401
427
  let n = namesOf(c)
402
428
  let single = isSingle(c)
403
- lines([
404
- `@@reventless.behavior`,
405
- ``,
406
- `// The set's rules are the trait's. What is left here is this host's own refusal`,
407
- `// and the mapping between its constructors and the trait's ops and facts.`,
408
- `module Attachments = TraitAttachments.Attachments_Rules`,
409
- ``,
410
- `type state = {exists: bool, attachments: Attachments.t}`,
411
- ``,
412
- `let initialState = {exists: false, attachments: Attachments.empty}`,
413
- ``,
414
- `let evolve = (state, event) => {`,
415
- ` let fold = fact => {...state, attachments: state.attachments->Attachments.evolve(fact)}`,
416
- ` switch event {`,
417
- // A creation event that carries no id is a bare constructor, so a wildcard
418
- // payload does not compile against it. The spec above already branches on
419
- // this; the fold has to branch with it.
420
- c.createdCarriesEntityId->Option.getOr(true)
421
- ? ` | ${c.created}(_) => {...state, exists: true}`
422
- : ` | ${c.created} => {...state, exists: true}`,
423
- ` | ${n.attached}({${c.file}}) => fold(Attached({ref: ${c.file}, altText: None}))`,
424
- ` | ${n.removed}({${c.file}}) => fold(Removed({ref: ${c.file}}))`,
425
- ...(single
426
- ? []
427
- : [` | ${n.primarySet}({${c.file}}) => fold(PrimarySet({ref: ${c.file}}))`]),
428
- ` | ${n.altTextSet}({${c.file}, altText}) => fold(AltTextSet({ref: ${c.file}, altText}))`,
429
- ` // TODO(graft): fold this host's own events into its own state.`,
430
- ` }`,
431
- `}`,
432
- ``,
433
- `let toOp = command =>`,
434
- ` switch command {`,
435
- ` | ${n.attachCmd}({${c.entityId}, ${c.file}, altText: ?altText}) => (`,
436
- ` ${c.entityId},`,
437
- ` Attachments.Attach({ref: ${c.file}, altText}),`,
438
- ` )`,
439
- // `Clear` is what a ref-less remove maps onto: the op that empties the set,
440
- // whatever it holds. Nothing here has to look the member up.
441
- ...(single
442
- ? [` | ${n.removeCmd}({${c.entityId}}) => (${c.entityId}, Attachments.Clear)`]
443
- : [
444
- ` | ${n.removeCmd}({${c.entityId}, ${c.file}}) => (`,
445
- ` ${c.entityId},`,
446
- ` Attachments.Remove({ref: ${c.file}}),`,
447
- ` )`,
448
- ` | ${n.setPrimaryCmd}({${c.entityId}, ${c.file}}) => (`,
449
- ` ${c.entityId},`,
450
- ` Attachments.SetPrimary({ref: ${c.file}}),`,
451
- ` )`,
452
- ]),
453
- ...(single
454
- ? [
455
- ` | ${n.setAltTextCmd}({${c.entityId}, altText}) => (`,
456
- ` ${c.entityId},`,
457
- // Not punned: a single-field inline record whose field shares its name
458
- // with the variable filling it is read as a record copy, and the
459
- // anonymous type then escapes its constructor.
460
- ` Attachments.SetPrimaryAltText({altText: altText}),`,
461
- ` )`,
462
- ]
463
- : [
464
- ` | ${n.setAltTextCmd}({${c.entityId}, ${c.file}, altText}) => (`,
465
- ` ${c.entityId},`,
466
- ` Attachments.SetAltText({ref: ${c.file}, altText}),`,
467
- ` )`,
468
- ]),
469
- ` }`,
470
- ``,
471
- // `Some`/`None` for a bounded set only. `fact` is the trait's type and so
472
- // lists a primary at both cardinalities, but a graft with no primary command
473
- // can never decide one — and an arm that fabricated some other event to keep
474
- // the switch total would be writing a fact nothing happened.
475
- `let toEvent = (${c.entityId}, fact) =>`,
476
- ` switch fact {`,
477
- ` | Attachments.Attached({ref, altText}) =>`,
478
- ` ${single ? "Some(" : ""}${n.attached}({${c.entityId}, ${c.file}: ref, altText: ?altText})${single
479
- ? ")"
480
- : ""}`,
481
- ` | Attachments.Removed({ref}) => ${single
482
- ? `Some(${n.removed}({${c.entityId}, ${c.file}: ref}))`
483
- : `${n.removed}({${c.entityId}, ${c.file}: ref})`}`,
484
- ...(single
485
- ? [
486
- ` // Unreachable: no command of this graft chooses a primary, because a set`,
487
- ` // of one has nothing to choose between. It contributes no event.`,
488
- ` | Attachments.PrimarySet(_) => None`,
489
- ]
490
- : [` | Attachments.PrimarySet({ref}) => ${n.primarySet}({${c.entityId}, ${c.file}: ref})`]),
491
- ` | Attachments.AltTextSet({ref, altText}) =>`,
492
- ` ${single ? "Some(" : ""}${n.altTextSet}({${c.entityId}, ${c.file}: ref, altText})${single
493
- ? ")"
494
- : ""}`,
495
- ` }`,
496
- ``,
497
- `let decide = (state, command) =>`,
498
- ` if !state.exists {`,
499
- ` Error(${n.notFound})`,
500
- ` } else {`,
501
- ` // TODO(graft): this host's own refusal goes here, ahead of the set's rules —`,
502
- ` // an \`else if\` returning the error added above. A graft with no extra refusal`,
503
- ` // is a complete graft, so leaving this is legitimate.`,
504
- ` let (${c.entityId}, op) = toOp(command)`,
505
- single
506
- ? ` switch state.attachments->Attachments.decide(~cardinality=Single, op) {`
507
- : ` switch state.attachments->Attachments.decide(op) {`,
508
- ` | Error(#NotAttached) => Error(${n.notAttached})`,
509
- single
510
- ? ` | Ok(facts) => Ok(facts->Array.filterMap(toEvent(${c.entityId}, _)))`
511
- : ` | Ok(facts) => Ok(facts->Array.map(toEvent(${c.entityId}, _)))`,
512
- ` }`,
513
- ` }`,
514
- ``,
515
- ])
429
+ lines(
430
+ [
431
+ `@@reventless.decision`,
432
+ ``,
433
+ `// The set's rules are the trait's. What is left here is this host's own refusal`,
434
+ `// and the mapping between its constructors and the trait's ops and facts.`,
435
+ `module Attachments = TraitAttachments.Attachments_Rules`,
436
+ ``,
437
+ `type state = {exists: bool, attachments: Attachments.t}`,
438
+ ``,
439
+ `let initialState = {exists: false, attachments: Attachments.empty}`,
440
+ ``,
441
+ `let evolve = (state, event) => {`,
442
+ ` let fold = fact => {...state, attachments: state.attachments->Attachments.evolve(fact)}`,
443
+ ` switch event {`,
444
+ // A creation event that carries no id is a bare constructor, so a wildcard
445
+ // payload does not compile against it. The spec above already branches on
446
+ // this; the fold has to branch with it.
447
+ c.createdCarriesEntityId->Option.getOr(true)
448
+ ? ` | ${c.created}(_) => {...state, exists: true}`
449
+ : ` | ${c.created} => {...state, exists: true}`,
450
+ ` | ${n.attached}({${c.file}}) => fold(Attached({ref: ${c.file}, altText: None}))`,
451
+ ` | ${n.removed}({${c.file}}) => fold(Removed({ref: ${c.file}}))`,
452
+ ...single ? [] : [` | ${n.primarySet}({${c.file}}) => fold(PrimarySet({ref: ${c.file}}))`],
453
+ ` | ${n.altTextSet}({${c.file}, altText}) => fold(AltTextSet({ref: ${c.file}, altText}))`,
454
+ ` | ${n.effectiveChanged}({${c.file}: ?ref}) => fold(EffectiveChanged({ref: ref}))`,
455
+ ` // TODO(graft): fold this host's own events into its own state.`,
456
+ ` }`,
457
+ `}`,
458
+ ``,
459
+ `let toOp = command =>`,
460
+ ` switch command {`,
461
+ ` | ${n.attachCmd}({${c.entityId}, ${c.file}, altText: ?altText}) => (`,
462
+ ` ${c.entityId},`,
463
+ ` Attachments.Attach({ref: ${c.file}, altText}),`,
464
+ ` )`,
465
+ // `Clear` is what a ref-less remove maps onto: the op that empties the set,
466
+ // whatever it holds. Nothing here has to look the member up.
467
+ ...single
468
+ ? [` | ${n.removeCmd}({${c.entityId}}) => (${c.entityId}, Attachments.Clear)`]
469
+ : [
470
+ ` | ${n.removeCmd}({${c.entityId}, ${c.file}}) => (`,
471
+ ` ${c.entityId},`,
472
+ ` Attachments.Remove({ref: ${c.file}}),`,
473
+ ` )`,
474
+ ` | ${n.setPrimaryCmd}({${c.entityId}, ${c.file}}) => (`,
475
+ ` ${c.entityId},`,
476
+ ` Attachments.SetPrimary({ref: ${c.file}}),`,
477
+ ` )`,
478
+ ],
479
+ ...single
480
+ ? [
481
+ ` | ${n.setAltTextCmd}({${c.entityId}, altText}) => (`,
482
+ ` ${c.entityId},`,
483
+ // Not punned: a single-field inline record whose field shares its name
484
+ // with the variable filling it is read as a record copy, and the
485
+ // anonymous type then escapes its constructor.
486
+ ` Attachments.SetPrimaryAltText({altText: altText}),`,
487
+ ` )`,
488
+ ]
489
+ : [
490
+ ` | ${n.setAltTextCmd}({${c.entityId}, ${c.file}, altText}) => (`,
491
+ ` ${c.entityId},`,
492
+ ` Attachments.SetAltText({ref: ${c.file}, altText}),`,
493
+ ` )`,
494
+ ],
495
+ ` }`,
496
+ ``,
497
+ // `Some`/`None` for a bounded set only. `fact` is the trait's type and so
498
+ // lists a primary at both cardinalities, but a graft with no primary command
499
+ // can never decide one — and an arm that fabricated some other event to keep
500
+ // the switch total would be writing a fact nothing happened.
501
+ `let toEvent = (${c.entityId}, fact) =>`,
502
+ ` switch fact {`,
503
+ ` | Attachments.Attached({ref, altText}) =>`,
504
+ ` ${single
505
+ ? "Some("
506
+ : ""}${n.attached}({${c.entityId}, ${c.file}: ref, altText: ?altText})${single
507
+ ? ")"
508
+ : ""}`,
509
+ ` | Attachments.Removed({ref}) => ${single
510
+ ? `Some(${n.removed}({${c.entityId}, ${c.file}: ref}))`
511
+ : `${n.removed}({${c.entityId}, ${c.file}: ref})`}`,
512
+ ...single
513
+ ? [
514
+ ` // Unreachable: no command of this graft chooses a primary, because a set`,
515
+ ` // of one has nothing to choose between. It contributes no event.`,
516
+ ` | Attachments.PrimarySet(_) => None`,
517
+ ]
518
+ : [` | Attachments.PrimarySet({ref}) => ${n.primarySet}({${c.entityId}, ${c.file}: ref})`],
519
+ ` | Attachments.AltTextSet({ref, altText}) =>`,
520
+ ` ${single ? "Some(" : ""}${n.altTextSet}({${c.entityId}, ${c.file}: ref, altText})${single
521
+ ? ")"
522
+ : ""}`,
523
+ ` | Attachments.EffectiveChanged({ref}) =>`,
524
+ ` ${single ? "Some(" : ""}${n.effectiveChanged}({${c.entityId}, ${c.file}: ?ref})${single
525
+ ? ")"
526
+ : ""}`,
527
+ ` }`,
528
+ ``,
529
+ `let decide = (state, command) =>`,
530
+ ` if !state.exists {`,
531
+ ` Error(${n.notFound})`,
532
+ ` } else {`,
533
+ ` // TODO(graft): this host's own refusal goes here, ahead of the set's rules —`,
534
+ ` // an \`else if\` returning the error added above. A graft with no extra refusal`,
535
+ ` // is a complete graft, so leaving this is legitimate.`,
536
+ ` let (${c.entityId}, op) = toOp(command)`,
537
+ single
538
+ ? ` switch state.attachments->Attachments.decide(~cardinality=Single, op) {`
539
+ : ` switch state.attachments->Attachments.decide(op) {`,
540
+ ` | Error(#NotAttached) => Error(${n.notAttached})`,
541
+ single
542
+ ? ` | Ok(facts) => Ok(facts->Array.filterMap(toEvent(${c.entityId}, _)))`
543
+ : ` | Ok(facts) => Ok(facts->Array.map(toEvent(${c.entityId}, _)))`,
544
+ ` }`,
545
+ ` }`,
546
+ ``,
547
+ ],
548
+ )
516
549
  }
517
550
 
518
551
  // ── The conformance binding ──────────────────────────────────────────────────
@@ -529,64 +562,101 @@ let conformanceBinding = (c: config): string => {
529
562
  let refB = c.refB->Option.getOr(`/uploads/00000000-0000-4000-8000-000000000002/b`)
530
563
  let createdValue =
531
564
  c.createdCarriesEntityId->Option.getOr(true)
532
- ? `${c.created}({ ${c.entityId}: "${id}"})`
565
+ ? `${c.created}({ ${c.entityId}: ${idValueOf(c, id)}})`
533
566
  : c.created
534
- lines([
535
- `// The Attachments trait's conformance suite, bound to \`${n.slice}\`.`,
536
- `// Emitted whole: every name here is one the graft already declared.`,
537
- ``,
538
- `module Binding = {`,
539
- ` type ref = string`,
540
- ` let refA = "${refA}"`,
541
- ` let refB = "${refB}"`,
542
- ``,
543
- ` module Spec = ${n.slice}`,
544
- ` module Behavior = ${n.slice}_Behavior`,
545
- ``,
546
- ` // Annotated: the slice consumes and emits same-named constructors.`,
547
- ` let created: array<${n.slice}.consumedEvent> = [${createdValue}]`,
548
- ` let attachedC = (ref): ${n.slice}.consumedEvent => ${n.attached}({ ${c.file}: ref})`,
549
- ` let removedC = (ref): ${n.slice}.consumedEvent => ${n.removed}({ ${c.file}: ref})`,
550
- ...(single
551
- ? []
552
- : [
553
- ` let primarySetC = (ref): ${n.slice}.consumedEvent => ${n.primarySet}({ ${c.file}: ref})`,
554
- ]),
555
- ` let altTextSetC = (ref, altText): ${n.slice}.consumedEvent =>`,
556
- ` ${n.altTextSet}({ ${c.file}: ref, altText})`,
557
- ``,
558
- ` let attach = ref => ${n.slice}.${n.attachCmd}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
559
- ...(single
560
- ? [` let clear = ${n.slice}.${n.removeCmd}({ ${c.entityId}: "${id}"})`]
561
- : [
562
- ` let remove = ref => ${n.slice}.${n.removeCmd}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
563
- ` let setPrimary = ref =>`,
564
- ` ${n.slice}.${n.setPrimaryCmd}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
565
- ]),
566
- single
567
- ? ` let setAltText = altText => ${n.slice}.${n.setAltTextCmd}({ ${c.entityId}: "${id}", altText})`
568
- : ` let setAltText = (ref, altText) =>\n ${n.slice}.${n.setAltTextCmd}({ ${c.entityId}: "${id}", ${c.file}: ref, altText})`,
569
- ``,
570
- ` let attached = ref => ${n.slice}.${n.attached}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
571
- ` let removed = ref => ${n.slice}.${n.removed}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
572
- ...(single
573
- ? []
574
- : [
575
- ` let primarySet = ref =>`,
576
- ` ${n.slice}.${n.primarySet}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
577
- ]),
578
- ` let altTextSet = (ref, altText) =>`,
579
- ` ${n.slice}.${n.altTextSet}({ ${c.entityId}: "${id}", ${c.file}: ref, altText})`,
580
- ` let notAttached = ${n.slice}.${n.notAttached}`,
581
- `}`,
582
- ``,
583
- single
584
- ? `module Conformance = TraitAttachments.Attachments_Conformance.MakeSingle(Binding)`
585
- : `module Conformance = TraitAttachments.Attachments_Conformance.Make(Binding)`,
586
- ``,
587
- `Conformance.register()`,
588
- ``,
589
- ])
567
+ lines(
568
+ [
569
+ `// The Attachments trait's conformance suite, bound to \`${n.slice}\`.`,
570
+ `// Emitted whole: every name here is one the graft already declared.`,
571
+ ``,
572
+ `module Binding = {`,
573
+ ` type ref = string`,
574
+ ` let refA = "${refA}"`,
575
+ ` let refB = "${refB}"`,
576
+ ``,
577
+ ` module Spec = ${n.slice}`,
578
+ ` module Decision = ${n.slice}_Decision`,
579
+ ``,
580
+ ` // Annotated: the slice consumes and emits same-named constructors.`,
581
+ ` // The same facts as the slice consumes them: no ${c.entityId}, because the`,
582
+ ` // partition already says which entity they belong to.`,
583
+ ` module Consumed = {`,
584
+ ` let created: array<${n.slice}.consumedEvent> = [${createdValue}]`,
585
+ ` let attached = (ref): ${n.slice}.consumedEvent => ${n.attached}({ ${c.file}: ref})`,
586
+ ` let removed = (ref): ${n.slice}.consumedEvent => ${n.removed}({ ${c.file}: ref})`,
587
+ ...single
588
+ ? []
589
+ : [
590
+ ` let primarySet = (ref): ${n.slice}.consumedEvent => ${n.primarySet}({ ${c.file}: ref})`,
591
+ ],
592
+ ` let altTextSet = (ref, altText): ${n.slice}.consumedEvent =>`,
593
+ ` ${n.altTextSet}({ ${c.file}: ref, altText})`,
594
+ ` let effectiveChanged = (ref): ${n.slice}.consumedEvent =>`,
595
+ ` ${n.effectiveChanged}({ ${c.file}: ?ref})`,
596
+ ` }`,
597
+ ``,
598
+ ` let attach = ref => ${n.slice}.${n.attachCmd}({ ${c.entityId}: ${idValueOf(
599
+ c,
600
+ id,
601
+ )}, ${c.file}: ref})`,
602
+ ...single
603
+ ? [` let clear = ${n.slice}.${n.removeCmd}({ ${c.entityId}: ${idValueOf(c, id)}})`]
604
+ : [
605
+ ` let remove = ref => ${n.slice}.${n.removeCmd}({ ${c.entityId}: ${idValueOf(
606
+ c,
607
+ id,
608
+ )}, ${c.file}: ref})`,
609
+ ` let setPrimary = ref =>`,
610
+ ` ${n.slice}.${n.setPrimaryCmd}({ ${c.entityId}: ${idValueOf(
611
+ c,
612
+ id,
613
+ )}, ${c.file}: ref})`,
614
+ ],
615
+ single
616
+ ? ` let setAltText = altText => ${n.slice}.${n.setAltTextCmd}({ ${c.entityId}: ${idValueOf(
617
+ c,
618
+ id,
619
+ )}, altText})`
620
+ : ` let setAltText = (ref, altText) =>\n ${n.slice}.${n.setAltTextCmd}({ ${c.entityId}: ${idValueOf(
621
+ c,
622
+ id,
623
+ )}, ${c.file}: ref, altText})`,
624
+ ``,
625
+ ` let attached = ref => ${n.slice}.${n.attached}({ ${c.entityId}: ${idValueOf(
626
+ c,
627
+ id,
628
+ )}, ${c.file}: ref})`,
629
+ ` let removed = ref => ${n.slice}.${n.removed}({ ${c.entityId}: ${idValueOf(
630
+ c,
631
+ id,
632
+ )}, ${c.file}: ref})`,
633
+ ...single
634
+ ? []
635
+ : [
636
+ ` let primarySet = ref =>`,
637
+ ` ${n.slice}.${n.primarySet}({ ${c.entityId}: ${idValueOf(c, id)}, ${c.file}: ref})`,
638
+ ],
639
+ ` let altTextSet = (ref, altText) =>`,
640
+ ` ${n.slice}.${n.altTextSet}({ ${c.entityId}: ${idValueOf(
641
+ c,
642
+ id,
643
+ )}, ${c.file}: ref, altText})`,
644
+ ` let effectiveChanged = ref =>`,
645
+ ` ${n.slice}.${n.effectiveChanged}({ ${c.entityId}: ${idValueOf(
646
+ c,
647
+ id,
648
+ )}, ${c.file}: ?ref})`,
649
+ ` let notAttached = ${n.slice}.${n.notAttached}`,
650
+ `}`,
651
+ ``,
652
+ single
653
+ ? `module Conformance = TraitAttachments.Attachments_Conformance.MakeSingle(Binding)`
654
+ : `module Conformance = TraitAttachments.Attachments_Conformance.Make(Binding)`,
655
+ ``,
656
+ `Conformance.register()`,
657
+ ``,
658
+ ],
659
+ )
590
660
  }
591
661
 
592
662
  // ── The projection patch ─────────────────────────────────────────────────────
@@ -607,7 +677,7 @@ let conformanceBinding = (c: config): string => {
607
677
  let singleProjectionPatch = (c: config): patch => {
608
678
  let n = namesOf(c)
609
679
  {
610
- into: `StateViewSliceStream/${c.view}_Projection.res`,
680
+ into: `StateViewStream/${c.view}_Projection.res`,
611
681
  at: `the projection's \`switch\`, and one field on \`${c.view}\`'s state`,
612
682
  contents: lines([
613
683
  `// On the view's state, one field — the reference and its text, in one value.`,
@@ -647,7 +717,7 @@ let manyProjectionPatch = (c: config): patch => {
647
717
  let n = namesOf(c)
648
718
  let set = setFieldOf(c)
649
719
  {
650
- into: `StateViewSliceStream/${c.view}_Projection.res`,
720
+ into: `StateViewStream/${c.view}_Projection.res`,
651
721
  at: `the projection's \`switch\`, and one field on \`${c.view}\`'s state`,
652
722
  contents: lines([
653
723
  `// On the view's state, one field — the set, primary first.`,
@@ -716,10 +786,10 @@ let emit = (~config: config, ~into: string, ~tests: string): output => {
716
786
  let n = namesOf(config)
717
787
  {
718
788
  files: [
719
- {path: `${into}/StateChangeSlice/${n.slice}.res`, contents: sliceSpec(config)},
789
+ {path: `${into}/StateChange/${n.slice}.res`, contents: sliceSpec(config)},
720
790
  {
721
- path: `${into}/StateChangeSlice/${n.slice}_Behavior.res`,
722
- contents: sliceBehavior(config),
791
+ path: `${into}/StateChange/${n.slice}_Decision.res`,
792
+ contents: sliceDecision(config),
723
793
  },
724
794
  {
725
795
  path: `${tests}/${n.slice}Conformance_GWT.res`,