@reventlessdev/trait-attachments 1.0.0-alpha.2 → 1.0.0-alpha.21
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 +5 -3
- package/package.json +7 -7
- package/src/Attachments.res +83 -8
- package/src/Attachments_Conformance.res +131 -12
- package/src/Attachments_Conformance.res.mjs +79 -10
- package/src/Attachments_Rules.res +167 -17
- package/src/Attachments_Rules.res.mjs +113 -36
- package/src/Attachments_Scaffold.res +537 -190
- package/src/Attachments_Scaffold.res.mjs +412 -162
|
@@ -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"`.
|
|
@@ -51,6 +53,18 @@ type config = {
|
|
|
51
53
|
noun: string,
|
|
52
54
|
/** The attachment field, named for the store it draws from: `"productImage"`. */
|
|
53
55
|
file: string,
|
|
56
|
+
/**
|
|
57
|
+
How many attachments this host's entity may hold. Absent ⇒ `Many`, so a graft
|
|
58
|
+
written before this key existed emits exactly what it emitted before.
|
|
59
|
+
|
|
60
|
+
It is not one rule among the emitted text: it changes which commands exist.
|
|
61
|
+
`Single` emits `Set{Entity}{Noun}` (which replaces), a `Remove{Entity}{Noun}`
|
|
62
|
+
that names no reference — there is only one, and asking a caller to name it is
|
|
63
|
+
asking them to repeat what the row already says — and no primary command at
|
|
64
|
+
all, because a set of one has nothing to choose between. The view field
|
|
65
|
+
changes with them: one captioned image, not an array of them.
|
|
66
|
+
*/
|
|
67
|
+
cardinality?: Attachments_Rules.cardinality,
|
|
54
68
|
/** The host event that brings the entity into existence: `"ProductAdded"`. */
|
|
55
69
|
created: string,
|
|
56
70
|
/** Whether that event carries the entity id. `false` for a payload-less
|
|
@@ -58,6 +72,17 @@ type config = {
|
|
|
58
72
|
createdCarriesEntityId?: bool,
|
|
59
73
|
/** The view whose lifecycle states `transition` names: `"Products"`. */
|
|
60
74
|
view: string,
|
|
75
|
+
/**
|
|
76
|
+
The name of that view's lifecycle enum *type*: `"shelfStatus"`. Only read when
|
|
77
|
+
`transition` is given, because only then is a `lifecycleState` binding emitted.
|
|
78
|
+
|
|
79
|
+
Carried rather than derived: the enum is a type on the host's view and nothing
|
|
80
|
+
in the other names reaches it — `Products` has a `shelfStatus`, but a host may
|
|
81
|
+
call its own `state`, `stage` or `phase`. Absent leaves a `TODO(graft)` marker
|
|
82
|
+
in its place, which does not compile; that is deliberate, and better than a
|
|
83
|
+
guess that compiles against the wrong type.
|
|
84
|
+
*/
|
|
85
|
+
lifecycleType?: string,
|
|
61
86
|
/** The semantic type of the reference. `Reventless.UploadableImage.t` unless
|
|
62
87
|
the host attaches something other than pictures. */
|
|
63
88
|
refType?: string,
|
|
@@ -103,22 +128,34 @@ type names = {
|
|
|
103
128
|
attached: string,
|
|
104
129
|
removed: string,
|
|
105
130
|
primarySet: string,
|
|
131
|
+
effectiveChanged: string,
|
|
106
132
|
altTextSet: string,
|
|
107
133
|
notFound: string,
|
|
108
134
|
notAttached: string,
|
|
109
135
|
}
|
|
110
136
|
|
|
137
|
+
let cardinalityOf = (c: config): Attachments_Rules.cardinality => c.cardinality->Option.getOr(Many)
|
|
138
|
+
|
|
139
|
+
let isSingle = (c: config): bool => cardinalityOf(c) == Single
|
|
140
|
+
|
|
111
141
|
let namesOf = (c: config): names => {
|
|
112
142
|
let subject = c.entity ++ c.noun
|
|
113
143
|
{
|
|
114
144
|
slice: subject ++ "s",
|
|
115
|
-
|
|
145
|
+
// `Set` rather than `Attach` for a bounded set: the command replaces what is
|
|
146
|
+
// there, and `Attach` would name the wrong half of what it does. The *event*
|
|
147
|
+
// stays `Attached` at both cardinalities — an image was attached is the fact,
|
|
148
|
+
// and a replacement is that fact preceded by a removal.
|
|
149
|
+
attachCmd: (isSingle(c) ? "Set" : "Attach") ++ subject,
|
|
116
150
|
removeCmd: "Remove" ++ subject,
|
|
117
151
|
setPrimaryCmd: "SetPrimary" ++ subject,
|
|
118
152
|
setAltTextCmd: "Set" ++ subject ++ "AltText",
|
|
119
153
|
attached: subject ++ "Attached",
|
|
120
154
|
removed: subject ++ "Removed",
|
|
121
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",
|
|
122
159
|
altTextSet: subject ++ "AltTextSet",
|
|
123
160
|
notFound: c.entity ++ "NotFound",
|
|
124
161
|
notAttached: subject ++ "NotAttached",
|
|
@@ -127,6 +164,71 @@ let namesOf = (c: config): names => {
|
|
|
127
164
|
|
|
128
165
|
let refTypeOf = (c: config) => c.refType->Option.getOr("Reventless.UploadableImage.t")
|
|
129
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
|
+
|
|
176
|
+
// What the VIEW holds, as against what an event's reference field holds. An
|
|
177
|
+
// event names a file; a view holds that file together with the text that goes
|
|
178
|
+
// with it, because a cell renderer is handed a field and a value and never the
|
|
179
|
+
// row — so a caption in a sibling field is a caption no cell can draw.
|
|
180
|
+
//
|
|
181
|
+
// Read off the reference type by name, as `contentArgOf` is. A host attaching
|
|
182
|
+
// something the vocabulary has no composite for keeps the bare reference, which
|
|
183
|
+
// is the shape it has today rather than a guess at one it does not.
|
|
184
|
+
let viewTypeOf = (c: config): string =>
|
|
185
|
+
refTypeOf(c)->String.includes("Image") ? "Reventless.CaptionedImage.t" : refTypeOf(c)
|
|
186
|
+
|
|
187
|
+
// The collection field a member-selecting command picks out of. Named for the
|
|
188
|
+
// plural of the attachment field, which is what the view calls it.
|
|
189
|
+
let setFieldOf = (c: config): string => c.file ++ "s"
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
The type a command field takes when it means *select one of the ones I have*
|
|
193
|
+
rather than *here is a new file*.
|
|
194
|
+
|
|
195
|
+
This is the distinction the whole graft used to be unable to make. Remove,
|
|
196
|
+
choose-primary and caption all name a reference the row already holds, and all
|
|
197
|
+
three were typed as the uploadable they select among — so a UI reading the
|
|
198
|
+
declaration bound an upload input to each of them, which on a remove command
|
|
199
|
+
offers the caller the one thing it cannot do.
|
|
200
|
+
|
|
201
|
+
`Single` declares no such field at all, so this is only ever reached for the
|
|
202
|
+
commands where a choice genuinely exists.
|
|
203
|
+
|
|
204
|
+
Spelled as a reference to a binding rather than inline: an `@s.matches(…)`
|
|
205
|
+
attribute has to fit on one line to parse, and the call with both names in it
|
|
206
|
+
does not — which is also the better shape, since the collection is one answer
|
|
207
|
+
and three inline copies are three chances for one to name a field that has moved.
|
|
208
|
+
*/
|
|
209
|
+
let selectionBinding = "selected"
|
|
210
|
+
|
|
211
|
+
let selectionTypeOf = (_c: config): string => `@s.matches(${selectionBinding}) string`
|
|
212
|
+
|
|
213
|
+
// What the members are, as the selection's own declaration states it — so a form
|
|
214
|
+
// drawing a picker shows thumbnails rather than a list of paths, without having
|
|
215
|
+
// to reach the collection field's element type.
|
|
216
|
+
//
|
|
217
|
+
// Read off the reference type by name, which is the only thing this module ever
|
|
218
|
+
// does. A host attaching something the vocabulary has no word for gets no
|
|
219
|
+
// content stated, and a reader falls back to its own rules — which is the honest
|
|
220
|
+
// answer rather than a guess with a default in it.
|
|
221
|
+
let contentArgOf = (c: config): string => {
|
|
222
|
+
let ref = refTypeOf(c)
|
|
223
|
+
if ref->String.includes("Image") {
|
|
224
|
+
`~content=Reventless.Semantic.Id.imageRef, `
|
|
225
|
+
} else if ref->String.includes("File") {
|
|
226
|
+
`~content=Reventless.Semantic.Id.fileRef, `
|
|
227
|
+
} else {
|
|
228
|
+
""
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
130
232
|
// `@authorize` is the host's policy, so an absent one emits nothing at all rather
|
|
131
233
|
// than a permissive default — a graft that silently declared "anyone" would be
|
|
132
234
|
// worse than one that declares nothing.
|
|
@@ -150,14 +252,31 @@ let commandAttributes = (c: config): string =>
|
|
|
150
252
|
// typechecker and matched as strings at assembly; in `Guards([Products.Listed])` they are
|
|
151
253
|
// constructor references the compiler resolves, so a config typo is a build
|
|
152
254
|
// error naming it. Same input, and the difference is only who checks it.
|
|
153
|
-
|
|
255
|
+
// Every command this graft declares, in declaration order. `SetPrimary` is
|
|
256
|
+
// absent for a bounded set — the one thing that changes the *shape* of the
|
|
257
|
+
// emitted surface rather than a rule inside it — and every emission below reads
|
|
258
|
+
// this list rather than repeating the condition.
|
|
259
|
+
let commandNames = (c: config): array<string> => {
|
|
154
260
|
let n = namesOf(c)
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
261
|
+
isSingle(c)
|
|
262
|
+
? [n.attachCmd, n.removeCmd, n.setAltTextCmd]
|
|
263
|
+
: [n.attachCmd, n.removeCmd, n.setPrimaryCmd, n.setAltTextCmd]
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
let commandTransitionBinding = (c: config): array<string> => {
|
|
267
|
+
let arms = commandNames(c)->Array.map(cmd => ` | ${cmd}(_)`)->Array.join("\n")
|
|
268
|
+
// The enum's *type*, which `lifecycleType` names. Absent leaves the marker,
|
|
269
|
+
// and the marker does not compile — which is the honest outcome for a graft
|
|
270
|
+
// that declared the states its commands are legal in and not the type they
|
|
271
|
+
// are constructors of. A guess here would compile against whatever enum the
|
|
272
|
+
// view happens to have first.
|
|
273
|
+
let lifecycleType = switch c.lifecycleType {
|
|
274
|
+
| Some(t) => `${c.view}.${t}`
|
|
275
|
+
| None => `${c.view}.<lifecycle> // TODO(graft): the enum's name`
|
|
276
|
+
}
|
|
158
277
|
switch c.transition {
|
|
159
278
|
| Some(states) => [
|
|
160
|
-
`type lifecycleState = ${
|
|
279
|
+
`type lifecycleState = ${lifecycleType}`,
|
|
161
280
|
`let commandTransition = (command: command): Reventless.Transition.t<lifecycleState> => {`,
|
|
162
281
|
` open Reventless.Transition`,
|
|
163
282
|
` switch command {`,
|
|
@@ -192,133 +311,241 @@ let lines = (ls: array<string>) => ls->Array.join("\n")
|
|
|
192
311
|
let sliceSpec = (c: config): string => {
|
|
193
312
|
let n = namesOf(c)
|
|
194
313
|
let ref = refTypeOf(c)
|
|
314
|
+
let sel = selectionTypeOf(c)
|
|
315
|
+
let single = isSingle(c)
|
|
195
316
|
let attrs = commandAttributes(c)
|
|
196
317
|
let createdArm =
|
|
197
318
|
c.createdCarriesEntityId->Option.getOr(true)
|
|
198
|
-
? ` | ${c.created}({ ${c.entityId}:
|
|
319
|
+
? ` | ${c.created}({ ${c.entityId}: ${idTypeOf(c)}})`
|
|
199
320
|
: ` | ${c.created}`
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
321
|
+
// Present only where a choice exists. A bounded set has one member, so
|
|
322
|
+
// nothing consumes or emits a primary — and the trait's rules never produce
|
|
323
|
+
// the fact, so listing the event would declare one nothing can write.
|
|
324
|
+
let primaryArm = (prefix: string) =>
|
|
325
|
+
single ? [] : [` | ${n.primarySet}({ ${prefix}${c.file}: ${ref}})`]
|
|
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(
|
|
394
|
+
c,
|
|
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
|
+
)
|
|
247
422
|
}
|
|
248
423
|
|
|
249
424
|
// ── The slice body ───────────────────────────────────────────────────────────
|
|
250
425
|
|
|
251
426
|
let sliceBehavior = (c: config): string => {
|
|
252
427
|
let n = namesOf(c)
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
428
|
+
let single = isSingle(c)
|
|
429
|
+
lines(
|
|
430
|
+
[
|
|
431
|
+
`@@reventless.behavior`,
|
|
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
|
+
)
|
|
322
549
|
}
|
|
323
550
|
|
|
324
551
|
// ── The conformance binding ──────────────────────────────────────────────────
|
|
@@ -329,54 +556,107 @@ let sliceBehavior = (c: config): string => {
|
|
|
329
556
|
|
|
330
557
|
let conformanceBinding = (c: config): string => {
|
|
331
558
|
let n = namesOf(c)
|
|
559
|
+
let single = isSingle(c)
|
|
332
560
|
let id = "e1"
|
|
333
561
|
let refA = c.refA->Option.getOr(`/uploads/00000000-0000-4000-8000-000000000001/a`)
|
|
334
562
|
let refB = c.refB->Option.getOr(`/uploads/00000000-0000-4000-8000-000000000002/b`)
|
|
335
563
|
let createdValue =
|
|
336
564
|
c.createdCarriesEntityId->Option.getOr(true)
|
|
337
|
-
? `${c.created}({ ${c.entityId}:
|
|
565
|
+
? `${c.created}({ ${c.entityId}: ${idValueOf(c, id)}})`
|
|
338
566
|
: c.created
|
|
339
|
-
lines(
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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 Behavior = ${n.slice}_Behavior`,
|
|
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
|
+
)
|
|
380
660
|
}
|
|
381
661
|
|
|
382
662
|
// ── The projection patch ─────────────────────────────────────────────────────
|
|
@@ -385,50 +665,117 @@ let conformanceBinding = (c: config): string => {
|
|
|
385
665
|
// `switch` the host wrote. Placing an arm in it is the one part of a graft this
|
|
386
666
|
// module deliberately does not automate.
|
|
387
667
|
|
|
388
|
-
|
|
668
|
+
// The bounded set's projection, which is a different patch rather than the same
|
|
669
|
+
// one with a branch in it: with one attachment the view carries the value itself
|
|
670
|
+
// and no collection at all, so there is no set to fold over and no primary to
|
|
671
|
+
// put first. Three assignments.
|
|
672
|
+
//
|
|
673
|
+
// The removal and the caption both guard on the reference they name. A
|
|
674
|
+
// replacement decides two facts — the old leaves, then the new arrives — and the
|
|
675
|
+
// guard is what makes each arm depend only on the row it finds rather than on
|
|
676
|
+
// those two reaching the projection in the order they were decided.
|
|
677
|
+
let singleProjectionPatch = (c: config): patch => {
|
|
389
678
|
let n = namesOf(c)
|
|
390
679
|
{
|
|
391
|
-
into: `
|
|
392
|
-
at: `the projection's \`switch\`, and
|
|
680
|
+
into: `StateViewStream/${c.view}_Projection.res`,
|
|
681
|
+
at: `the projection's \`switch\`, and one field on \`${c.view}\`'s state`,
|
|
393
682
|
contents: lines([
|
|
394
|
-
`// On the view's state,
|
|
395
|
-
`//
|
|
396
|
-
`//
|
|
683
|
+
`// On the view's state, one field — the reference and its text, in one value.`,
|
|
684
|
+
`// No collection: this entity holds one attachment, so the field a card, a`,
|
|
685
|
+
`// gallery tile and a list cell read IS the whole of what it has.`,
|
|
397
686
|
`//`,
|
|
398
|
-
`// ${c.file}
|
|
399
|
-
`// ${c.file}?: string,`,
|
|
687
|
+
`// ${c.file}?: ${viewTypeOf(c)},`,
|
|
400
688
|
``,
|
|
401
689
|
`| ${n.attached}({${c.entityId}, ${c.file}, altText: ?altText}) =>`,
|
|
402
690
|
` Update(${c.entityId}, state => {`,
|
|
403
|
-
`
|
|
404
|
-
`
|
|
691
|
+
` ...state,`,
|
|
692
|
+
` ${c.file}: {ref: ${c.file}, altText: ?altText},`,
|
|
405
693
|
` })`,
|
|
406
694
|
`| ${n.removed}({${c.entityId}, ${c.file}}) =>`,
|
|
695
|
+
` Update(${c.entityId}, state =>`,
|
|
696
|
+
` // Guarded on the reference: a removal that names something this row no`,
|
|
697
|
+
` // longer holds — the first half of a replacement, arriving late — must not`,
|
|
698
|
+
` // blank the one it does.`,
|
|
699
|
+
` heldRef(state) == Some(${c.file}) ? {...state, ${c.file}: ?None} : state`,
|
|
700
|
+
` )`,
|
|
701
|
+
`| ${n.altTextSet}({${c.entityId}, ${c.file}, altText}) =>`,
|
|
702
|
+
` Update(${c.entityId}, state =>`,
|
|
703
|
+
` switch state.${c.file} {`,
|
|
704
|
+
` | Some(held) if held.ref == ${c.file} => {...state, ${c.file}: {...held, altText}}`,
|
|
705
|
+
` | _ => state`,
|
|
706
|
+
` }`,
|
|
707
|
+
` )`,
|
|
708
|
+
``,
|
|
709
|
+
`// The reference this row holds, if it holds one. Named because both guards`,
|
|
710
|
+
`// above ask the same question of a value that is no longer the reference itself.`,
|
|
711
|
+
`let heldRef = (state: ${c.view}.state) => state.${c.file}->Option.map(held => held.ref)`,
|
|
712
|
+
]),
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
let manyProjectionPatch = (c: config): patch => {
|
|
717
|
+
let n = namesOf(c)
|
|
718
|
+
let set = setFieldOf(c)
|
|
719
|
+
{
|
|
720
|
+
into: `StateViewStream/${c.view}_Projection.res`,
|
|
721
|
+
at: `the projection's \`switch\`, and one field on \`${c.view}\`'s state`,
|
|
722
|
+
contents: lines([
|
|
723
|
+
`// On the view's state, one field — the set, primary first.`,
|
|
724
|
+
`//`,
|
|
725
|
+
`// The primary is the FIRST member rather than a scalar beside the set. That is`,
|
|
726
|
+
`// what a card, a gallery tile and a list cell read, so there is no second field`,
|
|
727
|
+
`// to keep in step with the set and no arm that can forget to. The text rides`,
|
|
728
|
+
`// inside each member for the same reason: a cell renderer is handed a field and`,
|
|
729
|
+
`// a value and never the row, so a caption in a sibling field is one no cell can`,
|
|
730
|
+
`// draw.`,
|
|
731
|
+
`//`,
|
|
732
|
+
`// What it costs, stated plainly: attachment order stops being readable off the`,
|
|
733
|
+
`// view. The log still has it.`,
|
|
734
|
+
`//`,
|
|
735
|
+
`// ${set}: array<${viewTypeOf(c)}>,`,
|
|
736
|
+
``,
|
|
737
|
+
`// Appended, so the first attached is the primary until one is chosen.`,
|
|
738
|
+
`| ${n.attached}({${c.entityId}, ${c.file}, altText: ?altText}) =>`,
|
|
739
|
+
` Update(${c.entityId}, state =>`,
|
|
740
|
+
` state.${set}->Array.some(m => m.ref == ${c.file})`,
|
|
741
|
+
` ? state`,
|
|
742
|
+
` : {`,
|
|
743
|
+
` ...state,`,
|
|
744
|
+
` ${set}: state.${set}->Array.concat([{ref: ${c.file}, altText: ?altText}]),`,
|
|
745
|
+
` }`,
|
|
746
|
+
` )`,
|
|
747
|
+
`// Removing the head promotes the next member with no arm to say so.`,
|
|
748
|
+
`| ${n.removed}({${c.entityId}, ${c.file}}) =>`,
|
|
407
749
|
` Update(${c.entityId}, state => {`,
|
|
408
|
-
`
|
|
409
|
-
`
|
|
750
|
+
` ...state,`,
|
|
751
|
+
` ${set}: state.${set}->Array.filter(m => m.ref != ${c.file}),`,
|
|
410
752
|
` })`,
|
|
411
753
|
`| ${n.primarySet}({${c.entityId}, ${c.file}}) =>`,
|
|
412
|
-
` Update(${c.entityId}, state =>
|
|
754
|
+
` Update(${c.entityId}, state => primaryFirst(state, ${c.file}))`,
|
|
413
755
|
`| ${n.altTextSet}({${c.entityId}, ${c.file}, altText}) =>`,
|
|
414
756
|
` Update(${c.entityId}, state => {`,
|
|
415
757
|
` ...state,`,
|
|
416
|
-
` ${
|
|
417
|
-
` m.${c.file} == ${c.file} ? {...m, altText} : m`,
|
|
418
|
-
` ),`,
|
|
758
|
+
` ${set}: state.${set}->Array.map(m => m.ref == ${c.file} ? {...m, altText} : m),`,
|
|
419
759
|
` })`,
|
|
420
760
|
``,
|
|
421
|
-
`//
|
|
422
|
-
`// the
|
|
423
|
-
|
|
424
|
-
`
|
|
761
|
+
`// Choosing the primary is moving it to the front — the trait's rule, applied`,
|
|
762
|
+
`// over the view's rows. The only arm that reorders; the rest leave the head`,
|
|
763
|
+
`// where they found it.`,
|
|
764
|
+
`let primaryFirst = (state: ${c.view}.state, chosen) => {`,
|
|
765
|
+
` ...state,`,
|
|
766
|
+
` ${set}: TraitAttachments.Attachments_Rules.primaryFirst(`,
|
|
425
767
|
` ~chosen,`,
|
|
426
|
-
` ~
|
|
427
|
-
`
|
|
768
|
+
` ~members=state.${set},`,
|
|
769
|
+
` ~ref=m => m.ref,`,
|
|
770
|
+
` ),`,
|
|
771
|
+
`}`,
|
|
428
772
|
]),
|
|
429
773
|
}
|
|
430
774
|
}
|
|
431
775
|
|
|
776
|
+
let projectionPatch = (c: config): patch =>
|
|
777
|
+
isSingle(c) ? singleProjectionPatch(c) : manyProjectionPatch(c)
|
|
778
|
+
|
|
432
779
|
/**
|
|
433
780
|
Emit a graft.
|
|
434
781
|
|
|
@@ -439,9 +786,9 @@ let emit = (~config: config, ~into: string, ~tests: string): output => {
|
|
|
439
786
|
let n = namesOf(config)
|
|
440
787
|
{
|
|
441
788
|
files: [
|
|
442
|
-
{path: `${into}/
|
|
789
|
+
{path: `${into}/StateChange/${n.slice}.res`, contents: sliceSpec(config)},
|
|
443
790
|
{
|
|
444
|
-
path: `${into}/
|
|
791
|
+
path: `${into}/StateChange/${n.slice}_Behavior.res`,
|
|
445
792
|
contents: sliceBehavior(config),
|
|
446
793
|
},
|
|
447
794
|
{
|