@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.
- package/dist/change-set-to-ops.d.ts.map +1 -1
- package/dist/change-set-to-ops.js +12 -0
- package/dist/change-set-to-ops.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 +137 -6
- package/dist/mutable-property-view.d.ts.map +1 -1
- package/dist/mutable-property-view.js +424 -17
- 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' });
|
|
@@ -870,13 +964,43 @@ export class MutablePropertyView {
|
|
|
870
964
|
return entity;
|
|
871
965
|
}
|
|
872
966
|
/**
|
|
873
|
-
* Mark an entity for deletion.
|
|
874
|
-
*
|
|
875
|
-
*
|
|
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
|
|
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
|
|
1068
|
-
return this.
|
|
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
|
|
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
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
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 = [];
|