@katn30/trakr 2.2.1 → 3.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,230 @@ 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
+ ### Migration
1583
+
1584
+ 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.
1585
+
1586
+ ---
1587
+
1364
1588
  ## License
1365
1589
 
1366
1590
  MIT — Nazario Mazzotti
@@ -21,6 +21,9 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
23
  AutoId: () => AutoId,
24
+ EventTracked: () => EventTracked,
25
+ EventTrackedCollection: () => EventTrackedCollection,
26
+ EventTracker: () => EventTracker,
24
27
  State: () => State,
25
28
  Tracked: () => Tracked,
26
29
  TrackedCollection: () => TrackedCollection,
@@ -1362,9 +1365,230 @@ var TrackedCollectionChanged = class {
1362
1365
  this.newCollection = newCollection;
1363
1366
  }
1364
1367
  };
1368
+
1369
+ // src/EventRegistry.ts
1370
+ var EVENT_METADATA = /* @__PURE__ */ Symbol("eventMetadata");
1371
+ var instanceState = /* @__PURE__ */ new WeakMap();
1372
+ var subscribedInstances = /* @__PURE__ */ new WeakSet();
1373
+ var itemOriginCollection = /* @__PURE__ */ new WeakMap();
1374
+ function hasOwnEventMetadata(proto) {
1375
+ return Object.prototype.hasOwnProperty.call(proto, EVENT_METADATA);
1376
+ }
1377
+ function ownEventMetadata(proto) {
1378
+ return hasOwnEventMetadata(proto) ? proto[EVENT_METADATA] : void 0;
1379
+ }
1380
+ function registerEventProperty(proto, property, eventType) {
1381
+ if (!hasOwnEventMetadata(proto)) {
1382
+ Object.defineProperty(proto, EVENT_METADATA, {
1383
+ value: /* @__PURE__ */ new Map(),
1384
+ configurable: true
1385
+ });
1386
+ }
1387
+ const map = proto[EVENT_METADATA];
1388
+ if (!map.has(property)) {
1389
+ map.set(property, eventType);
1390
+ }
1391
+ }
1392
+ function getEventMetadata(proto) {
1393
+ const chain = [];
1394
+ let current = proto;
1395
+ while (current) {
1396
+ chain.push(current);
1397
+ current = Object.getPrototypeOf(current);
1398
+ }
1399
+ const merged = /* @__PURE__ */ new Map();
1400
+ for (let i = chain.length - 1; i >= 0; i--) {
1401
+ const own = ownEventMetadata(chain[i]);
1402
+ if (!own) continue;
1403
+ own.forEach((eventType, property) => {
1404
+ if (!merged.has(property)) merged.set(property, eventType);
1405
+ });
1406
+ }
1407
+ return merged;
1408
+ }
1409
+ function ensureEventStateSubscription(target) {
1410
+ if (subscribedInstances.has(target)) return;
1411
+ subscribedInstances.add(target);
1412
+ target.changed.subscribe((evt) => {
1413
+ const meta = getEventMetadata(Object.getPrototypeOf(target));
1414
+ if (!meta.has(evt.property)) return;
1415
+ const state = getOrCreateEventState(target);
1416
+ let entry = state.get(evt.property);
1417
+ if (!entry) {
1418
+ entry = { originalValue: evt.oldValue, currentValue: evt.newValue };
1419
+ state.set(evt.property, entry);
1420
+ } else {
1421
+ entry.currentValue = evt.newValue;
1422
+ }
1423
+ if (entry.currentValue === entry.originalValue) {
1424
+ state.delete(evt.property);
1425
+ }
1426
+ });
1427
+ }
1428
+ function getOrCreateEventState(target) {
1429
+ let state = instanceState.get(target);
1430
+ if (!state) {
1431
+ state = /* @__PURE__ */ new Map();
1432
+ instanceState.set(target, state);
1433
+ }
1434
+ return state;
1435
+ }
1436
+ function getEventState(target) {
1437
+ return instanceState.get(target) ?? /* @__PURE__ */ new Map();
1438
+ }
1439
+ function clearEventState(target) {
1440
+ instanceState.delete(target);
1441
+ }
1442
+ function recordItemOrigin(item, collection) {
1443
+ itemOriginCollection.set(item, collection);
1444
+ }
1445
+ function getItemOrigin(item) {
1446
+ return itemOriginCollection.get(item);
1447
+ }
1448
+
1449
+ // src/EventTracker.ts
1450
+ var EventTracker = class extends Tracker {
1451
+ onCommit(keys) {
1452
+ super.onCommit(keys);
1453
+ for (const obj of this.trackedObjects) {
1454
+ clearEventState(obj);
1455
+ }
1456
+ }
1457
+ generateEvents() {
1458
+ const events = [];
1459
+ for (const obj of this.trackedObjects) {
1460
+ const meta = getEventMetadata(Object.getPrototypeOf(obj));
1461
+ const origin = getItemOrigin(obj);
1462
+ const lifecycle = origin?._lifecycleOptions;
1463
+ switch (obj.state) {
1464
+ case "Insert" /* Insert */:
1465
+ if (lifecycle?.itemAdded) {
1466
+ events.push(buildItemAddedEvent(obj, meta, lifecycle.itemAdded));
1467
+ }
1468
+ break;
1469
+ case "Deleted" /* Deleted */:
1470
+ if (lifecycle?.itemRemoved) {
1471
+ events.push(buildItemRemovedEvent(obj, lifecycle.itemRemoved));
1472
+ }
1473
+ break;
1474
+ case "Changed" /* Changed */:
1475
+ pushFieldClusterEvents(obj, meta, events);
1476
+ break;
1477
+ case "Unchanged" /* Unchanged */:
1478
+ default:
1479
+ break;
1480
+ }
1481
+ }
1482
+ return events;
1483
+ }
1484
+ };
1485
+ function buildItemAddedEvent(obj, meta, eventType) {
1486
+ const payload = {};
1487
+ for (const [propertyName] of meta) {
1488
+ const value = obj[propertyName];
1489
+ payload[propertyName] = normalizeValue(value);
1490
+ }
1491
+ return {
1492
+ eventType,
1493
+ payload,
1494
+ trackingId: obj.trackingId
1495
+ };
1496
+ }
1497
+ function buildItemRemovedEvent(obj, eventType) {
1498
+ const autoIdProp = getAutoIdProperty(Object.getPrototypeOf(obj));
1499
+ const targetId = autoIdProp ? obj[autoIdProp] : void 0;
1500
+ const event = {
1501
+ eventType,
1502
+ payload: {}
1503
+ };
1504
+ if (typeof targetId === "number") {
1505
+ event.targetId = targetId;
1506
+ }
1507
+ return event;
1508
+ }
1509
+ function pushFieldClusterEvents(obj, meta, events) {
1510
+ const state = getEventState(obj);
1511
+ if (state.size === 0) return;
1512
+ const grouped = /* @__PURE__ */ new Map();
1513
+ for (const [propertyName, eventType] of meta) {
1514
+ if (!state.has(propertyName)) continue;
1515
+ let bucket = grouped.get(eventType);
1516
+ if (!bucket) {
1517
+ bucket = [];
1518
+ grouped.set(eventType, bucket);
1519
+ }
1520
+ bucket.push(propertyName);
1521
+ }
1522
+ const autoIdProp = getAutoIdProperty(Object.getPrototypeOf(obj));
1523
+ const targetIdRaw = autoIdProp ? obj[autoIdProp] : void 0;
1524
+ for (const [eventType, propertyNames] of grouped) {
1525
+ const payload = {};
1526
+ for (const propertyName of propertyNames) {
1527
+ payload[propertyName] = normalizeValue(state.get(propertyName).currentValue);
1528
+ }
1529
+ const event = {
1530
+ eventType,
1531
+ payload,
1532
+ trackingId: obj.trackingId
1533
+ };
1534
+ if (typeof targetIdRaw === "number") {
1535
+ event.targetId = targetIdRaw;
1536
+ }
1537
+ events.push(event);
1538
+ }
1539
+ }
1540
+ function normalizeValue(value) {
1541
+ return value === void 0 ? null : value;
1542
+ }
1543
+
1544
+ // src/EventTracked.ts
1545
+ function EventTracked(eventType, validator, onChange, options) {
1546
+ const trackedDecorator = Tracked(validator, onChange, options);
1547
+ function decorator(target, context) {
1548
+ const result = trackedDecorator(target, context);
1549
+ const propertyName = String(context.name);
1550
+ context.addInitializer(function() {
1551
+ registerEventProperty(
1552
+ Object.getPrototypeOf(this),
1553
+ propertyName,
1554
+ eventType
1555
+ );
1556
+ ensureEventStateSubscription(this);
1557
+ });
1558
+ return result;
1559
+ }
1560
+ return decorator;
1561
+ }
1562
+
1563
+ // src/EventTrackedCollection.ts
1564
+ var EventTrackedCollection = class extends TrackedCollection {
1565
+ constructor(tracker, items, validator, eventOptions) {
1566
+ super(tracker, items, validator);
1567
+ this._eventOptions = eventOptions;
1568
+ for (const item of this.collection) {
1569
+ if (item instanceof TrackedObject) {
1570
+ recordItemOrigin(item, this);
1571
+ }
1572
+ }
1573
+ this.changed.subscribe((evt) => {
1574
+ for (const item of evt.added) {
1575
+ if (item instanceof TrackedObject) {
1576
+ recordItemOrigin(item, this);
1577
+ }
1578
+ }
1579
+ });
1580
+ }
1581
+ /** @internal */
1582
+ get _lifecycleOptions() {
1583
+ return this._eventOptions;
1584
+ }
1585
+ };
1365
1586
  // Annotate the CommonJS export names for ESM import in node:
1366
1587
  0 && (module.exports = {
1367
1588
  AutoId,
1589
+ EventTracked,
1590
+ EventTrackedCollection,
1591
+ EventTracker,
1368
1592
  State,
1369
1593
  Tracked,
1370
1594
  TrackedCollection,