@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.
- package/README.md +33 -0
- package/dist/change-set-to-ops.d.ts.map +1 -1
- package/dist/change-set-to-ops.js +55 -2
- package/dist/change-set-to-ops.js.map +1 -1
- package/dist/csv-connector.d.ts.map +1 -1
- package/dist/csv-connector.js +3 -0
- package/dist/csv-connector.js.map +1 -1
- package/dist/effective-changes.d.ts +60 -0
- package/dist/effective-changes.d.ts.map +1 -0
- package/dist/effective-changes.js +277 -0
- package/dist/effective-changes.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mutable-property-view.d.ts +133 -5
- package/dist/mutable-property-view.d.ts.map +1 -1
- package/dist/mutable-property-view.js +502 -28
- package/dist/mutable-property-view.js.map +1 -1
- package/dist/types.d.ts +49 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
645
|
-
|
|
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
|
|
978
|
-
//
|
|
979
|
-
//
|
|
980
|
-
//
|
|
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
|
|
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
|
|
1095
|
-
return this.
|
|
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
|
-
|
|
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
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
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
|
-
|
|
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 —
|
|
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);
|