Skip to content

Commit a9b512e

Browse files
ellyxirEllyse
andauthored
prevent circular proxy loop by collapsing immediate‑parent deref links (#1656)
* prevent circular proxy loop by collapsing immediate‑parent deref links - add guard in `normalizeAndDiff` cell-link branch: when a link comes from query-result dereferencing and targets the same-doc immediate parent, embed a snapshot via `tx.readValueOrThrow` instead of writing the link - switch snapshot/alias reads to `tx.readValueOrThrow` for transaction consistency * updated linkedlist pattern to no longer copy the list before calling cell.set() * removed comments * chore(runner): compute childPath lazily in debug lambda (normalizeAndDiff) --------- Co-authored-by: Ellyse <ellyse@common.tools>
1 parent 8bcc347 commit a9b512e

File tree

2 files changed

+137
-15
lines changed

2 files changed

+137
-15
lines changed

packages/patterns/linkedlist-in-cell.tsx

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
/// <cts-enable />
22
import {
3-
h,
43
Cell,
54
cell,
65
Default,
76
derive,
7+
h,
88
handler,
99
NAME,
1010
recipe,
@@ -30,21 +30,11 @@ interface ListState {
3030
items_list: Cell<LinkedList>;
3131
}
3232

33-
// Helper function to copy a linked list
34-
function copyList(list: LinkedList): LinkedList {
35-
return {
36-
value: list.value,
37-
next: list.next ? copyList(list.next) : undefined,
38-
};
39-
}
40-
4133
// Helper function to add a node to the linked list
42-
// copy the list because it has reactive symbols in it
43-
// FIXME(@ellyxir): why do these symbols break things?
4434
function addNodeToList(list: LinkedList, value: string): LinkedList {
4535
return {
4636
value: value,
47-
next: copyList(list),
37+
next: list,
4838
};
4939
}
5040

packages/runner/src/data-updating.ts

Lines changed: 135 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { isRecord } from "@commontools/utils/types";
2+
import { getLogger } from "@commontools/utils/logger";
23
import { ID, ID_FIELD, type JSONSchema } from "./builder/types.ts";
34
import { type DocImpl, isDoc } from "./doc.ts";
45
import { createRef } from "./doc-map.ts";
56
import { isCell, RegularCell } from "./cell.ts";
67
import { resolveLink } from "./link-resolution.ts";
8+
import { toCell, toOpaqueRef } from "./back-to-cell.ts";
79
import {
810
areLinksSame,
911
areMaybeLinkAndNormalizedLinkSame,
@@ -55,6 +57,7 @@ export function diffAndUpdate(
5557
context,
5658
options,
5759
);
60+
diffLogger.debug(() => `[diffAndUpdate] changes: ${JSON.stringify(changes)}`);
5861
applyChangeSet(tx, changes);
5962
return changes.length > 0;
6063
}
@@ -85,6 +88,34 @@ type ChangeSet = {
8588
* @param context - The context of the change.
8689
* @returns An array of changes that should be written.
8790
*/
91+
const diffLogger = getLogger("normalizeAndDiff", {
92+
enabled: false,
93+
level: "debug",
94+
});
95+
96+
/**
97+
* Returns true if `target` is the immediate parent of `base` in the same document.
98+
*
99+
* Example:
100+
* - base.path = ["internal", "__#1", "next"]
101+
* - target.path = ["internal", "__#1"]
102+
*
103+
* This is used to decide when to collapse a self/parent link that would create
104+
* a tight self-loop (e.g., obj.next -> obj) while allowing references to
105+
* higher ancestors (like an item's `items` pointing to its containing array).
106+
*/
107+
function isImmediateParent(
108+
target: NormalizedFullLink,
109+
base: NormalizedFullLink,
110+
): boolean {
111+
return (
112+
target.id === base.id &&
113+
target.space === base.space &&
114+
target.path.length === base.path.length - 1 &&
115+
target.path.every((seg, i) => seg === base.path[i])
116+
);
117+
}
118+
88119
export function normalizeAndDiff(
89120
runtime: IRuntime,
90121
tx: IExtendedStorageTransaction,
@@ -96,9 +127,21 @@ export function normalizeAndDiff(
96127
): ChangeSet {
97128
const changes: ChangeSet = [];
98129

130+
// Log entry with value type and symbol presence
131+
const valueType = Array.isArray(newValue) ? "array" : typeof newValue;
132+
const pathStr = link.path.join(".");
133+
diffLogger.debug(() =>
134+
`[DIFF_ENTER] path=${pathStr} type=${valueType} newValue=${
135+
JSON.stringify(newValue as any)
136+
}`
137+
);
138+
99139
// When detecting a circular reference on JS objects, turn it into a cell,
100140
// which below will be turned into a relative link.
101141
if (seen.has(newValue)) {
142+
diffLogger.debug(() =>
143+
`[SEEN_CHECK] Already seen object at path=${pathStr}, converting to cell`
144+
);
102145
newValue = new RegularCell(runtime, seen.get(newValue)!, tx);
103146
}
104147

@@ -114,6 +157,9 @@ export function normalizeAndDiff(
114157
isRecord(newValue) &&
115158
newValue[ID_FIELD] !== undefined
116159
) {
160+
diffLogger.debug(() =>
161+
`[BRANCH_ID_FIELD] Processing ID_FIELD redirect at path=${pathStr}`
162+
);
117163
const { [ID_FIELD]: fieldName, ...rest } = newValue as
118164
& { [ID_FIELD]: string }
119165
& Record<string, JSONValue>;
@@ -167,16 +213,35 @@ export function normalizeAndDiff(
167213

168214
// Unwrap proxies and handle special types
169215
if (isQueryResultForDereferencing(newValue)) {
170-
newValue = createSigilLinkFromParsedLink(parseLink(newValue));
216+
const parsedLink = parseLink(newValue);
217+
const sigilLink = createSigilLinkFromParsedLink(parsedLink);
218+
diffLogger.debug(() =>
219+
`[BRANCH_QUERY_RESULT] Converted query result to sigil link at path=${pathStr} link=${sigilLink} parsedLink=${parsedLink}`
220+
);
221+
newValue = sigilLink;
171222
}
172223

173224
if (isDoc(newValue)) {
174225
throw new Error("Docs are not supported anymore");
175226
}
176-
if (isCell(newValue)) newValue = newValue.getAsLink();
227+
// Track whether this link originates from a Cell value (either a cycle we wrapped
228+
// into a RegularCell above, or a user-supplied Cell). For Cell-origin links we
229+
// preserve the link (do NOT collapse). For links created via query-result
230+
// dereferencing (non-Cell), we may collapse immediate-parent self-links.
231+
let linkOriginFromCell = false;
232+
if (isCell(newValue)) {
233+
diffLogger.debug(() =>
234+
`[BRANCH_CELL] Converting cell to link at path=${pathStr}`
235+
);
236+
linkOriginFromCell = true;
237+
newValue = newValue.getAsLink();
238+
}
177239

178240
// If we're about to create a reference to ourselves, no-op
179241
if (areMaybeLinkAndNormalizedLinkSame(newValue, link)) {
242+
diffLogger.debug(() =>
243+
`[BRANCH_SELF_REF] Self-reference detected, no-op at path=${pathStr}`
244+
);
180245
return [];
181246
}
182247

@@ -189,15 +254,24 @@ export function normalizeAndDiff(
189254
isWriteRedirectLink(currentValue) &&
190255
areNormalizedLinksSame(parseLink(currentValue, link), link)
191256
) {
257+
diffLogger.debug(() =>
258+
`[BRANCH_WRITE_REDIRECT] Same redirect, no-op at path=${pathStr}`
259+
);
192260
return [];
193261
} else {
262+
diffLogger.debug(() =>
263+
`[BRANCH_WRITE_REDIRECT] Different redirect, updating at path=${pathStr}`
264+
);
194265
changes.push({ location: link, value: newValue as JSONValue });
195266
return changes;
196267
}
197268
}
198269

199270
// Handle alias in current value (at this point: if newValue is not an alias)
200271
if (isWriteRedirectLink(currentValue)) {
272+
diffLogger.debug(() =>
273+
`[BRANCH_CURRENT_ALIAS] Following current value alias at path=${pathStr}`
274+
);
201275
// Log reads of the alias, so that changing aliases cause refreshes
202276
const redirectLink = resolveLink(
203277
tx,
@@ -216,12 +290,47 @@ export function normalizeAndDiff(
216290
}
217291

218292
if (isAnyCellLink(newValue)) {
293+
diffLogger.debug(() =>
294+
`[BRANCH_CELL_LINK] Processing cell link at path=${pathStr} link=${
295+
JSON.stringify(newValue as any)
296+
}`
297+
);
219298
const parsedLink = parseLink(newValue, link);
299+
300+
// Collapse same-document self/parent links created by query-result dereferencing.
301+
// Example: "internal.__#1.next" -> "internal.__#1". Writing that link would
302+
// create a tight self-loop, so we instead embed the target's current value
303+
// (a plain JSON snapshot). Do not collapse when the link came from converting
304+
// a seen cycle to a Cell, and only collapse when the target is the immediate
305+
// parent path.
306+
if (!linkOriginFromCell && isImmediateParent(parsedLink, link)) {
307+
diffLogger.debug(() =>
308+
`[CELL_LINK_COLLAPSE] Same-doc ancestor/self link detected at path=${pathStr} -> embedding snapshot from ${
309+
parsedLink.path.join(".")
310+
}`
311+
);
312+
const snapshot = tx.readValueOrThrow(
313+
parsedLink,
314+
options,
315+
) as unknown;
316+
return normalizeAndDiff(
317+
runtime,
318+
tx,
319+
link,
320+
snapshot,
321+
context,
322+
options,
323+
seen,
324+
);
325+
}
220326
if (parsedLink.id.startsWith("data:")) {
327+
diffLogger.debug(() =>
328+
`[BRANCH_CELL_LINK] Data link detected, treating as contents at path=${pathStr}`
329+
);
221330
// If there is a data link treat it as writing it's contents instead.
222331

223332
// Use the tx code to make sure we read it the same way
224-
let dataValue: any = runtime.edit().readValueOrThrow({
333+
let dataValue: any = tx.readValueOrThrow({
225334
...parsedLink,
226335
path: [],
227336
}, options);
@@ -260,8 +369,14 @@ export function normalizeAndDiff(
260369
isAnyCellLink(currentValue) &&
261370
areLinksSame(newValue, currentValue, link)
262371
) {
372+
diffLogger.debug(() =>
373+
`[BRANCH_CELL_LINK] Same cell link, no-op at path=${pathStr}`
374+
);
263375
return [];
264376
} else {
377+
diffLogger.debug(() =>
378+
`[BRANCH_CELL_LINK] Different cell link, updating at path=${pathStr}`
379+
);
265380
return [
266381
// TODO(seefeld): Normalize the link to a sigil link?
267382
{ location: link, value: newValue as JSONValue },
@@ -271,6 +386,9 @@ export function normalizeAndDiff(
271386

272387
// Handle ID-based object (convert to entity)
273388
if (isRecord(newValue) && newValue[ID] !== undefined) {
389+
diffLogger.debug(() =>
390+
`[BRANCH_ID_OBJECT] Processing ID-based object at path=${pathStr}`
391+
);
274392
const { [ID]: id, ...rest } = newValue as
275393
& { [ID]: string }
276394
& Record<string, JSONValue>;
@@ -329,6 +447,9 @@ export function normalizeAndDiff(
329447

330448
// Handle arrays
331449
if (Array.isArray(newValue)) {
450+
diffLogger.debug(() =>
451+
`[BRANCH_ARRAY] Processing array at path=${pathStr} length=${newValue.length}`
452+
);
332453
// If the current value is not an array, set it to an empty array
333454
if (!Array.isArray(currentValue)) {
334455
changes.push({ location: link, value: [] });
@@ -385,9 +506,15 @@ export function normalizeAndDiff(
385506

386507
// Handle objects
387508
if (isRecord(newValue)) {
509+
diffLogger.debug(() =>
510+
`[BRANCH_OBJECT] Processing object at path=${pathStr}`
511+
);
388512
// If the current value is not a (regular) object, set it to an empty object
389513
// Note that the alias case is handled above
390514
if (!isRecord(currentValue) || isAnyCellLink(currentValue)) {
515+
diffLogger.debug(() =>
516+
`[BRANCH_OBJECT] Current value is not a record or cell link, setting to empty object at path=${pathStr}`
517+
);
391518
changes.push({ location: link, value: {} });
392519
currentValue = {};
393520
}
@@ -396,6 +523,11 @@ export function normalizeAndDiff(
396523
seen.set(newValue, link);
397524

398525
for (const key in newValue) {
526+
diffLogger.debug(() => {
527+
const childPath = [...link.path, key].join(".");
528+
return `[DIFF_RECURSE] Recursing into key='${key}' childPath=${childPath}`;
529+
});
530+
399531
const childSchema = runtime.cfc.getSchemaAtPath(
400532
link.schema,
401533
[key],

0 commit comments

Comments
 (0)