@ifc-lite/mutations 1.22.0 → 1.24.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.
@@ -3,10 +3,12 @@
3
3
  * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
4
  import { PropertyValueType, QuantityType } from '@ifc-lite/data';
5
5
  import { propertyKey, quantityKey, attributeKey, generateMutationId } from './types.js';
6
+ import { collectEffectiveChanges } from './effective-changes.js';
6
7
  export class MutablePropertyView {
7
8
  baseTable;
8
9
  onDemandExtractor = null;
9
10
  quantityExtractor = null;
11
+ attributeExtractor = null;
10
12
  propertyMutations = new Map();
11
13
  quantityMutations = new Map();
12
14
  /**
@@ -28,6 +30,39 @@ export class MutablePropertyView {
28
30
  typeMutations = new Map(); // entityId -> retype intent
29
31
  newEntities = new Map();
30
32
  tombstones = new Set();
33
+ /**
34
+ * Ids `createEntity` allocated and `deleteEntity` then forgot (removed from
35
+ * `newEntities`, per that method's "existing entities are tombstoned; new
36
+ * entities are simply forgotten" contract). Tracked separately so
37
+ * `getEffectiveChanges()` / `collectEffectiveChanges` can tell "overlay-created
38
+ * then forgotten" apart from "an ordinary source-buffer entity" — both are
39
+ * otherwise indistinguishable, being simply absent from `newEntities`.
40
+ * `restoreNewEntity` (the undo-of-delete counterpart) clears the id back out.
41
+ */
42
+ forgottenCreatedEntities = new Set();
43
+ /**
44
+ * Snapshot of a forgotten-created entity's overlay rows, stashed by
45
+ * `deleteEntity` and restored by `restoreNewEntity`.
46
+ *
47
+ * `deleteEntity` on an overlay-created entity does more than drop it from
48
+ * `newEntities` — it also PURGES every other overlay entry the entity left
49
+ * behind (property/quantity/attribute/positional/type mutations, its
50
+ * `newPsets`/`newQsets` entries, and its own `mutationHistory` records).
51
+ * Without that purge, an entity that was created, edited, then deleted
52
+ * before export left a dangling reference: `StepExporter` derives its
53
+ * property/quantity work list from `getMutations()` (the append-only
54
+ * history) and reads `getForEntity()` / `getQuantitiesForEntity()` straight
55
+ * off `newPsets` / `newQsets` — neither of which the review-side
56
+ * `forgottenCreatedEntities` filter in `effective-changes.ts` touches. The
57
+ * review dialog looked clean while the exported file still contained an
58
+ * `IFCPROPERTYSET` + `IFCRELDEFINESBYPROPERTIES` pointing at an expressId
59
+ * that was never actually created (maintainer finding on #1967).
60
+ *
61
+ * The purged data is captured here, not discarded, because `restoreNewEntity`
62
+ * (undo of the delete) must bring it all back — rows AND count AND what the
63
+ * exporter would see — not just re-add the bare `NewEntity` record.
64
+ */
65
+ forgottenEntityOverlay = new Map();
31
66
  /**
32
67
  * Overlay-entity → source-entity aliases for property/quantity reads.
33
68
  *
@@ -143,6 +178,16 @@ export class MutablePropertyView {
143
178
  setQuantityExtractor(extractor) {
144
179
  this.quantityExtractor = extractor;
145
180
  }
181
+ /**
182
+ * Set the base entity-attribute extractor (Name, Description, ObjectType,
183
+ * Tag, ...), used only to resolve `previousValue` in `getEffectiveChanges()`.
184
+ * Without one, attribute `previousValue` falls back to whatever `oldValue`
185
+ * the overlay entry itself carries — which undo can leave stale/absent (see
186
+ * `getEffectiveChanges()` doc).
187
+ */
188
+ setAttributeExtractor(extractor) {
189
+ this.attributeExtractor = extractor;
190
+ }
146
191
  /**
147
192
  * Get base properties for an entity (before mutations)
148
193
  * Uses on-demand extraction if available, otherwise falls back to base table.
@@ -385,7 +430,23 @@ export class MutablePropertyView {
385
430
  if (oldValue === null && !inNewPset) {
386
431
  return null; // Property doesn't exist
387
432
  }
388
- this.setPropertyMutation(entityId, key, { operation: 'DELETE' });
433
+ // A DELETE marker in `propertyMutations` only earns its keep when it is
434
+ // masking a value that genuinely exists in the base data — that's what
435
+ // `getForEntity`'s base-pset walk (and `collectPropertyChanges`) needs to
436
+ // skip. A purely in-session property (added via `setProperty`/
437
+ // `createPropertySet`, never in base) has nothing to mask: leaving a
438
+ // DELETE marker for it kept `collectModifiedEntityIds()` counting this
439
+ // entity as modified with zero effective rows to show for it (the same
440
+ // class of bug as the `newPsets` empty-map leak above, #1967 finding
441
+ // 2(b)) — so drop the mutation entry outright instead.
442
+ const basePsets = this.getBasePropertiesForEntity(entityId);
443
+ const propExistsInBase = basePsets.some(p => p.name === psetName && p.properties.some(prop => prop.name === propName));
444
+ if (propExistsInBase) {
445
+ this.setPropertyMutation(entityId, key, { operation: 'DELETE' });
446
+ }
447
+ else {
448
+ this.deletePropertyMutation(entityId, key);
449
+ }
389
450
  // Keep the verbatim newPsets read path (getForEntity / STEP export)
390
451
  // consistent with getPropertyValue when the prop lives in an in-session
391
452
  // pset: splice it out, and drop the pset if it becomes empty.
@@ -395,6 +456,14 @@ export class MutablePropertyView {
395
456
  newPset.properties = newPset.properties.filter(p => p.name !== propName);
396
457
  if (newPset.properties.length === 0) {
397
458
  entityPsets.delete(psetName);
459
+ // An empty Map is still truthy, so leaving it in `newPsets` would keep
460
+ // `collectModifiedEntityIds()` / `hasChanges(entityId)` reporting this
461
+ // entity as modified with zero rows to show for it (maintainer finding
462
+ // 2(b) on #1967 — deleting the last property of an auto-created pset
463
+ // never cleared the entity out of `newPsets`).
464
+ if (entityPsets.size === 0) {
465
+ this.newPsets.delete(entityId);
466
+ }
398
467
  }
399
468
  }
400
469
  const mutation = {
@@ -459,16 +528,41 @@ export class MutablePropertyView {
459
528
  * Delete an entire property set
460
529
  */
461
530
  deletePropertySet(entityId, psetName) {
462
- this.deletedPsets.add(`${entityId}:${psetName}`);
463
531
  // Also remove from new psets if it was created in this session
464
532
  const entityPsets = this.newPsets.get(entityId);
465
- if (entityPsets) {
533
+ const inSessionPset = entityPsets?.get(psetName);
534
+ if (entityPsets && inSessionPset) {
466
535
  entityPsets.delete(psetName);
536
+ // An empty Map is still truthy, so leaving it in `newPsets` would keep
537
+ // `collectModifiedEntityIds()` / `hasChanges(entityId)` reporting this
538
+ // entity as modified with zero rows to show for it (maintainer finding
539
+ // 2(b) on #1967 — the `newPsets` empty-map leak that also affects
540
+ // `deleteProperty`).
541
+ if (entityPsets.size === 0) {
542
+ this.newPsets.delete(entityId);
543
+ }
544
+ // The individual SET mutations `createPropertySet` recorded for this
545
+ // pset's properties have nothing to mask either — same argument as
546
+ // `deleteProperty`'s in-session branch below, applied to every
547
+ // property this in-session pset carried, so drop each entry outright
548
+ // instead of leaving it orphaned in `propertyMutations`.
549
+ for (const prop of inSessionPset.properties) {
550
+ const key = propertyKey(entityId, psetName, prop.name);
551
+ this.deletePropertyMutation(entityId, key);
552
+ }
467
553
  }
468
- // Mark all properties as deleted
554
+ // A DELETE marker in `deletedPsets` only earns its keep when it is
555
+ // masking a pset that genuinely exists in the base data — same argument
556
+ // as `deleteProperty` one level down (see the comment above its own
557
+ // base-existence check): a purely in-session pset (added via
558
+ // `createPropertySet`, never in the base file) has nothing to mask, so
559
+ // dropping the pset above already nets to nothing and there is no
560
+ // deletion to report. Recording it as deleted here told the export
561
+ // review a pset would be removed when the net change was zero.
469
562
  const existingPsets = this.getBasePropertiesForEntity(entityId);
470
563
  const pset = existingPsets.find(p => p.name === psetName);
471
564
  if (pset) {
565
+ this.deletedPsets.add(`${entityId}:${psetName}`);
472
566
  for (const prop of pset.properties) {
473
567
  const key = propertyKey(entityId, psetName, prop.name);
474
568
  this.setPropertyMutation(entityId, key, { operation: 'DELETE' });
@@ -870,13 +964,43 @@ export class MutablePropertyView {
870
964
  return entity;
871
965
  }
872
966
  /**
873
- * Mark an entity for deletion. Existing entities are tombstoned; new
874
- * entities (from `createEntity`) are simply forgotten. Returns false if
875
- * the id is unknown to this view.
967
+ * Mark an entity for deletion. Returns false if the id is unknown to this
968
+ * view, or was already tombstoned.
969
+ *
970
+ * An overlay-created entity is dropped from `newEntities` — so it is emitted
971
+ * nowhere, which is the right answer for something created and deleted in one
972
+ * session — AND tombstoned, so `isDeleted` tells the truth about it.
973
+ *
974
+ * It used to be only forgotten, and that made `isDeleted` lie: every guard
975
+ * that asks "was this deleted" got `false` for an entity that no longer
976
+ * exists, so the export still emitted the `IFCRELDEFINESBYPROPERTIES` for a
977
+ * pset queued on it, dangling at a record nothing wrote (#2012). Forgetting
978
+ * without tombstoning cannot be made safe one guard at a time, because the
979
+ * question the guards ask has no true answer to find.
980
+ *
981
+ * Consumers that count entities must therefore intersect tombstones with the
982
+ * source store rather than subtracting `tombstones.size` wholesale — a
983
+ * created-then-deleted id is absent from BOTH the store and `getNewEntities`,
984
+ * so counting it as a deletion would subtract it twice.
876
985
  */
877
986
  deleteEntity(expressId) {
878
987
  if (this.newEntities.has(expressId)) {
879
988
  this.newEntities.delete(expressId);
989
+ // Both sets are needed: `tombstones` is what the unified isDeleted() /
990
+ // getEffectiveEntityIndex() answer from (#2036), while
991
+ // `forgottenCreatedEntities` is what collectEffectiveChanges()'s row
992
+ // filter uses to drop ALL rows for a created-then-deleted entity
993
+ // (create and delete cancel out) rather than keeping an entity-deleted
994
+ // row the way a tombstoned source entity does.
995
+ this.tombstones.add(expressId);
996
+ this.forgottenCreatedEntities.add(expressId);
997
+ // Purge every other overlay trace of this entity — property/quantity/
998
+ // attribute/positional/type mutations, `newPsets`/`newQsets`, and this
999
+ // entity's own mutation-history records — BEFORE pushing the
1000
+ // DELETE_ENTITY record below, so that record is the only history entry
1001
+ // left for this id. See `forgottenEntityOverlay`'s doc for why this is
1002
+ // a stash-and-remove rather than an outright discard.
1003
+ this.stashAndPurgeEntityOverlay(expressId);
880
1004
  this.mutationHistory.push({
881
1005
  id: generateMutationId(),
882
1006
  type: 'DELETE_ENTITY',
@@ -958,12 +1082,169 @@ export class MutablePropertyView {
958
1082
  */
959
1083
  restoreNewEntity(entity) {
960
1084
  this.newEntities.set(entity.expressId, entity);
1085
+ // `deleteEntity` both tombstones an overlay-created entity (for the
1086
+ // unified isDeleted() / getEffectiveEntityIndex() answer) and forgets it
1087
+ // (for collectEffectiveChanges()'s row filter), so the inverse has to
1088
+ // clear both — otherwise the restored record is either still "deleted"
1089
+ // per isDeleted() (stale tombstone) or still invisible to the review
1090
+ // diff (stale forgotten-entity mark).
1091
+ this.tombstones.delete(entity.expressId);
1092
+ this.forgottenCreatedEntities.delete(entity.expressId);
961
1093
  // Without this the next createEntity() can hand out the same id and
962
1094
  // overwrite the restored entity.
963
1095
  if (entity.expressId > this.nextAllocatedId) {
964
1096
  this.nextAllocatedId = entity.expressId;
965
1097
  }
1098
+ // Bring back whatever `deleteEntity` purged (property/quantity/attribute
1099
+ // mutations, newPsets/newQsets, history) — a no-op if this entity was
1100
+ // never forgotten (e.g. a plain create with nothing purged).
1101
+ this.unstashEntityOverlay(entity.expressId);
966
1102
  }
1103
+ /**
1104
+ * Move every current overlay entry for `expressId` out of the live maps
1105
+ * and into `forgottenEntityOverlay`, and drop this entity's own records
1106
+ * from `mutationHistory`. Called by `deleteEntity` when it forgets a
1107
+ * created entity. Only stashes a key if something was actually captured,
1108
+ * so `unstashEntityOverlay` on a plain (never-edited) create is a no-op.
1109
+ */
1110
+ stashAndPurgeEntityOverlay(expressId) {
1111
+ const stash = {
1112
+ propertyEntries: [],
1113
+ quantityEntries: [],
1114
+ attributeEntries: [],
1115
+ positionalAttrs: null,
1116
+ typeMutation: null,
1117
+ newPsets: null,
1118
+ newQsets: null,
1119
+ deletedPsetKeys: [],
1120
+ deletedQsetKeys: [],
1121
+ historyEntries: [],
1122
+ };
1123
+ for (const key of Array.from(this.propertyKeysByEntity.get(expressId) ?? [])) {
1124
+ const mutation = this.propertyMutations.get(key);
1125
+ if (mutation)
1126
+ stash.propertyEntries.push([key, mutation]);
1127
+ this.deletePropertyMutation(expressId, key);
1128
+ }
1129
+ for (const key of Array.from(this.quantityKeysByEntity.get(expressId) ?? [])) {
1130
+ const mutation = this.quantityMutations.get(key);
1131
+ if (mutation)
1132
+ stash.quantityEntries.push([key, mutation]);
1133
+ this.deleteQuantityMutation(expressId, key);
1134
+ }
1135
+ for (const key of Array.from(this.attributeKeysByEntity.get(expressId) ?? [])) {
1136
+ const mutation = this.attributeMutations.get(key);
1137
+ if (mutation)
1138
+ stash.attributeEntries.push([key, mutation]);
1139
+ this.deleteAttributeMutation(expressId, key);
1140
+ }
1141
+ const positional = this.positionalAttrMutations.get(expressId);
1142
+ if (positional) {
1143
+ stash.positionalAttrs = new Map(positional);
1144
+ this.positionalAttrMutations.delete(expressId);
1145
+ }
1146
+ const typeMutation = this.typeMutations.get(expressId);
1147
+ if (typeMutation) {
1148
+ stash.typeMutation = typeMutation;
1149
+ this.typeMutations.delete(expressId);
1150
+ }
1151
+ const psets = this.newPsets.get(expressId);
1152
+ if (psets) {
1153
+ stash.newPsets = new Map(psets);
1154
+ this.newPsets.delete(expressId);
1155
+ }
1156
+ const qsets = this.newQsets.get(expressId);
1157
+ if (qsets) {
1158
+ stash.newQsets = new Map(qsets);
1159
+ this.newQsets.delete(expressId);
1160
+ }
1161
+ const psetPrefix = `${expressId}:`;
1162
+ for (const key of Array.from(this.deletedPsets)) {
1163
+ if (!key.startsWith(psetPrefix))
1164
+ continue;
1165
+ stash.deletedPsetKeys.push(key);
1166
+ this.deletedPsets.delete(key);
1167
+ }
1168
+ for (const key of Array.from(this.deletedQsets)) {
1169
+ if (!key.startsWith(psetPrefix))
1170
+ continue;
1171
+ stash.deletedQsetKeys.push(key);
1172
+ this.deletedQsets.delete(key);
1173
+ }
1174
+ const keptHistory = [];
1175
+ for (const mutation of this.mutationHistory) {
1176
+ if (mutation.entityId === expressId) {
1177
+ stash.historyEntries.push(mutation);
1178
+ }
1179
+ else {
1180
+ keptHistory.push(mutation);
1181
+ }
1182
+ }
1183
+ this.mutationHistory = keptHistory;
1184
+ const hasStashedData = stash.propertyEntries.length > 0 ||
1185
+ stash.quantityEntries.length > 0 ||
1186
+ stash.attributeEntries.length > 0 ||
1187
+ stash.positionalAttrs !== null ||
1188
+ stash.typeMutation !== null ||
1189
+ stash.newPsets !== null ||
1190
+ stash.newQsets !== null ||
1191
+ stash.deletedPsetKeys.length > 0 ||
1192
+ stash.deletedQsetKeys.length > 0 ||
1193
+ stash.historyEntries.length > 0;
1194
+ if (hasStashedData) {
1195
+ this.forgottenEntityOverlay.set(expressId, stash);
1196
+ }
1197
+ }
1198
+ /**
1199
+ * Reverse `stashAndPurgeEntityOverlay`: put everything `deleteEntity`
1200
+ * purged back into the live overlay maps. Called by `restoreNewEntity`.
1201
+ * A no-op if nothing was stashed for `expressId`.
1202
+ */
1203
+ unstashEntityOverlay(expressId) {
1204
+ const stash = this.forgottenEntityOverlay.get(expressId);
1205
+ if (!stash)
1206
+ return;
1207
+ this.forgottenEntityOverlay.delete(expressId);
1208
+ for (const [key, mutation] of stash.propertyEntries)
1209
+ this.setPropertyMutation(expressId, key, mutation);
1210
+ for (const [key, mutation] of stash.quantityEntries)
1211
+ this.setQuantityMutation(expressId, key, mutation);
1212
+ for (const [key, mutation] of stash.attributeEntries)
1213
+ this.setAttributeMutation(expressId, key, mutation);
1214
+ if (stash.positionalAttrs)
1215
+ this.positionalAttrMutations.set(expressId, stash.positionalAttrs);
1216
+ if (stash.typeMutation)
1217
+ this.typeMutations.set(expressId, stash.typeMutation);
1218
+ if (stash.newPsets)
1219
+ this.newPsets.set(expressId, stash.newPsets);
1220
+ if (stash.newQsets)
1221
+ this.newQsets.set(expressId, stash.newQsets);
1222
+ for (const key of stash.deletedPsetKeys)
1223
+ this.deletedPsets.add(key);
1224
+ for (const key of stash.deletedQsetKeys)
1225
+ this.deletedQsets.add(key);
1226
+ // The DELETE_ENTITY `deleteEntity` pushed AFTER the purge (so it would be
1227
+ // the only history entry left for this id) is superseded by this
1228
+ // restore — same reasoning `forgottenCreatedEntities` already applies to
1229
+ // collectEffectiveChanges()'s row filter one layer down: a create and
1230
+ // its delete cancel, they don't survive as a create followed by a delete.
1231
+ // Re-appending the stashed CREATE_ENTITY/CREATE_PROPERTY records BEHIND
1232
+ // that DELETE_ENTITY (the old bug) reordered mutationHistory to
1233
+ // DELETE_ENTITY,CREATE_ENTITY,..., which defeats applyMutations()'s
1234
+ // skippedCreateIds guard (#2036) on replay: the DELETE_ENTITY is seen
1235
+ // before the CREATE_ENTITY it should pair with, so it tombstones an id
1236
+ // that was never really deleted — silent data loss through
1237
+ // exportMutations()/importMutations() on a published package.
1238
+ this.mutationHistory = this.mutationHistory.filter(m => !(m.entityId === expressId && m.type === 'DELETE_ENTITY'));
1239
+ if (stash.historyEntries.length > 0)
1240
+ this.mutationHistory.push(...stash.historyEntries);
1241
+ }
1242
+ /**
1243
+ * Every express id this session deleted — source-buffer entities AND ones it
1244
+ * created and then deleted. The two are not distinguishable from this set
1245
+ * alone; a caller that needs to tell them apart intersects it with the store's
1246
+ * own index (see `deleteEntity`).
1247
+ */
967
1248
  getTombstones() {
968
1249
  return new Set(this.tombstones);
969
1250
  }
@@ -1017,6 +1298,12 @@ export class MutablePropertyView {
1017
1298
  qset.quantities = qset.quantities.filter(q => q.name !== quantName);
1018
1299
  if (qset.quantities.length === 0) {
1019
1300
  entityQsets.delete(qsetName);
1301
+ // Same empty-Map trap as `deleteProperty`/`newPsets` (#1967
1302
+ // finding 2(b)) — an empty Map is still truthy, so leave no
1303
+ // trace of this entity in `newQsets` once its last qset is gone.
1304
+ if (entityQsets.size === 0) {
1305
+ this.newQsets.delete(entityId);
1306
+ }
1020
1307
  }
1021
1308
  }
1022
1309
  }
@@ -1026,6 +1313,9 @@ export class MutablePropertyView {
1026
1313
  const entityQsets = this.newQsets.get(entityId);
1027
1314
  if (entityQsets) {
1028
1315
  entityQsets.delete(qsetName);
1316
+ if (entityQsets.size === 0) {
1317
+ this.newQsets.delete(entityId);
1318
+ }
1029
1319
  }
1030
1320
  // Remove all quantity mutations for this qset (only those for this entity).
1031
1321
  const bucket = this.quantityKeysByEntity.get(entityId);
@@ -1061,13 +1351,70 @@ export class MutablePropertyView {
1061
1351
  return this.mutationHistory.filter(m => m.entityId === entityId);
1062
1352
  }
1063
1353
  /**
1064
- * Check if an entity has any mutations
1354
+ * Check if an entity currently carries an overlay change.
1355
+ *
1356
+ * Reads the live overlay (same footprint as {@link hasPendingChanges}),
1357
+ * NOT the append-only `mutationHistory` — undo does not pop history (see
1358
+ * `getMutations()`), so a history-based check could report `true` for an
1359
+ * entity whose edit was fully undone. Called with no `entityId`, this is
1360
+ * exactly {@link hasPendingChanges}.
1361
+ *
1362
+ * Unlike {@link getModifiedEntityCount} (derived from
1363
+ * {@link getEffectiveChanges} so it can't diverge), this is a direct
1364
+ * per-entity map lookup kept O(1)-ish for callers that probe many entities
1365
+ * (e.g. a per-row "has changes" indicator) — re-deriving effective changes
1366
+ * per call would be O(overlay size) each time. That means it can still
1367
+ * report `true` for an entity whose only overlay entry is a no-op edit
1368
+ * (undo landed it back at the base value, so `previousValue === newValue`
1369
+ * — see {@link getEffectiveChanges}'s doc). Over-reporting here is the same
1370
+ * safe direction {@link hasPendingChanges} already documents; nothing in
1371
+ * this repo reads this per-entity form in production as of #1967.
1065
1372
  */
1066
1373
  hasChanges(entityId) {
1067
- if (entityId !== undefined) {
1068
- return this.mutationHistory.some(m => m.entityId === entityId);
1374
+ if (entityId === undefined) {
1375
+ return this.hasPendingChanges();
1376
+ }
1377
+ // A create->delete entity is forgotten, not tombstoned (see `deleteEntity`),
1378
+ // so any other per-entity map entries it left behind (attribute/property/
1379
+ // quantity edits made before the delete) are orphaned — they belong to an
1380
+ // entity that will never be exported. `getEffectiveChanges()` already drops
1381
+ // every row for these ids with no exception; this must agree (issue: the
1382
+ // #1915 forgotten-created blind spot). `restoreNewEntity` removes the id
1383
+ // from this set, so a restored entity falls through to the checks below
1384
+ // exactly as before.
1385
+ if (this.forgottenCreatedEntities.has(entityId))
1386
+ return false;
1387
+ if (this.propertyKeysByEntity.has(entityId))
1388
+ return true;
1389
+ if (this.quantityKeysByEntity.has(entityId))
1390
+ return true;
1391
+ if (this.positionalAttrMutations.has(entityId))
1392
+ return true;
1393
+ if (this.typeMutations.has(entityId))
1394
+ return true;
1395
+ if (this.newPsets.has(entityId))
1396
+ return true;
1397
+ if (this.newQsets.has(entityId))
1398
+ return true;
1399
+ if (this.newEntities.has(entityId))
1400
+ return true;
1401
+ if (this.tombstones.has(entityId))
1402
+ return true;
1403
+ const attrPrefix = `${entityId}:attr:`;
1404
+ for (const key of this.attributeMutations.keys()) {
1405
+ if (key.startsWith(attrPrefix))
1406
+ return true;
1407
+ }
1408
+ const setPrefix = `${entityId}:`;
1409
+ for (const key of this.deletedPsets) {
1410
+ if (key.startsWith(setPrefix))
1411
+ return true;
1412
+ }
1413
+ for (const key of this.deletedQsets) {
1414
+ if (key.startsWith(setPrefix))
1415
+ return true;
1069
1416
  }
1070
- return this.mutationHistory.length > 0;
1417
+ return false;
1071
1418
  }
1072
1419
  /**
1073
1420
  * True when the overlay currently carries anything the STEP exporter would
@@ -1098,14 +1445,72 @@ export class MutablePropertyView {
1098
1445
  this.tombstones.size > 0);
1099
1446
  }
1100
1447
  /**
1101
- * Get count of modified entities
1448
+ * Get count of modified entities.
1449
+ *
1450
+ * Reads the live overlay, NOT `mutationHistory` (issue #1915): undo does
1451
+ * not pop history, so a history-based count could over-report — e.g. after
1452
+ * `setAttribute` + `removeAttributeMutation` (exactly what undoing a
1453
+ * freshly-created attribute mutation does), the overlay is empty again but
1454
+ * history still holds the one entry. This must agree with
1455
+ * {@link hasPendingChanges}: zero here iff that is `false`.
1456
+ *
1457
+ * Must also agree with {@link getEffectiveChanges} — an entity contributing
1458
+ * zero effective rows (a create -> edit -> delete `deleteEntity` forgot, or
1459
+ * an edit fully undone back to its base value) must not be counted here
1460
+ * either. `collectModifiedEntityIds` is deliberately DERIVED FROM
1461
+ * `getEffectiveChanges()` rather than hand-walking the overlay maps a
1462
+ * second time, so the two structurally cannot diverge again (issue: the
1463
+ * #1915 forgotten-created blind spot, and the #1967 no-op-edit blind spot
1464
+ * that a second hand-rolled walk reintroduced).
1102
1465
  */
1103
1466
  getModifiedEntityCount() {
1104
- const entities = new Set();
1105
- for (const mutation of this.mutationHistory) {
1106
- entities.add(mutation.entityId);
1107
- }
1108
- return entities.size;
1467
+ return this.collectModifiedEntityIds().size;
1468
+ }
1469
+ /** Distinct entity ids with at least one row in {@link getEffectiveChanges}. */
1470
+ collectModifiedEntityIds() {
1471
+ const ids = new Set();
1472
+ for (const change of this.getEffectiveChanges())
1473
+ ids.add(change.entityId);
1474
+ return ids;
1475
+ }
1476
+ /**
1477
+ * Enumerate every change the overlay currently carries, as it stands right
1478
+ * now — never from `mutationHistory` (see {@link getModifiedEntityCount}).
1479
+ * This is what the export-review UI (issue #1915) and any snapshot test
1480
+ * should read: `previousValue` is derived from the base data (property
1481
+ * table / on-demand extractor / attribute extractor), so an undo→redo
1482
+ * cycle reports the true original, not a stale history entry.
1483
+ *
1484
+ * Whole-pset/qset deletes and creates are reported as a single
1485
+ * `pset-added` / `pset-deleted` / `qset-added` / `qset-deleted` row rather
1486
+ * than one row per property/quantity inside them (deletePropertySet /
1487
+ * createPropertySet also populate individual property/quantity mutations
1488
+ * internally — those are intentionally not double-reported here).
1489
+ *
1490
+ * Deterministic ordering: entityId, then kind, then name, then setName.
1491
+ */
1492
+ getEffectiveChanges() {
1493
+ return collectEffectiveChanges({
1494
+ attributeMutations: this.attributeMutations,
1495
+ positionalAttrMutations: this.positionalAttrMutations,
1496
+ typeMutations: this.typeMutations,
1497
+ newPsets: this.newPsets,
1498
+ deletedPsets: this.deletedPsets,
1499
+ newQsets: this.newQsets,
1500
+ deletedQsets: this.deletedQsets,
1501
+ propertyKeysByEntity: this.propertyKeysByEntity,
1502
+ propertyMutations: this.propertyMutations,
1503
+ quantityKeysByEntity: this.quantityKeysByEntity,
1504
+ quantityMutations: this.quantityMutations,
1505
+ newEntities: this.newEntities,
1506
+ tombstones: this.tombstones,
1507
+ forgottenCreatedEntities: this.forgottenCreatedEntities,
1508
+ }, {
1509
+ attributeExtractor: this.attributeExtractor,
1510
+ resolveBaseEntityId: (entityId) => this.resolveBaseEntityId(entityId),
1511
+ getBasePropertiesForEntity: (entityId) => this.getBasePropertiesForEntity(entityId),
1512
+ getBaseQuantitiesForEntity: (entityId) => this.getBaseQuantitiesForEntity(entityId),
1513
+ });
1109
1514
  }
1110
1515
  /**
1111
1516
  * Clear all mutations (reset to base state)
@@ -1125,6 +1530,8 @@ export class MutablePropertyView {
1125
1530
  this.typeMutations.clear();
1126
1531
  this.newEntities.clear();
1127
1532
  this.tombstones.clear();
1533
+ this.forgottenCreatedEntities.clear();
1534
+ this.forgottenEntityOverlay.clear();
1128
1535
  this.entityAliases.clear();
1129
1536
  this.nextAllocatedId = 0;
1130
1537
  this.mutationHistory = [];