@reventlessdev/trait-attachments 1.0.0-alpha.1 → 1.0.0-alpha.10
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/package.json +3 -3
- 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 +495 -193
- package/src/Attachments_Scaffold.res.mjs +390 -156
|
@@ -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
|
-
|
|
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.
|
|
137
|
-
//
|
|
138
|
-
//
|
|
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
|
|
150
|
-
// matched as strings at
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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 = ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: `
|
|
392
|
-
at: `the projection's \`switch\`, and
|
|
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,
|
|
395
|
-
`//
|
|
396
|
-
`//
|
|
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}
|
|
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
|
-
`
|
|
404
|
-
`
|
|
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
|
-
`
|
|
409
|
-
`
|
|
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 =>
|
|
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
|
-
` ${
|
|
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
|
-
`//
|
|
422
|
-
`// the
|
|
423
|
-
|
|
424
|
-
`
|
|
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
|
-
` ~
|
|
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}/
|
|
744
|
+
{path: `${into}/StateChange/${n.slice}.res`, contents: sliceSpec(config)},
|
|
443
745
|
{
|
|
444
|
-
path: `${into}/
|
|
746
|
+
path: `${into}/StateChange/${n.slice}_Behavior.res`,
|
|
445
747
|
contents: sliceBehavior(config),
|
|
446
748
|
},
|
|
447
749
|
{
|