@reventlessdev/reventless-core 3.0.0-alpha.284 → 3.0.0-alpha.286
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/CHANGELOG.md +23 -0
- package/package.json +7 -7
- package/src/Message.res.mjs +3 -0
- package/src/Roles.res +3 -0
- package/src/Roles.res.mjs +2 -0
- package/src/admin/Platform_AdminGroup.res +12 -0
- package/src/admin/Platform_AdminGroup.res.mjs +23 -0
- package/src/admin/Platform_Admin_Structure.res +14 -10
- package/src/admin/Platform_Admin_Structure.res.mjs +22 -33
- package/src/components/Aggregate/Aggregate_Callback.res +116 -39
- package/src/components/Aggregate/Aggregate_Callback.res.mjs +96 -37
- package/src/components/AutomationSlice/AutomationSlice_Builder.res +2 -1
- package/src/components/CommandGenerator/CommandGenerator_Callback.res +9 -8
- package/src/components/CommandGenerator/CommandGenerator_Callback.res.mjs +10 -8
- package/src/components/CommandTopic/CommandTopic.res.mjs +3 -0
- package/src/components/CommandTopic/CommandTopic_Helpers.res +37 -1
- package/src/components/CommandTopic/CommandTopic_Helpers.res.mjs +29 -0
- package/src/components/Counter/Counter_Builder.res +4 -2
- package/src/components/Dcb/Dcb_Builder.res +9 -6
- package/src/components/Dcb/Dcb_Builder.res.mjs +6 -6
- package/src/components/InboundTranslationSlice/InboundTranslationSlice_Builder.res +2 -1
- package/src/components/OutboundTranslationSlice/OutboundTranslationSlice_Builder.res +2 -1
- package/src/components/QueryDb/QueryDb_Builder.res +1 -1
- package/src/components/QueryDb/QueryDb_Builder.res.mjs +3 -2
- package/src/components/StateChangeSlice/StateChangeSlice_Callback.res +191 -141
- package/src/components/StateChangeSlice/StateChangeSlice_Callback.res.mjs +66 -12
- package/src/components/StateViewSlice/StateViewSlice_Builder.res +1 -0
- package/src/plugin/api/PluginBaseFragment.res +1 -1
- package/src/plugin/api/PluginBaseFragment.res.mjs +2 -1
- package/src/plugin/component/Plugin_Builder.res +7 -3
- package/src/plugin/component/Plugin_Builder.res.mjs +6 -3
- package/src/plugin/component/Plugin_Helpers.res +35 -6
- package/src/plugin/component/Plugin_Helpers.res.mjs +47 -5
- package/src/plugin/component/Plugin_Structure.res +52 -19
- package/src/plugin/component/Plugin_Structure.res.mjs +40 -15
- package/src/plugin/lifecycle/PluginsReadModelSpec.res +1 -1
- package/src/plugin/lifecycle/PluginsReadModelSpec.res.mjs +1 -1
- package/tests/admin/AdminApiSchemaDriftTest.res +9 -3
- package/tests/admin/AdminApiSchemaDriftTest.res.mjs +3 -3
- package/tests/admin/Platform_Admin_StructureTest.res +17 -14
- package/tests/admin/Platform_Admin_StructureTest.res.mjs +31 -19
- package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +8 -72
- package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +9 -39
- package/tests/api/GraphQL_FragmentGeneratorTest.res +1 -1
- package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +1 -1
- package/tests/commandgenerator/OwnerActingTest.res +601 -0
- package/tests/commandgenerator/OwnerActingTest.res.mjs +1018 -0
- package/tests/commandgenerator/OwnerStampingTest.res +90 -0
- package/tests/commandgenerator/OwnerStampingTest.res.mjs +87 -0
- package/tests/commandtopic/CommandOutcomeHookTest.res +1 -0
- package/tests/commandtopic/CommandOutcomeHookTest.res.mjs +11 -1
- package/tests/plugin/PluginStructureAccessTest.res +30 -1
- package/tests/plugin/PluginStructureAccessTest.res.mjs +28 -1
- package/tests/plugin/StateChange/PsGatedCommands.res +9 -1
- package/tests/plugin/StateChange/PsGatedCommands.res.mjs +4 -1
- package/tests/plugin/StateView/PsGatedView.res +1 -1
- package/tests/plugin/StateView/PsGatedView.res.mjs +1 -1
- package/tests/plugin/pluginDefinitionRequiredScalars.txt +5 -0
|
@@ -102,6 +102,13 @@ module Make = (
|
|
|
102
102
|
~eventSchema=Spec.eventSchema,
|
|
103
103
|
)
|
|
104
104
|
|
|
105
|
+
// The consumed events whose `@owner` field names who owns what a command acts
|
|
106
|
+
// on, read off the slice's own schema. Empty for a slice that marks none.
|
|
107
|
+
let ownerFieldsByEventType = Reventless.Owner.fieldNamesByVariant(
|
|
108
|
+
Spec.consumedEventSchema->S.castToUnknown,
|
|
109
|
+
)
|
|
110
|
+
let actsOnOwned = ownerFieldsByEventType->Dict.keysToArray->Array.length > 0
|
|
111
|
+
|
|
105
112
|
// Extracts the entity id of a read event from its own tags, for logging
|
|
106
113
|
// which events the decision model was built from. Prefers this slice's
|
|
107
114
|
// partition key, but consumed events from other sources are tagged by their
|
|
@@ -153,9 +160,11 @@ module Make = (
|
|
|
153
160
|
// Capacity is fixed at 100 entries; a per-slice knob is a future refinement
|
|
154
161
|
// (see the plan's Step 4).
|
|
155
162
|
let projectionCacheCapacity = 100
|
|
163
|
+
// The owners folded so far ride along with the state: a delta read never
|
|
164
|
+
// sees the event that recorded them.
|
|
156
165
|
let projectionCache: Lru.t<
|
|
157
166
|
string,
|
|
158
|
-
(Behavior.state, option<Reventless.DcbTag.sequencePosition>),
|
|
167
|
+
(Behavior.state, option<Reventless.DcbTag.sequencePosition>, array<string>),
|
|
159
168
|
> = Lru.make(~capacity=projectionCacheCapacity)
|
|
160
169
|
|
|
161
170
|
let resetCache = () => projectionCache->Lru.clear
|
|
@@ -227,6 +236,30 @@ module Make = (
|
|
|
227
236
|
| Some(ByEventType(_)) | None => None
|
|
228
237
|
}
|
|
229
238
|
|
|
239
|
+
// The owner fields of a read event, when it lies in this command's own
|
|
240
|
+
// partition. A cross-partition read (a catalog product) never says who owns
|
|
241
|
+
// the thing the command acts on, even if its event marks an owner.
|
|
242
|
+
let ownerFieldsOf = (raw: ReventlessInfra.DcbEventLog.rawSequencedEvent) =>
|
|
243
|
+
switch (partitionTag, entityId) {
|
|
244
|
+
| (Some(pt), Some(eid))
|
|
245
|
+
if actsOnOwned && raw.tags->Reventless.DcbTag.partitionValueOfTags(pt) == eid =>
|
|
246
|
+
ownerFieldsByEventType->Dict.get(raw.eventType)->Option.getOr([])
|
|
247
|
+
| _ => []
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Before `decide`: an owner-marked slice refuses a caller who does not own
|
|
251
|
+
// the partition. `None` lets `decide` run.
|
|
252
|
+
let ownershipRefusal = (owners: array<string>) =>
|
|
253
|
+
actsOnOwned
|
|
254
|
+
? Reventless.OwnerScope.decideActing(
|
|
255
|
+
~claim=command'.meta.callerClaim,
|
|
256
|
+
~recordedOwners=entityId->Option.map(_ => owners),
|
|
257
|
+
)->CommandTopic_Helpers.ownershipRefusal(
|
|
258
|
+
~component=Spec.name,
|
|
259
|
+
~command=cmdJson.commandJson->Message.variantNameOfJson,
|
|
260
|
+
)
|
|
261
|
+
: None
|
|
262
|
+
|
|
230
263
|
// Fail closed: a query that can't be serialized yields `None`, not the empty
|
|
231
264
|
// string. The old `""` fallback made every unserializable query collide on
|
|
232
265
|
// one shared cache slot, so one slice's folded state could seed another's —
|
|
@@ -268,9 +301,9 @@ module Make = (
|
|
|
268
301
|
)
|
|
269
302
|
|
|
270
303
|
let rec attempt = (~retries) => {
|
|
271
|
-
let (baseState, afterPos) = switch seed.contents {
|
|
272
|
-
| Some((state, head)) => (state, head)
|
|
273
|
-
| None => (Behavior.initialState, None)
|
|
304
|
+
let (baseState, afterPos, baseOwners) = switch seed.contents {
|
|
305
|
+
| Some((state, head, owners)) => (state, head, owners)
|
|
306
|
+
| None => (Behavior.initialState, None, [])
|
|
274
307
|
}
|
|
275
308
|
let cacheHit = seed.contents->Option.isSome
|
|
276
309
|
|
|
@@ -291,15 +324,14 @@ module Make = (
|
|
|
291
324
|
|
|
292
325
|
dcbEventLog.readStream(~query, ~after=?afterPos, ~strongConsistency)
|
|
293
326
|
->Stream.map(raw => {
|
|
294
|
-
let
|
|
295
|
-
|
|
296
|
-
~data=raw.data->JSON.Decode.object->Option.getOr(Dict.make()),
|
|
297
|
-
)
|
|
327
|
+
let data = raw.data->JSON.Decode.object->Option.getOr(Dict.make())
|
|
328
|
+
let decoded = decoder.decode(~eventType=raw.eventType, ~data)
|
|
298
329
|
decoded->Option.map(event => (
|
|
299
330
|
event,
|
|
300
331
|
raw.position,
|
|
301
332
|
raw.eventType,
|
|
302
333
|
readEventId(~partitionTag, raw.tags),
|
|
334
|
+
(ownerFieldsOf(raw), data),
|
|
303
335
|
))
|
|
304
336
|
})
|
|
305
337
|
->Stream.flatMap(opt =>
|
|
@@ -311,9 +343,9 @@ module Make = (
|
|
|
311
343
|
// The head accumulator starts at `afterPos` (the seeded head), not `None`,
|
|
312
344
|
// so an empty delta read keeps the cached head — the append must condition
|
|
313
345
|
// on the head that actually exists, not assert "no events exist yet".
|
|
314
|
-
->Stream.runFold((baseState, afterPos, []), (
|
|
315
|
-
(dm, _pos, reads),
|
|
316
|
-
(event, position, eventType, eventId),
|
|
346
|
+
->Stream.runFold((baseState, afterPos, [], baseOwners), (
|
|
347
|
+
(dm, _pos, reads, owners),
|
|
348
|
+
(event, position, eventType, eventId, (ownerFields, data)),
|
|
317
349
|
) => (
|
|
318
350
|
Behavior.evolve(dm, event),
|
|
319
351
|
Some(position),
|
|
@@ -323,8 +355,9 @@ module Make = (
|
|
|
323
355
|
| None => eventType->LogFormat.bold
|
|
324
356
|
},
|
|
325
357
|
]),
|
|
358
|
+
owners->Reventless.OwnerScope.recordOwners(~fields=ownerFields, data),
|
|
326
359
|
))
|
|
327
|
-
->Effect.tap(((_, _, reads)) => {
|
|
360
|
+
->Effect.tap(((_, _, reads, _)) => {
|
|
328
361
|
// On a warm first attempt, record how many events the delta read
|
|
329
362
|
// returned — near-zero on a healthy hit; a large value means the entity
|
|
330
363
|
// churned between commands, so the cache saved little. Gated to the first
|
|
@@ -347,7 +380,7 @@ module Make = (
|
|
|
347
380
|
: ` [${reads->Array.join(", ")}]`}`,
|
|
348
381
|
)
|
|
349
382
|
})
|
|
350
|
-
->Effect.flatMap(((state, headPosition, _)) =>
|
|
383
|
+
->Effect.flatMap(((state, headPosition, _, owners)) =>
|
|
351
384
|
// Identity, not content: the decision model folds the whole matched
|
|
352
385
|
// history, so serialising it here makes the line's size a function of
|
|
353
386
|
// how long the entity has been in use.
|
|
@@ -357,143 +390,160 @@ module Make = (
|
|
|
357
390
|
"-",
|
|
358
391
|
)} cmd=${cmdJson->LogFormat.cmdName}`,
|
|
359
392
|
)->Effect.flatMap(_ =>
|
|
360
|
-
switch
|
|
361
|
-
|
|
|
362
|
-
//
|
|
363
|
-
cachePut((state, headPosition))
|
|
364
|
-
CommandTopic_Helpers.
|
|
393
|
+
switch ownershipRefusal(owners) {
|
|
394
|
+
| Some({rejected, isDefect}) =>
|
|
395
|
+
// Refused before `decide`; the read snapshot is still valid.
|
|
396
|
+
cachePut((state, headPosition, owners))
|
|
397
|
+
CommandTopic_Helpers.reportRejected(
|
|
365
398
|
~component=Spec.name,
|
|
399
|
+
~cause=AccessRefusal,
|
|
366
400
|
cmdJson.meta.msgId,
|
|
367
|
-
|
|
368
|
-
| Some(eid) => {entityId: eid, eventCount: 0}
|
|
369
|
-
| None => {eventCount: 0}
|
|
370
|
-
},
|
|
401
|
+
rejected,
|
|
371
402
|
)
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
->Array.map(((k, v)) => `${k}:${v->JSON.stringify}`)
|
|
389
|
-
->Array.join(",")
|
|
390
|
-
f == "" ? "" : `({${f}})`
|
|
391
|
-
| _ => ""
|
|
392
|
-
}
|
|
393
|
-
`${LogFormat.bold(e.eventType)}${fields}`
|
|
403
|
+
let line = `refused: ${rejected.errorDetail} id=${entityId->Option.getOr("-")}`
|
|
404
|
+
|
|
405
|
+
(
|
|
406
|
+
isDefect ? EffectLogger.logError(~comp, line) : EffectLogger.logWarn(~comp, line)
|
|
407
|
+
)->Effect.map(_ => Ok("rejected"))
|
|
408
|
+
| None =>
|
|
409
|
+
switch Behavior.decide(state, command'.command) {
|
|
410
|
+
| Ok(newEvents) if newEvents->Array.length == 0 =>
|
|
411
|
+
// No append, but the read snapshot is valid — cache it for the next command.
|
|
412
|
+
cachePut((state, headPosition, owners))
|
|
413
|
+
CommandTopic_Helpers.reportAccepted(
|
|
414
|
+
~component=Spec.name,
|
|
415
|
+
cmdJson.meta.msgId,
|
|
416
|
+
switch entityId {
|
|
417
|
+
| Some(eid) => {entityId: eid, eventCount: 0}
|
|
418
|
+
| None => {eventCount: 0}
|
|
394
419
|
},
|
|
395
420
|
)
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
CommandTopic_Helpers.reportAccepted(
|
|
417
|
-
~component=Spec.name,
|
|
418
|
-
cmdJson.meta.msgId,
|
|
419
|
-
switch entityId {
|
|
420
|
-
| Some(eid) => {entityId: eid, eventCount: rawEvents->Array.length}
|
|
421
|
-
| None => {eventCount: rawEvents->Array.length}
|
|
422
|
-
},
|
|
423
|
-
)
|
|
424
|
-
EffectLogger.logInfo(~comp, `append: ${eventCount} event(s)`)->Effect.map(
|
|
425
|
-
_ => Ok("ok"),
|
|
426
|
-
)
|
|
427
|
-
| Error(err) =>
|
|
428
|
-
// Typed classification (was substring-matching the error string,
|
|
429
|
-
// which disagreed with the backends' casing).
|
|
430
|
-
let (errorCode, errDetail) = switch err {
|
|
431
|
-
| ReventlessInfra.DcbEventLog.Conflict => (
|
|
432
|
-
"Conflict",
|
|
433
|
-
"conflict: condition check failed",
|
|
434
|
-
)
|
|
435
|
-
| StorageFailure(msg) => ("AppendFailed", msg)
|
|
436
|
-
}
|
|
437
|
-
if retries > 0 {
|
|
438
|
-
// Re-seed from the just-read snapshot so the retry reads only
|
|
439
|
-
// the delta the conflicting writer added, not full history.
|
|
440
|
-
seed := Some((state, headPosition))
|
|
441
|
-
// Per-slice CloudWatch counter: rising AppendRetry signals a
|
|
442
|
-
// slice feeling contention (the metric to watch the eventual
|
|
443
|
-
// default with — see dcb-high-contention-handling.md).
|
|
444
|
-
Metrics.emitCount(~metric="AppendRetry", ~slice=Spec.name)
|
|
445
|
-
EffectLogger.logWarn(
|
|
446
|
-
~comp,
|
|
447
|
-
`append failed (retrying ${(maxRetries - retries + 1)
|
|
448
|
-
->Int.toString}/${maxRetries->Int.toString}): ${errDetail}`,
|
|
449
|
-
)->Effect.flatMap(_ => attempt(~retries=retries - 1))
|
|
450
|
-
} else {
|
|
451
|
-
// Retries exhausted — drop any cached snapshot so the next
|
|
452
|
-
// command for this entity takes the cold full-read path.
|
|
453
|
-
cacheInvalidate()
|
|
454
|
-
|
|
455
|
-
// A surfaced Conflict means the 3-retry loop could not absorb
|
|
456
|
-
// the contention — the operator signal for "this slice is hot"
|
|
457
|
-
// (consider sharding / async — Issue 10, §4 of the analysis).
|
|
458
|
-
if errorCode == "Conflict" {
|
|
459
|
-
Metrics.emitCount(~metric="AppendConflict", ~slice=Spec.name)
|
|
421
|
+
EffectLogger.logInfo(~comp, "no events produced")->Effect.map(_ => Ok("ok"))
|
|
422
|
+
| Ok(newEvents) =>
|
|
423
|
+
let rawEvents =
|
|
424
|
+
newEvents->Array.map(
|
|
425
|
+
e => encodeEvent(~parentMeta=command'.meta, ~tagKeysByEventType, e),
|
|
426
|
+
)
|
|
427
|
+
let eventCount = rawEvents->Array.length->Int.toString
|
|
428
|
+
let eventDetails =
|
|
429
|
+
rawEvents
|
|
430
|
+
->Array.map(
|
|
431
|
+
e => {
|
|
432
|
+
let fields = switch e.data {
|
|
433
|
+
| Object(dict) =>
|
|
434
|
+
let f =
|
|
435
|
+
dict
|
|
436
|
+
->Dict.toArray
|
|
437
|
+
->Array.map(((k, v)) => `${k}:${v->JSON.stringify}`)
|
|
438
|
+
->Array.join(",")
|
|
439
|
+
f == "" ? "" : `({${f}})`
|
|
440
|
+
| _ => ""
|
|
460
441
|
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
442
|
+
`${LogFormat.bold(e.eventType)}${fields}`
|
|
443
|
+
},
|
|
444
|
+
)
|
|
445
|
+
->Array.join(", ")
|
|
446
|
+
let eventJsons = rawEvents->Array.map(e => e.data)->JSON.Encode.array
|
|
447
|
+
let condition: Reventless.DcbTag.appendCondition = {
|
|
448
|
+
query,
|
|
449
|
+
after: ?headPosition,
|
|
450
|
+
}
|
|
451
|
+
EffectLogger.logInfo(
|
|
452
|
+
~comp,
|
|
453
|
+
~detail=eventJsons,
|
|
454
|
+
`produced ${eventCount} event(s): [${eventDetails}]`,
|
|
455
|
+
)
|
|
456
|
+
->Effect.flatMap(_ => Effect.promise(() => dcbEventLog.append(rawEvents, ~condition)))
|
|
457
|
+
->Effect.flatMap(
|
|
458
|
+
appendResult =>
|
|
459
|
+
switch appendResult {
|
|
460
|
+
| Ok(_position) =>
|
|
461
|
+
// Cache the decided-on state at the read head (NOT including the
|
|
462
|
+
// events we just appended, and NOT the returned position): the
|
|
463
|
+
// next command's delta read picks our events up from `headPosition`.
|
|
464
|
+
cachePut((state, headPosition, owners))
|
|
465
|
+
CommandTopic_Helpers.reportAccepted(
|
|
465
466
|
~component=Spec.name,
|
|
466
|
-
~cause=InfrastructureFailure,
|
|
467
467
|
cmdJson.meta.msgId,
|
|
468
|
-
|
|
468
|
+
switch entityId {
|
|
469
|
+
| Some(eid) => {entityId: eid, eventCount: rawEvents->Array.length}
|
|
470
|
+
| None => {eventCount: rawEvents->Array.length}
|
|
471
|
+
},
|
|
469
472
|
)
|
|
470
|
-
EffectLogger.
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
473
|
+
EffectLogger.logInfo(~comp, `append: ${eventCount} event(s)`)->Effect.map(
|
|
474
|
+
_ => Ok("ok"),
|
|
475
|
+
)
|
|
476
|
+
| Error(err) =>
|
|
477
|
+
// Typed classification (was substring-matching the error string,
|
|
478
|
+
// which disagreed with the backends' casing).
|
|
479
|
+
let (errorCode, errDetail) = switch err {
|
|
480
|
+
| ReventlessInfra.DcbEventLog.Conflict => (
|
|
481
|
+
"Conflict",
|
|
482
|
+
"conflict: condition check failed",
|
|
483
|
+
)
|
|
484
|
+
| StorageFailure(msg) => ("AppendFailed", msg)
|
|
485
|
+
}
|
|
486
|
+
if retries > 0 {
|
|
487
|
+
// Re-seed from the just-read snapshot so the retry reads only
|
|
488
|
+
// the delta the conflicting writer added, not full history.
|
|
489
|
+
seed := Some((state, headPosition, owners))
|
|
490
|
+
// Per-slice CloudWatch counter: rising AppendRetry signals a
|
|
491
|
+
// slice feeling contention (the metric to watch the eventual
|
|
492
|
+
// default with — see dcb-high-contention-handling.md).
|
|
493
|
+
Metrics.emitCount(~metric="AppendRetry", ~slice=Spec.name)
|
|
494
|
+
EffectLogger.logWarn(
|
|
495
|
+
~comp,
|
|
496
|
+
`append failed (retrying ${(maxRetries - retries + 1)
|
|
497
|
+
->Int.toString}/${maxRetries->Int.toString}): ${errDetail}`,
|
|
498
|
+
)->Effect.flatMap(_ => attempt(~retries=retries - 1))
|
|
499
|
+
} else {
|
|
500
|
+
// Retries exhausted — drop any cached snapshot so the next
|
|
501
|
+
// command for this entity takes the cold full-read path.
|
|
502
|
+
cacheInvalidate()
|
|
503
|
+
|
|
504
|
+
// A surfaced Conflict means the 3-retry loop could not absorb
|
|
505
|
+
// the contention — the operator signal for "this slice is hot"
|
|
506
|
+
// (consider sharding / async — Issue 10, §4 of the analysis).
|
|
507
|
+
if errorCode == "Conflict" {
|
|
508
|
+
Metrics.emitCount(~metric="AppendConflict", ~slice=Spec.name)
|
|
509
|
+
}
|
|
510
|
+
// Both arms here are storage, not the model: a surfaced Conflict is
|
|
511
|
+
// contention the retry loop couldn't absorb, and AppendFailed is the
|
|
512
|
+
// store failing. `decide` had already said Ok.
|
|
513
|
+
CommandTopic_Helpers.reportRejected(
|
|
514
|
+
~component=Spec.name,
|
|
515
|
+
~cause=InfrastructureFailure,
|
|
516
|
+
cmdJson.meta.msgId,
|
|
517
|
+
{errorCode, errorDetail: errDetail},
|
|
518
|
+
)
|
|
519
|
+
EffectLogger.logError(
|
|
520
|
+
~comp,
|
|
521
|
+
`append failed, retries exhausted: ${errDetail}`,
|
|
522
|
+
)->Effect.map(_ => Error(errDetail))
|
|
523
|
+
}
|
|
524
|
+
},
|
|
525
|
+
)
|
|
526
|
+
| Error(error) =>
|
|
527
|
+
// Business-rule rejection — no append, but the read snapshot is valid; cache it.
|
|
528
|
+
cachePut((state, headPosition, owners))
|
|
529
|
+
let errorJson = error->Reventless.Util_Sury.toJson(Spec.errorSchema)
|
|
530
|
+
let errorCode = errorJson->Message.variantNameOfJson
|
|
531
|
+
let (_, payloadDict) = errorJson->Message.splitMessage
|
|
532
|
+
let errorDetail =
|
|
533
|
+
payloadDict->Dict.toArray->Array.length == 0
|
|
534
|
+
? ""
|
|
535
|
+
: payloadDict->JSON.Encode.object->JSON.stringify
|
|
536
|
+
CommandTopic_Helpers.reportRejected(
|
|
537
|
+
~component=Spec.name,
|
|
538
|
+
~cause=DomainRejection,
|
|
539
|
+
cmdJson.meta.msgId,
|
|
540
|
+
{errorCode, errorDetail},
|
|
541
|
+
)
|
|
542
|
+
EffectLogger.logError(
|
|
543
|
+
~comp,
|
|
544
|
+
`decide rejected: ${errorCode} ${errorDetail}`,
|
|
545
|
+
)->Effect.map(_ => Ok("rejected"))
|
|
546
|
+
}
|
|
497
547
|
}
|
|
498
548
|
)
|
|
499
549
|
)
|
|
@@ -8,10 +8,12 @@ import * as Id$Reventless from "@reventlessdev/reventless-spec/src/types/Id.res.
|
|
|
8
8
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
9
9
|
import * as Effect from "effect/Effect";
|
|
10
10
|
import * as Stream$1 from "effect/Stream";
|
|
11
|
+
import * as Owner$Reventless from "@reventlessdev/reventless-spec/src/components/Owner.res.mjs";
|
|
11
12
|
import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
|
|
12
13
|
import * as Lru$ReventlessCore from "../../util/Lru.res.mjs";
|
|
13
14
|
import * as DcbDecode$Reventless from "@reventlessdev/reventless-spec/src/components/DcbDecode.res.mjs";
|
|
14
15
|
import * as Util_Sury$Reventless from "@reventlessdev/reventless-spec/src/util/Util_Sury.res.mjs";
|
|
16
|
+
import * as OwnerScope$Reventless from "@reventlessdev/reventless-spec/src/types/OwnerScope.res.mjs";
|
|
15
17
|
import * as Message$ReventlessCore from "../../Message.res.mjs";
|
|
16
18
|
import * as Metrics$ReventlessCore from "../../util/Metrics.res.mjs";
|
|
17
19
|
import * as LogFormat$ReventlessCore from "../../util/LogFormat.res.mjs";
|
|
@@ -37,6 +39,8 @@ function Make(Spec) {
|
|
|
37
39
|
}
|
|
38
40
|
});
|
|
39
41
|
let scopeShape = DcbTag$Reventless.sliceShapeFromSchemas(Spec.name, Spec.commandSchema, Spec.consumedEventSchema, Spec.eventSchema, undefined);
|
|
42
|
+
let ownerFieldsByEventType = Owner$Reventless.fieldNamesByVariant(Spec.consumedEventSchema);
|
|
43
|
+
let actsOnOwned = Object.keys(ownerFieldsByEventType).length !== 0;
|
|
40
44
|
let readEventId = (partitionTag, tags) => {
|
|
41
45
|
let ownTagValues = () => {
|
|
42
46
|
let vals = Stdlib_Array.reduce(tags, [], (acc, t) => {
|
|
@@ -104,6 +108,26 @@ function Make(Spec) {
|
|
|
104
108
|
} else {
|
|
105
109
|
entityId = undefined;
|
|
106
110
|
}
|
|
111
|
+
let ownerFieldsOf = raw => {
|
|
112
|
+
if (partitionTag !== undefined) {
|
|
113
|
+
if (entityId !== undefined) {
|
|
114
|
+
if (actsOnOwned && DcbTag$Reventless.partitionValueOfTags(raw.tags, partitionTag) === entityId) {
|
|
115
|
+
return Stdlib_Option.getOr(ownerFieldsByEventType[raw.eventType], []);
|
|
116
|
+
} else {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
let ownershipRefusal = owners => {
|
|
127
|
+
if (actsOnOwned) {
|
|
128
|
+
return CommandTopic_Helpers$ReventlessCore.ownershipRefusal(OwnerScope$Reventless.decideActing(command$p.meta.callerClaim, Stdlib_Option.map(entityId, param => owners)), Spec.name, Message$ReventlessCore.variantNameOfJson(cmdJson.commandJson));
|
|
129
|
+
}
|
|
130
|
+
};
|
|
107
131
|
let cacheKey = JSON.stringify(query);
|
|
108
132
|
let cacheGet = () => {
|
|
109
133
|
if (cacheKey !== undefined) {
|
|
@@ -128,10 +152,12 @@ function Make(Spec) {
|
|
|
128
152
|
let match = seed.contents;
|
|
129
153
|
let match$1 = match !== undefined ? [
|
|
130
154
|
match[0],
|
|
131
|
-
match[1]
|
|
155
|
+
match[1],
|
|
156
|
+
match[2]
|
|
132
157
|
] : [
|
|
133
158
|
Behavior.initialState,
|
|
134
|
-
undefined
|
|
159
|
+
undefined,
|
|
160
|
+
[]
|
|
135
161
|
];
|
|
136
162
|
let afterPos = match$1[1];
|
|
137
163
|
let cacheHit = Stdlib_Option.isSome(seed.contents);
|
|
@@ -148,12 +174,17 @@ function Make(Spec) {
|
|
|
148
174
|
break;
|
|
149
175
|
}
|
|
150
176
|
return Effect.flatMap(Effect.tap(Stream$1.runFold(Stream$1.flatMap(Stream$1.map(dcbEventLog.readStream(query, afterPos, strongConsistency), raw => {
|
|
151
|
-
let
|
|
177
|
+
let data = Stdlib_Option.getOr(Stdlib_JSON.Decode.object(raw.data), {});
|
|
178
|
+
let decoded = decoder.decode(raw.eventType, data);
|
|
152
179
|
return Stdlib_Option.map(decoded, event => [
|
|
153
180
|
event,
|
|
154
181
|
raw.position,
|
|
155
182
|
raw.eventType,
|
|
156
|
-
readEventId(partitionTag, raw.tags)
|
|
183
|
+
readEventId(partitionTag, raw.tags),
|
|
184
|
+
[
|
|
185
|
+
ownerFieldsOf(raw),
|
|
186
|
+
data
|
|
187
|
+
]
|
|
157
188
|
]);
|
|
158
189
|
}), opt => {
|
|
159
190
|
if (opt !== undefined) {
|
|
@@ -164,14 +195,17 @@ function Make(Spec) {
|
|
|
164
195
|
}), [
|
|
165
196
|
match$1[0],
|
|
166
197
|
afterPos,
|
|
167
|
-
[]
|
|
198
|
+
[],
|
|
199
|
+
match$1[2]
|
|
168
200
|
], (param, param$1) => {
|
|
201
|
+
let match = param$1[4];
|
|
169
202
|
let eventId = param$1[3];
|
|
170
203
|
let eventType = param$1[2];
|
|
171
204
|
return [
|
|
172
205
|
Behavior.evolve(param[0], param$1[0]),
|
|
173
206
|
param$1[1],
|
|
174
|
-
param[2].concat([eventId !== undefined ? LogFormat$ReventlessCore.bold(eventType) + `(` + eventId + `)` : LogFormat$ReventlessCore.bold(eventType)])
|
|
207
|
+
param[2].concat([eventId !== undefined ? LogFormat$ReventlessCore.bold(eventType) + `(` + eventId + `)` : LogFormat$ReventlessCore.bold(eventType)]),
|
|
208
|
+
OwnerScope$Reventless.recordOwners(param[3], match[0], match[1])
|
|
175
209
|
];
|
|
176
210
|
}), param => {
|
|
177
211
|
let reads = param[2];
|
|
@@ -184,16 +218,33 @@ function Make(Spec) {
|
|
|
184
218
|
reads.length === 0 ? "" : ` [` + reads.join(", ") + `]`
|
|
185
219
|
));
|
|
186
220
|
}), param => {
|
|
221
|
+
let owners = param[3];
|
|
187
222
|
let headPosition = param[1];
|
|
188
223
|
let state = param[0];
|
|
189
224
|
return Effect.flatMap(EffectLogger$ReventlessCore.logDebug(comp, undefined, `deciding: id=` + Stdlib_Option.getOr(entityId, "-") + ` head=` + Stdlib_Option.getOr(headPosition, "-") + ` cmd=` + LogFormat$ReventlessCore.cmdName(cmdJson)), () => {
|
|
225
|
+
let match = ownershipRefusal(owners);
|
|
226
|
+
if (match !== undefined) {
|
|
227
|
+
let rejected = match.rejected;
|
|
228
|
+
cachePut([
|
|
229
|
+
state,
|
|
230
|
+
headPosition,
|
|
231
|
+
owners
|
|
232
|
+
]);
|
|
233
|
+
CommandTopic_Helpers$ReventlessCore.reportRejected(Spec.name, "AccessRefusal", cmdJson.meta.msgId, rejected);
|
|
234
|
+
let line = `refused: ` + rejected.errorDetail + ` id=` + Stdlib_Option.getOr(entityId, "-");
|
|
235
|
+
return Effect.map(match.isDefect ? EffectLogger$ReventlessCore.logError(comp, undefined, line) : EffectLogger$ReventlessCore.logWarn(comp, undefined, line), () => ({
|
|
236
|
+
TAG: "Ok",
|
|
237
|
+
_0: "rejected"
|
|
238
|
+
}));
|
|
239
|
+
}
|
|
190
240
|
let newEvents = Behavior.decide(state, command$p.command);
|
|
191
241
|
if (newEvents.TAG === "Ok") {
|
|
192
242
|
let newEvents$1 = newEvents._0;
|
|
193
243
|
if (newEvents$1.length === 0) {
|
|
194
244
|
cachePut([
|
|
195
245
|
state,
|
|
196
|
-
headPosition
|
|
246
|
+
headPosition,
|
|
247
|
+
owners
|
|
197
248
|
]);
|
|
198
249
|
CommandTopic_Helpers$ReventlessCore.reportAccepted(Spec.name, cmdJson.meta.msgId, entityId !== undefined ? ({
|
|
199
250
|
entityId: entityId,
|
|
@@ -243,7 +294,8 @@ function Make(Spec) {
|
|
|
243
294
|
if (appendResult.TAG === "Ok") {
|
|
244
295
|
cachePut([
|
|
245
296
|
state,
|
|
246
|
-
headPosition
|
|
297
|
+
headPosition,
|
|
298
|
+
owners
|
|
247
299
|
]);
|
|
248
300
|
CommandTopic_Helpers$ReventlessCore.reportAccepted(Spec.name, cmdJson.meta.msgId, entityId !== undefined ? ({
|
|
249
301
|
entityId: entityId,
|
|
@@ -270,7 +322,8 @@ function Make(Spec) {
|
|
|
270
322
|
if (retries > 0) {
|
|
271
323
|
seed.contents = [
|
|
272
324
|
state,
|
|
273
|
-
headPosition
|
|
325
|
+
headPosition,
|
|
326
|
+
owners
|
|
274
327
|
];
|
|
275
328
|
Metrics$ReventlessCore.emitCount("AppendRetry", Spec.name, undefined);
|
|
276
329
|
return Effect.flatMap(EffectLogger$ReventlessCore.logWarn(comp, undefined, `append failed (retrying ` + ((3 - retries | 0) + 1 | 0).toString() + `/` + (3).toString() + `): ` + errDetail), () => attempt(retries - 1 | 0));
|
|
@@ -292,12 +345,13 @@ function Make(Spec) {
|
|
|
292
345
|
}
|
|
293
346
|
cachePut([
|
|
294
347
|
state,
|
|
295
|
-
headPosition
|
|
348
|
+
headPosition,
|
|
349
|
+
owners
|
|
296
350
|
]);
|
|
297
351
|
let errorJson = Util_Sury$Reventless.toJson(newEvents._0, Spec.errorSchema);
|
|
298
352
|
let errorCode = Message$ReventlessCore.variantNameOfJson(errorJson);
|
|
299
|
-
let match = Message$ReventlessCore.splitMessage(errorJson);
|
|
300
|
-
let payloadDict = match[1];
|
|
353
|
+
let match$1 = Message$ReventlessCore.splitMessage(errorJson);
|
|
354
|
+
let payloadDict = match$1[1];
|
|
301
355
|
let errorDetail = Object.entries(payloadDict).length === 0 ? "" : JSON.stringify(payloadDict);
|
|
302
356
|
CommandTopic_Helpers$ReventlessCore.reportRejected(Spec.name, "DomainRejection", cmdJson.meta.msgId, {
|
|
303
357
|
errorCode: errorCode,
|
|
@@ -38,7 +38,7 @@ let queryEntries: array<querySchemaEntry> = [
|
|
|
38
38
|
// read: the entry path uses only the group, and the per-`@index` auth-table
|
|
39
39
|
// pipeline params come from `indexConfig.authorization`, which is untouched.
|
|
40
40
|
authorization: None,
|
|
41
|
-
permission: PluginsReadModelSpec.authorization,
|
|
41
|
+
permission: PluginsReadModelSpec.authorization->Reventless.Authorization.named,
|
|
42
42
|
// No `excludeFields`: the three fields this entry used to name are declared
|
|
43
43
|
// `@internal` on the spec, and `deriveObjectTypeWithNested` reads that off the
|
|
44
44
|
// schema itself.
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
4
4
|
import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
|
|
5
|
+
import * as Authorization$Reventless from "@reventlessdev/reventless-spec/src/types/Authorization.res.mjs";
|
|
5
6
|
import * as Api_Naming$ReventlessCore from "../../components/Api/Api_Naming.res.mjs";
|
|
6
7
|
import * as PluginSpec$ReventlessCore from "../lifecycle/PluginSpec.res.mjs";
|
|
7
8
|
import * as ApiNoApiHelpers$ReventlessCore from "../../components/Api/ApiNoApiHelpers.res.mjs";
|
|
@@ -20,7 +21,7 @@ let queryEntries = [{
|
|
|
20
21
|
stateSchema: PluginsReadModelSpec$ReventlessCore.stateSchema,
|
|
21
22
|
specName: PluginsReadModelSpec$ReventlessCore.name,
|
|
22
23
|
authorization: undefined,
|
|
23
|
-
permission: PluginsReadModelSpec$ReventlessCore.authorization,
|
|
24
|
+
permission: Authorization$Reventless.named(PluginsReadModelSpec$ReventlessCore.authorization),
|
|
24
25
|
subIdField: Stdlib_Option.map(undefined, c => c.subIdField),
|
|
25
26
|
indexQueries: indexQueriesOfConfig(PluginsReadModelSpec$ReventlessCore.config)
|
|
26
27
|
}];
|
|
@@ -214,7 +214,8 @@ module Make = (
|
|
|
214
214
|
~kind=Aggregate,
|
|
215
215
|
~fields=fieldNames,
|
|
216
216
|
~commandSchema,
|
|
217
|
-
~commandAuthorization=
|
|
217
|
+
~commandAuthorization=command =>
|
|
218
|
+
M.Spec.commandAuthorization(command->Obj.magic)->Reventless.Authorization.named,
|
|
218
219
|
)
|
|
219
220
|
)
|
|
220
221
|
let aggDef =
|
|
@@ -235,7 +236,8 @@ module Make = (
|
|
|
235
236
|
cname,
|
|
236
237
|
)
|
|
237
238
|
let syntheticCmd: unknown = hasPayload ? {"TAG": cname}->Obj.magic : cname->Obj.magic
|
|
238
|
-
let rule =
|
|
239
|
+
let rule =
|
|
240
|
+
M.Spec.commandAuthorization(syntheticCmd->Obj.magic)->Reventless.Authorization.named
|
|
239
241
|
fieldPermissions->Dict.set(fieldName, rule)
|
|
240
242
|
})
|
|
241
243
|
[
|
|
@@ -272,7 +274,7 @@ module Make = (
|
|
|
272
274
|
returnTypeName: qn.returnTypeName,
|
|
273
275
|
stateSchema: R.Spec.stateSchema->S.castToUnknown,
|
|
274
276
|
authorization: None,
|
|
275
|
-
permission: R.Spec.authorization,
|
|
277
|
+
permission: R.Spec.authorization->Reventless.Authorization.named,
|
|
276
278
|
connectionSpec: true,
|
|
277
279
|
?subIdField,
|
|
278
280
|
indexQueries: ?(indexes->Array.length > 0 ? Some(indexes) : None),
|
|
@@ -1149,6 +1151,8 @@ module Make = (
|
|
|
1149
1151
|
~opts=?,
|
|
1150
1152
|
) => {
|
|
1151
1153
|
PluginRuntimeBuilder.registerPluginName(name)
|
|
1154
|
+
// Here, outside any `Output.apply`, so a preview refuses the plugin too.
|
|
1155
|
+
pluginStructure->Option.forEach(s => Reventless.RoleCoverage.check(s, ~plugin=name))
|
|
1152
1156
|
let version = Reventless.PackageVersion.fromCaller()
|
|
1153
1157
|
Component.make(
|
|
1154
1158
|
~componentType=Plugin.componentType->ComponentType.toString,
|