@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.
- package/CHANGELOG.md +18 -0
- package/package.json +2 -2
- package/src/components/DcbScopeInference.res +224 -73
- package/src/components/DcbScopeInference.res.mjs +187 -49
- package/src/components/DcbTag.res +142 -186
- package/src/components/DcbTag.res.mjs +137 -128
- package/src/components/DcbValidation.res +50 -0
- package/src/components/DcbValidation.res.mjs +51 -0
- package/src/generator/Codegen.res +2 -1
- package/src/generator/Codegen.res.mjs +1 -1
- package/src/lifecycle/CheckLifecycleModel.res +114 -4
- package/src/lifecycle/CheckLifecycleModel.res.mjs +148 -4
- package/src/types/AccountsManifest.res +6 -0
- package/src/types/AccountsManifest.res.mjs +2 -1
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
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(
|
|
1403
|
+
seen->Set.add(k)
|
|
1464
1404
|
true
|
|
1465
1405
|
}
|
|
1466
1406
|
})
|
|
1467
1407
|
}
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
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
|
-
|
|
1486
|
-
|
|
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
|
-
|
|
1490
|
-
|
|
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
|
-
|
|
1493
|
-
->Array.
|
|
1494
|
-
->
|
|
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
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
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
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
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.
|