trakked 1.2.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:
@@ -467,12 +495,12 @@ Use this instead of `TrackedObject` when your database is **versioned (temporal)
467
495
  import {
468
496
  VersionedTrackedObject,
469
497
  VersionedObjectState,
470
- ExternallyAssigned,
498
+ AutoId,
471
499
  Tracked,
472
500
  } from 'trakked';
473
501
 
474
502
  class OrderModel extends VersionedTrackedObject {
475
- @ExternallyAssigned
503
+ @AutoId
476
504
  id: number = 0;
477
505
 
478
506
  @Tracked()
@@ -522,7 +550,7 @@ Objects default to `Unchanged`. Set properties inside the constructor — they a
522
550
 
523
551
  ```typescript
524
552
  class OrderModel extends VersionedTrackedObject {
525
- @ExternallyAssigned id: number = 0;
553
+ @AutoId id: number = 0;
526
554
  @Tracked() accessor description: string = '';
527
555
  constructor(tracker: Tracker, data?: { id: number; description: string }) {
528
556
  super(tracker); // initialState defaults to Unchanged
@@ -664,14 +692,14 @@ import {
664
692
  VersionedTrackedObject,
665
693
  VersionedObjectState,
666
694
  Tracked,
667
- ExternallyAssigned,
695
+ AutoId,
668
696
  TrackedCollection,
669
697
  } from 'trakked';
670
698
 
671
699
  const tracker = new Tracker();
672
700
 
673
701
  class OrderLine extends VersionedTrackedObject {
674
- @ExternallyAssigned
702
+ @AutoId
675
703
  id: number = 0;
676
704
 
677
705
  @Tracked((_, v) => !v ? 'Description is required' : undefined)
@@ -686,7 +714,7 @@ class OrderLine extends VersionedTrackedObject {
686
714
  }
687
715
 
688
716
  class OrderModel extends VersionedTrackedObject {
689
- @ExternallyAssigned
717
+ @AutoId
690
718
  id: number = 0;
691
719
 
692
720
  @Tracked((_, v) => !v ? 'Status is required' : undefined)
@@ -776,13 +804,13 @@ for (const obj of tracker.trackedObjects) {
776
804
 
777
805
  ---
778
806
 
779
- ### `@ExternallyAssigned`
807
+ ### `@AutoId`
780
808
 
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.
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.
782
810
 
783
811
  ```typescript
784
812
  class InvoiceModel extends TrackedObject {
785
- @ExternallyAssigned
813
+ @AutoId
786
814
  id: number = 0;
787
815
 
788
816
  @Tracked()
@@ -826,7 +854,7 @@ The placeholder counter never resets — each cycle continues from where it left
826
854
 
827
855
  ### `@Tracked()`
828
856
 
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**.
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**.
830
858
 
831
859
  **With `accessor` (recommended):**
832
860
 
@@ -870,6 +898,42 @@ class ProductModel extends TrackedObject {
870
898
  }
871
899
  ```
872
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
+
873
937
  **With a validator:**
874
938
 
875
939
  The validator receives the model instance and the incoming value. Return an error string to fail, `undefined` to pass.
@@ -896,6 +960,8 @@ class OrderModel extends TrackedObject {
896
960
 
897
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`.
898
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
+
899
965
  **No-op detection**
900
966
 
901
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
@@ -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,