Skip to content

Commit d6afa47

Browse files
authored
fix(viewer): collapse the global-id ownership rule to one owner (#3343) (#3380)
`localIdInParseRange` / `localIdInOverlay` (modelSlice.ts, #2697) and `teardown-scope.ts`'s `modelRemovedScope` survivor check re-spelled the same "does a surviving model own this global id" rule by hand — the latter's own comment already flagged this as known duplication that "consolidating is a change of its own". Move the two predicates into `store/globalId.ts`, the cycle-free home the comment already named, and have both modelSlice.ts and teardown-scope.ts call them instead of re-deriving the range/overlay arithmetic. Issue #3343 described this as four copies, two of which ignored the extraction and disagreed. That was accurate when filed, but an unrelated teardown refactor (#3358, already merged) collapsed the two disagreeing copies (modelSlice's removeModel and syncSourceModel's purgeStaleEntityState) into the single teardown-scope.ts copy fixed here. A 300k-case property check (isStale's old hand-rolled loop vs. the shared functions, and identity resolution vs. staleness, across random federated-model layouts with overlay ids bled into a neighbor's parse range) found zero behavioral disagreement between the current duplicate and the extraction — the boolean "some survivor owns it" is a straight OR across survivors, so check order never mattered for that question, only for the (unrelated, already-correct) identity resolution the modelSlice comment about the two-pass order already covers. The remaining risk was structural, not a live bug: nothing stopped the next edit to the rule from being made in one place and not the other. `apps/viewer/src/lib/lens/adapter.ts` holds a fifth, parse-range-only spelling in a result-object-reusing hot loop over its own `ModelEntry` type; left alone as the issue asks (different module, no overlay pass, doubly justified now since the shared function's callers all resolve global ids into `Map` entries rather than reuse a pooled object across a hot loop). RED proven by stashing only the source changes (globalId.ts, modelSlice.ts, teardown-scope.ts) and running the new test against the restored old code: it failed to import `localIdInOverlay` (SyntaxError: does not provide an export), then passed 20/20 after restoring the fix.
1 parent a76bc4e commit d6afa47

4 files changed

Lines changed: 169 additions & 40 deletions

File tree

apps/viewer/src/store/globalId.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,12 @@ import {
2323
toGlobalIdFromModels,
2424
fromGlobalIdFromModels,
2525
toGlobalIdForRef,
26+
localIdInParseRange,
27+
localIdInOverlay,
2628
type ForwardModelMapLike,
29+
type OwnershipView,
2730
} from './globalId.js';
31+
import { modelRemovedScope } from './teardown-scope.js';
2832

2933
type ReverseEntry = Pick<FederatedModel, 'idOffset' | 'maxExpressId'>;
3034

@@ -138,3 +142,97 @@ describe('fromGlobalIdFromModels', () => {
138142
}
139143
});
140144
});
145+
146+
/**
147+
* `localIdInParseRange` / `localIdInOverlay` — the "does a surviving model
148+
* own this global id" rule (#3343). Exported here so `modelSlice.ts`'s
149+
* resolvers and `teardown-scope.ts`'s `modelRemovedScope` survivor check
150+
* share ONE implementation instead of three hand-written copies (the third,
151+
* `fromGlobalIdFromModels` above, stays a deliberate fourth spelling — see
152+
* its own boundary comment).
153+
*/
154+
describe('localIdInParseRange', () => {
155+
it('returns the local id inside [idOffset, idOffset + maxExpressId], both boundaries included', () => {
156+
const model = { idOffset: 1000, maxExpressId: 300 };
157+
assert.equal(localIdInParseRange(model, 1000), 0);
158+
assert.equal(localIdInParseRange(model, 1300), 300);
159+
assert.equal(localIdInParseRange(model, 1150), 150);
160+
});
161+
162+
it('returns null one past either boundary', () => {
163+
const model = { idOffset: 1000, maxExpressId: 300 };
164+
assert.equal(localIdInParseRange(model, 999), null);
165+
assert.equal(localIdInParseRange(model, 1301), null);
166+
});
167+
});
168+
169+
describe('localIdInOverlay', () => {
170+
it('returns null with no mutation view', () => {
171+
const model = { idOffset: 0, maxExpressId: 100 };
172+
assert.equal(localIdInOverlay(model, 150, undefined), null);
173+
});
174+
175+
it('returns null for an id inside the parse range — not overlay\'s business', () => {
176+
const model = { idOffset: 0, maxExpressId: 100 };
177+
const view: OwnershipView = { getNewEntity: () => ({}) };
178+
assert.equal(localIdInOverlay(model, 50, view), null);
179+
});
180+
181+
it('returns the local id when the overlay view holds an entity above maxExpressId', () => {
182+
const model = { idOffset: 0, maxExpressId: 100 };
183+
const view: OwnershipView = { getNewEntity: (id) => (id === 150 ? {} : null) };
184+
assert.equal(localIdInOverlay(model, 150, view), 150);
185+
});
186+
187+
it('returns null when the overlay view has nothing at that local id', () => {
188+
const model = { idOffset: 0, maxExpressId: 100 };
189+
const view: OwnershipView = { getNewEntity: () => null };
190+
assert.equal(localIdInOverlay(model, 150, view), null);
191+
});
192+
});
193+
194+
/**
195+
* The scenario `modelSlice.ts`'s `resolveGlobalIdFromModels` doc-comment
196+
* warns about: model A's overlay-allocated ids can land inside model B's
197+
* PARSE-time range, because overlay ids simply increment past A's
198+
* `maxExpressId` with no knowledge of where B starts. `resolveGlobalIdFromModels`
199+
* handles this with two full passes (every model's parse range, THEN every
200+
* model's overlay) specifically so a real, parsed entity in B always wins
201+
* identity resolution over a synthetic overlay id in A.
202+
*
203+
* `modelRemovedScope`'s survivor check only ever needs "does SOME survivor
204+
* own this id", not "which one" — and that boolean is the same regardless of
205+
* which model or which check (parse range vs. overlay) is tried first, since
206+
* it is a plain OR across survivors. This test pins that: it is what makes it
207+
* safe for `modelRemovedScope` to check each survivor's parse range THEN
208+
* overlay before moving to the next, rather than mirroring the two full
209+
* passes `resolveGlobalIdFromModels` needs for identity.
210+
*/
211+
describe('parse-range vs. overlay ownership — cross-model shadowing', () => {
212+
it('a survivor\'s PARSE-range id is not mistaken for stale even when an earlier survivor\'s overlay could also claim it', () => {
213+
// A: parse range [0, 100], overlay claims local id 150 (global 150).
214+
// B: parse range [101, 200] — globalId 150 falls in B's parse range too
215+
// (150 - 101 = 49, inside [0, 100]).
216+
const modelA = { id: 'A', idOffset: 0, maxExpressId: 100 };
217+
const modelB = { id: 'B', idOffset: 101, maxExpressId: 100 };
218+
const overlayA: OwnershipView = { getNewEntity: (id) => (id === 150 ? {} : null) };
219+
220+
// Both models claim globalId 150 by DIFFERENT rules — A via overlay, B
221+
// via its own parse range. `modelRemovedScope` only needs to know it is
222+
// owned by someone, and does not care which; it must not report this id
223+
// stale regardless of survivor iteration order.
224+
const state = {
225+
models: new Map([
226+
['A', modelA],
227+
['B', modelB],
228+
]),
229+
mutationViews: new Map([['A', overlayA]]),
230+
} as Parameters<typeof modelRemovedScope>[0];
231+
232+
const scope = modelRemovedScope(state, 'unrelated-removed-model');
233+
assert.equal(scope.isStale(150), false, 'globalId 150 is owned (by B\'s parse range, at least) — must not be purged');
234+
235+
// And B's own answer for that id is unambiguous, independent of A's overlay.
236+
assert.equal(localIdInParseRange(modelB, 150), 49);
237+
});
238+
});

apps/viewer/src/store/globalId.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,49 @@ export function toGlobalIdForRef(
8282
): number {
8383
return toGlobalIdFromModels(models, ref.modelId, ref.expressId);
8484
}
85+
86+
/** The model shape both `localIdInParseRange` and `localIdInOverlay` need. */
87+
export type OwnershipModel = Pick<FederatedModel, 'idOffset' | 'maxExpressId'>;
88+
89+
/** The mutation-view shape `localIdInOverlay` needs — just enough to ask "does an overlay entity live at this local id". */
90+
export interface OwnershipView {
91+
getNewEntity(id: number): unknown;
92+
}
93+
94+
/**
95+
* Parse-time ownership: a model owns `[idOffset, idOffset + maxExpressId]` from
96+
* the original parse. Returns the LOCAL express id, or `null`.
97+
*
98+
* `model.idOffset` bare, no `?? 0`: it is a required `number` on
99+
* `FederatedModel` (`store/types.ts`), and every caller of this has always
100+
* read it bare. `null` is returned for a miss, so a caller must test
101+
* `!== null` — local id `0` is a legitimate answer and a truthiness test
102+
* would drop it.
103+
*
104+
* The single shared home for this rule (#3343): `modelSlice.ts`'s unscoped
105+
* and scoped resolvers, and `teardown-scope.ts`'s `modelRemovedScope` survivor
106+
* check, all call this instead of re-spelling the range arithmetic. Before
107+
* the consolidation the three copies had already drifted once (#2697) and
108+
* were independently re-merged by an unrelated teardown refactor (#3358) —
109+
* nothing structurally stopped them drifting again.
110+
*/
111+
export function localIdInParseRange(model: OwnershipModel, globalId: number): number | null {
112+
const localId = globalId - model.idOffset;
113+
return localId >= 0 && localId <= model.maxExpressId ? localId : null;
114+
}
115+
116+
/**
117+
* Overlay ownership: duplicates / scripted adds through StoreEditor land ABOVE
118+
* the model's parse-time `maxExpressId`, so `localIdInParseRange` cannot see
119+
* them; the model's mutation view can. Returns the LOCAL express id, or `null`.
120+
*/
121+
export function localIdInOverlay(
122+
model: OwnershipModel,
123+
globalId: number,
124+
view: OwnershipView | undefined,
125+
): number | null {
126+
if (!view) return null;
127+
const localId = globalId - model.idOffset;
128+
if (localId <= model.maxExpressId) return null; // parse-range's business
129+
return view.getNewEntity(localId) !== null ? localId : null;
130+
}

apps/viewer/src/store/slices/modelSlice.ts

Lines changed: 9 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type { StateCreator } from 'zustand';
1515
import type { FederatedModel } from '../types.js';
1616
import { federationRegistry, type GlobalIdLookup } from '@ifc-lite/renderer';
1717
import type { ViewerState } from '../index.js';
18+
import { localIdInParseRange, localIdInOverlay } from '../globalId.js';
1819
import { viewerTeardown } from '../teardown-registry.js';
1920
import { modelRemovedScope } from '../teardown-scope.js';
2021
import {
@@ -96,7 +97,9 @@ export interface ModelSlice {
9697
* It shares the range and overlay predicates with the unscoped resolver
9798
* above, so the two cannot drift — a private range check in a caller is how
9899
* this codebase produced two resolvers that disagreed about the same id space
99-
* (#2697).
100+
* (#2697). Those predicates (`localIdInParseRange` / `localIdInOverlay`) live
101+
* in `store/globalId.ts`, the same functions `teardown-scope.ts`'s
102+
* `modelRemovedScope` calls for its survivor check (#3343).
100103
*
101104
* Not the only spelling in the repo, and this doc must not claim otherwise:
102105
* `store/globalId.ts` `fromGlobalIdFromModels` holds an independent copy that
@@ -110,35 +113,12 @@ export interface ModelSlice {
110113
}
111114

112115
/**
113-
* Parse-time ownership: a model owns `[idOffset, idOffset + maxExpressId]` from
114-
* the original parse. Returns the LOCAL express id, or `null`.
115-
*
116-
* `model.idOffset` bare, no `?? 0`: it is a required `number` on
117-
* `FederatedModel` (`store/types.ts`), and the unscoped resolver this is
118-
* extracted from has always read it bare. `null` is returned for a miss, so a
119-
* caller must test `!== null` — local id `0` is a legitimate answer and a
120-
* truthiness test would drop it.
121-
*/
122-
function localIdInParseRange(model: FederatedModel, globalId: number): number | null {
123-
const localId = globalId - model.idOffset;
124-
return localId >= 0 && localId <= model.maxExpressId ? localId : null;
125-
}
126-
127-
/**
128-
* Overlay ownership: duplicates / scripted adds through StoreEditor land ABOVE
129-
* the model's parse-time `maxExpressId`, so `localIdInParseRange` cannot see
130-
* them; the model's mutation view can. Returns the LOCAL express id, or `null`.
116+
* `localIdInParseRange` / `localIdInOverlay` live in `store/globalId.ts` now
117+
* (#3343) — that is the cycle-free home for the "does a surviving model own
118+
* this global id" rule shared with `teardown-scope.ts`'s `modelRemovedScope`.
119+
* They used to be defined here; keep this pointer so a reader who remembers
120+
* that lands in the right file.
131121
*/
132-
function localIdInOverlay(
133-
model: FederatedModel,
134-
globalId: number,
135-
view: { getNewEntity: (id: number) => unknown } | undefined,
136-
): number | null {
137-
if (!view) return null;
138-
const localId = globalId - model.idOffset;
139-
if (localId <= model.maxExpressId) return null; // parse-range's business
140-
return view.getNewEntity(localId) !== null ? localId : null;
141-
}
142122

143123
/** The mutation views registered on the store, if the owning slice is present. */
144124
function mutationViewsOf(

apps/viewer/src/store/teardown-scope.ts

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,26 +19,33 @@
1919
*/
2020

2121
import type { TeardownScope, TeardownState } from './teardown.js';
22+
import { localIdInParseRange, localIdInOverlay } from './globalId.js';
2223

2324
/** The `model-removed` arm, once its predicate is known. */
2425
export type ModelRemovedScope = Extract<TeardownScope, { kind: 'model-removed' }>;
2526

2627
/**
2728
* Build the scope for "this model is going away".
2829
*
29-
* KNOWN DUPLICATION, deliberately left: the survivor predicate below is a third
30-
* statement of the ownership rule that `modelSlice`'s `localIdInParseRange` /
31-
* `localIdInOverlay` (#2697) and `store/globalId.ts` already carry. It cannot
32-
* import `modelSlice` — that file imports this one — and `store/globalId.ts` is
33-
* the cycle-free home all three should share. Consolidating them is a change of
34-
* its own; until then, a boundary change has to be made in three places.
30+
* The survivor predicate below calls `localIdInParseRange` / `localIdInOverlay`
31+
* (`store/globalId.ts`) — the same functions `modelSlice`'s unscoped and
32+
* scoped resolvers call (#2697) — instead of re-spelling the range/overlay
33+
* arithmetic a third time (#3343). `store/globalId.ts` is the cycle-free home:
34+
* this file cannot import `modelSlice` (that file imports this one), and
35+
* `globalId.ts` imports neither.
3536
*
3637
* The predicate mirrors the two-pass resolution in `modelSlice`'s
3738
* `resolveGlobalIdFromModels`: a global id survives if some SURVIVING model
3839
* owns it, either inside its parse-time range (`idOffset` ..
3940
* `idOffset + maxExpressId`) or as an overlay-allocated entity above that
4041
* range in its mutation view (StoreEditor duplicates, scripted adds). An id no
41-
* survivor owns is stale, and every global-id-keyed slice drops it.
42+
* survivor owns is stale, and every global-id-keyed slice drops it. Unlike
43+
* `resolveGlobalIdFromModels`'s two full passes (parse range for every model,
44+
* THEN overlay for every model — needed there because it must pick a single
45+
* WINNING model), this only needs a yes/no per id, so it checks each survivor
46+
* fully (parse range, then overlay) before moving to the next: the two orders
47+
* agree on membership because "some survivor owns it via A or B" is the same
48+
* boolean regardless of which survivor or which check is tried first.
4249
*
4350
* @param state - the store as it stands BEFORE the teardown's `set`. Read-only
4451
* and partial: `slices/modelSlice.test.ts` drives `removeModel` through a
@@ -69,10 +76,8 @@ export function modelRemovedScope(
6976

7077
const isStale = (id: number): boolean => {
7178
for (const survivor of survivors) {
72-
const localId = id - survivor.idOffset;
73-
if (localId < 0) continue;
74-
if (localId <= survivor.maxExpressId) return false;
75-
if (mutationViews?.get(survivor.id)?.getNewEntity(localId) != null) return false;
79+
if (localIdInParseRange(survivor, id) !== null) return false;
80+
if (localIdInOverlay(survivor, id, mutationViews?.get(survivor.id)) !== null) return false;
7681
}
7782
return true;
7883
};

0 commit comments

Comments
 (0)