@reventlessdev/reventless-spec 3.0.0-alpha.135 → 3.0.0-alpha.136

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.
@@ -259,11 +259,13 @@ type compositePartitionSpec = {
259
259
  // @schema-generated `derivedPartitionTagSchema`, breaking downstream
260
260
  // `Reventless.DcbTag.derivedPartitionTagSchema` references (e.g. reventless-aws
261
261
  // PgChangeFeedRelay). This forces a clean rebuild + republish.
262
- /** Union of simple and composite partition tag strategies. */
262
+ /** How a DCB event log files events: under one key, a composite of several, or
263
+ each event type under the key of the slice that writes it. */
263
264
  @schema
264
265
  type derivedPartitionTag =
265
266
  | Simple(partitionTag)
266
267
  | Composite(compositePartitionSpec)
268
+ | ByEventType(dict<string>)
267
269
 
268
270
  // --- Tag extraction from sury schemas ---
269
271
 
@@ -1172,6 +1174,23 @@ let extractPartitionTagFields = (schema: S.t<'event>): array<string> => {
1172
1174
  }
1173
1175
  }
1174
1176
 
1177
+ /**
1178
+ The chapter a module sits in, read off its `moduleUrl`: the first directory under
1179
+ the last `src/` that is not a kind folder (`…/src/Order/StateChange/PlaceOrder.res.mjs`
1180
+ → `Order`). The same rule the generator applies to paths on disk, applied to the
1181
+ one value every call site has — the deployed Lambda included.
1182
+ */
1183
+ let chapterOfModuleUrl = (moduleUrl: string): option<string> =>
1184
+ switch moduleUrl->String.split("/src/") {
1185
+ | [] | [_] => None
1186
+ | parts =>
1187
+ let segments = parts->Array.getUnsafe(parts->Array.length - 1)->String.split("/")
1188
+ switch segments->Array.get(0) {
1189
+ | Some(first) if segments->Array.length > 1 && !ComponentKind.isKindFolder(first) => Some(first)
1190
+ | _ => None
1191
+ }
1192
+ }
1193
+
1175
1194
  /**
1176
1195
  Builds the `DcbScopeInference.sliceShape` for one slice from its sury schemas.
1177
1196
  The `command` fields are flattened across command variants; `consumed` / `produced`
@@ -1183,6 +1202,7 @@ let sliceShapeFromSchemas = (
1183
1202
  ~commandSchema: S.t<'c>,
1184
1203
  ~consumedEventSchema: S.t<'ce>,
1185
1204
  ~eventSchema: S.t<'e>,
1205
+ ~moduleUrl: option<string>=?,
1186
1206
  ): DcbScopeInference.sliceShape => {
1187
1207
  // An explicit @partitionTag on the produced event is the escape hatch for
1188
1208
  // slices whose own events carry two owned keys (e.g. RecordProductDemand).
@@ -1196,6 +1216,7 @@ let sliceShapeFromSchemas = (
1196
1216
  consumed: eventShapesOfSchema(consumedEventSchema),
1197
1217
  produced: eventShapesOfSchema(eventSchema),
1198
1218
  partitionHint,
1219
+ chapter: ?(moduleUrl->Option.flatMap(chapterOfModuleUrl)),
1199
1220
  }
1200
1221
  }
1201
1222
 
@@ -1205,8 +1226,19 @@ type sliceSchemas = {
1205
1226
  commandSchema: S.t<unknown>,
1206
1227
  consumedEventSchema: S.t<unknown>,
1207
1228
  eventSchema: S.t<unknown>,
1229
+ /** Where the slice's spec lives; its chapter breaks partition ties. */
1230
+ moduleUrl?: string,
1208
1231
  }
1209
1232
 
1233
+ let sliceShape = (s: sliceSchemas) =>
1234
+ sliceShapeFromSchemas(
1235
+ ~name=s.name,
1236
+ ~commandSchema=s.commandSchema,
1237
+ ~consumedEventSchema=s.consumedEventSchema,
1238
+ ~eventSchema=s.eventSchema,
1239
+ ~moduleUrl=?s.moduleUrl,
1240
+ )
1241
+
1210
1242
  /**
1211
1243
  The DCB decision-read scope threaded into every StateChangeSlice callback:
1212
1244
  - `crossPartitionTagKeys` — keys whose scalar command tag must be fanned into its
@@ -1265,16 +1297,7 @@ let deriveEffectiveScope = (slices: array<sliceSchemas>): effectiveScope => {
1265
1297
  }
1266
1298
  let annotatedTagKeys =
1267
1299
  producedSchemas->Array.map(extractTagKeysByEventType)->mergeTagKeysByEventType
1268
- let shapes =
1269
- slices->Array.map(s =>
1270
- sliceShapeFromSchemas(
1271
- ~name=s.name,
1272
- ~commandSchema=s.commandSchema,
1273
- ~consumedEventSchema=s.consumedEventSchema,
1274
- ~eventSchema=s.eventSchema,
1275
- )
1276
- )
1277
- let inferred = DcbScopeInference.infer(shapes)
1300
+ let inferred = DcbScopeInference.infer(slices->Array.map(sliceShape))
1278
1301
  let useInferred = inferred.ambiguities->Array.length == 0
1279
1302
  {
1280
1303
  crossPartitionTagKeys: useInferred ? inferred.crossPartitionTagKeys : annotatedCross,
@@ -1286,71 +1309,6 @@ let deriveEffectiveScope = (slices: array<sliceSchemas>): effectiveScope => {
1286
1309
  }
1287
1310
  }
1288
1311
 
1289
- /**
1290
- Checks whether any single variant in a schema has multiple tagged fields.
1291
- If so, a partition tag annotation is needed to disambiguate.
1292
- */
1293
- let hasMultiTagVariant = (schema: S.t<unknown>): bool =>
1294
- switch schema {
1295
- | AnyOf({anyOf}) =>
1296
- anyOf->Array.some(variantSchema =>
1297
- switch variantSchema {
1298
- | Object({properties}) => {
1299
- let tagCount =
1300
- properties
1301
- ->Dict.toArray
1302
- ->Array.filter(((_, fieldSchema)) => isTagged(fieldSchema))
1303
- ->Array.length
1304
- tagCount > 1
1305
- }
1306
- | _ => false
1307
- }
1308
- )
1309
- | Object({properties}) => {
1310
- let tagCount =
1311
- properties
1312
- ->Dict.toArray
1313
- ->Array.filter(((_, fieldSchema)) => isTagged(fieldSchema))
1314
- ->Array.length
1315
- tagCount > 1
1316
- }
1317
- | _ => false
1318
- }
1319
-
1320
- /**
1321
- Returns the names of variants within a schema that have multiple tagged fields.
1322
- Used to build diagnostic context for partition tag errors.
1323
- */
1324
- let findMultiTagVariantNames = (schema: S.t<unknown>): array<string> => {
1325
- // Extract the variant name from a single object-variant schema via its TAG item.
1326
- let variantName = (variantSchema: S.t<unknown>): option<string> =>
1327
- switch variantSchema {
1328
- | Object({properties}) => {
1329
- let tagCount =
1330
- properties
1331
- ->Dict.toArray
1332
- ->Array.filter(((_, fieldSchema)) => isTagged(fieldSchema))
1333
- ->Array.length
1334
- if tagCount > 1 {
1335
- Some(variantTagName(properties)->Option.getOr("(unknown)"))
1336
- } else {
1337
- None
1338
- }
1339
- }
1340
- | _ => None
1341
- }
1342
-
1343
- switch schema {
1344
- | AnyOf({anyOf}) => anyOf->Array.filterMap(variantName)
1345
- | _ =>
1346
- // Single-variant event type — schema is the object directly
1347
- switch variantName(schema) {
1348
- | Some(name) => [name]
1349
- | None => []
1350
- }
1351
- }
1352
- }
1353
-
1354
1312
  // --- Composite partition key helpers ---
1355
1313
 
1356
1314
  type compositePartitionFieldInfo = {name: string, position: int, sep: string}
@@ -1428,135 +1386,133 @@ let getCompositePartitionKeyValue = (tags: array<tag>, spec: compositePartitionS
1428
1386
  // --- Partition tag derivation ---
1429
1387
 
1430
1388
  /**
1431
- Derives the partition tag strategy from an array of named event schemas.
1432
-
1433
- Returns `Simple(partitionTag)` when the schema uses `@partitionTag` (or a single tag),
1434
- or `Composite(compositePartitionSpec)` when it uses `@compositePartitionTag`.
1435
-
1436
- Rules for simple strategy:
1437
- - If only one tagged field exists across all schemas, it is automatically selected.
1438
- - If multiple tagged fields exist but each event variant has at most one tagged
1439
- field (multi-entity DCB), the first field alphabetically is selected.
1440
- - If any event variant has multiple tagged fields and exactly one is annotated
1441
- with `DcbTag.partition`, that one is selected.
1442
- - If any event variant has multiple tagged fields and none (or multiple) are
1443
- annotated with `DcbTag.partition`, throws an error naming the affected slice,
1444
- variant(s), and source file path.
1445
-
1446
- Throws when:
1447
- - A schema mixes `@compositePartitionTag` and `@partitionTag` fields.
1448
- - Fewer than 2 fields are annotated with `@compositePartitionTag`.
1449
- */
1450
- let derivePartitionTag = (
1451
- namedSchemas: array<(string, string, S.t<unknown>)>,
1452
- ): derivedPartitionTag => {
1453
- let schemas = namedSchemas->Array.map(((_, _, schema)) => schema)
1389
+ The composite partition declared by `@compositePartitionTag` across the given
1390
+ event schemas, if any.
1454
1391
 
1455
- let allCompositeFields = {
1392
+ Throws when a schema mixes `@compositePartitionTag` and `@partitionTag`, or when
1393
+ fewer than 2 fields are annotated with `@compositePartitionTag`.
1394
+ */
1395
+ let compositePartitionOf = (schemas: array<S.t<unknown>>): option<compositePartitionSpec> => {
1396
+ let dedupe = (items, keyOf) => {
1456
1397
  let seen = Set.make()
1457
- schemas
1458
- ->Array.flatMap(schema => extractCompositePartitionFields(schema))
1459
- ->Array.filter(info => {
1460
- if seen->Set.has(info.name) {
1398
+ items->Array.filter(item => {
1399
+ let k = keyOf(item)
1400
+ if seen->Set.has(k) {
1461
1401
  false
1462
1402
  } else {
1463
- seen->Set.add(info.name)
1403
+ seen->Set.add(k)
1464
1404
  true
1465
1405
  }
1466
1406
  })
1467
1407
  }
1468
-
1469
- let hasComposite = allCompositeFields->Array.length > 0
1470
-
1471
- let allPartitionFields = {
1472
- let seen = Set.make()
1473
- schemas
1474
- ->Array.flatMap(schema => extractPartitionTagFields(schema))
1475
- ->Array.filter(f => {
1476
- if seen->Set.has(f) {
1477
- false
1478
- } else {
1479
- seen->Set.add(f)
1480
- true
1481
- }
1408
+ let compositeFields =
1409
+ schemas->Array.flatMap(extractCompositePartitionFields)->dedupe(info => info.name)
1410
+ let partitionFields = schemas->Array.flatMap(extractPartitionTagFields)->dedupe(f => f)
1411
+ switch compositeFields {
1412
+ | [] => None
1413
+ | _ if partitionFields->Array.length > 0 =>
1414
+ JsError.throwWithMessage(`DCB spec mixes @compositePartitionTag and @partitionTag — use one strategy per schema`)
1415
+ | [_] =>
1416
+ JsError.throwWithMessage(`@compositePartitionTag requires at least 2 annotated fields — only 1 found`)
1417
+ | _ =>
1418
+ let sorted = compositeFields->Array.toSorted((a, b) => Int.compare(a.position, b.position))
1419
+ Some({
1420
+ keys: sorted->Array.map(info => info.name),
1421
+ seps: sorted
1422
+ ->Array.slice(~start=0, ~end=sorted->Array.length - 1)
1423
+ ->Array.map(info => info.sep),
1482
1424
  })
1483
1425
  }
1426
+ }
1484
1427
 
1485
- if hasComposite && allPartitionFields->Array.length > 0 {
1486
- JsError.throwWithMessage(`DCB spec mixes @compositePartitionTag and @partitionTag — use one strategy per schema`)
1487
- }
1428
+ /** The storage partition of one DCB consistency boundary. */
1429
+ type boundaryPartition = {
1430
+ /** sliceName -> the key its events are filed under; empty for a composite boundary. */
1431
+ partitionBySlice: dict<string>,
1432
+ /** What the event log files each event under. */
1433
+ partitionTag: derivedPartitionTag,
1434
+ }
1488
1435
 
1489
- if hasComposite {
1490
- if allCompositeFields->Array.length < 2 {
1436
+ /**
1437
+ Derives where a boundary's events are stored — the one derivation behind the
1438
+ storage partition, the consistency fence, the command envelope id and the
1439
+ decision-read scope, so the four cannot disagree.
1440
+
1441
+ Each slice's key comes from `DcbScopeInference.resolvePartitions`, and every event
1442
+ type is filed under the key of the slice that writes it. `@compositePartitionTag`
1443
+ stays explicit and applies to the whole boundary.
1444
+
1445
+ Throws, naming the slice, when a partition cannot be inferred, when a slice's
1446
+ event does not carry its partition key as a tag, or when two slices write one
1447
+ event type under different keys. Each of these would otherwise file events where
1448
+ the decision read does not look.
1449
+ */
1450
+ let deriveBoundaryPartition = (slices: array<sliceSchemas>): boundaryPartition =>
1451
+ switch compositePartitionOf(slices->Array.map(s => s.eventSchema)) {
1452
+ | Some(spec) => {partitionBySlice: Dict.make(), partitionTag: Composite(spec)}
1453
+ | None =>
1454
+ let resolution = DcbScopeInference.resolvePartitions(slices->Array.map(sliceShape))
1455
+ if resolution.ambiguities->Array.length > 0 {
1491
1456
  JsError.throwWithMessage(
1492
- `@compositePartitionTag requires at least 2 annotated fields — only ${allCompositeFields
1493
- ->Array.length
1494
- ->Int.toString} found`,
1457
+ `DCB partition key cannot be inferred — ${resolution.ambiguities
1458
+ ->Array.map(((slice, reason)) => `${slice}: ${reason}`)
1459
+ ->Array.join(" | ")}`,
1495
1460
  )
1496
1461
  }
1497
- let sorted = allCompositeFields->Array.toSorted((a, b) => Int.compare(a.position, b.position))
1498
- let keys = sorted->Array.map(info => info.name)
1499
- let seps =
1500
- sorted->Array.slice(~start=0, ~end=sorted->Array.length - 1)->Array.map(info => info.sep)
1501
- Composite({keys, seps})
1502
- } else {
1503
- let allTaggedFields = {
1504
- let seen = Set.make()
1505
- schemas
1506
- ->Array.flatMap(schema => extractTaggedFields(schema))
1507
- ->Array.filter(f => {
1508
- if seen->Set.has(f) {
1509
- false
1510
- } else {
1511
- seen->Set.add(f)
1512
- true
1462
+ let byEventType = Dict.make()
1463
+ slices->Array.forEach(s => {
1464
+ let key = resolution.partitionBySlice->Dict.getUnsafe(s.name)
1465
+ extractTagKeysByEventType(s.eventSchema)
1466
+ ->Dict.toArray
1467
+ ->Array.forEach(((eventType, tagKeys)) => {
1468
+ if tagKeys->Array.length > 0 && !(tagKeys->Array.includes(key)) {
1469
+ JsError.throwWithMessage(
1470
+ `DCB slice ${s.name} is partitioned by ${key}, but its event ${eventType} carries no ${key} tag (it carries ${tagKeys->Array.join(
1471
+ ", ",
1472
+ )}) — add ${key} to the event, or declare the partition with @partitionTag`,
1473
+ )
1474
+ }
1475
+ switch byEventType->Dict.get(eventType) {
1476
+ | Some(other) if other != key =>
1477
+ JsError.throwWithMessage(
1478
+ `DCB event ${eventType} is written under two partition keys (${other}, ${key}) — every slice writing it must be partitioned by the same key`,
1479
+ )
1480
+ | _ => byEventType->Dict.set(eventType, key)
1513
1481
  }
1514
1482
  })
1515
- }
1483
+ })
1484
+ {partitionBySlice: resolution.partitionBySlice, partitionTag: ByEventType(byEventType)}
1485
+ }
1516
1486
 
1517
- switch allTaggedFields {
1518
- | [] =>
1519
- JsError.throwWithMessage("DCB spec has no tagged fields — cannot derive partition tag")
1520
- | [singleField] => Simple({key: singleField})
1521
- | multipleFields => {
1522
- let needsExplicitPartition = schemas->Array.some(schema => hasMultiTagVariant(schema))
1523
-
1524
- if needsExplicitPartition {
1525
- let context =
1526
- namedSchemas
1527
- ->Array.filterMap(((sliceName, path, schema)) => {
1528
- let variantNames = findMultiTagVariantNames(schema)
1529
- if variantNames->Array.length > 0 {
1530
- Some(`${sliceName} (${variantNames->Array.join(", ")}) @ ${path}`)
1531
- } else {
1532
- None
1533
- }
1534
- })
1535
- ->Array.join(", ")
1536
-
1537
- switch allPartitionFields {
1538
- | [singlePartition] => Simple({key: singlePartition})
1539
- | [] =>
1540
- JsError.throwWithMessage(
1541
- `DCB spec has variants with multiple tagged fields (${multipleFields->Array.join(
1542
- ", ",
1543
- )}) but none is annotated with @partitionTag — affected: ${context} — mark one field as the partition key`,
1544
- )
1545
- | multiplePartitions =>
1546
- JsError.throwWithMessage(
1547
- `DCB spec has multiple fields annotated with @partitionTag (${multiplePartitions->Array.join(
1548
- ", ",
1549
- )}) — only one is allowed — affected: ${context}`,
1550
- )
1551
- }
1552
- } else {
1553
- let sorted = multipleFields->Array.toSorted((a, b) => String.compare(a, b))
1554
- Simple({key: sorted->Array.getUnsafe(0)})
1555
- }
1556
- }
1557
- }
1487
+ /** One slice's partition within a derived boundary: `Simple` or `Composite`. */
1488
+ let slicePartitionTag = (bp: boundaryPartition, sliceName: string): option<derivedPartitionTag> =>
1489
+ switch bp.partitionTag {
1490
+ | ByEventType(_) =>
1491
+ bp.partitionBySlice->Dict.get(sliceName)->Option.map(key => Simple({key: key}))
1492
+ | other => Some(other)
1493
+ }
1494
+
1495
+ /**
1496
+ A slice's partition derived from the slice alone, for callers without the rest of
1497
+ its boundary. A producer out of sight makes every consumed arm a reference, so
1498
+ this can fail where the boundary resolves; it throws naming the arms to change.
1499
+ */
1500
+ let deriveSlicePartition = (slice: sliceSchemas): derivedPartitionTag =>
1501
+ switch deriveBoundaryPartition([slice])->slicePartitionTag(slice.name) {
1502
+ | Some(tag) => tag
1503
+ | None => JsError.throwWithMessage(`DCB slice ${slice.name} has no partition key`)
1504
+ }
1505
+
1506
+ /**
1507
+ The partition key value a command is routed by: the value of the handling
1508
+ slice's partition field on it, or `""` when the command does not carry it.
1509
+ */
1510
+ let partitionValueOfTags = (tags: array<tag>, pt: derivedPartitionTag): string =>
1511
+ switch pt {
1512
+ | Simple({key}) => tags->Array.findMap(t => t.key == key ? Some(t.value) : None)->Option.getOr("")
1513
+ | Composite(spec) => getCompositePartitionKeyValue(tags, spec)
1514
+ | ByEventType(_) => ""
1558
1515
  }
1559
- }
1560
1516
 
1561
1517
  /**
1562
1518
  Extracts the partition tag value from a query.