@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.
Files changed (58) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/package.json +7 -7
  3. package/src/Message.res.mjs +3 -0
  4. package/src/Roles.res +3 -0
  5. package/src/Roles.res.mjs +2 -0
  6. package/src/admin/Platform_AdminGroup.res +12 -0
  7. package/src/admin/Platform_AdminGroup.res.mjs +23 -0
  8. package/src/admin/Platform_Admin_Structure.res +14 -10
  9. package/src/admin/Platform_Admin_Structure.res.mjs +22 -33
  10. package/src/components/Aggregate/Aggregate_Callback.res +116 -39
  11. package/src/components/Aggregate/Aggregate_Callback.res.mjs +96 -37
  12. package/src/components/AutomationSlice/AutomationSlice_Builder.res +2 -1
  13. package/src/components/CommandGenerator/CommandGenerator_Callback.res +9 -8
  14. package/src/components/CommandGenerator/CommandGenerator_Callback.res.mjs +10 -8
  15. package/src/components/CommandTopic/CommandTopic.res.mjs +3 -0
  16. package/src/components/CommandTopic/CommandTopic_Helpers.res +37 -1
  17. package/src/components/CommandTopic/CommandTopic_Helpers.res.mjs +29 -0
  18. package/src/components/Counter/Counter_Builder.res +4 -2
  19. package/src/components/Dcb/Dcb_Builder.res +9 -6
  20. package/src/components/Dcb/Dcb_Builder.res.mjs +6 -6
  21. package/src/components/InboundTranslationSlice/InboundTranslationSlice_Builder.res +2 -1
  22. package/src/components/OutboundTranslationSlice/OutboundTranslationSlice_Builder.res +2 -1
  23. package/src/components/QueryDb/QueryDb_Builder.res +1 -1
  24. package/src/components/QueryDb/QueryDb_Builder.res.mjs +3 -2
  25. package/src/components/StateChangeSlice/StateChangeSlice_Callback.res +191 -141
  26. package/src/components/StateChangeSlice/StateChangeSlice_Callback.res.mjs +66 -12
  27. package/src/components/StateViewSlice/StateViewSlice_Builder.res +1 -0
  28. package/src/plugin/api/PluginBaseFragment.res +1 -1
  29. package/src/plugin/api/PluginBaseFragment.res.mjs +2 -1
  30. package/src/plugin/component/Plugin_Builder.res +7 -3
  31. package/src/plugin/component/Plugin_Builder.res.mjs +6 -3
  32. package/src/plugin/component/Plugin_Helpers.res +35 -6
  33. package/src/plugin/component/Plugin_Helpers.res.mjs +47 -5
  34. package/src/plugin/component/Plugin_Structure.res +52 -19
  35. package/src/plugin/component/Plugin_Structure.res.mjs +40 -15
  36. package/src/plugin/lifecycle/PluginsReadModelSpec.res +1 -1
  37. package/src/plugin/lifecycle/PluginsReadModelSpec.res.mjs +1 -1
  38. package/tests/admin/AdminApiSchemaDriftTest.res +9 -3
  39. package/tests/admin/AdminApiSchemaDriftTest.res.mjs +3 -3
  40. package/tests/admin/Platform_Admin_StructureTest.res +17 -14
  41. package/tests/admin/Platform_Admin_StructureTest.res.mjs +31 -19
  42. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +8 -72
  43. package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +9 -39
  44. package/tests/api/GraphQL_FragmentGeneratorTest.res +1 -1
  45. package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +1 -1
  46. package/tests/commandgenerator/OwnerActingTest.res +601 -0
  47. package/tests/commandgenerator/OwnerActingTest.res.mjs +1018 -0
  48. package/tests/commandgenerator/OwnerStampingTest.res +90 -0
  49. package/tests/commandgenerator/OwnerStampingTest.res.mjs +87 -0
  50. package/tests/commandtopic/CommandOutcomeHookTest.res +1 -0
  51. package/tests/commandtopic/CommandOutcomeHookTest.res.mjs +11 -1
  52. package/tests/plugin/PluginStructureAccessTest.res +30 -1
  53. package/tests/plugin/PluginStructureAccessTest.res.mjs +28 -1
  54. package/tests/plugin/StateChange/PsGatedCommands.res +9 -1
  55. package/tests/plugin/StateChange/PsGatedCommands.res.mjs +4 -1
  56. package/tests/plugin/StateView/PsGatedView.res +1 -1
  57. package/tests/plugin/StateView/PsGatedView.res.mjs +1 -1
  58. 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 decoded = decoder.decode(
295
- ~eventType=raw.eventType,
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 Behavior.decide(state, command'.command) {
361
- | Ok(newEvents) if newEvents->Array.length == 0 =>
362
- // No append, but the read snapshot is valid — cache it for the next command.
363
- cachePut((state, headPosition))
364
- CommandTopic_Helpers.reportAccepted(
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
- switch entityId {
368
- | Some(eid) => {entityId: eid, eventCount: 0}
369
- | None => {eventCount: 0}
370
- },
401
+ rejected,
371
402
  )
372
- EffectLogger.logInfo(~comp, "no events produced")->Effect.map(_ => Ok("ok"))
373
- | Ok(newEvents) =>
374
- let rawEvents =
375
- newEvents->Array.map(
376
- e => encodeEvent(~parentMeta=command'.meta, ~tagKeysByEventType, e),
377
- )
378
- let eventCount = rawEvents->Array.length->Int.toString
379
- let eventDetails =
380
- rawEvents
381
- ->Array.map(
382
- e => {
383
- let fields = switch e.data {
384
- | Object(dict) =>
385
- let f =
386
- dict
387
- ->Dict.toArray
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
- ->Array.join(", ")
397
- let eventJsons = rawEvents->Array.map(e => e.data)->JSON.Encode.array
398
- let condition: Reventless.DcbTag.appendCondition = {
399
- query,
400
- after: ?headPosition,
401
- }
402
- EffectLogger.logInfo(
403
- ~comp,
404
- ~detail=eventJsons,
405
- `produced ${eventCount} event(s): [${eventDetails}]`,
406
- )
407
- ->Effect.flatMap(_ => Effect.promise(() => dcbEventLog.append(rawEvents, ~condition)))
408
- ->Effect.flatMap(
409
- appendResult =>
410
- switch appendResult {
411
- | Ok(_position) =>
412
- // Cache the decided-on state at the read head (NOT including the
413
- // events we just appended, and NOT the returned position): the
414
- // next command's delta read picks our events up from `headPosition`.
415
- cachePut((state, headPosition))
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
- // Both arms here are storage, not the model: a surfaced Conflict is
462
- // contention the retry loop couldn't absorb, and AppendFailed is the
463
- // store failing. `decide` had already said Ok.
464
- CommandTopic_Helpers.reportRejected(
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
- {errorCode, errorDetail: errDetail},
468
+ switch entityId {
469
+ | Some(eid) => {entityId: eid, eventCount: rawEvents->Array.length}
470
+ | None => {eventCount: rawEvents->Array.length}
471
+ },
469
472
  )
470
- EffectLogger.logError(
471
- ~comp,
472
- `append failed, retries exhausted: ${errDetail}`,
473
- )->Effect.map(_ => Error(errDetail))
474
- }
475
- },
476
- )
477
- | Error(error) =>
478
- // Business-rule rejection — no append, but the read snapshot is valid; cache it.
479
- cachePut((state, headPosition))
480
- let errorJson = error->Reventless.Util_Sury.toJson(Spec.errorSchema)
481
- let errorCode = errorJson->Message.variantNameOfJson
482
- let (_, payloadDict) = errorJson->Message.splitMessage
483
- let errorDetail =
484
- payloadDict->Dict.toArray->Array.length == 0
485
- ? ""
486
- : payloadDict->JSON.Encode.object->JSON.stringify
487
- CommandTopic_Helpers.reportRejected(
488
- ~component=Spec.name,
489
- ~cause=DomainRejection,
490
- cmdJson.meta.msgId,
491
- {errorCode, errorDetail},
492
- )
493
- EffectLogger.logError(
494
- ~comp,
495
- `decide rejected: ${errorCode} ${errorDetail}`,
496
- )->Effect.map(_ => Ok("rejected"))
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 decoded = decoder.decode(raw.eventType, Stdlib_Option.getOr(Stdlib_JSON.Decode.object(raw.data), {}));
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,
@@ -33,6 +33,7 @@ module Make = (
33
33
  let stateSchema = Spec.stateSchema
34
34
  let config = Spec.config
35
35
  let subIdConfig = Spec.subIdConfig
36
+ type role = Spec.role
36
37
  let authorization = Spec.authorization
37
38
  let visibility = Spec.visibility
38
39
  }
@@ -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=M.Spec.commandAuthorization->Obj.magic,
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 = M.Spec.commandAuthorization(syntheticCmd->Obj.magic)
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,