@ifc-lite/mutations 1.23.0 → 1.24.2

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' });
@@ -639,10 +733,28 @@ export class MutablePropertyView {
639
733
  qset.quantities.push({ name: quantName, type: qType, value, unit });
640
734
  }
641
735
  }
642
- // Get old value for undo and to determine CREATE vs UPDATE
736
+ // Get old value for undo and to determine CREATE vs UPDATE. An overlay
737
+ // mutation (a prior edit this session) wins; otherwise fall back to the
738
+ // base quantity's own value — `qsetExistsInBase` alone is not enough,
739
+ // since a *new* quantity name can be added to an already-existing qset.
740
+ // Without the base-value fallback, the first edit of an existing base
741
+ // quantity reported `oldValue: null` (UPDATE_QUANTITY with nothing to
742
+ // restore), which is exactly the null the viewer's undo handler treats
743
+ // as "nothing to revert to" — undo silently did nothing (#2297 shape).
643
744
  const existingMutation = this.quantityMutations.get(key);
644
- const oldValue = existingMutation?.value ?? null;
645
- const isUpdate = existingMutation != null || qsetExistsInBase;
745
+ let oldValue;
746
+ let isUpdate;
747
+ if (existingMutation) {
748
+ oldValue = existingMutation.value ?? null;
749
+ isUpdate = true;
750
+ }
751
+ else {
752
+ const baseQuantity = baseQsets
753
+ .find(q => q.name === qsetName)
754
+ ?.quantities.find(q => q.name === quantName);
755
+ oldValue = baseQuantity ? baseQuantity.value : null;
756
+ isUpdate = baseQuantity !== undefined;
757
+ }
646
758
  this.setQuantityMutation(entityId, key, {
647
759
  operation: 'SET',
648
760
  value,
@@ -892,7 +1004,21 @@ export class MutablePropertyView {
892
1004
  deleteEntity(expressId) {
893
1005
  if (this.newEntities.has(expressId)) {
894
1006
  this.newEntities.delete(expressId);
1007
+ // Both sets are needed: `tombstones` is what the unified isDeleted() /
1008
+ // getEffectiveEntityIndex() answer from (#2036), while
1009
+ // `forgottenCreatedEntities` is what collectEffectiveChanges()'s row
1010
+ // filter uses to drop ALL rows for a created-then-deleted entity
1011
+ // (create and delete cancel out) rather than keeping an entity-deleted
1012
+ // row the way a tombstoned source entity does.
895
1013
  this.tombstones.add(expressId);
1014
+ this.forgottenCreatedEntities.add(expressId);
1015
+ // Purge every other overlay trace of this entity — property/quantity/
1016
+ // attribute/positional/type mutations, `newPsets`/`newQsets`, and this
1017
+ // entity's own mutation-history records — BEFORE pushing the
1018
+ // DELETE_ENTITY record below, so that record is the only history entry
1019
+ // left for this id. See `forgottenEntityOverlay`'s doc for why this is
1020
+ // a stash-and-remove rather than an outright discard.
1021
+ this.stashAndPurgeEntityOverlay(expressId);
896
1022
  this.mutationHistory.push({
897
1023
  id: generateMutationId(),
898
1024
  type: 'DELETE_ENTITY',
@@ -974,16 +1100,162 @@ export class MutablePropertyView {
974
1100
  */
975
1101
  restoreNewEntity(entity) {
976
1102
  this.newEntities.set(entity.expressId, entity);
977
- // `deleteEntity` tombstones an overlay-created entity as well as forgetting
978
- // it, so the inverse has to lift the tombstone — otherwise the restored
979
- // record is live in `newEntities` and still deleted according to
980
- // `isDeleted`, and the export drops it again.
1103
+ // `deleteEntity` both tombstones an overlay-created entity (for the
1104
+ // unified isDeleted() / getEffectiveEntityIndex() answer) and forgets it
1105
+ // (for collectEffectiveChanges()'s row filter), so the inverse has to
1106
+ // clear both — otherwise the restored record is either still "deleted"
1107
+ // per isDeleted() (stale tombstone) or still invisible to the review
1108
+ // diff (stale forgotten-entity mark).
981
1109
  this.tombstones.delete(entity.expressId);
1110
+ this.forgottenCreatedEntities.delete(entity.expressId);
982
1111
  // Without this the next createEntity() can hand out the same id and
983
1112
  // overwrite the restored entity.
984
1113
  if (entity.expressId > this.nextAllocatedId) {
985
1114
  this.nextAllocatedId = entity.expressId;
986
1115
  }
1116
+ // Bring back whatever `deleteEntity` purged (property/quantity/attribute
1117
+ // mutations, newPsets/newQsets, history) — a no-op if this entity was
1118
+ // never forgotten (e.g. a plain create with nothing purged).
1119
+ this.unstashEntityOverlay(entity.expressId);
1120
+ }
1121
+ /**
1122
+ * Move every current overlay entry for `expressId` out of the live maps
1123
+ * and into `forgottenEntityOverlay`, and drop this entity's own records
1124
+ * from `mutationHistory`. Called by `deleteEntity` when it forgets a
1125
+ * created entity. Only stashes a key if something was actually captured,
1126
+ * so `unstashEntityOverlay` on a plain (never-edited) create is a no-op.
1127
+ */
1128
+ stashAndPurgeEntityOverlay(expressId) {
1129
+ const stash = {
1130
+ propertyEntries: [],
1131
+ quantityEntries: [],
1132
+ attributeEntries: [],
1133
+ positionalAttrs: null,
1134
+ typeMutation: null,
1135
+ newPsets: null,
1136
+ newQsets: null,
1137
+ deletedPsetKeys: [],
1138
+ deletedQsetKeys: [],
1139
+ historyEntries: [],
1140
+ };
1141
+ for (const key of Array.from(this.propertyKeysByEntity.get(expressId) ?? [])) {
1142
+ const mutation = this.propertyMutations.get(key);
1143
+ if (mutation)
1144
+ stash.propertyEntries.push([key, mutation]);
1145
+ this.deletePropertyMutation(expressId, key);
1146
+ }
1147
+ for (const key of Array.from(this.quantityKeysByEntity.get(expressId) ?? [])) {
1148
+ const mutation = this.quantityMutations.get(key);
1149
+ if (mutation)
1150
+ stash.quantityEntries.push([key, mutation]);
1151
+ this.deleteQuantityMutation(expressId, key);
1152
+ }
1153
+ for (const key of Array.from(this.attributeKeysByEntity.get(expressId) ?? [])) {
1154
+ const mutation = this.attributeMutations.get(key);
1155
+ if (mutation)
1156
+ stash.attributeEntries.push([key, mutation]);
1157
+ this.deleteAttributeMutation(expressId, key);
1158
+ }
1159
+ const positional = this.positionalAttrMutations.get(expressId);
1160
+ if (positional) {
1161
+ stash.positionalAttrs = new Map(positional);
1162
+ this.positionalAttrMutations.delete(expressId);
1163
+ }
1164
+ const typeMutation = this.typeMutations.get(expressId);
1165
+ if (typeMutation) {
1166
+ stash.typeMutation = typeMutation;
1167
+ this.typeMutations.delete(expressId);
1168
+ }
1169
+ const psets = this.newPsets.get(expressId);
1170
+ if (psets) {
1171
+ stash.newPsets = new Map(psets);
1172
+ this.newPsets.delete(expressId);
1173
+ }
1174
+ const qsets = this.newQsets.get(expressId);
1175
+ if (qsets) {
1176
+ stash.newQsets = new Map(qsets);
1177
+ this.newQsets.delete(expressId);
1178
+ }
1179
+ const psetPrefix = `${expressId}:`;
1180
+ for (const key of Array.from(this.deletedPsets)) {
1181
+ if (!key.startsWith(psetPrefix))
1182
+ continue;
1183
+ stash.deletedPsetKeys.push(key);
1184
+ this.deletedPsets.delete(key);
1185
+ }
1186
+ for (const key of Array.from(this.deletedQsets)) {
1187
+ if (!key.startsWith(psetPrefix))
1188
+ continue;
1189
+ stash.deletedQsetKeys.push(key);
1190
+ this.deletedQsets.delete(key);
1191
+ }
1192
+ const keptHistory = [];
1193
+ for (const mutation of this.mutationHistory) {
1194
+ if (mutation.entityId === expressId) {
1195
+ stash.historyEntries.push(mutation);
1196
+ }
1197
+ else {
1198
+ keptHistory.push(mutation);
1199
+ }
1200
+ }
1201
+ this.mutationHistory = keptHistory;
1202
+ const hasStashedData = stash.propertyEntries.length > 0 ||
1203
+ stash.quantityEntries.length > 0 ||
1204
+ stash.attributeEntries.length > 0 ||
1205
+ stash.positionalAttrs !== null ||
1206
+ stash.typeMutation !== null ||
1207
+ stash.newPsets !== null ||
1208
+ stash.newQsets !== null ||
1209
+ stash.deletedPsetKeys.length > 0 ||
1210
+ stash.deletedQsetKeys.length > 0 ||
1211
+ stash.historyEntries.length > 0;
1212
+ if (hasStashedData) {
1213
+ this.forgottenEntityOverlay.set(expressId, stash);
1214
+ }
1215
+ }
1216
+ /**
1217
+ * Reverse `stashAndPurgeEntityOverlay`: put everything `deleteEntity`
1218
+ * purged back into the live overlay maps. Called by `restoreNewEntity`.
1219
+ * A no-op if nothing was stashed for `expressId`.
1220
+ */
1221
+ unstashEntityOverlay(expressId) {
1222
+ const stash = this.forgottenEntityOverlay.get(expressId);
1223
+ if (!stash)
1224
+ return;
1225
+ this.forgottenEntityOverlay.delete(expressId);
1226
+ for (const [key, mutation] of stash.propertyEntries)
1227
+ this.setPropertyMutation(expressId, key, mutation);
1228
+ for (const [key, mutation] of stash.quantityEntries)
1229
+ this.setQuantityMutation(expressId, key, mutation);
1230
+ for (const [key, mutation] of stash.attributeEntries)
1231
+ this.setAttributeMutation(expressId, key, mutation);
1232
+ if (stash.positionalAttrs)
1233
+ this.positionalAttrMutations.set(expressId, stash.positionalAttrs);
1234
+ if (stash.typeMutation)
1235
+ this.typeMutations.set(expressId, stash.typeMutation);
1236
+ if (stash.newPsets)
1237
+ this.newPsets.set(expressId, stash.newPsets);
1238
+ if (stash.newQsets)
1239
+ this.newQsets.set(expressId, stash.newQsets);
1240
+ for (const key of stash.deletedPsetKeys)
1241
+ this.deletedPsets.add(key);
1242
+ for (const key of stash.deletedQsetKeys)
1243
+ this.deletedQsets.add(key);
1244
+ // The DELETE_ENTITY `deleteEntity` pushed AFTER the purge (so it would be
1245
+ // the only history entry left for this id) is superseded by this
1246
+ // restore — same reasoning `forgottenCreatedEntities` already applies to
1247
+ // collectEffectiveChanges()'s row filter one layer down: a create and
1248
+ // its delete cancel, they don't survive as a create followed by a delete.
1249
+ // Re-appending the stashed CREATE_ENTITY/CREATE_PROPERTY records BEHIND
1250
+ // that DELETE_ENTITY (the old bug) reordered mutationHistory to
1251
+ // DELETE_ENTITY,CREATE_ENTITY,..., which defeats applyMutations()'s
1252
+ // skippedCreateIds guard (#2036) on replay: the DELETE_ENTITY is seen
1253
+ // before the CREATE_ENTITY it should pair with, so it tombstones an id
1254
+ // that was never really deleted — silent data loss through
1255
+ // exportMutations()/importMutations() on a published package.
1256
+ this.mutationHistory = this.mutationHistory.filter(m => !(m.entityId === expressId && m.type === 'DELETE_ENTITY'));
1257
+ if (stash.historyEntries.length > 0)
1258
+ this.mutationHistory.push(...stash.historyEntries);
987
1259
  }
988
1260
  /**
989
1261
  * Every express id this session deleted — source-buffer entities AND ones it
@@ -1044,6 +1316,12 @@ export class MutablePropertyView {
1044
1316
  qset.quantities = qset.quantities.filter(q => q.name !== quantName);
1045
1317
  if (qset.quantities.length === 0) {
1046
1318
  entityQsets.delete(qsetName);
1319
+ // Same empty-Map trap as `deleteProperty`/`newPsets` (#1967
1320
+ // finding 2(b)) — an empty Map is still truthy, so leave no
1321
+ // trace of this entity in `newQsets` once its last qset is gone.
1322
+ if (entityQsets.size === 0) {
1323
+ this.newQsets.delete(entityId);
1324
+ }
1047
1325
  }
1048
1326
  }
1049
1327
  }
@@ -1053,6 +1331,9 @@ export class MutablePropertyView {
1053
1331
  const entityQsets = this.newQsets.get(entityId);
1054
1332
  if (entityQsets) {
1055
1333
  entityQsets.delete(qsetName);
1334
+ if (entityQsets.size === 0) {
1335
+ this.newQsets.delete(entityId);
1336
+ }
1056
1337
  }
1057
1338
  // Remove all quantity mutations for this qset (only those for this entity).
1058
1339
  const bucket = this.quantityKeysByEntity.get(entityId);
@@ -1088,13 +1369,70 @@ export class MutablePropertyView {
1088
1369
  return this.mutationHistory.filter(m => m.entityId === entityId);
1089
1370
  }
1090
1371
  /**
1091
- * Check if an entity has any mutations
1372
+ * Check if an entity currently carries an overlay change.
1373
+ *
1374
+ * Reads the live overlay (same footprint as {@link hasPendingChanges}),
1375
+ * NOT the append-only `mutationHistory` — undo does not pop history (see
1376
+ * `getMutations()`), so a history-based check could report `true` for an
1377
+ * entity whose edit was fully undone. Called with no `entityId`, this is
1378
+ * exactly {@link hasPendingChanges}.
1379
+ *
1380
+ * Unlike {@link getModifiedEntityCount} (derived from
1381
+ * {@link getEffectiveChanges} so it can't diverge), this is a direct
1382
+ * per-entity map lookup kept O(1)-ish for callers that probe many entities
1383
+ * (e.g. a per-row "has changes" indicator) — re-deriving effective changes
1384
+ * per call would be O(overlay size) each time. That means it can still
1385
+ * report `true` for an entity whose only overlay entry is a no-op edit
1386
+ * (undo landed it back at the base value, so `previousValue === newValue`
1387
+ * — see {@link getEffectiveChanges}'s doc). Over-reporting here is the same
1388
+ * safe direction {@link hasPendingChanges} already documents; nothing in
1389
+ * this repo reads this per-entity form in production as of #1967.
1092
1390
  */
1093
1391
  hasChanges(entityId) {
1094
- if (entityId !== undefined) {
1095
- return this.mutationHistory.some(m => m.entityId === entityId);
1392
+ if (entityId === undefined) {
1393
+ return this.hasPendingChanges();
1394
+ }
1395
+ // A create->delete entity is forgotten, not tombstoned (see `deleteEntity`),
1396
+ // so any other per-entity map entries it left behind (attribute/property/
1397
+ // quantity edits made before the delete) are orphaned — they belong to an
1398
+ // entity that will never be exported. `getEffectiveChanges()` already drops
1399
+ // every row for these ids with no exception; this must agree (issue: the
1400
+ // #1915 forgotten-created blind spot). `restoreNewEntity` removes the id
1401
+ // from this set, so a restored entity falls through to the checks below
1402
+ // exactly as before.
1403
+ if (this.forgottenCreatedEntities.has(entityId))
1404
+ return false;
1405
+ if (this.propertyKeysByEntity.has(entityId))
1406
+ return true;
1407
+ if (this.quantityKeysByEntity.has(entityId))
1408
+ return true;
1409
+ if (this.positionalAttrMutations.has(entityId))
1410
+ return true;
1411
+ if (this.typeMutations.has(entityId))
1412
+ return true;
1413
+ if (this.newPsets.has(entityId))
1414
+ return true;
1415
+ if (this.newQsets.has(entityId))
1416
+ return true;
1417
+ if (this.newEntities.has(entityId))
1418
+ return true;
1419
+ if (this.tombstones.has(entityId))
1420
+ return true;
1421
+ const attrPrefix = `${entityId}:attr:`;
1422
+ for (const key of this.attributeMutations.keys()) {
1423
+ if (key.startsWith(attrPrefix))
1424
+ return true;
1425
+ }
1426
+ const setPrefix = `${entityId}:`;
1427
+ for (const key of this.deletedPsets) {
1428
+ if (key.startsWith(setPrefix))
1429
+ return true;
1096
1430
  }
1097
- return this.mutationHistory.length > 0;
1431
+ for (const key of this.deletedQsets) {
1432
+ if (key.startsWith(setPrefix))
1433
+ return true;
1434
+ }
1435
+ return false;
1098
1436
  }
1099
1437
  /**
1100
1438
  * True when the overlay currently carries anything the STEP exporter would
@@ -1125,14 +1463,72 @@ export class MutablePropertyView {
1125
1463
  this.tombstones.size > 0);
1126
1464
  }
1127
1465
  /**
1128
- * Get count of modified entities
1466
+ * Get count of modified entities.
1467
+ *
1468
+ * Reads the live overlay, NOT `mutationHistory` (issue #1915): undo does
1469
+ * not pop history, so a history-based count could over-report — e.g. after
1470
+ * `setAttribute` + `removeAttributeMutation` (exactly what undoing a
1471
+ * freshly-created attribute mutation does), the overlay is empty again but
1472
+ * history still holds the one entry. This must agree with
1473
+ * {@link hasPendingChanges}: zero here iff that is `false`.
1474
+ *
1475
+ * Must also agree with {@link getEffectiveChanges} — an entity contributing
1476
+ * zero effective rows (a create -> edit -> delete `deleteEntity` forgot, or
1477
+ * an edit fully undone back to its base value) must not be counted here
1478
+ * either. `collectModifiedEntityIds` is deliberately DERIVED FROM
1479
+ * `getEffectiveChanges()` rather than hand-walking the overlay maps a
1480
+ * second time, so the two structurally cannot diverge again (issue: the
1481
+ * #1915 forgotten-created blind spot, and the #1967 no-op-edit blind spot
1482
+ * that a second hand-rolled walk reintroduced).
1129
1483
  */
1130
1484
  getModifiedEntityCount() {
1131
- const entities = new Set();
1132
- for (const mutation of this.mutationHistory) {
1133
- entities.add(mutation.entityId);
1134
- }
1135
- return entities.size;
1485
+ return this.collectModifiedEntityIds().size;
1486
+ }
1487
+ /** Distinct entity ids with at least one row in {@link getEffectiveChanges}. */
1488
+ collectModifiedEntityIds() {
1489
+ const ids = new Set();
1490
+ for (const change of this.getEffectiveChanges())
1491
+ ids.add(change.entityId);
1492
+ return ids;
1493
+ }
1494
+ /**
1495
+ * Enumerate every change the overlay currently carries, as it stands right
1496
+ * now — never from `mutationHistory` (see {@link getModifiedEntityCount}).
1497
+ * This is what the export-review UI (issue #1915) and any snapshot test
1498
+ * should read: `previousValue` is derived from the base data (property
1499
+ * table / on-demand extractor / attribute extractor), so an undo→redo
1500
+ * cycle reports the true original, not a stale history entry.
1501
+ *
1502
+ * Whole-pset/qset deletes and creates are reported as a single
1503
+ * `pset-added` / `pset-deleted` / `qset-added` / `qset-deleted` row rather
1504
+ * than one row per property/quantity inside them (deletePropertySet /
1505
+ * createPropertySet also populate individual property/quantity mutations
1506
+ * internally — those are intentionally not double-reported here).
1507
+ *
1508
+ * Deterministic ordering: entityId, then kind, then name, then setName.
1509
+ */
1510
+ getEffectiveChanges() {
1511
+ return collectEffectiveChanges({
1512
+ attributeMutations: this.attributeMutations,
1513
+ positionalAttrMutations: this.positionalAttrMutations,
1514
+ typeMutations: this.typeMutations,
1515
+ newPsets: this.newPsets,
1516
+ deletedPsets: this.deletedPsets,
1517
+ newQsets: this.newQsets,
1518
+ deletedQsets: this.deletedQsets,
1519
+ propertyKeysByEntity: this.propertyKeysByEntity,
1520
+ propertyMutations: this.propertyMutations,
1521
+ quantityKeysByEntity: this.quantityKeysByEntity,
1522
+ quantityMutations: this.quantityMutations,
1523
+ newEntities: this.newEntities,
1524
+ tombstones: this.tombstones,
1525
+ forgottenCreatedEntities: this.forgottenCreatedEntities,
1526
+ }, {
1527
+ attributeExtractor: this.attributeExtractor,
1528
+ resolveBaseEntityId: (entityId) => this.resolveBaseEntityId(entityId),
1529
+ getBasePropertiesForEntity: (entityId) => this.getBasePropertiesForEntity(entityId),
1530
+ getBaseQuantitiesForEntity: (entityId) => this.getBaseQuantitiesForEntity(entityId),
1531
+ });
1136
1532
  }
1137
1533
  /**
1138
1534
  * Clear all mutations (reset to base state)
@@ -1152,6 +1548,8 @@ export class MutablePropertyView {
1152
1548
  this.typeMutations.clear();
1153
1549
  this.newEntities.clear();
1154
1550
  this.tombstones.clear();
1551
+ this.forgottenCreatedEntities.clear();
1552
+ this.forgottenEntityOverlay.clear();
1155
1553
  this.entityAliases.clear();
1156
1554
  this.nextAllocatedId = 0;
1157
1555
  this.mutationHistory = [];
@@ -1166,8 +1564,46 @@ export class MutablePropertyView {
1166
1564
  // entity that never made it into this view — that stale tombstone
1167
1565
  // would later suppress a freshly-allocated overlay entity reusing
1168
1566
  // the same expressId.
1567
+ // Pass 1: collect every CREATE_ENTITY id up front, over the whole
1568
+ // array, before applying anything. CREATE_ENTITY is unconditionally
1569
+ // skipped below (every id it's called for lands here) — but a caller
1570
+ // supplying an arbitrary (e.g. imported/merged) Mutation[] may not have
1571
+ // its CREATE_ENTITY appear before the mutations that depend on it. A
1572
+ // single incremental forward pass would only "see" a create once the
1573
+ // loop reaches it, so a dependent mutation earlier in the array would
1574
+ // replay before its own entity's creation was known to be skipped —
1575
+ // reproducing the orphaned-pset bug via ordering instead of via the
1576
+ // original bug shape. Doing the full collection first makes the result
1577
+ // order-independent.
1169
1578
  const skippedCreateIds = new Set();
1170
1579
  for (const mutation of mutations) {
1580
+ if (mutation.type === 'CREATE_ENTITY') {
1581
+ skippedCreateIds.add(mutation.entityId);
1582
+ }
1583
+ }
1584
+ // Pass 2: apply mutations against the now-complete skip set.
1585
+ for (const mutation of mutations) {
1586
+ // Any mutation recorded against an entity whose own CREATE_ENTITY was
1587
+ // skipped above would otherwise replay into an orphan — a pset (or
1588
+ // attribute/quantity/type edit) keyed to an expressId that exists in
1589
+ // neither the source buffer nor `newEntities`. Refuse those too, so
1590
+ // the round trip is lossy (entity + its edits both dropped) rather
1591
+ // than corrupting (edits surviving without their entity). This keys
1592
+ // off `skippedCreateIds`, not "id absent from newEntities", so a
1593
+ // mutation against a normal, pre-existing source-buffer entity is
1594
+ // never affected — only ids that had their own CREATE_ENTITY skipped
1595
+ // in this same batch land here.
1596
+ // The `newEntities` check makes the condition "the create was skipped
1597
+ // AND nothing else supplied the entity". A caller following the
1598
+ // documented recovery flow calls `restoreNewEntity()` first and
1599
+ // *then* replays the history; the id is live by the time we get here,
1600
+ // so there is no orphan to guard against and dropping its edits would
1601
+ // silently lose data on the exact path the console.warn recommends.
1602
+ if (mutation.type !== 'CREATE_ENTITY' &&
1603
+ skippedCreateIds.has(mutation.entityId) &&
1604
+ !this.newEntities.has(mutation.entityId)) {
1605
+ continue;
1606
+ }
1171
1607
  switch (mutation.type) {
1172
1608
  case 'CREATE_PROPERTY':
1173
1609
  case 'UPDATE_PROPERTY':
@@ -1190,6 +1626,22 @@ export class MutablePropertyView {
1190
1626
  if (mutation.psetName && mutation.propName && mutation.newValue !== undefined) {
1191
1627
  this.setQuantity(mutation.entityId, mutation.psetName, mutation.propName, Number(mutation.newValue), mutation.quantityType ?? QuantityType.Count, mutation.unit);
1192
1628
  }
1629
+ else if (mutation.type === 'CREATE_QUANTITY' &&
1630
+ mutation.psetName &&
1631
+ Array.isArray(mutation.newValue)) {
1632
+ // `createQuantitySet()` (whole-qset creation, e.g.
1633
+ // `StoreEditor.addQuantitySet`) records ONE CREATE_QUANTITY mutation
1634
+ // for the whole set — no `propName`, `newValue` is the full
1635
+ // quantities array — unlike `setQuantity()`'s per-quantity
1636
+ // CREATE_QUANTITY, which always carries both. Mirrors the
1637
+ // CREATE_PROPERTY_SET handling below. Without this branch the
1638
+ // `psetName && propName` check above is false and the record
1639
+ // matched this `case` with nothing done — never falling through to
1640
+ // the "unhandled mutation type" warning either — so a freshly
1641
+ // created quantity set silently vanished on
1642
+ // exportMutations()/importMutations() round trip.
1643
+ this.createQuantitySet(mutation.entityId, mutation.psetName, mutation.newValue);
1644
+ }
1193
1645
  break;
1194
1646
  case 'UPDATE_POSITIONAL_ATTRIBUTE': {
1195
1647
  // attributeName is `@<index>` for positional mutations.
@@ -1229,15 +1681,19 @@ export class MutablePropertyView {
1229
1681
  // doesn't carry the type+attributes payload — applying a bare
1230
1682
  // CREATE_ENTITY would lose the entity. We log and skip rather
1231
1683
  // than silently dropping it, so callers see they need to
1232
- // restore the payload through the dedicated path.
1233
- skippedCreateIds.add(mutation.entityId);
1684
+ // restore the payload through the dedicated path. Unless the
1685
+ // caller already restored it, every other mutation recorded
1686
+ // against this id in this batch is dropped too (see the guard
1687
+ // above this switch) — otherwise the entity is gone but its edits
1688
+ // survive as an orphan. (skippedCreateIds was already fully
1689
+ // populated in pass 1, above.)
1234
1690
  // eslint-disable-next-line no-console
1235
- console.warn(`applyMutations: CREATE_ENTITY for #${mutation.entityId} requires a NewEntity payload — restore via restoreNewEntity()`);
1691
+ console.warn(`applyMutations: CREATE_ENTITY for #${mutation.entityId} requires a NewEntity payload — ` +
1692
+ `restore via restoreNewEntity(). Skipping the record; dependent mutations recorded against ` +
1693
+ `#${mutation.entityId} are dropped too unless the entity was restored before this call.`);
1236
1694
  break;
1237
1695
  }
1238
1696
  case 'DELETE_ENTITY':
1239
- if (skippedCreateIds.has(mutation.entityId))
1240
- break;
1241
1697
  this.deleteEntity(mutation.entityId);
1242
1698
  break;
1243
1699
  default:
@@ -1250,7 +1706,9 @@ export class MutablePropertyView {
1250
1706
  }
1251
1707
  }
1252
1708
  /**
1253
- * Export mutations as JSON
1709
+ * Export mutations as JSON. Includes every record in `mutationHistory`,
1710
+ * including `CREATE_ENTITY` — but see `importMutations` for why replaying
1711
+ * that record on another view does not reconstruct the entity.
1254
1712
  */
1255
1713
  exportMutations() {
1256
1714
  return JSON.stringify({
@@ -1260,7 +1718,23 @@ export class MutablePropertyView {
1260
1718
  }, null, 2);
1261
1719
  }
1262
1720
  /**
1263
- * Import mutations from JSON
1721
+ * Import mutations from JSON produced by `exportMutations`.
1722
+ *
1723
+ * **Not a full inverse of `exportMutations`.** A `CREATE_ENTITY` record
1724
+ * carries only the expressId in the history — not the entity's type and
1725
+ * attributes — so `importMutations` cannot rebuild the entity from the
1726
+ * record alone: it logs a `console.warn` and skips the record, and drops
1727
+ * every other mutation recorded against that same entity id in the same
1728
+ * batch too (so the round trip is lossy — entity and edits both dropped —
1729
+ * rather than leaving an orphaned property/attribute/quantity keyed to an
1730
+ * id that was never created on the receiving view).
1731
+ *
1732
+ * To carry an overlay-created entity across, call `restoreNewEntity()`
1733
+ * with its `NewEntity` payload (from `getNewEntity`/`getNewEntities` on
1734
+ * the source view) **before** calling `importMutations`. Once the id is
1735
+ * live in `newEntities`, its dependent mutations replay normally — only
1736
+ * the `console.warn` for the (now redundant) `CREATE_ENTITY` record still
1737
+ * fires.
1264
1738
  */
1265
1739
  importMutations(json) {
1266
1740
  const data = JSON.parse(json);