@katn30/trakr 2.2.1 → 4.0.0

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/README.md CHANGED
@@ -1361,6 +1361,300 @@ For `Deleted` items the PK never changes — the backend just closes the existin
1361
1361
 
1362
1362
  ---
1363
1363
 
1364
+ ## Event generation (opt-in)
1365
+
1366
+ trakr's core is a **state diff** — after edits, iterate `tracker.trackedObjects`, group by `state`, and ship a bulk payload. That model works for most backends, but consumers moving to **event-sourced DDD** need something different: on Save, produce a **list of typed events**, one per meaningful change, that the backend appends to an event stream.
1367
+
1368
+ `EventTracker`, `@EventTracked`, and `EventTrackedCollection` provide a **fully opt-in** layer on top of the base API that turns dirty state into a typed event list on demand. Everything else stays identical — consumers using `Tracker` / `TrackedObject` / `TrackedCollection` / `@Tracked` see zero behavioural change.
1369
+
1370
+ ### API at a glance
1371
+
1372
+ ```typescript
1373
+ import {
1374
+ EventTracker,
1375
+ EventTracked,
1376
+ EventTrackedCollection,
1377
+ TrackedObject,
1378
+ AutoId,
1379
+ Tracker,
1380
+ GeneratedEvent,
1381
+ } from '@katn30/trakr';
1382
+
1383
+ enum IssueEvents {
1384
+ SubmittedDetailsRevised = 'SubmittedDetailsRevised',
1385
+ AnalysisRevised = 'AnalysisRevised',
1386
+ StageTransitioned = 'StageTransitioned',
1387
+ CommentAdded = 'CommentAdded',
1388
+ CommentRemoved = 'CommentRemoved',
1389
+ CommentEdited = 'CommentEdited',
1390
+ CommentStatusChanged = 'CommentStatusChanged',
1391
+ }
1392
+
1393
+ class CommentModel extends TrackedObject {
1394
+ @AutoId
1395
+ id: number = 0;
1396
+
1397
+ @EventTracked(IssueEvents.CommentEdited)
1398
+ accessor text: string = '';
1399
+
1400
+ @EventTracked(IssueEvents.CommentStatusChanged)
1401
+ accessor status: string = 'open';
1402
+
1403
+ constructor(t: Tracker) { super(t); }
1404
+ }
1405
+
1406
+ class IssueModel extends TrackedObject {
1407
+ @AutoId
1408
+ id: number = 0;
1409
+
1410
+ @EventTracked(IssueEvents.SubmittedDetailsRevised)
1411
+ accessor name: string = '';
1412
+
1413
+ @EventTracked(IssueEvents.SubmittedDetailsRevised)
1414
+ accessor description: string = '';
1415
+
1416
+ @EventTracked(IssueEvents.AnalysisRevised)
1417
+ accessor analysisSummary: string | null = null;
1418
+
1419
+ @EventTracked(IssueEvents.AnalysisRevised)
1420
+ accessor rootCause: string | null = null;
1421
+
1422
+ // Declared LAST — see "Ordering" below.
1423
+ @EventTracked(IssueEvents.StageTransitioned)
1424
+ accessor stage: string = 'Submitted';
1425
+
1426
+ readonly comments: EventTrackedCollection<CommentModel>;
1427
+
1428
+ constructor(t: Tracker) {
1429
+ super(t);
1430
+ this.comments = new EventTrackedCollection<CommentModel>(
1431
+ t,
1432
+ [],
1433
+ undefined,
1434
+ {
1435
+ itemAdded: IssueEvents.CommentAdded,
1436
+ itemRemoved: IssueEvents.CommentRemoved,
1437
+ },
1438
+ );
1439
+ }
1440
+ }
1441
+
1442
+ const tracker = new EventTracker();
1443
+ const issue = tracker.construct(() => new IssueModel(tracker));
1444
+ tracker.onCommit(); // start clean (issue is loaded)
1445
+
1446
+ // user makes some edits
1447
+ issue.name = 'Faulty widget';
1448
+ issue.stage = 'InAnalysis';
1449
+
1450
+ // on Save:
1451
+ const events: GeneratedEvent<IssueEvents>[] = tracker.generateEvents<IssueEvents>();
1452
+ // events = [
1453
+ // { eventType: 'SubmittedDetailsRevised', payload: { name: 'Faulty widget' }, trackingId: 1, targetId: ... },
1454
+ // { eventType: 'StageTransitioned', payload: { stage: 'InAnalysis' }, trackingId: 1, targetId: ... },
1455
+ // ]
1456
+ await api.publishEvents(events);
1457
+ tracker.onCommit();
1458
+ ```
1459
+
1460
+ `generateEvents()` is a **pure read** — no mutation, no state transitions. Calling it twice with no intervening writes returns the same list.
1461
+
1462
+ ### `@EventTracked`
1463
+
1464
+ Drop-in replacement for `@Tracked` that adds an **event-type tag** as the first argument. Everything else — validator, `onChange`, `coalesceWithin`, undo/redo, dependency tracking, no-op detection — is identical.
1465
+
1466
+ ```typescript
1467
+ @EventTracked(eventType, validator?, onChange?, options?)
1468
+ ```
1469
+
1470
+ | Parameter | Type | Description |
1471
+ |---|---|---|
1472
+ | `eventType` | `string` (typically an enum value) | The tag this field contributes to |
1473
+ | `validator` | `(self, newValue) => string \| undefined` | Same as `@Tracked` |
1474
+ | `onChange` | `(self, newValue, oldValue) => void` | Same as `@Tracked` |
1475
+ | `options.coalesceWithin` | `number` | Same as `@Tracked` |
1476
+
1477
+ **Field-cluster grouping.** All fields on a class that share the same tag collapse into **one event**, whose payload contains **only the fields that are currently dirty relative to the last committed state**. Untouched fields are never included. `@Tracked` (untagged) fields continue to work — they participate in undo/redo/validation but do not contribute to event generation.
1478
+
1479
+ **Do not name an `@EventTracked` field `state`** — it collides with `TrackedObject.state`, which is the enum used by the state machine. Use a different name (`stage`, `status`, `phase`, `workflowState`…).
1480
+
1481
+ ### `EventTracker`
1482
+
1483
+ Extends `Tracker` with one method:
1484
+
1485
+ ```typescript
1486
+ generateEvents<TEventType extends string = string>(): GeneratedEvent<TEventType>[]
1487
+ ```
1488
+
1489
+ Everything else is unchanged. An `EventTracker` used as a plain `Tracker` (never calling `generateEvents`) behaves **exactly** like a v2 `Tracker`. The one internal difference: `EventTracker.onCommit(keys)` also clears per-instance event-diff state, which is what makes "after commit, `generateEvents` returns `[]`" work.
1490
+
1491
+ ### `EventTrackedCollection<T>`
1492
+
1493
+ Extends `TrackedCollection<T>` with an optional **lifecycle mapping** for item add/remove:
1494
+
1495
+ ```typescript
1496
+ new EventTrackedCollection<CommentModel>(
1497
+ tracker,
1498
+ initialItems,
1499
+ validator,
1500
+ {
1501
+ itemAdded: IssueEvents.CommentAdded,
1502
+ itemRemoved: IssueEvents.CommentRemoved,
1503
+ },
1504
+ );
1505
+ ```
1506
+
1507
+ For items that are themselves `TrackedObject`s, the following per-item rules apply at generation time:
1508
+
1509
+ | Item state | With `itemAdded` set | With `itemAdded` omitted |
1510
+ |---|---|---|
1511
+ | `Insert` | One `itemAdded` event, payload = **all** `@EventTracked` fields on the item (regardless of tag) | No event |
1512
+ | `Deleted` | One `itemRemoved` event, payload = `{}`, `targetId` = the item's `@AutoId` value | No event |
1513
+ | `Changed` | Per-field-cluster events (as if the item were a standalone `Changed` model) | Same — per-field-cluster events |
1514
+ | `Unchanged` | No event | No event |
1515
+
1516
+ Consumers can opt in to some lifecycle events but not others — the two options are independent. If both are omitted, `EventTrackedCollection` behaves like a plain `TrackedCollection` from the events perspective: only per-field events on `Changed` items are emitted.
1517
+
1518
+ **Insert-then-remove collapses to zero events.** If a `TrackedObject` is pushed to a collection and then removed before Save, its state returns to `Unchanged` (see [Object state machine](#object-state-machine) — `removed/do` from `Insert` collapses to `Unchanged`). No `itemAdded` event is emitted for an object that was never really added.
1519
+
1520
+ **Collections of primitives** — `EventTrackedCollection<string>`, `EventTrackedCollection<number>`, etc. — accept the lifecycle option bag but do not currently emit lifecycle events, because primitives have no `trackingId` or per-field tags. Track primitive add/remove via `collection.changed` if you need those events.
1521
+
1522
+ ### `GeneratedEvent`
1523
+
1524
+ ```typescript
1525
+ interface GeneratedEvent<
1526
+ TEventType extends string = string,
1527
+ TPayload = Record<string, unknown>,
1528
+ > {
1529
+ eventType: TEventType;
1530
+ payload: TPayload;
1531
+ trackingId?: number;
1532
+ targetId?: number;
1533
+ }
1534
+ ```
1535
+
1536
+ | Field | When present | Notes |
1537
+ |---|---|---|
1538
+ | `eventType` | Always | The tag value from the consumer's enum / string-literal union |
1539
+ | `payload` | Always | For lifecycle `itemAdded`: all `@EventTracked` fields' current values. For field-cluster events: only the dirty fields carrying that tag. For lifecycle `itemRemoved`: `{}`. Values of `undefined` are normalised to `null` |
1540
+ | `trackingId` | On events emitted from `Insert` or `Changed` items | Correlate with the backend's `IdAssignment[]` response — same mechanism as v2 |
1541
+ | `targetId` | On events emitted from `Changed` or `Deleted` items when the model has `@AutoId` | The current `@AutoId` value; the backend uses it to identify the row |
1542
+
1543
+ ### Semantic rules
1544
+
1545
+ 1. **Only actual differences produce events.** trakr subscribes to `TrackedObject.changed` and maintains a per-property "original vs. current" diff since the last commit. A write, followed by another write back to the original value (or a `tracker.undo()`), removes the entry — no event is emitted for that field.
1546
+
1547
+ 2. **Insert emits everything, Changed emits deltas.** For `Insert` items, `itemAdded` sends the full snapshot of `@EventTracked` fields (a new aggregate is "born" with all its state). For `Changed` items, field-cluster events send only fields that actually changed.
1548
+
1549
+ 3. **`onCommit` resets the baseline.** After `tracker.onCommit()`, every property's "original" is its now-committed value. Subsequent edits are diffed against this new baseline. Undoing past a commit re-populates the diff naturally, because the property undo closures emit `changed` with the reversed old/new values.
1550
+
1551
+ 4. **`@Tracked` and `@EventTracked` are freely mixable on the same class.** `@Tracked` fields participate in undo/redo/validation as usual; they simply never appear in event payloads.
1552
+
1553
+ ### Ordering
1554
+
1555
+ Event ordering is **deterministic**:
1556
+
1557
+ 1. **Object order** — objects appear in the events in the order they were registered with the tracker (typically the order they were pushed into their collections).
1558
+ 2. **Field-cluster event order within one object** — determined by which of that object's `@EventTracked` fields with the same tag was **first declared** in the class body.
1559
+
1560
+ Consumers who need semantic ordering — for example, "field revisions before a state transition, so the backend's transition precondition sees the freshly-set field values" — control it by **declaring the transition field last** in the class body. trakr does not need to know which events are "transitions"; declaration order is the entire mechanism.
1561
+
1562
+ ```typescript
1563
+ class IssueModel extends TrackedObject {
1564
+ @EventTracked(IssueEvents.SubmittedDetailsRevised) accessor name: string = '';
1565
+ @EventTracked(IssueEvents.AnalysisRevised) accessor analysisSummary: string | null = null;
1566
+ // Declared LAST → its event comes after all others.
1567
+ @EventTracked(IssueEvents.StageTransitioned) accessor stage: string = 'Submitted';
1568
+ }
1569
+ ```
1570
+
1571
+ Given `issue.stage = 'InAnalysis'` and `issue.analysisSummary = 'AS'`, `generateEvents()` returns:
1572
+
1573
+ ```
1574
+ [
1575
+ { eventType: 'AnalysisRevised', payload: { analysisSummary: 'AS' }, ... },
1576
+ { eventType: 'StageTransitioned', payload: { stage: 'InAnalysis' }, ... },
1577
+ ]
1578
+ ```
1579
+
1580
+ Subclass `@EventTracked` fields appear **after** base-class fields, matching the declaration order across the prototype chain.
1581
+
1582
+ ### Scalars vs. sequences: choosing the primitive
1583
+
1584
+ `@EventTracked` and `EventTrackedCollection` look interchangeable when both can carry the same field on the wire — a `stage` property could sit on the model as either an accessor or an item in a collection. They are **not** interchangeable: they encode different semantics, and picking the wrong one silently loses information at Save time.
1585
+
1586
+ **`@EventTracked` accessor — the field is a *value*.** Multiple writes in one session collapse to **one** event carrying the field's **final** value. That is correct behaviour: consumers care about "what the field is now", not "how many times the user retyped it". Typing `Faulty widget` character by character produces one `SubmittedDetailsRevised{name: 'Faulty widget'}`, not eleven.
1587
+
1588
+ **`EventTrackedCollection` — the field is a *sequence of actions*.** Each push emits its own `itemAdded` event, in insertion order. Two pushes produce two events; they never collapse.
1589
+
1590
+ The trap: state-machine-style fields *look* like scalars ("current stage"), so it is tempting to model them as `@EventTracked accessor stage`. That is wrong. A transition is not an update to a value — it is a discrete action, and every intermediate state must be persisted for the backend's transition preconditions (`from → to` allowed?) to hold on replay.
1591
+
1592
+ ```typescript
1593
+ // WRONG — accessor collapses "Submitted → InAnalysis → InFixing" into one event
1594
+ class IssueModel extends TrackedObject {
1595
+ @EventTracked(IssueEvents.StageTransitioned) accessor stage: string = 'Submitted';
1596
+ }
1597
+
1598
+ issue.stage = 'InAnalysis';
1599
+ issue.stage = 'InFixing';
1600
+
1601
+ // generateEvents() returns ONE event with the final value:
1602
+ // [{ eventType: 'StageTransitioned', payload: { stage: 'InFixing' } }]
1603
+ // Backend replays: Submitted → InFixing, rejects as illegal transition.
1604
+ ```
1605
+
1606
+ ```typescript
1607
+ // RIGHT — one StageTransitioned event per push, in order
1608
+ class StageTransition extends TrackedObject {
1609
+ @EventTracked(IssueEvents.StageTransitioned) accessor stage: string;
1610
+ transitionedAt: string;
1611
+ transitionedBy: string | null;
1612
+
1613
+ constructor(t: Tracker, stage: string, at: string, by: string | null) {
1614
+ super(t);
1615
+ this.stage = stage;
1616
+ this.transitionedAt = at;
1617
+ this.transitionedBy = by;
1618
+ }
1619
+ }
1620
+
1621
+ class IssueModel extends TrackedObject {
1622
+ stage: string = 'Submitted'; // plain field, drives UI only
1623
+ readonly transitions: EventTrackedCollection<StageTransition>;
1624
+
1625
+ constructor(t: Tracker) {
1626
+ super(t);
1627
+ this.transitions = new EventTrackedCollection<StageTransition>(
1628
+ t, [], undefined, { itemAdded: IssueEvents.StageTransitioned },
1629
+ );
1630
+ }
1631
+
1632
+ transitionTo(target: string, by: string | null): void {
1633
+ this.stage = target;
1634
+ this.transitions.push(
1635
+ this.tracker.construct(() => new StageTransition(this.tracker, target, new Date().toISOString(), by)),
1636
+ );
1637
+ }
1638
+ }
1639
+
1640
+ issue.transitionTo('InAnalysis', 'alice');
1641
+ issue.transitionTo('InFixing', 'alice');
1642
+
1643
+ // generateEvents() returns TWO events, in insertion order:
1644
+ // [
1645
+ // { eventType: 'StageTransitioned', payload: { stage: 'InAnalysis' }, ... },
1646
+ // { eventType: 'StageTransitioned', payload: { stage: 'InFixing' }, ... },
1647
+ // ]
1648
+ ```
1649
+
1650
+ **Rule of thumb.** If the field's meaning is *"what it is now"* and the backend does not need to see every intermediate write, use `@EventTracked` on an accessor. If each write is a *distinct, ordered action* the backend must apply sequentially — state transitions, audit log entries, phase changes, workflow steps — use `EventTrackedCollection` with an `itemAdded` tag.
1651
+
1652
+ ### Migration
1653
+
1654
+ There is **nothing to migrate** from v2 → v3 for existing code. `Tracker`, `TrackedObject`, `TrackedCollection`, `@Tracked`, and `@AutoId` are unchanged. Opt in per class or per collection whenever the consumer needs event generation. A single tracker instance can mix event-tracked and non-event-tracked objects.
1655
+
1656
+ ---
1657
+
1364
1658
  ## License
1365
1659
 
1366
1660
  MIT — Nazario Mazzotti