trakked 1.1.0 → 2.1.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
@@ -120,6 +120,34 @@ To disable coalescing for a specific `string` or `number` property while leaving
120
120
  accessor version: number = 0; // every increment is its own undo step
121
121
  ```
122
122
 
123
+ ### Dependency tracking
124
+
125
+ Validators can read other properties of the same model — for example, a `scheduleDays` field might be required only when `isEnabled` is `true`. Trakked automatically tracks which properties each validator reads, and re-runs only the affected validators when those properties change.
126
+
127
+ This works via a lightweight dependency tracking mechanism built into the `@Tracked` getter. Every time a validator runs, Trakked collects every `@Tracked` property that is read during the call. These are recorded as dependencies. When any of those properties is written next, only the validators that declared a dependency on it are re-evaluated — not the entire model.
128
+
129
+ **Consequence for `get`/`set` pairs:** The dependency is registered through the getter, not the setter. If a property is written via a plain setter and its getter is not decorated with `@Tracked`, any validator that reads it will not discover the dependency — and will not re-run when the property changes.
130
+
131
+ ```typescript
132
+ // WRONG — isEnabled getter is plain; validators that read self.isEnabled
133
+ // will not re-run when isEnabled changes
134
+ get isEnabled(): boolean { return this._isEnabled; }
135
+
136
+ @Tracked()
137
+ set isEnabled(value: boolean) { this._isEnabled = value; }
138
+ ```
139
+
140
+ ```typescript
141
+ // CORRECT — both getter and setter are decorated
142
+ @Tracked()
143
+ get isEnabled(): boolean { return this._isEnabled; }
144
+
145
+ @Tracked()
146
+ set isEnabled(value: boolean) { this._isEnabled = value; }
147
+ ```
148
+
149
+ When using `accessor` fields this is never an issue — the getter and setter share the same decoration.
150
+
123
151
  ### Construction via tracker.construct()
124
152
 
125
153
  All tracked model objects must be created inside `tracker.construct()`. This call:
@@ -229,6 +257,8 @@ const tracker = new Tracker(undefined); // coalescing disabled
229
257
  | `isDirtyChanged` | `TypedEvent<boolean>` | Fires whenever `isDirty` changes |
230
258
  | `isValidChanged` | `TypedEvent<boolean>` | Fires whenever `isValid` changes |
231
259
  | `canCommitChanged` | `TypedEvent<boolean>` | Fires whenever `canCommit` changes |
260
+ | `version` | `number` | Monotonically changing counter — starts at `0`, increments on every new operation, decrements on undo, increments on redo |
261
+ | `versionChanged` | `TypedEvent<number>` | Fires with the new version every time `version` changes |
232
262
  | `trackedObjects` | `TrackedObjectBase[]` | All registered models |
233
263
  | `trackedCollections` | `TrackedCollection<any>[]` | All registered collections |
234
264
 
@@ -336,6 +366,38 @@ function openEditModal(model: PersonModel) {
336
366
  }
337
367
  ```
338
368
 
369
+ **React integration — `useSyncExternalStore`**
370
+
371
+ `version` and `versionChanged` are designed to plug directly into React's `useSyncExternalStore`. Subscribe to `versionChanged` as the store and snapshot `tracker.version` — any component that calls the hook will automatically re-render on every tracked mutation, undo, or redo with no bridging code required:
372
+
373
+ ```typescript
374
+ import { useSyncExternalStore } from 'react';
375
+ import { Tracker } from 'trakked';
376
+
377
+ function useTrackerVersion(tracker: Tracker): number {
378
+ return useSyncExternalStore(
379
+ (onStoreChange) => tracker.versionChanged.subscribe(onStoreChange),
380
+ () => tracker.version,
381
+ );
382
+ }
383
+ ```
384
+
385
+ Any component that calls `useTrackerVersion(tracker)` will re-render whenever the tracker's state changes.
386
+
387
+ ```tsx
388
+ function InvoiceForm({ tracker, invoice }: { tracker: Tracker; invoice: InvoiceModel }) {
389
+ useTrackerVersion(tracker); // re-renders on every mutation, undo, or redo
390
+
391
+ return (
392
+ <form>
393
+ <input value={invoice.status} onChange={(e) => { invoice.status = e.target.value; }} />
394
+ <button disabled={!tracker.canUndo} onClick={() => tracker.undo()}>Undo</button>
395
+ <button disabled={!tracker.canCommit} onClick={save}>Save</button>
396
+ </form>
397
+ );
398
+ }
399
+ ```
400
+
339
401
  ---
340
402
 
341
403
  ### `TrackedObject`
@@ -433,12 +495,12 @@ Use this instead of `TrackedObject` when your database is **versioned (temporal)
433
495
  import {
434
496
  VersionedTrackedObject,
435
497
  VersionedObjectState,
436
- ExternallyAssigned,
498
+ AutoId,
437
499
  Tracked,
438
500
  } from 'trakked';
439
501
 
440
502
  class OrderModel extends VersionedTrackedObject {
441
- @ExternallyAssigned
503
+ @AutoId
442
504
  id: number = 0;
443
505
 
444
506
  @Tracked()
@@ -488,7 +550,7 @@ Objects default to `Unchanged`. Set properties inside the constructor — they a
488
550
 
489
551
  ```typescript
490
552
  class OrderModel extends VersionedTrackedObject {
491
- @ExternallyAssigned id: number = 0;
553
+ @AutoId id: number = 0;
492
554
  @Tracked() accessor description: string = '';
493
555
  constructor(tracker: Tracker, data?: { id: number; description: string }) {
494
556
  super(tracker); // initialState defaults to Unchanged
@@ -630,14 +692,14 @@ import {
630
692
  VersionedTrackedObject,
631
693
  VersionedObjectState,
632
694
  Tracked,
633
- ExternallyAssigned,
695
+ AutoId,
634
696
  TrackedCollection,
635
697
  } from 'trakked';
636
698
 
637
699
  const tracker = new Tracker();
638
700
 
639
701
  class OrderLine extends VersionedTrackedObject {
640
- @ExternallyAssigned
702
+ @AutoId
641
703
  id: number = 0;
642
704
 
643
705
  @Tracked((_, v) => !v ? 'Description is required' : undefined)
@@ -652,7 +714,7 @@ class OrderLine extends VersionedTrackedObject {
652
714
  }
653
715
 
654
716
  class OrderModel extends VersionedTrackedObject {
655
- @ExternallyAssigned
717
+ @AutoId
656
718
  id: number = 0;
657
719
 
658
720
  @Tracked((_, v) => !v ? 'Status is required' : undefined)
@@ -742,13 +804,13 @@ for (const obj of tracker.trackedObjects) {
742
804
 
743
805
  ---
744
806
 
745
- ### `@ExternallyAssigned`
807
+ ### `@AutoId`
746
808
 
747
- Marks a numeric ID property as assigned by the server. Works with both `TrackedObject` and `VersionedTrackedObject`. Enables the `beforeCommit` / `onCommit` lifecycle for ID management.
809
+ Marks a property as the server-assigned autoincrement primary key for this model. Works with both `TrackedObject` and `VersionedTrackedObject`. Only one `@AutoId` field is allowed per class. Enables the `beforeCommit` / `onCommit` lifecycle for placeholder ID management.
748
810
 
749
811
  ```typescript
750
812
  class InvoiceModel extends TrackedObject {
751
- @ExternallyAssigned
813
+ @AutoId
752
814
  id: number = 0;
753
815
 
754
816
  @Tracked()
@@ -792,7 +854,7 @@ The placeholder counter never resets — each cycle continues from where it left
792
854
 
793
855
  ### `@Tracked()`
794
856
 
795
- The property decorator. Intercepts every write, records an undo/redo pair, and optionally validates the new value. Works with both `accessor` fields and explicit `get`/`set` pairs. Place it on the **accessor** or the **setter**.
857
+ The property decorator. Intercepts every write, records an undo/redo pair, and optionally validates the new value. Works with `accessor` fields, explicit `get`/`set` pairs, and plain getters. Place it on the **accessor**, the **setter**, or the **getter**.
796
858
 
797
859
  **With `accessor` (recommended):**
798
860
 
@@ -836,6 +898,42 @@ class ProductModel extends TrackedObject {
836
898
  }
837
899
  ```
838
900
 
901
+ **With `get`/`set` and side effects** — decorate both getter and setter:
902
+
903
+ When the setter contains side-effect logic that must stay intact (e.g. cascading writes to other properties), decorate both the getter and the setter. The getter decoration registers `isEnabled` as a dependency source — any validator that reads it will automatically re-run when the setter fires. The setter decoration handles undo/redo as usual.
904
+
905
+ ```typescript
906
+ class RuleModel extends TrackedObject {
907
+ private _isEnabled: boolean = false;
908
+
909
+ @Tracked()
910
+ get isEnabled(): boolean { return this._isEnabled; }
911
+
912
+ @Tracked()
913
+ set isEnabled(value: boolean) {
914
+ this._isEnabled = value;
915
+ if (value) {
916
+ this.scheduleDays = 'mon';
917
+ } else {
918
+ this.scheduleDays = '';
919
+ }
920
+ }
921
+
922
+ @Tracked((self: RuleModel, v) =>
923
+ self.isEnabled && !v ? 'Day is required' : undefined
924
+ )
925
+ accessor scheduleDays: string = '';
926
+
927
+ constructor(tracker: Tracker) {
928
+ super(tracker);
929
+ }
930
+ }
931
+ ```
932
+
933
+ When `isEnabled` is set to `true`, `scheduleDays`'s validator automatically re-runs because the getter declared the dependency. No manual `revalidate()` call is needed.
934
+
935
+ > Note: decorating just the getter (without the setter) is valid when the getter is purely computed — it registers the property as a dependency source without attaching any undo/redo logic.
936
+
839
937
  **With a validator:**
840
938
 
841
939
  The validator receives the model instance and the incoming value. Return an error string to fail, `undefined` to pass.
@@ -862,6 +960,8 @@ class OrderModel extends TrackedObject {
862
960
 
863
961
  Validators are re-evaluated after every tracked write and after every undo/redo. Results are stored in `model.validationMessages` and rolled up into `tracker.isValid`.
864
962
 
963
+ Validators that read other properties automatically re-run when those properties change — this is handled by the dependency tracking mechanism (see [Dependency tracking](#dependency-tracking) in Concepts). For this to work, every property read inside a validator must be exposed through a `@Tracked`-decorated getter. `accessor` fields satisfy this automatically. For `get`/`set` pairs, both the getter and setter must be decorated with `@Tracked` — see the "getter + setter with side effects" example above.
964
+
865
965
  **No-op detection**
866
966
 
867
967
  Assigning the same value twice does not create an undo step and does not mark the model dirty. `null` and `undefined` are treated as equivalent to `''` for string properties.
@@ -20,7 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
- ExternallyAssigned: () => ExternallyAssigned,
23
+ AutoId: () => AutoId,
24
24
  ObjectState: () => ObjectState,
25
25
  OperationProperties: () => OperationProperties,
26
26
  PropertyType: () => PropertyType,
@@ -127,17 +127,17 @@ var CollectionUtilities = class {
127
127
  };
128
128
 
129
129
  // src/ExternallyAssigned.ts
130
- var EXTERNALLY_ASSIGNED = /* @__PURE__ */ Symbol("externallyAssigned");
131
- function ExternallyAssigned(_target, context) {
130
+ var AUTO_ID = /* @__PURE__ */ Symbol("autoId");
131
+ function AutoId(_target, context) {
132
132
  context.addInitializer(function() {
133
- Object.defineProperty(Object.getPrototypeOf(this), EXTERNALLY_ASSIGNED, {
133
+ Object.defineProperty(Object.getPrototypeOf(this), AUTO_ID, {
134
134
  value: String(context.name),
135
135
  configurable: true
136
136
  });
137
137
  });
138
138
  }
139
- function getExternallyAssignedProperty(proto) {
140
- return EXTERNALLY_ASSIGNED in proto ? proto[EXTERNALLY_ASSIGNED] : void 0;
139
+ function getAutoIdProperty(proto) {
140
+ return AUTO_ID in proto ? proto[AUTO_ID] : void 0;
141
141
  }
142
142
 
143
143
  // src/DependencyTracker.ts
@@ -277,9 +277,11 @@ var Tracker = class {
277
277
  this._externallyAssignedPlaceholderCounter = -1;
278
278
  this._invalidCount = 0;
279
279
  this._constructionDepth = 0;
280
+ this._version = 0;
280
281
  this.trackedObjects = [];
281
282
  this.trackedCollections = [];
282
283
  this.isDirtyChanged = new TypedEvent();
284
+ this.versionChanged = new TypedEvent();
283
285
  this.isValidChanged = new TypedEvent();
284
286
  this.canCommitChanged = new TypedEvent();
285
287
  this.coalescingWindowMs = coalescingWindowMs;
@@ -306,6 +308,9 @@ var Tracker = class {
306
308
  this.updateCanCommit();
307
309
  }
308
310
  }
311
+ get version() {
312
+ return this._version;
313
+ }
309
314
  get isValid() {
310
315
  return this._isValid;
311
316
  }
@@ -414,6 +419,8 @@ var Tracker = class {
414
419
  this._undoOperations.push(this._currentOperation);
415
420
  this._redoOperations.length = 0;
416
421
  this.reset();
422
+ this._version++;
423
+ this.versionChanged.emit(this._version);
417
424
  }
418
425
  }
419
426
  this._currentOperation?.add(
@@ -469,7 +476,7 @@ var Tracker = class {
469
476
  onCommit(keys) {
470
477
  const lastOp = CollectionUtilities.getLast(this._undoOperations);
471
478
  if (keys) {
472
- this.trackedObjects.forEach((obj) => obj.applyExternalAssignments(keys, lastOp));
479
+ this.trackedObjects.forEach((obj) => obj.applyServerIds(keys, lastOp));
473
480
  }
474
481
  this.trackedObjects.forEach((obj) => obj.onCommitted(lastOp));
475
482
  this._commitStateOperation = lastOp;
@@ -480,7 +487,7 @@ var Tracker = class {
480
487
  }
481
488
  beforeCommit() {
482
489
  this.trackedObjects.forEach((model) => {
483
- const propertyName = getExternallyAssignedProperty(
490
+ const propertyName = getAutoIdProperty(
484
491
  Object.getPrototypeOf(model)
485
492
  );
486
493
  if (propertyName && model[propertyName] <= 0) {
@@ -493,6 +500,53 @@ var Tracker = class {
493
500
  this.canRedo = this._redoOperations.length > 0;
494
501
  this.isDirty = CollectionUtilities.getLast(this._undoOperations) !== this._commitStateOperation;
495
502
  }
503
+ startCoalescing() {
504
+ if (this._composingBaseIndex !== void 0) return;
505
+ this._composingBaseIndex = this._undoOperations.length;
506
+ this._composingRedoLength = this._redoOperations.length;
507
+ }
508
+ endCoalescing() {
509
+ if (this._composingBaseIndex === void 0) return;
510
+ const composed = this._undoOperations.splice(this._composingBaseIndex);
511
+ this._redoOperations.splice(this._composingRedoLength);
512
+ this._composingBaseIndex = void 0;
513
+ this._composingRedoLength = void 0;
514
+ if (composed.length === 0) {
515
+ this.reset();
516
+ return;
517
+ }
518
+ if (composed.length === 1) {
519
+ this._undoOperations.push(composed[0]);
520
+ this.reset();
521
+ return;
522
+ }
523
+ const merged = new Operation();
524
+ for (const op of composed) {
525
+ for (const action of op.actions) {
526
+ merged.add(action.redoAction, action.undoAction, action.properties);
527
+ }
528
+ }
529
+ this._undoOperations.push(merged);
530
+ this.reset();
531
+ }
532
+ rollbackCoalescing() {
533
+ if (this._composingBaseIndex === void 0) return;
534
+ const toRevert = this._undoOperations.splice(this._composingBaseIndex);
535
+ this._redoOperations.splice(this._composingRedoLength);
536
+ this._composingBaseIndex = void 0;
537
+ this._composingRedoLength = void 0;
538
+ this.withTrackingSuppressed(() => {
539
+ for (let i = toRevert.length - 1; i >= 0; i--) {
540
+ toRevert[i].undo();
541
+ }
542
+ });
543
+ this.reset();
544
+ this.revalidate();
545
+ if (toRevert.length > 0) {
546
+ this._version -= toRevert.length;
547
+ this.versionChanged.emit(this._version);
548
+ }
549
+ }
496
550
  undo() {
497
551
  if (!this.canUndo) {
498
552
  return;
@@ -502,6 +556,8 @@ var Tracker = class {
502
556
  this._redoOperations.push(undoOperation);
503
557
  this.reset();
504
558
  this.revalidate();
559
+ this._version--;
560
+ this.versionChanged.emit(this._version);
505
561
  }
506
562
  redo() {
507
563
  if (!this.canRedo) {
@@ -512,6 +568,8 @@ var Tracker = class {
512
568
  this._undoOperations.push(redoOperation);
513
569
  this.reset();
514
570
  this.revalidate();
571
+ this._version++;
572
+ this.versionChanged.emit(this._version);
515
573
  }
516
574
  revalidate() {
517
575
  this.trackedObjects.forEach((x) => validate(x));
@@ -568,8 +626,8 @@ var TrackedObjectBase = class {
568
626
  set dirtyCounter(value) {
569
627
  this._dirtyCounter = value;
570
628
  }
571
- applyExternalAssignments(keys, lastOp) {
572
- const propertyName = getExternallyAssignedProperty(Object.getPrototypeOf(this));
629
+ applyServerIds(keys, lastOp) {
630
+ const propertyName = getAutoIdProperty(Object.getPrototypeOf(this));
573
631
  if (!propertyName || this[propertyName] >= 0) return;
574
632
  const response = keys.find((x) => x.placeholder === this[propertyName]);
575
633
  if (!response) return;
@@ -702,10 +760,10 @@ var VersionedTrackedObject = class extends TrackedObjectBase {
702
760
  }
703
761
  this.dirtyCounter = 0;
704
762
  }
705
- applyExternalAssignments(keys, lastOp) {
706
- const propertyName = getExternallyAssignedProperty(Object.getPrototypeOf(this));
763
+ applyServerIds(keys, lastOp) {
764
+ const propertyName = getAutoIdProperty(Object.getPrototypeOf(this));
707
765
  const response = propertyName && this[propertyName] < 0 ? keys.find((x) => x.placeholder === this[propertyName]) : void 0;
708
- super.applyExternalAssignments(keys, lastOp);
766
+ super.applyServerIds(keys, lastOp);
709
767
  if (!response) return;
710
768
  const newValue = response.value;
711
769
  const redoFn = () => {
@@ -737,7 +795,7 @@ var VersionedTrackedObject = class extends TrackedObjectBase {
737
795
  const prev = this._committedState;
738
796
  const isNeverPersisted = prev === "New" /* New */ || prev === "InsertReverted" /* InsertReverted */;
739
797
  const target = isNeverPersisted ? "Unchanged" /* Unchanged */ : "Deleted" /* Deleted */;
740
- const propertyName = getExternallyAssignedProperty(Object.getPrototypeOf(this));
798
+ const propertyName = getAutoIdProperty(Object.getPrototypeOf(this));
741
799
  const prevId = propertyName !== void 0 ? this[propertyName] : void 0;
742
800
  const prevPendingHardDeletes = new Set(this.pendingHardDeletes);
743
801
  const prevDirtyCounter = this.dirtyCounter;
@@ -789,6 +847,13 @@ var VersionedTrackedObject = class extends TrackedObjectBase {
789
847
  function Tracked(validator, options) {
790
848
  function decorator(target, context) {
791
849
  const propertyName = String(context.name);
850
+ if (context.kind === "getter") {
851
+ const getterFn = target;
852
+ return function() {
853
+ DependencyTracker.record(this, propertyName);
854
+ return getterFn.call(this);
855
+ };
856
+ }
792
857
  if (context.kind === "accessor") {
793
858
  const accessorTarget = target;
794
859
  if (validator) {
@@ -1230,7 +1295,7 @@ var TrackedCollectionChanged = class {
1230
1295
  };
1231
1296
  // Annotate the CommonJS export names for ESM import in node:
1232
1297
  0 && (module.exports = {
1233
- ExternallyAssigned,
1298
+ AutoId,
1234
1299
  ObjectState,
1235
1300
  OperationProperties,
1236
1301
  PropertyType,