trakked 1.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 ADDED
@@ -0,0 +1,974 @@
1
+ # Trakked
2
+
3
+ A TypeScript library for frontend state management — undo/redo, dirty tracking, validation, and server-assigned ID handling.
4
+
5
+ Built on the **TC39 decorator standard** (Stage 3). Requires TypeScript 5+ with `experimentalDecorators` **not** set.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install trakked
11
+ ```
12
+
13
+ ```json
14
+ // tsconfig.json — no experimentalDecorators needed
15
+ {
16
+ "compilerOptions": {
17
+ "target": "ES2022",
18
+ "lib": ["ES2022"]
19
+ }
20
+ }
21
+ ```
22
+
23
+ ---
24
+
25
+ ## Quick Start
26
+
27
+ ```typescript
28
+ import {
29
+ Tracker,
30
+ TrackedObject,
31
+ Tracked,
32
+ TrackedCollection,
33
+ } from 'trakked';
34
+
35
+ const tracker = new Tracker();
36
+
37
+ class InvoiceModel extends TrackedObject {
38
+ @Tracked()
39
+ accessor status: string = '';
40
+
41
+ @Tracked((self, value) => !value ? 'Status is required' : undefined)
42
+ accessor total: number = 0;
43
+
44
+ readonly lines: TrackedCollection<string>;
45
+
46
+ constructor(tracker: Tracker) {
47
+ super(tracker);
48
+ this.lines = new TrackedCollection(tracker);
49
+ }
50
+ }
51
+
52
+ const invoice = tracker.construct(() => new InvoiceModel(tracker));
53
+
54
+ invoice.status = 'draft'; // recorded
55
+ invoice.total = 100; // recorded
56
+ invoice.lines.push('item-1'); // recorded
57
+
58
+ tracker.isDirty; // true
59
+ tracker.canUndo; // true
60
+
61
+ tracker.undo(); // reverts lines.push
62
+ tracker.undo(); // reverts total
63
+ tracker.undo(); // reverts status
64
+
65
+ tracker.isDirty; // false
66
+ ```
67
+
68
+ ---
69
+
70
+ ## Concepts
71
+
72
+ ### Undo/redo strategy
73
+
74
+ The two common patterns for implementing undo/redo are:
75
+
76
+ - **Command** — every change stores a `redoAction` and an `undoAction` closure pair. Undoing calls the inverse function; redoing calls the original. No state is copied.
77
+ - **Memento** — the entire state (or a relevant slice) is snapshotted before each change and restored on undo. Simpler to implement because no inverse logic is required, but carries memory and copying overhead on every change.
78
+
79
+ Trakked uses the **Command pattern** because, once correctly implemented, it is strictly more efficient: no memory overhead, no copying, and undo granularity is exactly as fine or coarse as designed.
80
+
81
+ ### How undo steps are created
82
+
83
+ Every tracked write — a `@Tracked()` property assignment or a `TrackedCollection` mutation — becomes its own undo step **unless** it fires as a synchronous side-effect of another tracked write that is already in progress.
84
+
85
+ ```
86
+ invoice.status = 'void' → undo step A
87
+ invoice.lines.clear() → undo step B (independent)
88
+ ```
89
+
90
+ If a `TrackedCollection.changed` listener updates a `@Tracked()` property synchronously, both the collection mutation and the property update land in the **same** undo step:
91
+
92
+ ```
93
+ order.items.push('x') → undo step A
94
+ └─ changed listener: order.itemCount = 1 (nested, same step A)
95
+
96
+ tracker.undo() → items back to [], itemCount back to 0
97
+ ```
98
+
99
+ This nesting is detected automatically. No extra API is needed.
100
+
101
+ ### String and number aggregation
102
+
103
+ Rapid consecutive writes to the same `string` or `number` property on the same model are merged into a single undo step when they fall within the `coalescingWindowMs` threshold passed to the `Tracker` constructor (default: `3000` ms). Pass `undefined` to disable coalescing entirely.
104
+
105
+ ```typescript
106
+ invoice.status = 'd';
107
+ invoice.status = 'dr';
108
+ invoice.status = 'dra';
109
+ invoice.status = 'draft';
110
+
111
+ tracker.undo(); // reverts all four at once → status = ''
112
+ ```
113
+
114
+ `Date`, `boolean`, and `object` properties are never coalesced.
115
+
116
+ To disable coalescing for a specific `string` or `number` property while leaving it enabled globally, pass `{ noCoalesce: true }` to `@Tracked()`:
117
+
118
+ ```typescript
119
+ @Tracked(undefined, { noCoalesce: true })
120
+ accessor version: number = 0; // every increment is its own undo step
121
+ ```
122
+
123
+ ### Construction via tracker.construct()
124
+
125
+ All tracked model objects must be created inside `tracker.construct()`. This call:
126
+
127
+ - Suppresses tracking for the entire constructor body — property writes during construction are silently applied without creating undo entries
128
+ - Validates the object once after construction
129
+ - Triggers a tracker-wide `revalidate()` to sync `tracker.isValid`
130
+
131
+ The tracker is clean and `canUndo` is `false` immediately after `tracker.construct()` returns.
132
+
133
+ **Single object:**
134
+
135
+ ```typescript
136
+ const invoice = tracker.construct(() => new InvoiceModel(tracker));
137
+ ```
138
+
139
+ **Multiple objects at once:**
140
+
141
+ ```typescript
142
+ tracker.construct(() => {
143
+ new OrderModel(tracker);
144
+ new OrderLine(tracker);
145
+ });
146
+ ```
147
+
148
+ ### Development vs production builds
149
+
150
+ Trakked ships two builds: a development build (`dist/dev/`) and a production build (`dist/prod/`).
151
+
152
+ **Development build** — creating a tracked object outside `tracker.construct()` throws immediately with a descriptive error:
153
+
154
+ ```
155
+ MyModel must be created inside tracker.construct()
156
+ ```
157
+
158
+ This catches accidental bare `new MyModel(tracker)` calls at the earliest possible moment during development.
159
+
160
+ **Production build** — the construction guard is compiled away entirely. There is zero runtime overhead for the check.
161
+
162
+ **Build selection is automatic.** Bundlers that support the `exports` field in `package.json` — Vite, webpack 5+, and others — pick the development build when building in development mode and the production build when building for production. Nothing extra is required from consumers; the correct build is selected via the `development` export condition in Trakked's `package.json`.
163
+
164
+ ### Bulk construction
165
+
166
+ `tracker.construct()` is the canonical way to create any number of objects — single or many. When constructing multiple objects, pass them all inside a single `tracker.construct()` callback. Trakked suppresses tracking for the entire block and calls `tracker.revalidate()` exactly once after all objects are constructed, keeping bulk creation O(n):
167
+
168
+ ```typescript
169
+ tracker.construct(() => {
170
+ for (const row of serverRows) {
171
+ const item = new ItemModel(tracker);
172
+ item.name = row.name;
173
+ }
174
+ });
175
+ // tracker.revalidate() is called once here — not once per object
176
+ ```
177
+
178
+ ### Default state: Unchanged
179
+
180
+ Both `TrackedObject` and `VersionedTrackedObject` default to `Unchanged` at construction time. This matches the most common scenario — objects are loaded from the database and are already persisted.
181
+
182
+ ```typescript
183
+ const item = tracker.construct(() => new ItemModel(tracker)); // state: Unchanged (DB-loaded default)
184
+ ```
185
+
186
+ To create a **new** item that needs to be inserted, add it to a `TrackedCollection` via `push`. The collection is responsible for transitioning the object to `New`:
187
+
188
+ ```typescript
189
+ const item = tracker.construct(() => new ItemModel(tracker));
190
+ items.push(item); // state: New — tracked, undoable
191
+ tracker.undo(); // state: Unchanged, removed from collection
192
+ ```
193
+
194
+ Items passed to the `TrackedCollection` **constructor** are treated as already-persisted rows and are **not** marked as `New`:
195
+
196
+ ```typescript
197
+ const items = new TrackedCollection<ItemModel>(tracker, [dbItem]); // dbItem stays Unchanged
198
+ ```
199
+
200
+ When you need a `New` object outside of a collection, pass the initial state explicitly:
201
+
202
+ ```typescript
203
+ const item = tracker.construct(() => new ItemModel(tracker, ItemState.New));
204
+ ```
205
+
206
+ ---
207
+
208
+ ## API Reference
209
+
210
+ ### `Tracker`
211
+
212
+ The central coordinator. Create one per page or form context and pass it to every model and collection.
213
+
214
+ ```typescript
215
+ const tracker = new Tracker(); // coalescing enabled, 3 second window
216
+ const tracker = new Tracker(5000); // coalesce writes within 5 seconds
217
+ const tracker = new Tracker(undefined); // coalescing disabled
218
+ ```
219
+
220
+ **State properties**
221
+
222
+ | Property | Type | Description |
223
+ |---|---|---|
224
+ | `isDirty` | `boolean` | `true` when uncommitted changes exist |
225
+ | `canUndo` | `boolean` | `true` when there is at least one undo step |
226
+ | `canRedo` | `boolean` | `true` when there are undone steps to redo |
227
+ | `isValid` | `boolean` | `true` when every registered model and collection passes validation |
228
+ | `canCommit` | `boolean` | `true` when `isDirty && isValid` — ready to submit to the server |
229
+ | `isDirtyChanged` | `TypedEvent<boolean>` | Fires whenever `isDirty` changes |
230
+ | `isValidChanged` | `TypedEvent<boolean>` | Fires whenever `isValid` changes |
231
+ | `canCommitChanged` | `TypedEvent<boolean>` | Fires whenever `canCommit` changes |
232
+ | `trackedObjects` | `TrackedObjectBase[]` | All registered models |
233
+ | `trackedCollections` | `TrackedCollection<any>[]` | All registered collections |
234
+
235
+ **Undo / redo**
236
+
237
+ ```typescript
238
+ tracker.undo(); // reverts the last undo step
239
+ tracker.redo(); // re-applies the last undone step
240
+ ```
241
+
242
+ Calling `undo()` or `redo()` when the respective flag is `false` is a no-op.
243
+
244
+ **Commit lifecycle**
245
+
246
+ ```typescript
247
+ tracker.onCommit(); // mark current state as committed — isDirty → false
248
+ tracker.onCommit(keys); // same, plus swap placeholder IDs for real server IDs
249
+ tracker.beforeCommit(); // assign temporary negative IDs to new models before committing
250
+ ```
251
+
252
+ `onCommit()` automatically transitions every tracked object's `state` to `Unchanged` and appends the state change into the existing last undo operation — so undo atomically reverts both the user's edits and the committed state together (no spurious extra undo steps).
253
+
254
+ **Object construction**
255
+
256
+ ```typescript
257
+ // Single object — returns the constructed instance
258
+ const model = tracker.construct(() => new MyModel(tracker));
259
+
260
+ // Multiple objects — returns void
261
+ tracker.construct(() => {
262
+ new ModelA(tracker);
263
+ new ModelB(tracker);
264
+ });
265
+ ```
266
+
267
+ `tracker.construct()` suppresses tracking for the entire callback, runs validators once after all objects are created, and calls `tracker.revalidate()` exactly once at the end.
268
+
269
+ **Tracking suppression**
270
+
271
+ ```typescript
272
+ // Callback form — preferred
273
+ tracker.withTrackingSuppressed(() => {
274
+ model.field = 'silent'; // applied but not recorded, not dirty
275
+ });
276
+
277
+ // Explicit begin/end — useful when the suppressed block spans async boundaries
278
+ tracker.beginSuppressTracking();
279
+ model.field = 'silent';
280
+ tracker.endSuppressTracking();
281
+ ```
282
+
283
+ Suppression is **nestable** via a counter, so calling `beginSuppressTracking()` twice requires two `endSuppressTracking()` calls to resume tracking.
284
+
285
+ ---
286
+
287
+ ### `TrackedObject`
288
+
289
+ `TrackedObject` is the abstract base class for all trackable models in **non-versioned (standard CRUD) databases**. For versioned (temporal) databases see [`VersionedTrackedObject`](#versionedtrackedobject) below.
290
+
291
+ All subclass instances must be created via `tracker.construct()`.
292
+
293
+ ```typescript
294
+ class InvoiceModel extends TrackedObject {
295
+ constructor(tracker: Tracker) {
296
+ super(tracker); // registers the model with the tracker
297
+ }
298
+ }
299
+
300
+ const invoice = tracker.construct(() => new InvoiceModel(tracker));
301
+ ```
302
+
303
+ **Model properties and methods**
304
+
305
+ | Member | Type | Description |
306
+ |---|---|---|
307
+ | `tracker` | `Tracker` | The tracker this model belongs to (set via `super(tracker)`) |
308
+ | `isDirty` | `boolean` | `true` when this model has uncommitted changes |
309
+ | `dirtyCounter` | `number` | Net number of tracked changes since last save. Increments on every tracked write, decrements on undo |
310
+ | `isValid` | `boolean` | `true` when all `@Tracked()` validators pass |
311
+ | `validationMessages` | `Map<string, string>` | Maps property name → error message for each failing validator |
312
+ | `state` | `ObjectState` | Computed DB operation required at save time |
313
+ | `_committedState` | `ObjectState` | The persisted state. Defaults to `Unchanged`. Pass `initialState` to the constructor to override |
314
+ | `destroy()` | `void` | Removes this model from the tracker |
315
+ | `onCommitted()` | `void` | Called automatically by `tracker.onCommit()` — resets `dirtyCounter` to `0` |
316
+
317
+ ---
318
+
319
+ ### `ObjectState`
320
+
321
+ Used by `TrackedObject` for non-versioned CRUD databases. Read via `obj.state`.
322
+
323
+ ```typescript
324
+ import { ObjectState } from 'trakked';
325
+ ```
326
+
327
+ | Value | Meaning | Required DB operation |
328
+ |---|---|---|
329
+ | `New` | Created by user, never saved | INSERT |
330
+ | `Unchanged` | Loaded from DB or just saved — no pending action | — |
331
+ | `Edited` | `Unchanged` + unsaved property changes (derived) | UPDATE |
332
+ | `Deleted` | Removed from a `TrackedCollection` | DELETE |
333
+
334
+ `Edited` is **derived**: when `_committedState === Unchanged` and the object has unsaved property changes (`isDirty === true`), `state` returns `Edited`. It is never stored directly.
335
+
336
+ **Loading from DB:**
337
+
338
+ Objects default to `Unchanged`, so no extra setup is needed. Property values set inside the constructor are suppressed by `tracker.construct()`:
339
+
340
+ ```typescript
341
+ class InvoiceModel extends TrackedObject {
342
+ @Tracked() accessor status: string = '';
343
+ constructor(tracker: Tracker, data?: { status: string }) {
344
+ super(tracker); // initialState defaults to Unchanged
345
+ if (data) this.status = data.status; // suppressed — not tracked
346
+ }
347
+ }
348
+
349
+ const invoice = tracker.construct(() => new InvoiceModel(tracker, { status: 'active' })); // state: Unchanged
350
+ ```
351
+
352
+ **Saving:**
353
+
354
+ ```typescript
355
+ for (const obj of tracker.trackedObjects) {
356
+ if (!(obj instanceof InvoiceModel)) continue;
357
+ switch (obj.state) {
358
+ case ObjectState.New: /* INSERT */ break;
359
+ case ObjectState.Edited: /* UPDATE */ break;
360
+ case ObjectState.Deleted: /* DELETE */ break;
361
+ case ObjectState.Unchanged: break;
362
+ }
363
+ }
364
+
365
+ await saveToServer();
366
+
367
+ tracker.onCommit(); // all objects → Unchanged; isDirty → false
368
+ ```
369
+
370
+ ---
371
+
372
+ ### `VersionedTrackedObject`
373
+
374
+ Use this instead of `TrackedObject` when your database is **versioned (temporal)** — records are never modified in-place; edits close the current row and insert a new version, and deletes are soft.
375
+
376
+ `VersionedTrackedObject` is also the right choice even for standard CRUD databases if you need the `*Reverted` states — i.e., your app must react when the user undoes a previously committed save.
377
+
378
+ ```typescript
379
+ import {
380
+ VersionedTrackedObject,
381
+ VersionedObjectState,
382
+ ExternallyAssigned,
383
+ Tracked,
384
+ } from 'trakked';
385
+
386
+ class OrderModel extends VersionedTrackedObject {
387
+ @ExternallyAssigned
388
+ id: number = 0;
389
+
390
+ @Tracked()
391
+ accessor description: string = '';
392
+
393
+ constructor(tracker: Tracker) {
394
+ super(tracker);
395
+ }
396
+ }
397
+
398
+ const order = tracker.construct(() => new OrderModel(tracker));
399
+ ```
400
+
401
+ **Additional members** (on top of `TrackedObject`'s API)
402
+
403
+ | Member | Type | Description |
404
+ |---|---|---|
405
+ | `state` | `VersionedObjectState` | 7-state version of `ObjectState` (see below) |
406
+ | `_committedState` | `VersionedObjectState` | The persisted state |
407
+ | `pendingHardDeletes` | `Set<number>` | Real DB ids that must be hard-deleted on the server before the next insert of this object |
408
+
409
+ ---
410
+
411
+ ### `VersionedObjectState`
412
+
413
+ ```typescript
414
+ import { VersionedObjectState } from 'trakked';
415
+ ```
416
+
417
+ | Value | Meaning | Required DB operation |
418
+ |---|---|---|
419
+ | `New` | Created by user, never saved | INSERT |
420
+ | `Unchanged` | Loaded from DB or just saved — no pending action | — |
421
+ | `Edited` | `Unchanged` + unsaved property changes (derived) | Close current row + INSERT new version |
422
+ | `Deleted` | Removed from a `TrackedCollection` | SOFT DELETE |
423
+ | `InsertReverted` | A saved insert was undone | HARD DELETE the inserted row |
424
+ | `EditReverted` | A saved edit was undone | HARD DELETE new version + REOPEN previous version |
425
+ | `DeleteReverted` | A saved delete was undone | REOPEN (clear end date / restore) |
426
+
427
+ `Edited` is **derived**, exactly as in `ObjectState`.
428
+
429
+ The three `*Reverted` states arise when the user undoes a `tracker.onCommit()` call. Each encodes the fact that a row now exists in the database that the user has logically rolled back, requiring an explicit compensating write on the server.
430
+
431
+ **Loading from DB:**
432
+
433
+ Objects default to `Unchanged`. Set properties inside the constructor — they are suppressed by `tracker.construct()`:
434
+
435
+ ```typescript
436
+ class OrderModel extends VersionedTrackedObject {
437
+ @ExternallyAssigned id: number = 0;
438
+ @Tracked() accessor description: string = '';
439
+ constructor(tracker: Tracker, data?: { id: number; description: string }) {
440
+ super(tracker); // initialState defaults to Unchanged
441
+ if (data) {
442
+ this.id = data.id;
443
+ this.description = data.description;
444
+ }
445
+ }
446
+ }
447
+
448
+ const order = tracker.construct(() => new OrderModel(tracker, { id: 42, description: 'Widget' })); // state: Unchanged
449
+ ```
450
+
451
+ **Creating a new item:**
452
+
453
+ ```typescript
454
+ const item = tracker.construct(() => new OrderModel(tracker)); // state: Unchanged
455
+ collection.push(item); // state: New — collection sets it
456
+ ```
457
+
458
+ ---
459
+
460
+ ### Versioned save lifecycle
461
+
462
+ This is the complete pattern a client should follow when saving with `VersionedTrackedObject`. Three concerns must be handled: deciding what DB operations each object needs, managing placeholder IDs for new rows, and issuing hard deletes when an insert is undone.
463
+
464
+ #### Step 1 — read pending operations
465
+
466
+ Before sending anything to the server, iterate `tracker.trackedObjects` and read `state` and `pendingHardDeletes` on each `VersionedTrackedObject`:
467
+
468
+ ```typescript
469
+ import { VersionedTrackedObject, VersionedObjectState } from 'trakked';
470
+
471
+ interface SavePayload {
472
+ inserts: { placeholder: number; data: unknown }[];
473
+ updates: { id: number; data: unknown }[];
474
+ softDeletes: { id: number }[];
475
+ hardDeletes: { id: number }[];
476
+ reopens: { id: number }[];
477
+ }
478
+
479
+ function buildPayload(tracker: Tracker): SavePayload {
480
+ const payload: SavePayload = {
481
+ inserts: [], updates: [], softDeletes: [],
482
+ hardDeletes: [], reopens: [],
483
+ };
484
+
485
+ // Assign placeholder IDs to all objects that need a new DB row
486
+ tracker.beforeCommit();
487
+
488
+ for (const obj of tracker.trackedObjects) {
489
+ if (!(obj instanceof VersionedTrackedObject)) continue;
490
+
491
+ // Hard deletes that must reach the server before the new insert
492
+ for (const id of obj.pendingHardDeletes) {
493
+ payload.hardDeletes.push({ id });
494
+ }
495
+
496
+ switch (obj.state) {
497
+ case VersionedObjectState.New:
498
+ // id is a negative placeholder assigned by beforeCommit()
499
+ payload.inserts.push({ placeholder: obj.id, data: serialize(obj) });
500
+ break;
501
+
502
+ case VersionedObjectState.Edited:
503
+ // Close current DB row + insert new version
504
+ payload.softDeletes.push({ id: obj.id });
505
+ payload.inserts.push({ placeholder: obj.id, data: serialize(obj) });
506
+ break;
507
+
508
+ case VersionedObjectState.Deleted:
509
+ payload.softDeletes.push({ id: obj.id });
510
+ break;
511
+
512
+ case VersionedObjectState.InsertReverted:
513
+ // pendingHardDeletes already added above; optionally re-insert
514
+ payload.inserts.push({ placeholder: obj.id, data: serialize(obj) });
515
+ break;
516
+
517
+ case VersionedObjectState.EditReverted:
518
+ // Hard delete new row + reopen the previous row
519
+ // pendingHardDeletes already added above
520
+ payload.reopens.push({ id: obj.previousId }); // your domain logic
521
+ break;
522
+
523
+ case VersionedObjectState.DeleteReverted:
524
+ payload.reopens.push({ id: obj.id });
525
+ break;
526
+
527
+ case VersionedObjectState.Unchanged:
528
+ break;
529
+ }
530
+ }
531
+
532
+ return payload;
533
+ }
534
+ ```
535
+
536
+ > **Order matters:** hard deletes in `pendingHardDeletes` must be sent to the server **before** (or in the same transaction as) the new insert for the same object, because the previous DB row for that id must not conflict with the incoming insert.
537
+
538
+ #### Step 2 — send to server and receive real IDs
539
+
540
+ ```typescript
541
+ const payload = buildPayload(tracker);
542
+ const response = await api.save(payload);
543
+ // response.ids: Array<{ placeholder: number; value: number }>
544
+ ```
545
+
546
+ #### Step 3 — apply real IDs and mark clean
547
+
548
+ ```typescript
549
+ tracker.onCommit(response.ids);
550
+ // Every VersionedTrackedObject → state Unchanged
551
+ // Placeholder IDs replaced with real DB ids
552
+ // tracker.isDirty === false
553
+ ```
554
+
555
+ After a successful `onCommit`, clear `pendingHardDeletes` on each object to avoid re-sending them on the next cycle:
556
+
557
+ ```typescript
558
+ for (const obj of tracker.trackedObjects) {
559
+ if (obj instanceof VersionedTrackedObject) {
560
+ obj.pendingHardDeletes.clear();
561
+ }
562
+ }
563
+ ```
564
+
565
+ #### Step 4 — handling rollback
566
+
567
+ If the server returns an error, do **not** call `tracker.onCommit()`. The tracker remains dirty, `state` values are unchanged, and the user can continue editing or retry.
568
+
569
+ ---
570
+
571
+ #### Complete versioned save example
572
+
573
+ ```typescript
574
+ import {
575
+ Tracker,
576
+ VersionedTrackedObject,
577
+ VersionedObjectState,
578
+ Tracked,
579
+ ExternallyAssigned,
580
+ TrackedCollection,
581
+ } from 'trakked';
582
+
583
+ const tracker = new Tracker();
584
+
585
+ class OrderLine extends VersionedTrackedObject {
586
+ @ExternallyAssigned
587
+ id: number = 0;
588
+
589
+ @Tracked((_, v) => !v ? 'Description is required' : undefined)
590
+ accessor description: string = '';
591
+
592
+ @Tracked((_, v) => v <= 0 ? 'Quantity must be positive' : undefined)
593
+ accessor quantity: number = 1;
594
+
595
+ constructor(tracker: Tracker) {
596
+ super(tracker);
597
+ }
598
+ }
599
+
600
+ class OrderModel extends VersionedTrackedObject {
601
+ @ExternallyAssigned
602
+ id: number = 0;
603
+
604
+ @Tracked((_, v) => !v ? 'Status is required' : undefined)
605
+ accessor status: string = '';
606
+
607
+ readonly lines: TrackedCollection<OrderLine>;
608
+
609
+ constructor(tracker: Tracker) {
610
+ super(tracker);
611
+ this.lines = new TrackedCollection<OrderLine>(
612
+ tracker,
613
+ [],
614
+ (list) => list.length === 0 ? 'At least one line is required' : undefined,
615
+ );
616
+ }
617
+ }
618
+
619
+ // ---- Create and edit ----
620
+
621
+ const { order, line1 } = tracker.construct(() => ({
622
+ order: new OrderModel(tracker),
623
+ line1: new OrderLine(tracker),
624
+ }));
625
+
626
+ order.status = 'draft';
627
+ line1.description = 'Widget';
628
+ line1.quantity = 3;
629
+ order.lines.push(line1);
630
+
631
+ // ---- Save (insert) ----
632
+
633
+ tracker.beforeCommit();
634
+ // order.id === -1, line1.id === -2
635
+
636
+ const response1 = await api.save({
637
+ inserts: [
638
+ { placeholder: order.id, data: { status: order.status } },
639
+ { placeholder: line1.id, data: { description: line1.description, quantity: line1.quantity } },
640
+ ],
641
+ });
642
+ // response1.ids: [{ placeholder: -1, value: 10 }, { placeholder: -2, value: 20 }]
643
+
644
+ tracker.onCommit(response1.ids);
645
+ // order.id === 10, line1.id === 20, state === Unchanged
646
+
647
+ // ---- User edits and saves again ----
648
+
649
+ order.status = 'confirmed';
650
+
651
+ tracker.beforeCommit();
652
+ // order.id is already positive — untouched by beforeCommit
653
+
654
+ const response2 = await api.save({
655
+ // Close row 10, open new version
656
+ softDeletes: [{ id: order.id }],
657
+ inserts: [{ placeholder: order.id, data: { status: order.status } }],
658
+ });
659
+
660
+ tracker.onCommit(response2.ids);
661
+
662
+ // ---- User undoes the second save ----
663
+
664
+ tracker.undo();
665
+ // order.state === EditReverted
666
+ // order.pendingHardDeletes contains the id of the new version that must be hard-deleted
667
+
668
+ // ---- Re-save from EditReverted ----
669
+
670
+ tracker.beforeCommit(); // reassigns a fresh placeholder (current id is negative placeholder)
671
+
672
+ const toHardDelete = [...order.pendingHardDeletes]; // ids to remove from DB
673
+
674
+ const response3 = await api.save({
675
+ hardDeletes: toHardDelete.map(id => ({ id })),
676
+ reopens: [{ id: 10 }], // reopen the previous version
677
+ });
678
+
679
+ tracker.onCommit(response3.ids);
680
+
681
+ // Clear pendingHardDeletes now that the server has processed them
682
+ for (const obj of tracker.trackedObjects) {
683
+ if (obj instanceof VersionedTrackedObject) {
684
+ obj.pendingHardDeletes.clear();
685
+ }
686
+ }
687
+ ```
688
+
689
+ ---
690
+
691
+ ### `@ExternallyAssigned`
692
+
693
+ Marks a numeric ID property as assigned by the server. Works with both `TrackedObject` and `VersionedTrackedObject`. Enables the `beforeCommit` / `onCommit` lifecycle for ID management.
694
+
695
+ ```typescript
696
+ class InvoiceModel extends TrackedObject {
697
+ @ExternallyAssigned
698
+ id: number = 0;
699
+
700
+ @Tracked()
701
+ accessor status: string = '';
702
+
703
+ constructor(tracker: Tracker) {
704
+ super(tracker);
705
+ }
706
+ }
707
+ ```
708
+
709
+ **Typical save flow:**
710
+
711
+ ```typescript
712
+ const invoice = tracker.construct(() => new InvoiceModel(tracker));
713
+ invoice.status = 'draft';
714
+
715
+ // 1. Just before sending to the server:
716
+ tracker.beforeCommit();
717
+ // invoice.id is now -1 (a temporary placeholder)
718
+ // Multiple new models get -1, -2, -3, ...
719
+
720
+ // 2. Send to server, receive real IDs back:
721
+ const serverIds = [{ placeholder: invoice.id, value: 42 }];
722
+
723
+ // 3. Apply real IDs and mark clean:
724
+ tracker.onCommit(serverIds);
725
+ // invoice.id is now 42
726
+ // tracker.isDirty is false
727
+ ```
728
+
729
+ `beforeCommit()` only assigns a placeholder if the property's current value is `≤ 0`. Models that already have a positive ID are left untouched.
730
+
731
+ `onCommit()` with no arguments (or an empty array) still marks the tracker as clean — it just skips the ID replacement step.
732
+
733
+ The placeholder counter never resets — each cycle continues from where it left off — so placeholder IDs are globally unique across the lifetime of the tracker and can never collide across save cycles.
734
+
735
+ **Undo restores the placeholder, not zero.** When the user undoes an `onCommit()`, the ID reverts to the negative placeholder that was active at save time (not `0`). This means `beforeCommit()` on the next cycle sees `id < 0` and correctly assigns a fresh unique placeholder.
736
+
737
+ ---
738
+
739
+ ### `@Tracked()`
740
+
741
+ 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**.
742
+
743
+ **With `accessor` (recommended):**
744
+
745
+ ```typescript
746
+ class ProductModel extends TrackedObject {
747
+ @Tracked()
748
+ accessor name: string = '';
749
+
750
+ @Tracked()
751
+ accessor price: number = 0;
752
+
753
+ @Tracked()
754
+ accessor active: boolean = true;
755
+
756
+ @Tracked()
757
+ accessor config: Record<string, unknown> = {};
758
+
759
+ @Tracked()
760
+ accessor createdAt: Date = new Date();
761
+
762
+ constructor(tracker: Tracker) {
763
+ super(tracker);
764
+ }
765
+ }
766
+ ```
767
+
768
+ **With `get`/`set`** — decorate the setter:
769
+
770
+ ```typescript
771
+ class ProductModel extends TrackedObject {
772
+ private _name: string = '';
773
+
774
+ get name(): string { return this._name; }
775
+
776
+ @Tracked()
777
+ set name(value: string) { this._name = value; }
778
+
779
+ constructor(tracker: Tracker) {
780
+ super(tracker);
781
+ }
782
+ }
783
+ ```
784
+
785
+ **With a validator:**
786
+
787
+ The validator receives the model instance and the incoming value. Return an error string to fail, `undefined` to pass.
788
+
789
+ ```typescript
790
+ class OrderModel extends TrackedObject {
791
+ @Tracked((self, value) => !value ? 'Status is required' : undefined)
792
+ accessor status: string = '';
793
+
794
+ @Tracked((self, value) => value < 0 ? 'Price must be positive' : undefined)
795
+ accessor price: number = 0;
796
+
797
+ // Validator can inspect other properties of the model
798
+ @Tracked((self: OrderModel, value) =>
799
+ value > self.price ? 'Discount exceeds price' : undefined
800
+ )
801
+ accessor discount: number = 0;
802
+
803
+ constructor(tracker: Tracker) {
804
+ super(tracker);
805
+ }
806
+ }
807
+ ```
808
+
809
+ 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`.
810
+
811
+ **No-op detection**
812
+
813
+ 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.
814
+
815
+ ```typescript
816
+ invoice.status = ''; // no-op (already '')
817
+ invoice.status = null; // no-op (null ≡ '')
818
+ invoice.status = 'draft'; // recorded
819
+ invoice.status = 'draft'; // no-op
820
+ ```
821
+
822
+ **Options**
823
+
824
+ An optional second argument controls decorator behaviour:
825
+
826
+ ```typescript
827
+ @Tracked(validator?, options?)
828
+ ```
829
+
830
+ | Option | Type | Default | Description |
831
+ |---|---|---|---|
832
+ | `noCoalesce` | `boolean` | `false` | When `true`, rapid consecutive writes always create separate undo steps, even if they fall within the tracker's coalescing window |
833
+
834
+ ```typescript
835
+ // Validator + noCoalesce together:
836
+ @Tracked((_, v) => v < 0 ? 'Must be positive' : undefined, { noCoalesce: true })
837
+ accessor quantity: number = 0;
838
+
839
+ // noCoalesce only (no validator):
840
+ @Tracked(undefined, { noCoalesce: true })
841
+ accessor version: number = 0;
842
+ ```
843
+
844
+ **Supported property types:** `string`, `number`, `boolean`, `Date`, `object`. Unsupported types throw at runtime.
845
+
846
+ ---
847
+
848
+ ### `TrackedCollection<T>`
849
+
850
+ A fully array-compatible tracked collection. All mutations are recorded and undoable. Implements `Array<T>` so it works anywhere an array is expected.
851
+
852
+ ```typescript
853
+ const items = new TrackedCollection<string>(tracker);
854
+
855
+ // With initial items:
856
+ const items = new TrackedCollection<string>(tracker, ['a', 'b']);
857
+
858
+ // With a validator:
859
+ const items = new TrackedCollection<string>(
860
+ tracker,
861
+ [],
862
+ (list) => list.length === 0 ? 'At least one item is required' : undefined,
863
+ );
864
+ ```
865
+
866
+ **Tracked mutation methods**
867
+
868
+ All of these create undo steps:
869
+
870
+ | Method | Description |
871
+ |---|---|
872
+ | `push(...items)` | Appends one or more items |
873
+ | `pop()` | Removes and returns the last item |
874
+ | `shift()` | Removes and returns the first item |
875
+ | `unshift(...items)` | Prepends one or more items |
876
+ | `splice(start, deleteCount, ...items)` | Low-level insert/remove at a position |
877
+ | `remove(item)` | Removes a specific item by reference. Returns `false` if not found |
878
+ | `replace(item, replacement)` | Replaces a specific item by reference. Returns `false` if not found |
879
+ | `replaceAt(index, replacement)` | Replaces the item at a given index |
880
+ | `clear()` | Removes all items |
881
+ | `reset(newItems)` | Replaces the entire collection with a new array |
882
+ | `fill(value, start?, end?)` | Fills a range with a value |
883
+ | `copyWithin(target, start, end?)` | Copies a slice to another position |
884
+
885
+ **Read-only / non-mutating methods**
886
+
887
+ `indexOf`, `lastIndexOf`, `includes`, `find`, `findIndex`, `findLast`, `findLastIndex`, `every`, `some`, `forEach`, `map`, `filter`, `flatMap`, `reduce`, `reduceRight`, `concat`, `join`, `slice`, `at`, `entries`, `keys`, `values`, `flat`, `reverse`, `sort`, `toReversed`, `toSorted`, `toSpliced`, `with`, `toString`, `toLocaleString`
888
+
889
+ **Additional properties**
890
+
891
+ | Member | Description |
892
+ |---|---|
893
+ | `length` | Number of items |
894
+ | `isDirty` | `true` when the collection has unsaved mutations |
895
+ | `isValid` | `true` when the validator passes (or no validator was provided) |
896
+ | `error` | The current validation error message, or `undefined` |
897
+ | `changed` | `TypedEvent<TrackedCollectionChanged<T>>` — fires after every mutation |
898
+ | `first()` | Returns the first item, or `undefined` if empty |
899
+ | `destroy()` | Removes the collection from the tracker |
900
+
901
+ **The `changed` event**
902
+
903
+ `TrackedCollectionChanged<T>` carries:
904
+
905
+ | Property | Description |
906
+ |---|---|
907
+ | `added` | Items that were inserted |
908
+ | `removed` | Items that were removed |
909
+ | `newCollection` | The full collection after the mutation |
910
+
911
+ ```typescript
912
+ items.changed.subscribe((e) => {
913
+ console.log('added:', e.added);
914
+ console.log('removed:', e.removed);
915
+ console.log('now:', e.newCollection);
916
+ });
917
+ ```
918
+
919
+ The `changed` event fires **outside** tracking suppression. This means a listener that writes to a `@Tracked()` property composes naturally with the collection mutation — both land in the same undo step:
920
+
921
+ ```typescript
922
+ class OrderModel extends TrackedObject {
923
+ @Tracked()
924
+ accessor itemCount: number = 0;
925
+
926
+ readonly items: TrackedCollection<string>;
927
+
928
+ constructor(tracker: Tracker) {
929
+ super(tracker);
930
+ this.items = new TrackedCollection(tracker);
931
+ this.items.changed.subscribe(() => {
932
+ this.itemCount = this.items.length; // composed into the same undo step
933
+ });
934
+ }
935
+ }
936
+
937
+ const order = tracker.construct(() => new OrderModel(tracker));
938
+ order.items.push('x'); // itemCount becomes 1
939
+
940
+ tracker.undo(); // items back to [], itemCount back to 0
941
+ ```
942
+
943
+ ---
944
+
945
+ ### `TypedEvent<T>`
946
+
947
+ A lightweight, strongly-typed event emitter. Used internally for `tracker.isDirtyChanged`, `tracker.isValidChanged`, and `TrackedCollection.changed`, and available for your own use.
948
+
949
+ ```typescript
950
+ const event = new TypedEvent<string>();
951
+
952
+ // subscribe returns an unsubscribe function
953
+ const unsubscribe = event.subscribe((value) => {
954
+ console.log('received:', value);
955
+ });
956
+
957
+ event.emit('hello'); // → "received: hello"
958
+
959
+ unsubscribe(); // stop listening
960
+
961
+ event.emit('world'); // → (nothing)
962
+ ```
963
+
964
+ | Method | Returns | Description |
965
+ |---|---|---|
966
+ | `subscribe(handler)` | `() => void` | Registers a listener. Returns an unsubscriber |
967
+ | `unsubscribe(handler)` | `void` | Removes a specific listener |
968
+ | `emit(value)` | `void` | Calls all registered listeners with the given value |
969
+
970
+ ---
971
+
972
+ ## License
973
+
974
+ MIT — Nazario Mazzotti