@katn30/trakr 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -55,7 +55,7 @@ class InvoiceModel extends TrackedObject {
55
55
 
56
56
  const invoices = new TrackedCollection<InvoiceModel>(tracker);
57
57
  const invoice = tracker.construct(() => new InvoiceModel(tracker));
58
- invoices.push(invoice); // state: Insert, idPlaceholder: -1
58
+ invoices.push(invoice); // state: Insert, trackingId: 1
59
59
 
60
60
  invoice.status = 'draft'; // recorded
61
61
  invoice.total = 100; // recorded
@@ -489,38 +489,38 @@ Every `TrackedObject` has a `state: State` property — the single source of tru
489
489
 
490
490
  #### Redo is always the same as do
491
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.
492
+ There is no separate redo transition. Redo simply re-runs the original `do` action. `trackingId` is assigned at construction and never changes, so it is always available regardless of undo/redo cycles.
493
493
 
494
494
  #### Full transition table
495
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`) |
496
+ | Event | Direction | From | To | `@AutoId` field |
497
+ |---|---|---|---|---|
498
+ | edit | do / redo | Unchanged | **Changed** | untouched |
499
+ | edit | do / redo | Changed | Changed | untouched |
500
+ | edit | undo (last edit) | Changed | **Unchanged** | untouched |
501
+ | edit | undo (not last) | Changed | Changed | untouched |
502
+ | added | do / redo | Unchanged | **Insert** | untouched |
503
+ | added | undo | Insert | **Unchanged** | untouched |
504
+ | removed | do / redo | Insert | **Unchanged** | untouched, dirtyCounter reset |
505
+ | removed | do / redo | Unchanged | **Deleted** | untouched |
506
+ | removed | undo | Unchanged (was Insert) | **Insert** | untouched |
507
+ | removed | undo | Deleted | **Unchanged** | untouched |
508
+ | committed | do / redo | Insert | **Unchanged** | written with real id (if key supplied) |
509
+ | committed | do / redo | Changed | **Unchanged** | written with real id (if key supplied) |
510
+ | committed | do / redo | Deleted | **Unchanged** | untouched |
511
+ | committed | undo | was Insert | **Deleted** | kept (real id for DELETE) |
512
+ | committed | undo | was Changed | **Changed** | untouched |
513
+ | committed | undo | was Deleted | **Insert** | kept (stale — use `trackingId` for POST) |
514
514
 
515
515
  #### Key notes
516
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.
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 as if the add never happened. Nothing needs to be sent to the server.
518
518
 
519
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
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`.
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 `trackingId` to identify the item in the POST payload, not `@AutoId`.
522
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.
523
+ **`trackingId` for `Insert` and `Changed` items** — `trackingId` is assigned at construction and never changes. Include it in the save payload for `Insert` and `Changed` items so the backend can echo back the new server-assigned PK for each. See [Recommended save pattern](#recommended-save-pattern) and [Temporally versioned tables](#temporally-versioned-tables) for usage.
524
524
 
525
525
  ### Recommended save pattern
526
526
 
@@ -528,7 +528,9 @@ trakr does not mandate a specific save strategy — you can send changes per-obj
528
528
 
529
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
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.
531
+ Every `TrackedObject` has a `trackingId` a positive integer assigned at construction time, stable for the lifetime of the object, unique across the tracker. Include `trackingId` in the save payload for `Insert` and `Changed` items. The backend echoes it back alongside the server-assigned PK for any item that produced a new row. `onCommit(keys)` then iterates every entry in `keys`, matches by `trackingId`, and writes the real PK to the `@AutoId` field of any match regardless of whether the item was `Insert` or `Changed`.
532
+
533
+ New objects can reference each other via their `trackingId` 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)` updates all matched objects 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
534
 
533
535
  **On failure, do not call `onCommit()`.**
534
536
 
@@ -577,10 +579,14 @@ Calling `undo()` or `redo()` when the respective flag is `false` is a no-op.
577
579
 
578
580
  ```typescript
579
581
  tracker.onCommit(); // mark current state as committed — isDirty → false
580
- tracker.onCommit(keys); // same, plus swap placeholder IDs for real server IDs
582
+ tracker.onCommit(keys); // same, plus write real server IDs to @AutoId fields
581
583
  ```
582
584
 
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).
585
+ `onCommit(keys?)` does three things:
586
+
587
+ 1. Iterates every entry in `keys`. For each entry it finds a tracked object whose `trackingId` matches `entry.trackingId` and writes `entry.value` to its `@AutoId` field. This applies to both `Insert` items (new rows) and `Changed` items (e.g. temporal tables where an update produces a new row with a new PK).
588
+ 2. Transitions every tracked object's `state` to `Unchanged` and resets `dirtyCounter`.
589
+ 3. 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
590
 
585
591
  **Manual composing**
586
592
 
@@ -675,7 +681,7 @@ const invoice = tracker.construct(() => new InvoiceModel(tracker));
675
681
  |---|---|---|
676
682
  | `tracker` | `Tracker` | The tracker this model belongs to (set via `super(tracker)`) |
677
683
  | `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 |
684
+ | `trackingId` | `number` | Positive client-assigned identifier, unique across the tracker, set at construction and never changed. Include in the save payload for `Insert` and `Changed` items so the backend can return the new server PK |
679
685
  | `isDirty` | `boolean` | `true` when this model has uncommitted property changes |
680
686
  | `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
687
  | `isValid` | `boolean` | `true` when all `@Tracked()` validators pass |
@@ -790,19 +796,20 @@ tracker.construct(() => {
790
796
  new InvoiceModel(tracker, { id: 2, status: 'sent' });
791
797
  });
792
798
 
793
- // Create a new invoice and add it to a collection (state → Insert, idPlaceholder auto-assigned)
799
+ // Create a new invoice and add it to a collection (state → Insert)
794
800
  const invoices = new TrackedCollection<InvoiceModel>(tracker);
795
801
  const newInvoice = tracker.construct(() => new InvoiceModel(tracker));
796
802
  invoices.push(newInvoice);
797
803
  newInvoice.status = 'pending';
798
- // newInvoice.idPlaceholder === -1 (auto-assigned negative temp key)
804
+ // newInvoice.trackingId === 3 (assigned at construction, never changes)
805
+ // newInvoice.id === 0 (untouched by the library until onCommit)
799
806
 
800
807
  // --- Save ---
801
808
 
802
809
  // Build the payload by reading each object's state
803
810
  const payload: {
804
- inserts: { placeholder: number; status: string }[];
805
- updates: { id: number; status: string }[];
811
+ inserts: { trackingId: number; status: string }[];
812
+ updates: { trackingId: number; id: number; status: string }[];
806
813
  deletes: { id: number }[];
807
814
  } = { inserts: [], updates: [], deletes: [] };
808
815
 
@@ -810,11 +817,11 @@ for (const obj of tracker.trackedObjects) {
810
817
  if (!(obj instanceof InvoiceModel)) continue;
811
818
  switch (obj.state) {
812
819
  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 });
820
+ // Send trackingId so the backend can echo back the new server PK
821
+ payload.inserts.push({ trackingId: obj.trackingId, status: obj.status });
815
822
  break;
816
823
  case State.Changed:
817
- payload.updates.push({ id: obj.id, status: obj.status });
824
+ payload.updates.push({ trackingId: obj.trackingId, id: obj.id, status: obj.status });
818
825
  break;
819
826
  case State.Deleted:
820
827
  payload.deletes.push({ id: obj.id });
@@ -826,14 +833,14 @@ for (const obj of tracker.trackedObjects) {
826
833
 
827
834
  // Send to server — backend runs everything in one transaction
828
835
  const response = await api.save(payload);
829
- // response.ids: [{ placeholder: -1, value: 42 }]
836
+ // response.ids: [{ trackingId: 3, value: 42 }]
830
837
 
831
838
  // Apply real IDs and mark everything clean — no page reload needed
832
839
  tracker.onCommit(response.ids);
833
840
  // newInvoice.id === 42, state === Unchanged
834
841
  // tracker.isDirty === false
835
842
 
836
- // When no new objects were created, keys can be omitted:
843
+ // When no new PKs were assigned, keys can be omitted:
837
844
  // tracker.onCommit();
838
845
  ```
839
846
 
@@ -857,33 +864,32 @@ class InvoiceModel extends TrackedObject {
857
864
  }
858
865
  ```
859
866
 
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`.
867
+ The `@AutoId` field is left at its initial value until `onCommit(keys)` writes the real server ID. The save layer identifies items that need a new PK via `trackingId` a stable, positive integer assigned at construction and never changed. Include `trackingId` in the save payload for `Insert` (and `Changed`, for temporal tables) items; the backend returns it alongside the new PK.
861
868
 
862
869
  **Typical save flow:**
863
870
 
864
871
  ```typescript
865
872
  const invoice = tracker.construct(() => new InvoiceModel(tracker));
866
873
  invoices.push(invoice);
867
- // invoice.idPlaceholder === -1 (auto-assigned when pushed)
868
- // invoice.id === 0 (untouched by the library)
874
+ // invoice.trackingId === 1 (assigned at construction, never changes)
875
+ // invoice.id === 0 (untouched by the library)
869
876
 
870
877
  invoice.status = 'draft';
871
878
 
872
- // 1. Build payload — use idPlaceholder for Insert items:
873
- const serverIds = [{ placeholder: invoice.idPlaceholder!, value: 42 }];
879
+ // 1. Build payload — send trackingId for Insert items:
880
+ const serverIds = [{ trackingId: invoice.trackingId, value: 42 }];
874
881
 
875
882
  // 2. Send to server, receive real IDs back.
876
883
 
877
884
  // 3. Apply real IDs and mark clean:
878
885
  tracker.onCommit(serverIds);
879
- // invoice.id === 42 (written by onCommit)
880
- // invoice.idPlaceholder === null
881
- // tracker.isDirty === false
886
+ // invoice.id === 42 (written by onCommit)
887
+ // tracker.isDirty === false
882
888
  ```
883
889
 
884
890
  `onCommit()` with no arguments (or an empty array) still marks the tracker as clean — it just skips the ID replacement step.
885
891
 
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.
892
+ `trackingId` values are globally unique across the lifetime of the tracker and never reused, so they can safely serve as correlation keys across multiple save cycles.
887
893
 
888
894
  ---
889
895
 
@@ -915,15 +921,15 @@ The shape of each entry in the `keys` array passed to `tracker.onCommit(keys)`:
915
921
 
916
922
  ```typescript
917
923
  import type { IdAssignment } from '@katn30/trakr';
918
- // { placeholder: number; value: number }
924
+ // { trackingId: number; value: number }
919
925
  ```
920
926
 
921
927
  | Field | Type | Description |
922
928
  |---|---|---|
923
- | `placeholder` | `number` | The negative `idPlaceholder` value that was active at save time |
924
- | `value` | `number` | The real server-assigned ID to substitute in |
929
+ | `trackingId` | `number` | The `trackingId` of the object that received a new server-assigned PK |
930
+ | `value` | `number` | The real server-assigned ID to write to the `@AutoId` field |
925
931
 
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.
932
+ The server returns one `IdAssignment` per item that produced a new database row — both inserted objects and, in temporal tables, updated objects (see [Temporally versioned tables](#temporally-versioned-tables)). `onCommit()` iterates every entry, matches by `trackingId` against every tracked object, and writes `value` to the `@AutoId` field of any match.
927
933
 
928
934
  ---
929
935
 
@@ -1203,6 +1209,122 @@ event.emit('world'); // → (nothing)
1203
1209
 
1204
1210
  ---
1205
1211
 
1212
+ ## Temporally versioned tables
1213
+
1214
+ Some databases never modify or delete rows in place. Instead, each row carries a validity period — typically `dt_start_validity` and `dt_end_validity` columns. An "update" means closing the current row (`dt_end_validity = now()`) and inserting a new row with `dt_end_validity = null`. A "delete" means closing the current row the same way. This is called **Method 2 temporal versioning**.
1215
+
1216
+ Because every update produces a new database row with a new auto-increment PK, the `@AutoId` field on a `Changed` object becomes stale after a successful save: the old row it pointed to has been closed, and the new row carries a different PK. The model must be updated with the new PK before the next save, otherwise the save layer would try to close the wrong row.
1217
+
1218
+ trakr handles this through `trackingId` and `onCommit`. The save flow for temporal tables is the same as the standard flow — the only difference is that the backend also returns `{ trackingId, value }` entries for `Changed` items (not just `Insert` items), and `onCommit(keys)` writes the new PK to those objects too.
1219
+
1220
+ ### The problem
1221
+
1222
+ In a standard (non-temporal) database, an UPDATE modifies a row in place. The PK stays the same. After commit, `obj.id` is still correct.
1223
+
1224
+ In a temporal database, an UPDATE closes the current row and inserts a new one. The new row has a fresh PK. After commit, `obj.id` points to the closed row — it is now stale.
1225
+
1226
+ ```
1227
+ Before save: obj.id = 10 (current, open row)
1228
+ Backend: closes row 10, inserts row 99
1229
+ After save: obj.id = 10 (stale — row 10 is closed)
1230
+ Next save: tries to close row 10 → wrong row
1231
+ ```
1232
+
1233
+ ### The solution
1234
+
1235
+ Include `trackingId` in the payload for `Changed` items. The backend returns `{ trackingId, value }` for every item that produced a new row — inserts and temporal updates alike. `onCommit(keys)` writes the new PK to the `@AutoId` field of every matched object.
1236
+
1237
+ ```
1238
+ Before save: obj.trackingId = 3, obj.id = 10
1239
+ Payload: { trackingId: 3, id: 10, ...fields }
1240
+ Backend: closes row 10, inserts row 99, echoes { trackingId: 3, value: 99 }
1241
+ onCommit: writes 99 to obj.id
1242
+ After save: obj.id = 99 (correct, open row), state = Unchanged
1243
+ ```
1244
+
1245
+ ### Full example
1246
+
1247
+ ```typescript
1248
+ import { Tracker, TrackedObject, TrackedCollection, State, Tracked, AutoId } from '@katn30/trakr';
1249
+
1250
+ class RuleModel extends TrackedObject {
1251
+ @AutoId
1252
+ id: number = 0;
1253
+
1254
+ @Tracked()
1255
+ accessor value: string = '';
1256
+
1257
+ constructor(tracker: Tracker) {
1258
+ super(tracker);
1259
+ }
1260
+ }
1261
+
1262
+ const tracker = new Tracker();
1263
+
1264
+ // Load existing rows from the server
1265
+ const rule = tracker.construct(() => new RuleModel(tracker));
1266
+ tracker.withTrackingSuppressed(() => { rule.id = 10; });
1267
+ // rule.state === Unchanged
1268
+ // rule.trackingId === 1 (assigned at construction)
1269
+ // rule.id === 10 (real server PK)
1270
+
1271
+ // User edits a value
1272
+ rule.value = '24h';
1273
+ // rule.state === Changed
1274
+
1275
+ // --- Save ---
1276
+
1277
+ const payload = {
1278
+ inserts: [] as { trackingId: number; value: string }[],
1279
+ changes: [] as { trackingId: number; id: number; value: string }[],
1280
+ deletes: [] as { id: number }[],
1281
+ };
1282
+
1283
+ for (const obj of tracker.trackedObjects) {
1284
+ if (!(obj instanceof RuleModel)) continue;
1285
+ switch (obj.state) {
1286
+ case State.Insert:
1287
+ payload.inserts.push({ trackingId: obj.trackingId, value: obj.value });
1288
+ break;
1289
+ case State.Changed:
1290
+ // Send both trackingId (to correlate the response) and id (to close the right row)
1291
+ payload.changes.push({ trackingId: obj.trackingId, id: obj.id, value: obj.value });
1292
+ break;
1293
+ case State.Deleted:
1294
+ payload.deletes.push({ id: obj.id });
1295
+ break;
1296
+ }
1297
+ }
1298
+
1299
+ // Backend closes row 10, inserts row 99, returns the mapping
1300
+ const response = await api.save(payload);
1301
+ // response.ids: [{ trackingId: 1, value: 99 }] ← returned for both inserts and temporal changes
1302
+
1303
+ // onCommit writes 99 to rule.id, transitions state to Unchanged
1304
+ tracker.onCommit(response.ids);
1305
+ // rule.id === 99 (new open row)
1306
+ // rule.state === Unchanged
1307
+ // tracker.isDirty === false
1308
+ ```
1309
+
1310
+ ### Undo after a temporal commit
1311
+
1312
+ If the user undoes past a committed temporal update, the object transitions back to `Changed` with the old field values restored by the property undo closures. On the next save, `obj.id` now holds `99` (the last committed PK), which is correct — the backend can use it to close row 99 and open a new one.
1313
+
1314
+ ```
1315
+ onCommit: rule.id = 99, state = Unchanged
1316
+ tracker.undo() rule.value restored to previous value, state = Changed
1317
+ Next save: payload.changes includes { trackingId: 1, id: 99, value: '...' }
1318
+ Backend: closes row 99, inserts row 100, returns { trackingId: 1, value: 100 }
1319
+ onCommit: rule.id = 100
1320
+ ```
1321
+
1322
+ ### Deleted items
1323
+
1324
+ For `Deleted` items the PK never changes — the backend just closes the existing row. No `trackingId` is needed in the delete payload; `obj.id` is always the correct row to close.
1325
+
1326
+ ---
1327
+
1206
1328
  ## License
1207
1329
 
1208
1330
  MIT — Nazario Mazzotti
@@ -253,7 +253,7 @@ function validateSingleProperty(tracked, property) {
253
253
  var Tracker = class {
254
254
  constructor() {
255
255
  this._suppressTrackingCounter = 0;
256
- this._placeholderCounter = -1;
256
+ this._trackingIdCounter = 1;
257
257
  this._invalidCount = 0;
258
258
  this._constructionDepth = 0;
259
259
  this._version = 0;
@@ -465,8 +465,8 @@ var Tracker = class {
465
465
  return (/* @__PURE__ */ new Date()).getTime() - CollectionUtilities.getLast(lastOperation.actions).time.getTime() < coalesceWithin;
466
466
  }
467
467
  /** @internal */
468
- _nextPlaceholder() {
469
- return this._placeholderCounter--;
468
+ _nextTrackingId() {
469
+ return this._trackingIdCounter++;
470
470
  }
471
471
  onCommit(keys) {
472
472
  const lastOp = CollectionUtilities.getLast(this._undoOperations);
@@ -603,10 +603,8 @@ function applyStateTransition(obj, event, direction, context) {
603
603
  function applyAdded(obj, direction) {
604
604
  if (direction === "undo") {
605
605
  obj._setState("Unchanged" /* Unchanged */);
606
- obj.idPlaceholder = null;
607
606
  } else {
608
607
  obj._setState("Insert" /* Insert */);
609
- obj.idPlaceholder = obj.tracker._nextPlaceholder();
610
608
  }
611
609
  }
612
610
  function applyRemoved(obj, direction, context) {
@@ -614,17 +612,13 @@ function applyRemoved(obj, direction, context) {
614
612
  const prev = context?.prevState ?? "Unchanged" /* Unchanged */;
615
613
  obj._setState(prev);
616
614
  if (prev === "Insert" /* Insert */) {
617
- obj.idPlaceholder = obj.tracker._nextPlaceholder();
618
615
  if (context?.prevDirtyCounter !== void 0) {
619
616
  obj._setDirtyCounter(context.prevDirtyCounter);
620
617
  }
621
- } else {
622
- obj.idPlaceholder = null;
623
618
  }
624
619
  } else {
625
620
  if (obj.state === "Insert" /* Insert */) {
626
621
  obj._setState("Unchanged" /* Unchanged */);
627
- obj.idPlaceholder = null;
628
622
  obj._setDirtyCounter(0);
629
623
  } else {
630
624
  obj._setState("Deleted" /* Deleted */);
@@ -636,18 +630,15 @@ function applyCommitted(obj, direction, context) {
636
630
  const prev = context?.prevState ?? "Unchanged" /* Unchanged */;
637
631
  if (prev === "Insert" /* Insert */) {
638
632
  obj._setState("Deleted" /* Deleted */);
639
- obj.idPlaceholder = null;
640
633
  } else if (prev === "Deleted" /* Deleted */) {
641
634
  obj._setState("Insert" /* Insert */);
642
- obj.idPlaceholder = obj.tracker._nextPlaceholder();
643
635
  } else if (prev === "Changed" /* Changed */) {
644
636
  obj._setState("Changed" /* Changed */);
645
637
  }
646
638
  } else {
647
- if (context?.prevState === "Insert" /* Insert */ && context.autoIdProp && context.realId !== void 0) {
639
+ if ((context?.prevState === "Insert" /* Insert */ || context?.prevState === "Changed" /* Changed */) && context.autoIdProp && context.realId !== void 0) {
648
640
  obj[context.autoIdProp] = context.realId;
649
641
  }
650
- obj.idPlaceholder = null;
651
642
  obj._setState("Unchanged" /* Unchanged */);
652
643
  obj._setDirtyCounter(0);
653
644
  }
@@ -655,8 +646,8 @@ function applyCommitted(obj, direction, context) {
655
646
  function buildCommittedContext(obj, autoIdProp, keys) {
656
647
  const prevState = obj.state;
657
648
  let realId;
658
- if (prevState === "Insert" /* Insert */ && obj.idPlaceholder !== null && keys) {
659
- realId = keys.find((k) => k.placeholder === obj.idPlaceholder)?.value;
649
+ if ((prevState === "Insert" /* Insert */ || prevState === "Changed" /* Changed */) && keys) {
650
+ realId = keys.find((k) => k.trackingId === obj.trackingId)?.value;
660
651
  }
661
652
  return { prevState, autoIdProp, realId };
662
653
  }
@@ -668,12 +659,12 @@ var TrackedObject = class {
668
659
  this._dirtyCounter = 0;
669
660
  this._isValid = true;
670
661
  this._state = "Unchanged" /* Unchanged */;
671
- this.idPlaceholder = null;
672
662
  this.changed = new TypedEvent();
673
663
  this.trackedChanged = new TypedEvent();
674
664
  if (!tracker._isConstructing) {
675
665
  throw new Error(`${this.constructor.name} must be created inside tracker.construct()`);
676
666
  }
667
+ this.trackingId = tracker._nextTrackingId();
677
668
  this.validationMessages = /* @__PURE__ */ new Map();
678
669
  tracker._trackObject(this);
679
670
  }