@katn30/trakr 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/LICENSE +21 -0
- package/README.md +1208 -0
- package/dist/dev/index.cjs +1273 -0
- package/dist/dev/index.cjs.map +1 -0
- package/dist/dev/index.d.cts +369 -0
- package/dist/dev/index.d.ts +369 -0
- package/dist/dev/index.js +1239 -0
- package/dist/dev/index.js.map +1 -0
- package/dist/prod/index.cjs +1273 -0
- package/dist/prod/index.cjs.map +1 -0
- package/dist/prod/index.js +1239 -0
- package/dist/prod/index.js.map +1 -0
- package/package.json +55 -0
package/README.md
ADDED
|
@@ -0,0 +1,1208 @@
|
|
|
1
|
+
# trakr
|
|
2
|
+
|
|
3
|
+
TypeScript state-management library implementing the Unit of Work pattern, with object tracking and undo/redo.
|
|
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 @katn30/trakr
|
|
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
|
+
TrackedCollection,
|
|
32
|
+
Tracked,
|
|
33
|
+
AutoId,
|
|
34
|
+
} from '@katn30/trakr';
|
|
35
|
+
|
|
36
|
+
const tracker = new Tracker();
|
|
37
|
+
|
|
38
|
+
class InvoiceModel extends TrackedObject {
|
|
39
|
+
@AutoId
|
|
40
|
+
id: number = 0;
|
|
41
|
+
|
|
42
|
+
@Tracked()
|
|
43
|
+
accessor status: string = '';
|
|
44
|
+
|
|
45
|
+
@Tracked((self, value) => value < 0 ? 'Total must be positive' : undefined)
|
|
46
|
+
accessor total: number = 0;
|
|
47
|
+
|
|
48
|
+
readonly lines: TrackedCollection<string>;
|
|
49
|
+
|
|
50
|
+
constructor(tracker: Tracker) {
|
|
51
|
+
super(tracker);
|
|
52
|
+
this.lines = new TrackedCollection(tracker);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const invoices = new TrackedCollection<InvoiceModel>(tracker);
|
|
57
|
+
const invoice = tracker.construct(() => new InvoiceModel(tracker));
|
|
58
|
+
invoices.push(invoice); // state: Insert, idPlaceholder: -1
|
|
59
|
+
|
|
60
|
+
invoice.status = 'draft'; // recorded
|
|
61
|
+
invoice.total = 100; // recorded
|
|
62
|
+
invoice.lines.push('item-1'); // recorded
|
|
63
|
+
|
|
64
|
+
tracker.isDirty; // true
|
|
65
|
+
tracker.canUndo; // true
|
|
66
|
+
|
|
67
|
+
tracker.undo(); // reverts lines.push
|
|
68
|
+
tracker.undo(); // reverts total
|
|
69
|
+
tracker.undo(); // reverts status
|
|
70
|
+
tracker.undo(); // reverts push — state back to Unchanged
|
|
71
|
+
|
|
72
|
+
tracker.isDirty; // false
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Concepts
|
|
78
|
+
|
|
79
|
+
### Undo/redo strategy
|
|
80
|
+
|
|
81
|
+
The two common patterns for implementing undo/redo are:
|
|
82
|
+
|
|
83
|
+
- **Command** — every change stores a `redoAction` and an `undoAction` closure pair. Undoing calls the inverse function; redoing calls the original. No state is copied.
|
|
84
|
+
- **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.
|
|
85
|
+
|
|
86
|
+
trakr 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.
|
|
87
|
+
|
|
88
|
+
### How undo steps are created
|
|
89
|
+
|
|
90
|
+
Every tracked write — a `@Tracked()` property assignment or a `TrackedCollection` mutation — becomes its own undo step unless it is automatically composed into an existing one (see [Automatic composing](#automatic-composing) below).
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
invoice.status = 'void' → undo step A
|
|
94
|
+
invoice.lines.clear() → undo step B (independent)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Automatic composing
|
|
98
|
+
|
|
99
|
+
Multiple tracked writes can automatically land in the same undo step in three cases. No extra API is needed.
|
|
100
|
+
|
|
101
|
+
**Case 1a — `@Tracked` setter body**
|
|
102
|
+
|
|
103
|
+
When a property's setter is decorated with `@Tracked`, the setter body runs as part of the tracked write. Any `@Tracked` property writes or `TrackedCollection` mutations made synchronously inside that setter body are automatically composed into the same undo step.
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
class NameModel extends TrackedObject {
|
|
107
|
+
private _firstName: string = '';
|
|
108
|
+
private _lastName: string = '';
|
|
109
|
+
|
|
110
|
+
get firstName(): string { return this._firstName; }
|
|
111
|
+
@Tracked() set firstName(value: string) { this._firstName = value; }
|
|
112
|
+
|
|
113
|
+
get lastName(): string { return this._lastName; }
|
|
114
|
+
@Tracked() set lastName(value: string) { this._lastName = value; }
|
|
115
|
+
|
|
116
|
+
get fullName(): string { return `${this._firstName} ${this._lastName}`.trim(); }
|
|
117
|
+
@Tracked() set fullName(value: string) {
|
|
118
|
+
const [first = '', last = ''] = value.split(' ');
|
|
119
|
+
this.firstName = first; // composed — same undo step as fullName
|
|
120
|
+
this.lastName = last; // composed — same undo step as fullName
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
model.fullName = 'John Doe';
|
|
125
|
+
|
|
126
|
+
tracker.undo(); // reverts firstName AND lastName together — one step
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The same applies when the setter mutates a `TrackedCollection`:
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
class TagModel extends TrackedObject {
|
|
133
|
+
private _tag: string = '';
|
|
134
|
+
readonly tags: TrackedCollection<string>;
|
|
135
|
+
|
|
136
|
+
constructor(tracker: Tracker) {
|
|
137
|
+
super(tracker);
|
|
138
|
+
this.tags = new TrackedCollection(tracker);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
get tag(): string { return this._tag; }
|
|
142
|
+
@Tracked() set tag(value: string) {
|
|
143
|
+
this._tag = value;
|
|
144
|
+
if (value) this.tags.push(value); // composed — same undo step as tag
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
model.tag = 'active';
|
|
149
|
+
|
|
150
|
+
tracker.undo(); // reverts tag AND removes 'active' from tags — one step
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
**Case 1b — `@Tracked` with `onChange` callback**
|
|
154
|
+
|
|
155
|
+
When side-effect logic needs to be kept separate from the setter body — or when using `accessor` fields where there is no setter body — pass an `onChange` callback as the second argument to `@Tracked()`. It receives `(self, newValue, oldValue)` and runs inside the tracked operation, so any `@Tracked` property writes or `TrackedCollection` mutations made inside it are automatically composed into the same undo step. The callback does not fire during undo or redo — the stored actions handle replay.
|
|
156
|
+
|
|
157
|
+
```typescript
|
|
158
|
+
class TagModel extends TrackedObject {
|
|
159
|
+
readonly tags: TrackedCollection<string>;
|
|
160
|
+
|
|
161
|
+
@Tracked(
|
|
162
|
+
undefined,
|
|
163
|
+
(self: TagModel, newValue, oldValue) => {
|
|
164
|
+
if (oldValue) self.tags.remove(oldValue);
|
|
165
|
+
if (newValue) self.tags.push(newValue); // composed — same undo step as tag
|
|
166
|
+
},
|
|
167
|
+
)
|
|
168
|
+
accessor tag: string = '';
|
|
169
|
+
|
|
170
|
+
constructor(tracker: Tracker) {
|
|
171
|
+
super(tracker);
|
|
172
|
+
this.tags = new TrackedCollection(tracker);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
model.tag = 'active';
|
|
177
|
+
|
|
178
|
+
tracker.undo(); // reverts tag AND removes 'active' from tags — one step
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
**Case 2 — `TrackedCollection` event callbacks**
|
|
182
|
+
|
|
183
|
+
When a `TrackedCollection` is mutated, both its `changed` and `trackedChanged` events fire synchronously inside the tracked operation. Any `@Tracked` property write made inside either subscriber is automatically composed into the same undo step as the collection mutation.
|
|
184
|
+
|
|
185
|
+
Use `changed` when you also want the callback to run during undo and redo. Use `trackedChanged` when you only want the callback to run on direct user mutations — it will not fire during undo or redo, and writes inside it are still composed on the initial write.
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
// Using changed — fires on initial write, undo, and redo
|
|
189
|
+
class OrderModel extends TrackedObject {
|
|
190
|
+
@Tracked() accessor itemCount: number = 0;
|
|
191
|
+
|
|
192
|
+
readonly items: TrackedCollection<string>;
|
|
193
|
+
|
|
194
|
+
constructor(tracker: Tracker) {
|
|
195
|
+
super(tracker);
|
|
196
|
+
this.items = new TrackedCollection(tracker);
|
|
197
|
+
this.items.changed.subscribe(() => {
|
|
198
|
+
this.itemCount = this.items.length; // composed into the same undo step
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
order.items.push('x'); // itemCount becomes 1
|
|
204
|
+
tracker.undo(); // items back to [], itemCount back to 0
|
|
205
|
+
|
|
206
|
+
// Using trackedChanged — fires only on initial write, writes still composed
|
|
207
|
+
class LoggedCollection extends TrackedObject {
|
|
208
|
+
@Tracked() accessor lastAdded: string = '';
|
|
209
|
+
|
|
210
|
+
readonly items: TrackedCollection<string>;
|
|
211
|
+
|
|
212
|
+
constructor(tracker: Tracker) {
|
|
213
|
+
super(tracker);
|
|
214
|
+
this.items = new TrackedCollection(tracker);
|
|
215
|
+
this.items.trackedChanged.subscribe((e) => {
|
|
216
|
+
if (e.added.length > 0) this.lastAdded = e.added[e.added.length - 1]; // composed
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
The same applies to `TrackedObject.trackedChanged`. A subscriber that writes to another `@Tracked` property is composed into the same undo step:
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
class TitleModel extends TrackedObject {
|
|
226
|
+
@Tracked() accessor summary: string = '';
|
|
227
|
+
|
|
228
|
+
private _title: string = '';
|
|
229
|
+
get title(): string { return this._title; }
|
|
230
|
+
@Tracked() set title(value: string) { this._title = value; }
|
|
231
|
+
|
|
232
|
+
constructor(tracker: Tracker) {
|
|
233
|
+
super(tracker);
|
|
234
|
+
this.trackedChanged.subscribe(({ property, newValue }) => {
|
|
235
|
+
if (property === 'title') {
|
|
236
|
+
this.summary = `Summary: ${newValue}`; // composed into the same undo step
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
model.title = 'Hello';
|
|
243
|
+
tracker.undo(); // reverts title AND summary together
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
### Coalescing consecutive writes
|
|
247
|
+
|
|
248
|
+
Rapid consecutive writes to the same `string` or `number` property on the same model can be merged into a single undo step. Coalescing is **opt-in per property** via the `coalesceWithin` option on `@Tracked()`. Pass the maximum gap in milliseconds between two writes that should still be considered part of the same edit:
|
|
249
|
+
|
|
250
|
+
```typescript
|
|
251
|
+
@Tracked(undefined, undefined, { coalesceWithin: 3000 })
|
|
252
|
+
accessor status: string = '';
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
```typescript
|
|
256
|
+
invoice.status = 'd';
|
|
257
|
+
invoice.status = 'dr';
|
|
258
|
+
invoice.status = 'dra';
|
|
259
|
+
invoice.status = 'draft';
|
|
260
|
+
|
|
261
|
+
tracker.undo(); // reverts all four at once → status = ''
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Properties without `coalesceWithin` — and all `Date`, `boolean`, and `object` properties — are never coalesced; every write produces its own undo step.
|
|
265
|
+
|
|
266
|
+
### Manual composing
|
|
267
|
+
|
|
268
|
+
When you need to group any set of writes — across multiple properties, objects, or collections — into a single undo step, use the manual composing API:
|
|
269
|
+
|
|
270
|
+
```typescript
|
|
271
|
+
tracker.startComposing();
|
|
272
|
+
|
|
273
|
+
model.firstName = 'Alice';
|
|
274
|
+
model.lastName = 'Smith';
|
|
275
|
+
model.email = 'alice@example.com';
|
|
276
|
+
|
|
277
|
+
tracker.endComposing(); // all three writes become one undo step
|
|
278
|
+
|
|
279
|
+
tracker.undo(); // reverts firstName, lastName, and email together
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Call `rollbackComposing()` instead of `endComposing()` to revert all changes made since `startComposing()`:
|
|
283
|
+
|
|
284
|
+
```typescript
|
|
285
|
+
tracker.startComposing();
|
|
286
|
+
|
|
287
|
+
model.firstName = 'Alice';
|
|
288
|
+
model.lastName = 'Smith';
|
|
289
|
+
|
|
290
|
+
tracker.rollbackComposing(); // all writes since startComposing are reverted
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
A second call to `startComposing()` while a session is already active is a no-op — nesting is not supported.
|
|
294
|
+
|
|
295
|
+
**Typical use case — edit modal**
|
|
296
|
+
|
|
297
|
+
Open a modal that edits a slice of the model. If the user confirms, the entire set of edits lands in the undo history as one step. If the user cancels, all edits are rolled back invisibly.
|
|
298
|
+
|
|
299
|
+
```typescript
|
|
300
|
+
function openEditModal(model: PersonModel) {
|
|
301
|
+
tracker.startComposing();
|
|
302
|
+
|
|
303
|
+
showModal({
|
|
304
|
+
model,
|
|
305
|
+
onConfirm: () => tracker.endComposing(),
|
|
306
|
+
onCancel: () => tracker.rollbackComposing(),
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
### Dependency tracking
|
|
312
|
+
|
|
313
|
+
Validators can read other properties of the same model — for example, a `scheduleDays` field might be required only when `isEnabled` is `true`. trakr automatically tracks which properties each validator reads, and re-runs only the affected validators when those properties change.
|
|
314
|
+
|
|
315
|
+
This works via a lightweight dependency tracking mechanism built into the `@Tracked` getter. Every time a validator runs, trakr 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.
|
|
316
|
+
|
|
317
|
+
**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.
|
|
318
|
+
|
|
319
|
+
```typescript
|
|
320
|
+
// WRONG — isEnabled getter is plain; validators that read self.isEnabled
|
|
321
|
+
// will not re-run when isEnabled changes
|
|
322
|
+
get isEnabled(): boolean { return this._isEnabled; }
|
|
323
|
+
|
|
324
|
+
@Tracked()
|
|
325
|
+
set isEnabled(value: boolean) { this._isEnabled = value; }
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
```typescript
|
|
329
|
+
// CORRECT — both getter and setter are decorated
|
|
330
|
+
@Tracked()
|
|
331
|
+
get isEnabled(): boolean { return this._isEnabled; }
|
|
332
|
+
|
|
333
|
+
@Tracked()
|
|
334
|
+
set isEnabled(value: boolean) { this._isEnabled = value; }
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
When using `accessor` fields this is never an issue — the getter and setter share the same decoration.
|
|
338
|
+
|
|
339
|
+
### Validation
|
|
340
|
+
|
|
341
|
+
Validators are inline functions passed as the first argument to `@Tracked()`. They receive the model instance and the incoming value and return an error string on failure or `undefined` on success.
|
|
342
|
+
|
|
343
|
+
trakr runs validators automatically — you never call them directly. They run:
|
|
344
|
+
|
|
345
|
+
- After every tracked write to the decorated property
|
|
346
|
+
- After every undo and redo
|
|
347
|
+
- Once for every property on every model after `tracker.construct()` completes
|
|
348
|
+
|
|
349
|
+
Results are stored per-property in `model.validationMessages: Map<string, string>` and aggregated into:
|
|
350
|
+
|
|
351
|
+
- `model.isValid: boolean` — `true` when all validators on this model pass
|
|
352
|
+
- `tracker.isValid: boolean` — `true` when every model and collection passes
|
|
353
|
+
- `tracker.canCommit: boolean` — `true` when `isDirty && isValid`
|
|
354
|
+
|
|
355
|
+
`tracker.isValidChanged` and `tracker.canCommitChanged` fire whenever these values change, so UI can bind directly to them without polling.
|
|
356
|
+
|
|
357
|
+
**Collection validators** are a separate function passed as the third argument to the `TrackedCollection` constructor. They receive the full array and return an error string or `undefined`. The result is exposed on `collection.error` and `collection.isValid`, and rolls up into `tracker.isValid`.
|
|
358
|
+
|
|
359
|
+
```typescript
|
|
360
|
+
const items = new TrackedCollection<string>(
|
|
361
|
+
tracker,
|
|
362
|
+
[],
|
|
363
|
+
(list) => list.length === 0 ? 'At least one item is required' : undefined,
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
items.isValid; // false — empty
|
|
367
|
+
items.push('a');
|
|
368
|
+
items.isValid; // true
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
### Construction via tracker.construct()
|
|
372
|
+
|
|
373
|
+
All tracked model objects must be created inside `tracker.construct()`. This call:
|
|
374
|
+
|
|
375
|
+
- Suppresses tracking for the entire constructor body — property writes during construction are silently applied without creating undo entries
|
|
376
|
+
- Validates every object once after construction
|
|
377
|
+
- Calls `tracker.revalidate()` exactly once at the end, keeping bulk creation O(n)
|
|
378
|
+
|
|
379
|
+
The tracker is clean and `canUndo` is `false` immediately after `tracker.construct()` returns.
|
|
380
|
+
|
|
381
|
+
```typescript
|
|
382
|
+
// Single object — returns the constructed instance
|
|
383
|
+
const invoice = tracker.construct(() => new InvoiceModel(tracker));
|
|
384
|
+
|
|
385
|
+
// Multiple objects — pass them all in one callback
|
|
386
|
+
tracker.construct(() => {
|
|
387
|
+
for (const row of serverRows) {
|
|
388
|
+
const item = new ItemModel(tracker);
|
|
389
|
+
item.name = row.name; // suppressed — not tracked
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
// tracker.revalidate() is called once here — not once per object
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
### Development vs production builds
|
|
396
|
+
|
|
397
|
+
trakr ships two builds: a development build (`dist/dev/`) and a production build (`dist/prod/`).
|
|
398
|
+
|
|
399
|
+
**Development build** — creating a tracked object outside `tracker.construct()` throws immediately with a descriptive error:
|
|
400
|
+
|
|
401
|
+
```
|
|
402
|
+
MyModel must be created inside tracker.construct()
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
This catches accidental bare `new MyModel(tracker)` calls at the earliest possible moment during development.
|
|
406
|
+
|
|
407
|
+
**Production build** — the construction guard is compiled away entirely. There is zero runtime overhead for the check.
|
|
408
|
+
|
|
409
|
+
**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 trakr's `package.json`.
|
|
410
|
+
|
|
411
|
+
### Default state: Unchanged
|
|
412
|
+
|
|
413
|
+
`TrackedObject` defaults to `Unchanged` at construction time. This matches the most common scenario — objects are loaded from the database and are already persisted.
|
|
414
|
+
|
|
415
|
+
```typescript
|
|
416
|
+
const item = tracker.construct(() => new ItemModel(tracker)); // state: Unchanged (DB-loaded default)
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
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 `Insert`:
|
|
420
|
+
|
|
421
|
+
```typescript
|
|
422
|
+
const item = tracker.construct(() => new ItemModel(tracker));
|
|
423
|
+
items.push(item); // state: Insert — tracked, undoable
|
|
424
|
+
tracker.undo(); // state: Unchanged, removed from collection
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
Items passed to the `TrackedCollection` **constructor** are treated as already-persisted rows and are **not** marked as `Insert`:
|
|
428
|
+
|
|
429
|
+
```typescript
|
|
430
|
+
const items = new TrackedCollection<ItemModel>(tracker, [dbItem]); // dbItem stays Unchanged
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
### Insert/Delete lifecycle
|
|
434
|
+
|
|
435
|
+
State transitions to `Insert` and `Deleted` are triggered by two mechanisms: collection mutations and `@Tracked` property assignments.
|
|
436
|
+
|
|
437
|
+
**Via TrackedCollection**
|
|
438
|
+
|
|
439
|
+
Adding or removing a `TrackedObject` from a `TrackedCollection` transitions its state automatically:
|
|
440
|
+
|
|
441
|
+
```typescript
|
|
442
|
+
const item = tracker.construct(() => new ItemModel(tracker)); // Unchanged
|
|
443
|
+
items.push(item); // → Insert
|
|
444
|
+
items.remove(item); // → Unchanged (was never saved)
|
|
445
|
+
|
|
446
|
+
const loaded = tracker.construct(() => new ItemModel(tracker, { id: 1 })); // Unchanged
|
|
447
|
+
items.push(loaded); // → Insert
|
|
448
|
+
items.remove(loaded); // → Deleted
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
**Via @Tracked property**
|
|
452
|
+
|
|
453
|
+
When a `@Tracked` property holds a `TrackedObject` value, assigning to it has the same effect: the outgoing value transitions to `Deleted` (or `Unchanged` if it was `Insert`), and the incoming value transitions to `Insert`:
|
|
454
|
+
|
|
455
|
+
```typescript
|
|
456
|
+
class OrderModel extends TrackedObject {
|
|
457
|
+
@Tracked()
|
|
458
|
+
accessor detail: DetailModel | null = null;
|
|
459
|
+
|
|
460
|
+
constructor(tracker: Tracker) { super(tracker); }
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const order = tracker.construct(() => new OrderModel(tracker));
|
|
464
|
+
const detail = tracker.construct(() => new DetailModel(tracker)); // Unchanged
|
|
465
|
+
|
|
466
|
+
order.detail = detail; // detail → Insert
|
|
467
|
+
order.detail = null; // detail → Deleted
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
Setting a new value while one is already assigned marks the old one removed and the new one added in the same undo step:
|
|
471
|
+
|
|
472
|
+
```typescript
|
|
473
|
+
const detail2 = tracker.construct(() => new DetailModel(tracker));
|
|
474
|
+
order.detail = detail2; // detail → Deleted, detail2 → Insert (one undo step)
|
|
475
|
+
tracker.undo(); // detail2 → Unchanged, detail → Insert
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
**Suppression**
|
|
479
|
+
|
|
480
|
+
State transitions respect tracking suppression. Inside `tracker.construct()` and `tracker.withTrackingSuppressed()`, collection mutations and property assignments are applied silently without state transitions. This means loading data inside `tracker.construct()` never accidentally marks objects as `Insert` or `Deleted`.
|
|
481
|
+
|
|
482
|
+
### Object state machine
|
|
483
|
+
|
|
484
|
+
Every `TrackedObject` has a `state: State` property — the single source of truth for what the save layer needs to do with that object. State transitions are driven by three types of events:
|
|
485
|
+
|
|
486
|
+
- **edit** — a `@Tracked` property is written
|
|
487
|
+
- **collection mutation** — the object is pushed to or removed from a `TrackedCollection`
|
|
488
|
+
- **commit** — `tracker.onCommit()` is called after a successful server save
|
|
489
|
+
|
|
490
|
+
#### Redo is always the same as do
|
|
491
|
+
|
|
492
|
+
There is no separate redo transition. Redo simply re-runs the original `do` action. This means `added/do` and `added/redo` are identical — both assign a **fresh** `idPlaceholder`. Placeholders from a previous do/undo cycle are never reused.
|
|
493
|
+
|
|
494
|
+
#### Full transition table
|
|
495
|
+
|
|
496
|
+
| Event | Direction | From | To | `idPlaceholder` | `@AutoId` field |
|
|
497
|
+
|---|---|---|---|---|---|
|
|
498
|
+
| edit | do / redo | Unchanged | **Changed** | null | untouched |
|
|
499
|
+
| edit | do / redo | Changed | Changed | null | untouched |
|
|
500
|
+
| edit | undo (last edit) | Changed | **Unchanged** | null | untouched |
|
|
501
|
+
| edit | undo (not last) | Changed | Changed | null | untouched |
|
|
502
|
+
| added | do / redo | Unchanged | **Insert** | new negative | untouched |
|
|
503
|
+
| added | undo | Insert | **Unchanged** | null | untouched |
|
|
504
|
+
| removed | do / redo | Insert | **Unchanged** | null, dirtyCounter reset | untouched |
|
|
505
|
+
| removed | do / redo | Unchanged | **Deleted** | null | untouched |
|
|
506
|
+
| removed | undo | Unchanged (was Insert) | **Insert** | new negative | untouched |
|
|
507
|
+
| removed | undo | Deleted | **Unchanged** | null | untouched |
|
|
508
|
+
| committed | do / redo | Insert | **Unchanged** | null | written with real id |
|
|
509
|
+
| committed | do / redo | Changed | **Unchanged** | null | untouched |
|
|
510
|
+
| committed | do / redo | Deleted | **Unchanged** | null | untouched |
|
|
511
|
+
| committed | undo | was Insert | **Deleted** | null | kept (real id for DELETE) |
|
|
512
|
+
| committed | undo | was Changed | **Changed** | null | untouched |
|
|
513
|
+
| committed | undo | was Deleted | **Insert** | new negative | kept (stale — use `idPlaceholder`) |
|
|
514
|
+
|
|
515
|
+
#### Key notes
|
|
516
|
+
|
|
517
|
+
**`removed/do` from `Insert` collapses to `Unchanged`** — if an object was added and then removed before ever being committed, it was never persisted. The transition resets `dirtyCounter` to zero and clears `idPlaceholder` as if the add never happened. Nothing needs to be sent to the server.
|
|
518
|
+
|
|
519
|
+
**`committed/undo` reverses the server operation** — undoing past a commit puts the object into the state that requires the inverse server operation. Undoing a committed INSERT requires a DELETE; undoing a committed DELETE requires a new INSERT; undoing a committed UPDATE requires another UPDATE with the pre-edit values.
|
|
520
|
+
|
|
521
|
+
**`@AutoId` is never zeroed out** — when `committed/undo` runs after a committed INSERT, the real server id stays on the `@AutoId` field so the save layer can send `DELETE /resource/{id}`. Similarly, after `committed/undo` of a DELETE, the `@AutoId` field still holds the old real id — but since `state` is now `Insert`, the save layer must use `idPlaceholder` as the POST key, not `@AutoId`.
|
|
522
|
+
|
|
523
|
+
**`idPlaceholder` for `Insert` items** — always use `obj.idPlaceholder` (never `obj.@AutoId`) to identify an `Insert` item in the save payload. After a `committed delete → undo` cycle, `@AutoId` holds a stale real id while `idPlaceholder` holds the correct fresh temp key.
|
|
524
|
+
|
|
525
|
+
### Recommended save pattern
|
|
526
|
+
|
|
527
|
+
trakr does not mandate a specific save strategy — you can send changes per-object, batch selectively, or structure your API however your application requires.
|
|
528
|
+
|
|
529
|
+
That said, a pattern that works well with trakr's design is **all-or-nothing saves**: when the user clicks Save, the frontend collects every dirty object across the tracker, serialises them into a single request, and the backend saves everything inside one transaction — either succeeding fully or returning an error without applying partial changes. The frontend then calls `tracker.onCommit()` only on success.
|
|
530
|
+
|
|
531
|
+
This pairs naturally with `@AutoId`: `idPlaceholder` values are automatically assigned as negative integers when an object is added to a collection. New objects can reference each other via their `idPlaceholder` in the payload (e.g. a new parent and its new children share consistent temp IDs before the server assigns real ones). After a successful save, `tracker.onCommit(keys)` swaps the placeholders for real server IDs in place — no page reload is needed. This is the intended experience for form-heavy back-office pages, though reloading or restructuring state on save is equally valid.
|
|
532
|
+
|
|
533
|
+
**On failure, do not call `onCommit()`.**
|
|
534
|
+
|
|
535
|
+
If the server returns an error, simply surface the error to the user and leave the tracker as-is. The tracker stays dirty, `canUndo` remains `true`, and the user can fix the problem and try again — or undo their changes. Nothing needs to be reset manually.
|
|
536
|
+
|
|
537
|
+
---
|
|
538
|
+
|
|
539
|
+
## API Reference
|
|
540
|
+
|
|
541
|
+
### `Tracker`
|
|
542
|
+
|
|
543
|
+
The central coordinator. Create one per page or form context and pass it to every model and collection.
|
|
544
|
+
|
|
545
|
+
```typescript
|
|
546
|
+
const tracker = new Tracker();
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
**State properties**
|
|
550
|
+
|
|
551
|
+
| Property | Type | Description |
|
|
552
|
+
|---|---|---|
|
|
553
|
+
| `isDirty` | `boolean` | `true` when uncommitted changes exist |
|
|
554
|
+
| `canUndo` | `boolean` | `true` when there is at least one undo step |
|
|
555
|
+
| `canRedo` | `boolean` | `true` when there are undone steps to redo |
|
|
556
|
+
| `isValid` | `boolean` | `true` when every registered model and collection passes validation |
|
|
557
|
+
| `canCommit` | `boolean` | `true` when `isDirty && isValid` — ready to submit to the server |
|
|
558
|
+
| `isDirtyChanged` | `TypedEvent<boolean>` | Fires whenever `isDirty` changes |
|
|
559
|
+
| `isValidChanged` | `TypedEvent<boolean>` | Fires whenever `isValid` changes |
|
|
560
|
+
| `canCommitChanged` | `TypedEvent<boolean>` | Fires whenever `canCommit` changes |
|
|
561
|
+
| `version` | `number` | Monotonically changing counter — starts at `0`, increments on every new operation, decrements on undo, increments on redo. Auto-coalesced writes do not increment `version` (no new undo step is created) but still emit `versionChanged` |
|
|
562
|
+
| `versionChanged` | `TypedEvent<number>` | Fires on every tracked write, undo, and redo — including auto-coalesced writes where `version` does not change. Use this as the notification signal for external subscribers such as React's `useSyncExternalStore` |
|
|
563
|
+
| `trackedObjects` | `TrackedObject[]` | All registered models. Read-only — iterate for save payloads; do not mutate directly |
|
|
564
|
+
| `deletedObjects` | `TrackedObject[]` | Subset of `trackedObjects` where `state === Deleted`. Use this to build delete requests — deleted objects are removed from collections and composed properties, making them unreachable from the model tree |
|
|
565
|
+
| `trackedCollections` | `TrackedCollection<any>[]` | All registered collections. Read-only — do not mutate directly |
|
|
566
|
+
|
|
567
|
+
**Undo / redo**
|
|
568
|
+
|
|
569
|
+
```typescript
|
|
570
|
+
tracker.undo(); // reverts the last undo step
|
|
571
|
+
tracker.redo(); // re-applies the last undone step
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
Calling `undo()` or `redo()` when the respective flag is `false` is a no-op.
|
|
575
|
+
|
|
576
|
+
**Commit lifecycle**
|
|
577
|
+
|
|
578
|
+
```typescript
|
|
579
|
+
tracker.onCommit(); // mark current state as committed — isDirty → false
|
|
580
|
+
tracker.onCommit(keys); // same, plus swap placeholder IDs for real server IDs
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
`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).
|
|
584
|
+
|
|
585
|
+
**Manual composing**
|
|
586
|
+
|
|
587
|
+
```typescript
|
|
588
|
+
tracker.startComposing(); // begin grouping subsequent changes
|
|
589
|
+
tracker.endComposing(); // commit — all changes become one undo step
|
|
590
|
+
tracker.rollbackComposing(); // revert — all changes since startComposing are rolled back
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
**Object construction**
|
|
594
|
+
|
|
595
|
+
```typescript
|
|
596
|
+
// Single object — returns the constructed instance
|
|
597
|
+
const model = tracker.construct(() => new MyModel(tracker));
|
|
598
|
+
|
|
599
|
+
// Multiple objects — returns void
|
|
600
|
+
tracker.construct(() => {
|
|
601
|
+
new ModelA(tracker);
|
|
602
|
+
new ModelB(tracker);
|
|
603
|
+
});
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
`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.
|
|
607
|
+
|
|
608
|
+
**Tracking suppression**
|
|
609
|
+
|
|
610
|
+
```typescript
|
|
611
|
+
// Callback form — preferred
|
|
612
|
+
tracker.withTrackingSuppressed(() => {
|
|
613
|
+
model.field = 'silent'; // applied but not recorded, not dirty
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
// Explicit begin/end — useful when the suppressed block spans async boundaries
|
|
617
|
+
tracker.beginSuppressTracking();
|
|
618
|
+
model.field = 'silent';
|
|
619
|
+
tracker.endSuppressTracking();
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
Suppression is **nestable** via a counter, so calling `beginSuppressTracking()` twice requires two `endSuppressTracking()` calls to resume tracking.
|
|
623
|
+
|
|
624
|
+
**React integration — `useSyncExternalStore`**
|
|
625
|
+
|
|
626
|
+
`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:
|
|
627
|
+
|
|
628
|
+
```typescript
|
|
629
|
+
import { useSyncExternalStore } from 'react';
|
|
630
|
+
import { Tracker } from '@katn30/trakr';
|
|
631
|
+
|
|
632
|
+
function useTrackerVersion(tracker: Tracker): number {
|
|
633
|
+
return useSyncExternalStore(
|
|
634
|
+
(onStoreChange) => tracker.versionChanged.subscribe(onStoreChange),
|
|
635
|
+
() => tracker.version,
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
Any component that calls `useTrackerVersion(tracker)` will re-render whenever the tracker's state changes.
|
|
641
|
+
|
|
642
|
+
```tsx
|
|
643
|
+
function InvoiceForm({ tracker, invoice }: { tracker: Tracker; invoice: InvoiceModel }) {
|
|
644
|
+
useTrackerVersion(tracker); // re-renders on every mutation, undo, or redo
|
|
645
|
+
|
|
646
|
+
return (
|
|
647
|
+
<form>
|
|
648
|
+
<input value={invoice.status} onChange={(e) => { invoice.status = e.target.value; }} />
|
|
649
|
+
<button disabled={!tracker.canUndo} onClick={() => tracker.undo()}>Undo</button>
|
|
650
|
+
<button disabled={!tracker.canCommit} onClick={save}>Save</button>
|
|
651
|
+
</form>
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
---
|
|
657
|
+
|
|
658
|
+
### `TrackedObject`
|
|
659
|
+
|
|
660
|
+
The abstract base class for all trackable models. All subclass instances must be created via `tracker.construct()`.
|
|
661
|
+
|
|
662
|
+
```typescript
|
|
663
|
+
class InvoiceModel extends TrackedObject {
|
|
664
|
+
constructor(tracker: Tracker) {
|
|
665
|
+
super(tracker); // registers the model with the tracker
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const invoice = tracker.construct(() => new InvoiceModel(tracker));
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
**Model properties and methods**
|
|
673
|
+
|
|
674
|
+
| Member | Type | Description |
|
|
675
|
+
|---|---|---|
|
|
676
|
+
| `tracker` | `Tracker` | The tracker this model belongs to (set via `super(tracker)`) |
|
|
677
|
+
| `state` | `State` | The current persistence state — `Unchanged`, `Insert`, `Changed`, or `Deleted` |
|
|
678
|
+
| `idPlaceholder` | `number \| null` | Negative temp key assigned automatically when the object is added to a `TrackedCollection`. `null` when state is not `Insert`. Always use this (not the `@AutoId` field) as the POST key for `Insert` items |
|
|
679
|
+
| `isDirty` | `boolean` | `true` when this model has uncommitted property changes |
|
|
680
|
+
| `dirtyCounter` | `number` | Net count of uncommitted property writes. Increments on each write, decrements on undo. Reset to `0` by `onCommit()`. Can be negative after undoing past a committed save |
|
|
681
|
+
| `isValid` | `boolean` | `true` when all `@Tracked()` validators pass |
|
|
682
|
+
| `validationMessages` | `Map<string, string>` | Maps property name → error message for each failing validator |
|
|
683
|
+
| `changed` | `TypedEvent<TrackedPropertyChanged>` | Fires on every property change, including changes triggered by undo and redo |
|
|
684
|
+
| `trackedChanged` | `TypedEvent<TrackedPropertyChanged>` | Fires only on direct user-initiated writes — never during undo or redo |
|
|
685
|
+
| `destroy()` | `void` | Removes this model from the tracker |
|
|
686
|
+
|
|
687
|
+
**Property change events**
|
|
688
|
+
|
|
689
|
+
Both `changed` and `trackedChanged` carry a `TrackedPropertyChanged` payload:
|
|
690
|
+
|
|
691
|
+
```typescript
|
|
692
|
+
import type { TrackedPropertyChanged } from '@katn30/trakr';
|
|
693
|
+
// { property: string; oldValue: unknown; newValue: unknown }
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
| Field | Description |
|
|
697
|
+
|---|---|
|
|
698
|
+
| `property` | The decorated property name |
|
|
699
|
+
| `oldValue` | The value before the write |
|
|
700
|
+
| `newValue` | The value after the write |
|
|
701
|
+
|
|
702
|
+
Both events fire synchronously **inside** the tracked operation, so any `@Tracked` property write made inside either listener is automatically composed into the same undo step as the triggering write (see [Automatic composing](#automatic-composing)).
|
|
703
|
+
|
|
704
|
+
The difference is when they fire:
|
|
705
|
+
- `changed` fires on every write, including during undo and redo replays
|
|
706
|
+
- `trackedChanged` fires only on direct user-initiated writes — never during undo or redo
|
|
707
|
+
|
|
708
|
+
```typescript
|
|
709
|
+
// changed — fires on initial write, undo, and redo; writes in callback are composed
|
|
710
|
+
this.changed.subscribe(({ property }) => {
|
|
711
|
+
if (property === 'price' || property === 'quantity') {
|
|
712
|
+
this.total = this.price * this.quantity; // composed into the same undo step
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
// trackedChanged — fires only on initial write; writes in callback are still composed
|
|
717
|
+
this.trackedChanged.subscribe(({ property, newValue }) => {
|
|
718
|
+
if (property === 'title') {
|
|
719
|
+
this.summary = `Summary: ${newValue}`; // composed on initial write only
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
---
|
|
725
|
+
|
|
726
|
+
### `State`
|
|
727
|
+
|
|
728
|
+
Read via `obj.state`.
|
|
729
|
+
|
|
730
|
+
```typescript
|
|
731
|
+
import { State } from '@katn30/trakr';
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
| Value | Meaning | Required DB operation |
|
|
735
|
+
|---|---|---|
|
|
736
|
+
| `Unchanged` | Loaded from DB or just saved — no pending action | — |
|
|
737
|
+
| `Insert` | Added to a collection, never committed | INSERT |
|
|
738
|
+
| `Changed` | Loaded or committed, then edited | UPDATE |
|
|
739
|
+
| `Deleted` | Removed from a collection | DELETE |
|
|
740
|
+
|
|
741
|
+
For the full set of transitions between these states — driven by edits, collection mutations, undo, redo, and commit — see [Object state machine](#object-state-machine) in Concepts.
|
|
742
|
+
|
|
743
|
+
**Loading from DB:**
|
|
744
|
+
|
|
745
|
+
Objects default to `Unchanged`. Property values set inside the constructor are suppressed by `tracker.construct()`:
|
|
746
|
+
|
|
747
|
+
```typescript
|
|
748
|
+
class InvoiceModel extends TrackedObject {
|
|
749
|
+
@Tracked() accessor status: string = '';
|
|
750
|
+
constructor(tracker: Tracker, data?: { status: string }) {
|
|
751
|
+
super(tracker);
|
|
752
|
+
if (data) this.status = data.status; // suppressed — not tracked
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const invoice = tracker.construct(() => new InvoiceModel(tracker, { status: 'active' })); // state: Unchanged
|
|
757
|
+
```
|
|
758
|
+
|
|
759
|
+
**Saving:**
|
|
760
|
+
|
|
761
|
+
Iterate `tracker.trackedObjects`, read `state` and the appropriate ID on each model, and call `tracker.onCommit()` after the server responds successfully.
|
|
762
|
+
|
|
763
|
+
> **Why `tracker.trackedObjects` and not your own model tree?**
|
|
764
|
+
> Deleted objects are no longer reachable through your model graph — a `TrackedCollection` removes them from its array, and a `@Tracked` property set to `null` (or replaced with another object) removes the reference. The tracker holds every registered object regardless of its state, so iterating `trackedObjects` is the only way to reach objects that need a DELETE request. `tracker.deletedObjects` is a convenience getter for the deleted subset only, but both approaches work.
|
|
765
|
+
|
|
766
|
+
```typescript
|
|
767
|
+
import { Tracker, TrackedObject, State, Tracked, AutoId, TrackedCollection } from '@katn30/trakr';
|
|
768
|
+
|
|
769
|
+
class InvoiceModel extends TrackedObject {
|
|
770
|
+
@AutoId
|
|
771
|
+
id: number = 0;
|
|
772
|
+
|
|
773
|
+
@Tracked()
|
|
774
|
+
accessor status: string = '';
|
|
775
|
+
|
|
776
|
+
constructor(tracker: Tracker, data?: { id: number; status: string }) {
|
|
777
|
+
super(tracker);
|
|
778
|
+
if (data) {
|
|
779
|
+
this.id = data.id;
|
|
780
|
+
this.status = data.status;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const tracker = new Tracker();
|
|
786
|
+
|
|
787
|
+
// Load existing rows from the server
|
|
788
|
+
tracker.construct(() => {
|
|
789
|
+
new InvoiceModel(tracker, { id: 1, status: 'draft' });
|
|
790
|
+
new InvoiceModel(tracker, { id: 2, status: 'sent' });
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
// Create a new invoice and add it to a collection (state → Insert, idPlaceholder auto-assigned)
|
|
794
|
+
const invoices = new TrackedCollection<InvoiceModel>(tracker);
|
|
795
|
+
const newInvoice = tracker.construct(() => new InvoiceModel(tracker));
|
|
796
|
+
invoices.push(newInvoice);
|
|
797
|
+
newInvoice.status = 'pending';
|
|
798
|
+
// newInvoice.idPlaceholder === -1 (auto-assigned negative temp key)
|
|
799
|
+
|
|
800
|
+
// --- Save ---
|
|
801
|
+
|
|
802
|
+
// Build the payload by reading each object's state
|
|
803
|
+
const payload: {
|
|
804
|
+
inserts: { placeholder: number; status: string }[];
|
|
805
|
+
updates: { id: number; status: string }[];
|
|
806
|
+
deletes: { id: number }[];
|
|
807
|
+
} = { inserts: [], updates: [], deletes: [] };
|
|
808
|
+
|
|
809
|
+
for (const obj of tracker.trackedObjects) {
|
|
810
|
+
if (!(obj instanceof InvoiceModel)) continue;
|
|
811
|
+
switch (obj.state) {
|
|
812
|
+
case State.Insert:
|
|
813
|
+
// Use idPlaceholder — NOT obj.id, which may hold a stale real server id
|
|
814
|
+
payload.inserts.push({ placeholder: obj.idPlaceholder!, status: obj.status });
|
|
815
|
+
break;
|
|
816
|
+
case State.Changed:
|
|
817
|
+
payload.updates.push({ id: obj.id, status: obj.status });
|
|
818
|
+
break;
|
|
819
|
+
case State.Deleted:
|
|
820
|
+
payload.deletes.push({ id: obj.id });
|
|
821
|
+
break;
|
|
822
|
+
case State.Unchanged:
|
|
823
|
+
break;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// Send to server — backend runs everything in one transaction
|
|
828
|
+
const response = await api.save(payload);
|
|
829
|
+
// response.ids: [{ placeholder: -1, value: 42 }]
|
|
830
|
+
|
|
831
|
+
// Apply real IDs and mark everything clean — no page reload needed
|
|
832
|
+
tracker.onCommit(response.ids);
|
|
833
|
+
// newInvoice.id === 42, state === Unchanged
|
|
834
|
+
// tracker.isDirty === false
|
|
835
|
+
|
|
836
|
+
// When no new objects were created, keys can be omitted:
|
|
837
|
+
// tracker.onCommit();
|
|
838
|
+
```
|
|
839
|
+
|
|
840
|
+
---
|
|
841
|
+
|
|
842
|
+
### `@AutoId`
|
|
843
|
+
|
|
844
|
+
Marks a property as the server-assigned autoincrement primary key for this model. Only one `@AutoId` field is allowed per class. Enables the `onCommit` lifecycle for real-ID assignment.
|
|
845
|
+
|
|
846
|
+
```typescript
|
|
847
|
+
class InvoiceModel extends TrackedObject {
|
|
848
|
+
@AutoId
|
|
849
|
+
id: number = 0;
|
|
850
|
+
|
|
851
|
+
@Tracked()
|
|
852
|
+
accessor status: string = '';
|
|
853
|
+
|
|
854
|
+
constructor(tracker: Tracker) {
|
|
855
|
+
super(tracker);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
```
|
|
859
|
+
|
|
860
|
+
When a new object is added to a `TrackedCollection`, a **negative placeholder ID** is automatically assigned to `idPlaceholder` on the `TrackedObject`. The `@AutoId` field itself is left at its initial value until a real server ID arrives. This separation matters: after undo/redo cycles the `@AutoId` field may hold a stale real server ID — the save layer must always use `idPlaceholder` when the state is `Insert`.
|
|
861
|
+
|
|
862
|
+
**Typical save flow:**
|
|
863
|
+
|
|
864
|
+
```typescript
|
|
865
|
+
const invoice = tracker.construct(() => new InvoiceModel(tracker));
|
|
866
|
+
invoices.push(invoice);
|
|
867
|
+
// invoice.idPlaceholder === -1 (auto-assigned when pushed)
|
|
868
|
+
// invoice.id === 0 (untouched by the library)
|
|
869
|
+
|
|
870
|
+
invoice.status = 'draft';
|
|
871
|
+
|
|
872
|
+
// 1. Build payload — use idPlaceholder for Insert items:
|
|
873
|
+
const serverIds = [{ placeholder: invoice.idPlaceholder!, value: 42 }];
|
|
874
|
+
|
|
875
|
+
// 2. Send to server, receive real IDs back.
|
|
876
|
+
|
|
877
|
+
// 3. Apply real IDs and mark clean:
|
|
878
|
+
tracker.onCommit(serverIds);
|
|
879
|
+
// invoice.id === 42 (written by onCommit)
|
|
880
|
+
// invoice.idPlaceholder === null
|
|
881
|
+
// tracker.isDirty === false
|
|
882
|
+
```
|
|
883
|
+
|
|
884
|
+
`onCommit()` with no arguments (or an empty array) still marks the tracker as clean — it just skips the ID replacement step.
|
|
885
|
+
|
|
886
|
+
The placeholder counter never resets — each cycle continues from where it left off — so `idPlaceholder` values are globally unique across the lifetime of the tracker and can never collide across save cycles.
|
|
887
|
+
|
|
888
|
+
---
|
|
889
|
+
|
|
890
|
+
### `ITracked`
|
|
891
|
+
|
|
892
|
+
The common interface implemented by both `TrackedObject` and `TrackedCollection`. Useful for writing utility functions that accept either:
|
|
893
|
+
|
|
894
|
+
```typescript
|
|
895
|
+
import { ITracked } from '@katn30/trakr';
|
|
896
|
+
|
|
897
|
+
function isReady(item: ITracked): boolean {
|
|
898
|
+
return item.isDirty && item.isValid;
|
|
899
|
+
}
|
|
900
|
+
```
|
|
901
|
+
|
|
902
|
+
| Member | Type | Description |
|
|
903
|
+
|---|---|---|
|
|
904
|
+
| `tracker` | `Tracker` | The tracker this object belongs to |
|
|
905
|
+
| `isDirty` | `boolean` | `true` when there are uncommitted changes |
|
|
906
|
+
| `dirtyCounter` | `number` | Net count of uncommitted writes |
|
|
907
|
+
| `state` | `State` | Current persistence state (always `Unchanged` for collections) |
|
|
908
|
+
| `destroy()` | `void` | Removes this object from the tracker |
|
|
909
|
+
|
|
910
|
+
---
|
|
911
|
+
|
|
912
|
+
### `IdAssignment`
|
|
913
|
+
|
|
914
|
+
The shape of each entry in the `keys` array passed to `tracker.onCommit(keys)`:
|
|
915
|
+
|
|
916
|
+
```typescript
|
|
917
|
+
import type { IdAssignment } from '@katn30/trakr';
|
|
918
|
+
// { placeholder: number; value: number }
|
|
919
|
+
```
|
|
920
|
+
|
|
921
|
+
| Field | Type | Description |
|
|
922
|
+
|---|---|---|
|
|
923
|
+
| `placeholder` | `number` | The negative `idPlaceholder` value that was active at save time |
|
|
924
|
+
| `value` | `number` | The real server-assigned ID to substitute in |
|
|
925
|
+
|
|
926
|
+
The server returns one `IdAssignment` per inserted object. `onCommit()` matches each entry against the `idPlaceholder` of every tracked object and writes `value` to the `@AutoId` field of any match.
|
|
927
|
+
|
|
928
|
+
---
|
|
929
|
+
|
|
930
|
+
### `@Tracked()`
|
|
931
|
+
|
|
932
|
+
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**.
|
|
933
|
+
|
|
934
|
+
**With `accessor` (recommended):**
|
|
935
|
+
|
|
936
|
+
```typescript
|
|
937
|
+
class ProductModel extends TrackedObject {
|
|
938
|
+
@Tracked()
|
|
939
|
+
accessor name: string = '';
|
|
940
|
+
|
|
941
|
+
@Tracked()
|
|
942
|
+
accessor price: number = 0;
|
|
943
|
+
|
|
944
|
+
@Tracked()
|
|
945
|
+
accessor active: boolean = true;
|
|
946
|
+
|
|
947
|
+
@Tracked()
|
|
948
|
+
accessor config: Record<string, unknown> = {};
|
|
949
|
+
|
|
950
|
+
@Tracked()
|
|
951
|
+
accessor createdAt: Date = new Date();
|
|
952
|
+
|
|
953
|
+
constructor(tracker: Tracker) {
|
|
954
|
+
super(tracker);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
```
|
|
958
|
+
|
|
959
|
+
**With `get`/`set`** — decorate the setter:
|
|
960
|
+
|
|
961
|
+
```typescript
|
|
962
|
+
class ProductModel extends TrackedObject {
|
|
963
|
+
private _name: string = '';
|
|
964
|
+
|
|
965
|
+
get name(): string { return this._name; }
|
|
966
|
+
|
|
967
|
+
@Tracked()
|
|
968
|
+
set name(value: string) { this._name = value; }
|
|
969
|
+
|
|
970
|
+
constructor(tracker: Tracker) {
|
|
971
|
+
super(tracker);
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
```
|
|
975
|
+
|
|
976
|
+
**With `get`/`set` and side effects** — decorate both getter and setter:
|
|
977
|
+
|
|
978
|
+
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.
|
|
979
|
+
|
|
980
|
+
```typescript
|
|
981
|
+
class RuleModel extends TrackedObject {
|
|
982
|
+
private _isEnabled: boolean = false;
|
|
983
|
+
|
|
984
|
+
@Tracked()
|
|
985
|
+
get isEnabled(): boolean { return this._isEnabled; }
|
|
986
|
+
|
|
987
|
+
@Tracked()
|
|
988
|
+
set isEnabled(value: boolean) {
|
|
989
|
+
this._isEnabled = value;
|
|
990
|
+
if (value) {
|
|
991
|
+
this.scheduleDays = 'mon';
|
|
992
|
+
} else {
|
|
993
|
+
this.scheduleDays = '';
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
@Tracked((self: RuleModel, v) =>
|
|
998
|
+
self.isEnabled && !v ? 'Day is required' : undefined
|
|
999
|
+
)
|
|
1000
|
+
accessor scheduleDays: string = '';
|
|
1001
|
+
|
|
1002
|
+
constructor(tracker: Tracker) {
|
|
1003
|
+
super(tracker);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
```
|
|
1007
|
+
|
|
1008
|
+
When `isEnabled` is set to `true`, `scheduleDays`'s validator automatically re-runs because the getter declared the dependency. No manual `revalidate()` call is needed.
|
|
1009
|
+
|
|
1010
|
+
> 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.
|
|
1011
|
+
|
|
1012
|
+
**With a validator:**
|
|
1013
|
+
|
|
1014
|
+
The validator receives the model instance and the incoming value. Return an error string to fail, `undefined` to pass.
|
|
1015
|
+
|
|
1016
|
+
```typescript
|
|
1017
|
+
class OrderModel extends TrackedObject {
|
|
1018
|
+
@Tracked((self, value) => !value ? 'Status is required' : undefined)
|
|
1019
|
+
accessor status: string = '';
|
|
1020
|
+
|
|
1021
|
+
@Tracked((self, value) => value < 0 ? 'Price must be positive' : undefined)
|
|
1022
|
+
accessor price: number = 0;
|
|
1023
|
+
|
|
1024
|
+
// Validator can inspect other properties of the model
|
|
1025
|
+
@Tracked((self: OrderModel, value) =>
|
|
1026
|
+
value > self.price ? 'Discount exceeds price' : undefined
|
|
1027
|
+
)
|
|
1028
|
+
accessor discount: number = 0;
|
|
1029
|
+
|
|
1030
|
+
constructor(tracker: Tracker) {
|
|
1031
|
+
super(tracker);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
```
|
|
1035
|
+
|
|
1036
|
+
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`.
|
|
1037
|
+
|
|
1038
|
+
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.
|
|
1039
|
+
|
|
1040
|
+
**No-op detection**
|
|
1041
|
+
|
|
1042
|
+
Assigning the same value twice does not create an undo step and does not mark the model dirty. Equality is checked with strict `===` — `null`, `undefined`, and `''` are all distinct values.
|
|
1043
|
+
|
|
1044
|
+
```typescript
|
|
1045
|
+
invoice.status = ''; // no-op (already '')
|
|
1046
|
+
invoice.status = null; // recorded (null !== '')
|
|
1047
|
+
invoice.status = 'draft'; // recorded
|
|
1048
|
+
invoice.status = 'draft'; // no-op
|
|
1049
|
+
```
|
|
1050
|
+
|
|
1051
|
+
**Signature**
|
|
1052
|
+
|
|
1053
|
+
```typescript
|
|
1054
|
+
@Tracked(validator?, onChange?, options?)
|
|
1055
|
+
```
|
|
1056
|
+
|
|
1057
|
+
| Parameter | Type | Applies to | Description |
|
|
1058
|
+
|---|---|---|---|
|
|
1059
|
+
| `validator` | `(self, newValue) => string \| undefined` | accessor, setter | Returns an error string on failure, `undefined` on success |
|
|
1060
|
+
| `onChange` | `(self, newValue, oldValue) => void` | accessor, setter | Side-effect callback. Runs inside the tracked operation — writes to other `@Tracked` properties or `TrackedCollection`s are composed into the same undo step. Does not fire during undo or redo |
|
|
1061
|
+
| `options.coalesceWithin` | `number` | accessor, setter | Maximum gap in ms between two consecutive writes to merge into one undo step. Omit to never coalesce |
|
|
1062
|
+
|
|
1063
|
+
```typescript
|
|
1064
|
+
// validator only:
|
|
1065
|
+
@Tracked((_, v) => v < 0 ? 'Must be positive' : undefined)
|
|
1066
|
+
accessor price: number = 0;
|
|
1067
|
+
|
|
1068
|
+
// onChange only — side effects composed into the same undo step:
|
|
1069
|
+
@Tracked(
|
|
1070
|
+
undefined,
|
|
1071
|
+
(self: TagModel, newValue, oldValue) => {
|
|
1072
|
+
if (oldValue) self.tags.remove(oldValue);
|
|
1073
|
+
if (newValue) self.tags.push(newValue);
|
|
1074
|
+
},
|
|
1075
|
+
)
|
|
1076
|
+
accessor tag: string = '';
|
|
1077
|
+
|
|
1078
|
+
// validator + onChange + coalesceWithin:
|
|
1079
|
+
@Tracked(
|
|
1080
|
+
(_, v) => !v ? 'Required' : undefined,
|
|
1081
|
+
(self: MyModel, newValue) => { self.log.push(newValue); },
|
|
1082
|
+
{ coalesceWithin: 3000 },
|
|
1083
|
+
)
|
|
1084
|
+
accessor name: string = '';
|
|
1085
|
+
|
|
1086
|
+
// coalesceWithin only:
|
|
1087
|
+
@Tracked(undefined, undefined, { coalesceWithin: 3000 })
|
|
1088
|
+
accessor status: string = '';
|
|
1089
|
+
```
|
|
1090
|
+
|
|
1091
|
+
**Supported property types:** `string`, `number`, `boolean`, `Date`, `object`. Unsupported types throw at runtime.
|
|
1092
|
+
|
|
1093
|
+
---
|
|
1094
|
+
|
|
1095
|
+
### `TrackedCollection<T>`
|
|
1096
|
+
|
|
1097
|
+
A fully array-compatible tracked collection. All mutations are recorded and undoable. Implements `Array<T>` so it works anywhere an array is expected.
|
|
1098
|
+
|
|
1099
|
+
```typescript
|
|
1100
|
+
const items = new TrackedCollection<string>(tracker);
|
|
1101
|
+
|
|
1102
|
+
// With initial items:
|
|
1103
|
+
const items = new TrackedCollection<string>(tracker, ['a', 'b']);
|
|
1104
|
+
|
|
1105
|
+
// With a validator:
|
|
1106
|
+
const items = new TrackedCollection<string>(
|
|
1107
|
+
tracker,
|
|
1108
|
+
[],
|
|
1109
|
+
(list) => list.length === 0 ? 'At least one item is required' : undefined,
|
|
1110
|
+
);
|
|
1111
|
+
```
|
|
1112
|
+
|
|
1113
|
+
**Tracked mutation methods**
|
|
1114
|
+
|
|
1115
|
+
All of these create undo steps:
|
|
1116
|
+
|
|
1117
|
+
| Method | Description |
|
|
1118
|
+
|---|---|
|
|
1119
|
+
| `push(...items)` | Appends one or more items |
|
|
1120
|
+
| `pop()` | Removes and returns the last item |
|
|
1121
|
+
| `shift()` | Removes and returns the first item |
|
|
1122
|
+
| `unshift(...items)` | Prepends one or more items |
|
|
1123
|
+
| `splice(start, deleteCount, ...items)` | Low-level insert/remove at a position |
|
|
1124
|
+
| `remove(item)` | Removes a specific item by reference. Returns `false` if not found |
|
|
1125
|
+
| `replace(item, replacement)` | Replaces a specific item by reference. Returns `false` if not found |
|
|
1126
|
+
| `replaceAt(index, replacement)` | Replaces the item at a given index |
|
|
1127
|
+
| `clear()` | Removes all items |
|
|
1128
|
+
| `reset(newItems)` | Replaces the entire collection with a new array |
|
|
1129
|
+
| `fill(value, start?, end?)` | Fills a range with a value |
|
|
1130
|
+
| `copyWithin(target, start, end?)` | Copies a slice to another position |
|
|
1131
|
+
|
|
1132
|
+
**Read-only / non-mutating methods**
|
|
1133
|
+
|
|
1134
|
+
`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`
|
|
1135
|
+
|
|
1136
|
+
**Additional properties**
|
|
1137
|
+
|
|
1138
|
+
| Member | Description |
|
|
1139
|
+
|---|---|
|
|
1140
|
+
| `length` | Number of items |
|
|
1141
|
+
| `isDirty` | `true` when the collection has unsaved mutations |
|
|
1142
|
+
| `isValid` | `true` when the validator passes (or no validator was provided) |
|
|
1143
|
+
| `error` | The current validation error message, or `undefined` |
|
|
1144
|
+
| `changed` | `TypedEvent<TrackedCollectionChanged<T>>` — fires on every mutation, including during undo and redo |
|
|
1145
|
+
| `trackedChanged` | `TypedEvent<TrackedCollectionChanged<T>>` — fires only on direct user-initiated mutations, never during undo or redo |
|
|
1146
|
+
| `first()` | Returns the first item, or `undefined` if empty |
|
|
1147
|
+
| `destroy()` | Removes the collection from the tracker |
|
|
1148
|
+
|
|
1149
|
+
**Collection change events**
|
|
1150
|
+
|
|
1151
|
+
Both events carry a `TrackedCollectionChanged<T>` payload:
|
|
1152
|
+
|
|
1153
|
+
| Property | Description |
|
|
1154
|
+
|---|---|
|
|
1155
|
+
| `added` | Items that were inserted |
|
|
1156
|
+
| `removed` | Items that were removed |
|
|
1157
|
+
| `newCollection` | The full collection after the mutation |
|
|
1158
|
+
|
|
1159
|
+
Both events fire synchronously **inside** the tracked operation, so any `@Tracked` property write made inside either listener is automatically composed into the same undo step as the collection mutation (see [Automatic composing](#automatic-composing)).
|
|
1160
|
+
|
|
1161
|
+
The difference is when they fire:
|
|
1162
|
+
- `changed` fires on every mutation, including during undo and redo replays
|
|
1163
|
+
- `trackedChanged` fires only on direct user-initiated mutations — never during undo or redo
|
|
1164
|
+
|
|
1165
|
+
```typescript
|
|
1166
|
+
// changed — fires on initial write, undo, and redo; writes in callback are composed
|
|
1167
|
+
items.changed.subscribe(() => {
|
|
1168
|
+
this.itemCount = items.length;
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
// trackedChanged — fires only on initial write; writes in callback are still composed
|
|
1172
|
+
items.trackedChanged.subscribe((e) => {
|
|
1173
|
+
this.lastAdded = e.added[e.added.length - 1] ?? ''; // composed on initial write only
|
|
1174
|
+
});
|
|
1175
|
+
```
|
|
1176
|
+
|
|
1177
|
+
---
|
|
1178
|
+
|
|
1179
|
+
### `TypedEvent<T>`
|
|
1180
|
+
|
|
1181
|
+
A lightweight, strongly-typed event emitter. Used internally for `tracker.isDirtyChanged`, `tracker.isValidChanged`, `TrackedObject.changed`, `TrackedObject.trackedChanged`, `TrackedCollection.changed`, and `TrackedCollection.trackedChanged`, and available for your own use.
|
|
1182
|
+
|
|
1183
|
+
```typescript
|
|
1184
|
+
const event = new TypedEvent<string>();
|
|
1185
|
+
|
|
1186
|
+
// subscribe returns an unsubscribe function
|
|
1187
|
+
const unsubscribe = event.subscribe((value) => {
|
|
1188
|
+
console.log('received:', value);
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
event.emit('hello'); // → "received: hello"
|
|
1192
|
+
|
|
1193
|
+
unsubscribe(); // stop listening
|
|
1194
|
+
|
|
1195
|
+
event.emit('world'); // → (nothing)
|
|
1196
|
+
```
|
|
1197
|
+
|
|
1198
|
+
| Method | Returns | Description |
|
|
1199
|
+
|---|---|---|
|
|
1200
|
+
| `subscribe(handler)` | `() => void` | Registers a listener. Returns an unsubscriber |
|
|
1201
|
+
| `unsubscribe(handler)` | `void` | Removes a specific listener |
|
|
1202
|
+
| `emit(value)` | `void` | Calls all registered listeners with the given value |
|
|
1203
|
+
|
|
1204
|
+
---
|
|
1205
|
+
|
|
1206
|
+
## License
|
|
1207
|
+
|
|
1208
|
+
MIT — Nazario Mazzotti
|