11import { isRecord } from "@commontools/utils/types" ;
2+ import { getLogger } from "@commontools/utils/logger" ;
23import { ID , ID_FIELD , type JSONSchema } from "./builder/types.ts" ;
34import { type DocImpl , isDoc } from "./doc.ts" ;
45import { createRef } from "./doc-map.ts" ;
56import { isCell , RegularCell } from "./cell.ts" ;
67import { resolveLink } from "./link-resolution.ts" ;
8+ import { toCell , toOpaqueRef } from "./back-to-cell.ts" ;
79import {
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+
88119export 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