trakked 1.2.0 → 2.2.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:
@@ -400,7 +428,7 @@ const invoice = tracker.construct(() => new InvoiceModel(tracker));
400
428
  | `state` | `ObjectState` | Computed DB operation required at save time |
401
429
  | `_committedState` | `ObjectState` | The persisted state. Defaults to `Unchanged`. Pass `initialState` to the constructor to override |
402
430
  | `destroy()` | `void` | Removes this model from the tracker |
403
- | `onCommitted()` | `void` | Called automatically by `tracker.onCommit()` — resets `dirtyCounter` to `0` |
431
+ | `onCommitted()` | `void` | Called automatically by `tracker.onCommit()` — transitions `_committedState` and records the inverse in the undo stack so that undoing a commit restores the correct state |
404
432
 
405
433
  ---
406
434
 
@@ -421,6 +449,16 @@ import { ObjectState } from 'trakked';
421
449
 
422
450
  `Edited` is **derived**: when `_committedState === Unchanged` and the object has unsaved property changes (`isDirty === true`), `state` returns `Edited`. It is never stored directly.
423
451
 
452
+ **Undo of a committed save:**
453
+
454
+ `tracker.onCommit()` records the state transition in the undo stack. Undoing past a commit reverses the server operation:
455
+
456
+ | Committed operation | State after undo | Required server operation |
457
+ |---|---|---|
458
+ | INSERT (`New`) | `Deleted` | DELETE |
459
+ | UPDATE (`Edited`) | `Edited` (with pre-edit values) | UPDATE |
460
+ | DELETE (`Deleted`) | `New` | INSERT |
461
+
424
462
  **Loading from DB:**
425
463
 
426
464
  Objects default to `Unchanged`, so no extra setup is needed. Property values set inside the constructor are suppressed by `tracker.construct()`:
@@ -467,12 +505,12 @@ Use this instead of `TrackedObject` when your database is **versioned (temporal)
467
505
  import {
468
506
  VersionedTrackedObject,
469
507
  VersionedObjectState,
470
- ExternallyAssigned,
508
+ AutoId,
471
509
  Tracked,
472
510
  } from 'trakked';
473
511
 
474
512
  class OrderModel extends VersionedTrackedObject {
475
- @ExternallyAssigned
513
+ @AutoId
476
514
  id: number = 0;
477
515
 
478
516
  @Tracked()
@@ -522,7 +560,7 @@ Objects default to `Unchanged`. Set properties inside the constructor — they a
522
560
 
523
561
  ```typescript
524
562
  class OrderModel extends VersionedTrackedObject {
525
- @ExternallyAssigned id: number = 0;
563
+ @AutoId id: number = 0;
526
564
  @Tracked() accessor description: string = '';
527
565
  constructor(tracker: Tracker, data?: { id: number; description: string }) {
528
566
  super(tracker); // initialState defaults to Unchanged
@@ -664,14 +702,14 @@ import {
664
702
  VersionedTrackedObject,
665
703
  VersionedObjectState,
666
704
  Tracked,
667
- ExternallyAssigned,
705
+ AutoId,
668
706
  TrackedCollection,
669
707
  } from 'trakked';
670
708
 
671
709
  const tracker = new Tracker();
672
710
 
673
711
  class OrderLine extends VersionedTrackedObject {
674
- @ExternallyAssigned
712
+ @AutoId
675
713
  id: number = 0;
676
714
 
677
715
  @Tracked((_, v) => !v ? 'Description is required' : undefined)
@@ -686,7 +724,7 @@ class OrderLine extends VersionedTrackedObject {
686
724
  }
687
725
 
688
726
  class OrderModel extends VersionedTrackedObject {
689
- @ExternallyAssigned
727
+ @AutoId
690
728
  id: number = 0;
691
729
 
692
730
  @Tracked((_, v) => !v ? 'Status is required' : undefined)
@@ -776,13 +814,13 @@ for (const obj of tracker.trackedObjects) {
776
814
 
777
815
  ---
778
816
 
779
- ### `@ExternallyAssigned`
817
+ ### `@AutoId`
780
818
 
781
- Marks a numeric ID property as assigned by the server. Works with both `TrackedObject` and `VersionedTrackedObject`. Enables the `beforeCommit` / `onCommit` lifecycle for ID management.
819
+ 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.
782
820
 
783
821
  ```typescript
784
822
  class InvoiceModel extends TrackedObject {
785
- @ExternallyAssigned
823
+ @AutoId
786
824
  id: number = 0;
787
825
 
788
826
  @Tracked()
@@ -826,7 +864,7 @@ The placeholder counter never resets — each cycle continues from where it left
826
864
 
827
865
  ### `@Tracked()`
828
866
 
829
- 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**.
867
+ 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**.
830
868
 
831
869
  **With `accessor` (recommended):**
832
870
 
@@ -870,6 +908,42 @@ class ProductModel extends TrackedObject {
870
908
  }
871
909
  ```
872
910
 
911
+ **With `get`/`set` and side effects** — decorate both getter and setter:
912
+
913
+ 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.
914
+
915
+ ```typescript
916
+ class RuleModel extends TrackedObject {
917
+ private _isEnabled: boolean = false;
918
+
919
+ @Tracked()
920
+ get isEnabled(): boolean { return this._isEnabled; }
921
+
922
+ @Tracked()
923
+ set isEnabled(value: boolean) {
924
+ this._isEnabled = value;
925
+ if (value) {
926
+ this.scheduleDays = 'mon';
927
+ } else {
928
+ this.scheduleDays = '';
929
+ }
930
+ }
931
+
932
+ @Tracked((self: RuleModel, v) =>
933
+ self.isEnabled && !v ? 'Day is required' : undefined
934
+ )
935
+ accessor scheduleDays: string = '';
936
+
937
+ constructor(tracker: Tracker) {
938
+ super(tracker);
939
+ }
940
+ }
941
+ ```
942
+
943
+ When `isEnabled` is set to `true`, `scheduleDays`'s validator automatically re-runs because the getter declared the dependency. No manual `revalidate()` call is needed.
944
+
945
+ > 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.
946
+
873
947
  **With a validator:**
874
948
 
875
949
  The validator receives the model instance and the incoming value. Return an error string to fail, `undefined` to pass.
@@ -896,6 +970,8 @@ class OrderModel extends TrackedObject {
896
970
 
897
971
  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`.
898
972
 
973
+ 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.
974
+
899
975
  **No-op detection**
900
976
 
901
977
  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
@@ -476,7 +476,7 @@ var Tracker = class {
476
476
  onCommit(keys) {
477
477
  const lastOp = CollectionUtilities.getLast(this._undoOperations);
478
478
  if (keys) {
479
- this.trackedObjects.forEach((obj) => obj.applyExternalAssignments(keys, lastOp));
479
+ this.trackedObjects.forEach((obj) => obj.applyServerIds(keys, lastOp));
480
480
  }
481
481
  this.trackedObjects.forEach((obj) => obj.onCommitted(lastOp));
482
482
  this._commitStateOperation = lastOp;
@@ -487,7 +487,7 @@ var Tracker = class {
487
487
  }
488
488
  beforeCommit() {
489
489
  this.trackedObjects.forEach((model) => {
490
- const propertyName = getExternallyAssignedProperty(
490
+ const propertyName = getAutoIdProperty(
491
491
  Object.getPrototypeOf(model)
492
492
  );
493
493
  if (propertyName && model[propertyName] <= 0) {
@@ -626,8 +626,8 @@ var TrackedObjectBase = class {
626
626
  set dirtyCounter(value) {
627
627
  this._dirtyCounter = value;
628
628
  }
629
- applyExternalAssignments(keys, lastOp) {
630
- const propertyName = getExternallyAssignedProperty(Object.getPrototypeOf(this));
629
+ applyServerIds(keys, lastOp) {
630
+ const propertyName = getAutoIdProperty(Object.getPrototypeOf(this));
631
631
  if (!propertyName || this[propertyName] >= 0) return;
632
632
  const response = keys.find((x) => x.placeholder === this[propertyName]);
633
633
  if (!response) return;
@@ -760,10 +760,10 @@ var VersionedTrackedObject = class extends TrackedObjectBase {
760
760
  }
761
761
  this.dirtyCounter = 0;
762
762
  }
763
- applyExternalAssignments(keys, lastOp) {
764
- const propertyName = getExternallyAssignedProperty(Object.getPrototypeOf(this));
763
+ applyServerIds(keys, lastOp) {
764
+ const propertyName = getAutoIdProperty(Object.getPrototypeOf(this));
765
765
  const response = propertyName && this[propertyName] < 0 ? keys.find((x) => x.placeholder === this[propertyName]) : void 0;
766
- super.applyExternalAssignments(keys, lastOp);
766
+ super.applyServerIds(keys, lastOp);
767
767
  if (!response) return;
768
768
  const newValue = response.value;
769
769
  const redoFn = () => {
@@ -795,7 +795,7 @@ var VersionedTrackedObject = class extends TrackedObjectBase {
795
795
  const prev = this._committedState;
796
796
  const isNeverPersisted = prev === "New" /* New */ || prev === "InsertReverted" /* InsertReverted */;
797
797
  const target = isNeverPersisted ? "Unchanged" /* Unchanged */ : "Deleted" /* Deleted */;
798
- const propertyName = getExternallyAssignedProperty(Object.getPrototypeOf(this));
798
+ const propertyName = getAutoIdProperty(Object.getPrototypeOf(this));
799
799
  const prevId = propertyName !== void 0 ? this[propertyName] : void 0;
800
800
  const prevPendingHardDeletes = new Set(this.pendingHardDeletes);
801
801
  const prevDirtyCounter = this.dirtyCounter;
@@ -847,6 +847,13 @@ var VersionedTrackedObject = class extends TrackedObjectBase {
847
847
  function Tracked(validator, options) {
848
848
  function decorator(target, context) {
849
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
+ }
850
857
  if (context.kind === "accessor") {
851
858
  const accessorTarget = target;
852
859
  if (validator) {
@@ -1288,7 +1295,7 @@ var TrackedCollectionChanged = class {
1288
1295
  };
1289
1296
  // Annotate the CommonJS export names for ESM import in node:
1290
1297
  0 && (module.exports = {
1291
- ExternallyAssigned,
1298
+ AutoId,
1292
1299
  ObjectState,
1293
1300
  OperationProperties,
1294
1301
  PropertyType,