11import { refer } from "merkle-reference" ;
2+ import { SchemaAll } from "@commontools/memory/schema" ;
23// TODO(@ubik2): Ideally this would use the following, but rollup has issues
34//import { isNumber, isObject, isString } from "@commontools/utils/types";
45import {
56 type Immutable ,
67 isNumber ,
78 isObject ,
9+ isRecord ,
810 isString ,
911} from "../../utils/src/types.ts" ;
1012import { getLogger } from "../../utils/src/logger.ts" ;
@@ -17,7 +19,6 @@ import type {
1719} from "./builder/types.ts" ;
1820import { deepEqual } from "./path-utils.ts" ;
1921import { isAnyCellLink , parseLink } from "./link-utils.ts" ;
20- import type { URI } from "./sigil-types.ts" ;
2122import { fromURI } from "./uri-utils.ts" ;
2223import type { IAttestation , IMemoryAddress } from "./storage/interface.ts" ;
2324
@@ -35,20 +36,35 @@ export type SchemaPathSelector = {
3536 * A data structure that maps keys to sets of values, allowing multiple values
3637 * to be associated with a single key without duplication.
3738 *
39+ * While the default behavior is to use object equality, you can provide an
40+ * `equalFn` parameter to the constructor, which will be used for the value
41+ * comparisons.
42+ *
3843 * @template K The type of keys in the map
3944 * @template V The type of values stored in the sets
4045 */
4146export class MapSet < K , V > {
4247 private map = new Map < K , Set < V > > ( ) ;
48+ private equalFn ?: ( a : V , b : V ) => boolean ;
49+
50+ constructor ( equalFn ?: ( a : V , b : V ) => boolean ) {
51+ this . equalFn = equalFn ;
52+ }
4353
4454 public get ( key : K ) : Set < V > | undefined {
4555 return this . map . get ( key ) ;
4656 }
4757
4858 public add ( key : K , value : V ) {
49- if ( ! this . map . has ( key ) ) {
59+ const values = this . map . get ( key ) ;
60+ if ( values === undefined ) {
5061 const values = new Set < V > ( [ value ] ) ;
5162 this . map . set ( key , values ) ;
63+ } else if (
64+ this . equalFn !== undefined &&
65+ ( values . values ( ) . some ( ( item ) => this . equalFn ! ( item , value ) ) )
66+ ) {
67+ return ;
5268 } else {
5369 this . map . get ( key ) ! . add ( value ) ;
5470 }
@@ -60,15 +76,28 @@ export class MapSet<K, V> {
6076
6177 public hasValue ( key : K , value : V ) : boolean {
6278 const values = this . map . get ( key ) ;
63- return ( values !== undefined && values . has ( value ) ) ;
79+ if ( values !== undefined && this . equalFn !== undefined ) {
80+ return values . values ( ) . some ( ( item ) => this . equalFn ! ( item , value ) ) ;
81+ }
82+ return values !== undefined && values . has ( value ) ;
6483 }
6584
6685 public deleteValue ( key : K , value : V ) : boolean {
6786 if ( ! this . map . has ( key ) ) {
6887 return false ;
6988 } else {
7089 const values = this . map . get ( key ) ! ;
71- const rv = values . delete ( value ) ;
90+ let existing : V = value ;
91+ if ( this . equalFn !== undefined ) {
92+ const match = values . values ( ) . find ( ( item ) =>
93+ this . equalFn ! ( item , value )
94+ ) ;
95+ if ( match === undefined ) {
96+ return false ;
97+ }
98+ existing = match ;
99+ }
100+ const rv = values . delete ( existing ) ;
72101 if ( values . size === 0 ) {
73102 this . map . delete ( key ) ;
74103 }
@@ -126,8 +155,57 @@ export class CycleTracker<K> {
126155 }
127156}
128157
129- export type PointerCycleTracker = CycleTracker <
130- Immutable < JSONValue >
158+ /**
159+ * Cycle tracker for more complex objects with multiple parts.
160+ *
161+ * This will not work correctly if the key is modified after being added.
162+ *
163+ * This will do an identity check on the partial key and a deepEqual check on
164+ * the ExtraKey.
165+ */
166+ export class CompoundCycleTracker < PartialKey , ExtraKey > {
167+ private partial : Map < PartialKey , ExtraKey [ ] > ;
168+ constructor ( ) {
169+ this . partial = new Map < PartialKey , ExtraKey [ ] > ( ) ;
170+ }
171+ include (
172+ partialKey : PartialKey ,
173+ extraKey : ExtraKey ,
174+ context ?: unknown ,
175+ ) : Disposable | null {
176+ let existing = this . partial . get ( partialKey ) ;
177+ if ( existing === undefined ) {
178+ existing = [ ] ;
179+ this . partial . set ( partialKey , existing ) ;
180+ }
181+ if ( existing . some ( ( item ) => deepEqual ( item , extraKey ) ) ) {
182+ return null ;
183+ }
184+ existing . push ( extraKey ) ;
185+ return {
186+ [ Symbol . dispose ] : ( ) => {
187+ const entries = this . partial . get ( partialKey ) ! ;
188+ const index = entries . indexOf ( extraKey ) ;
189+ if ( index === - 1 ) {
190+ logger . error ( ( ) => [
191+ "Failed to dispose of missing key" ,
192+ extraKey ,
193+ context ,
194+ ] ) ;
195+ }
196+ if ( entries . length === 0 ) {
197+ this . partial . delete ( partialKey ) ;
198+ } else {
199+ entries . splice ( index , 1 ) ;
200+ }
201+ } ,
202+ } ;
203+ }
204+ }
205+
206+ export type PointerCycleTracker = CompoundCycleTracker <
207+ Immutable < JSONValue > ,
208+ SchemaContext | undefined
131209> ;
132210
133211export interface ObjectStorageManager < K , S , V > {
@@ -220,7 +298,7 @@ export abstract class BaseObjectTraverser<S extends BaseMemoryAddress> {
220298 if ( isPrimitive ( doc . value ) ) {
221299 return doc . value ;
222300 } else if ( Array . isArray ( doc . value ) ) {
223- using t = tracker . include ( doc . value , doc ) ;
301+ using t = tracker . include ( doc . value , SchemaAll , doc ) ;
224302 if ( t === null ) {
225303 return null ;
226304 }
@@ -238,7 +316,7 @@ export abstract class BaseObjectTraverser<S extends BaseMemoryAddress> {
238316 schemaTracker ,
239317 )
240318 ) as Immutable < JSONValue > [ ] ;
241- } else if ( isObject ( doc . value ) ) {
319+ } else if ( isRecord ( doc . value ) ) {
242320 // First, see if we need special handling
243321 if ( isAnyCellLink ( doc . value ) ) {
244322 const [ newDoc , _ ] = getAtPath (
@@ -254,14 +332,12 @@ export abstract class BaseObjectTraverser<S extends BaseMemoryAddress> {
254332 }
255333 return this . traverseDAG ( newDoc , tracker , schemaTracker ) ;
256334 } else {
257- using t = tracker . include ( doc . value , doc ) ;
335+ using t = tracker . include ( doc . value , SchemaAll , doc ) ;
258336 if ( t === null ) {
259337 return null ;
260338 }
261339 return Object . fromEntries (
262- Object . entries ( doc . value ) . map ( (
263- [ k , value ] ,
264- ) => [
340+ Object . entries ( doc . value as JSONObject ) . map ( ( [ k , value ] ) => [
265341 k ,
266342 this . traverseDAG (
267343 {
@@ -388,11 +464,6 @@ function followPointer<S extends BaseMemoryAddress>(
388464 schemaTracker ?: MapSet < string , SchemaPathSelector > ,
389465 selector ?: SchemaPathSelector ,
390466) : [ IAttestation , SchemaPathSelector | undefined ] {
391- using t = tracker . include ( doc . value ! , doc ) ;
392- if ( t === null ) {
393- // Cycle detected - treat this as notFound to avoid traversal
394- return [ notFound ( doc . address ) , selector ] ;
395- }
396467 const link = parseLink ( doc . value ) ! ;
397468 const target : BaseMemoryAddress = ( link . id !== undefined )
398469 ? { id : link . id , type : "application/json" }
@@ -416,6 +487,11 @@ function followPointer<S extends BaseMemoryAddress>(
416487 link . path as string [ ] ,
417488 ) ;
418489 }
490+ using t = tracker . include ( doc . value ! , selector ?. schemaContext , doc ) ;
491+ if ( t === null ) {
492+ // Cycle detected - treat this as notFound to avoid traversal
493+ return [ notFound ( doc . address ) , selector ] ;
494+ }
419495 if ( link . id !== undefined ) {
420496 // We have a reference to a different cell, so track the dependency
421497 // and update our targetDoc
@@ -619,13 +695,14 @@ export class SchemaObjectTraverser<S extends BaseMemoryAddress>
619695 constructor (
620696 manager : BaseObjectManager < S , Immutable < JSONValue > | undefined > ,
621697 private selector : SchemaPathSelector ,
622- private tracker : PointerCycleTracker = new CycleTracker <
623- Immutable < JSONValue >
698+ private tracker : PointerCycleTracker = new CompoundCycleTracker <
699+ Immutable < JSONValue > ,
700+ SchemaContext | undefined
624701 > ( ) ,
625702 private schemaTracker : MapSet < string , SchemaPathSelector > = new MapSet <
626703 string ,
627704 SchemaPathSelector
628- > ( ) ,
705+ > ( deepEqual ) ,
629706 ) {
630707 super ( manager ) ;
631708 }
@@ -730,7 +807,7 @@ export class SchemaObjectTraverser<S extends BaseMemoryAddress>
730807 return this . isValidType ( schemaObj , "number" ) ? doc . value : undefined ;
731808 } else if ( Array . isArray ( doc . value ) ) {
732809 if ( this . isValidType ( schemaObj , "array" ) ) {
733- using t = this . tracker . include ( doc . value , doc ) ;
810+ using t = this . tracker . include ( doc . value , schemaContext , doc ) ;
734811 if ( t === null ) {
735812 return null ;
736813 }
@@ -746,10 +823,8 @@ export class SchemaObjectTraverser<S extends BaseMemoryAddress>
746823 schema : schemaObj ,
747824 rootSchema : schemaContext . rootSchema ,
748825 } ) ;
749- // TODO(@ubik2): it might be technically ok to follow the same pointer more than once, since we might have
750- // a different schema the second time, which could prevent an infinite cycle, but for now, just reject these.
751826 } else if ( this . isValidType ( schemaObj , "object" ) ) {
752- using t = this . tracker . include ( doc . value , doc ) ;
827+ using t = this . tracker . include ( doc . value , schemaContext , doc ) ;
753828 if ( t === null ) {
754829 return null ;
755830 }
@@ -897,7 +972,7 @@ export class SchemaObjectTraverser<S extends BaseMemoryAddress>
897972 // but we may have a pointer cycle of docs, and we've finished resolving
898973 // the pointer now. To avoid descending into a cycle, track entry to the
899974 // doc we were called with (not the one we resolved, which may be a pointer).
900- using t = this . tracker . include ( doc . value ! , doc ) ;
975+ using t = this . tracker . include ( doc . value ! , schemaContext , doc ) ;
901976 if ( t === null ) {
902977 return null ;
903978 }
0 commit comments