@@ -12,8 +12,8 @@ import {
1212} from '#/src/relation.js'
1313import {
1414 cloneWithInternals ,
15+ sanitizeInitialValues ,
1516 definePropertyAtPath ,
16- isObject ,
1717 isRecord ,
1818 toDeepEntries ,
1919} from '#/src/utils.js'
@@ -71,6 +71,7 @@ export type RecordType<V = Record<string, any>> = V & {
7171export const kCollectionId = Symbol ( 'kCollectionId' )
7272export const kPrimaryKey = Symbol ( 'kPrimaryKey' )
7373export const kRelationMap = Symbol ( 'kRelationMap' )
74+ export const kRestore = Symbol ( 'kRestore' )
7475
7576/**
7677 * A collection of data.
@@ -106,42 +107,109 @@ export class Collection<Schema extends StandardSchemaV1> {
106107 public async create (
107108 initialValues : StandardSchemaV1 . InferInput < Schema > ,
108109 ) : Promise < RecordType < StandardSchemaV1 . InferOutput < Schema > > > {
109- let logger = this . #logger. extend ( 'create' )
110+ const logger = this . #logger. extend ( 'create' )
110111 logger . log ( 'initial values:' , initialValues )
111112
112- const { sanitizedInitialValues, restoreProperties } =
113- this . #sanitizeInitialValues( initialValues )
113+ const record = await this . #validateInitialValues( initialValues )
114114
115- const validationResult = await this . options . schema [ '~standard' ] . validate (
116- sanitizedInitialValues ,
117- )
115+ /**
116+ * @note Initial values that are already a record mean that an existing record
117+ * is being restored (e.g. synced from another tab).
118+ * Restored records keep their primary key.
119+ */
120+ const restored = isRecord ( initialValues )
121+ const primaryKey = restored
122+ ? initialValues [ kPrimaryKey ]
123+ : crypto . randomUUID ( )
118124
119- if ( validationResult . issues ) {
120- console . error ( validationResult . issues )
125+ this . #defineInternals( record , primaryKey )
121126
122- throw new OperationError (
123- 'Failed to create a new record with initial values: does not match the schema. Please see the schema validation errors above.' ,
124- OperationErrorCodes . INVALID_INITIAL_VALUES ,
127+ if ( this . hooks . listenerCount ( 'create' ) > 0 ) {
128+ await this . hooks . emitAsPromise (
129+ new TypedEvent ( 'create' , {
130+ data : { record, initialValues, restored } ,
131+ } ) ,
125132 )
126133 }
134+ logger . log ( 'create hooks done!' )
135+
136+ this . #records. push ( record )
137+ logger . log ( 'create done!' , record )
127138
128- let record = validationResult . value as RecordType
139+ return record
140+ }
141+
142+ /**
143+ * Restores an existing record synchronously, without emitting any hooks.
144+ * Meant for extensions hydrating the collection during its construction,
145+ * before any hooks can be attached. Requires the schema to validate synchronously.
146+ */
147+ public [ kRestore ] (
148+ initialValues : RecordType < StandardSchemaV1 . InferInput < Schema > > ,
149+ ) : RecordType < StandardSchemaV1 . InferOutput < Schema > > {
150+ const record = this . #validateInitialValues( initialValues )
129151
130152 invariant . as (
131- OperationError . for ( OperationErrorCodes . INVALID_INITIAL_VALUES ) ,
132- typeof record === 'object' ,
133- 'Failed to create a record with initial values (%j): expected the record to be an object or an array ' ,
134- initialValues ,
153+ OperationError . for ( OperationErrorCodes . ASYNCHRONOUS_SCHEMA ) ,
154+ ! ( record instanceof Promise ) ,
155+ 'Failed to restore a record in collection "%s": the schema validates asynchronously. Restoring records requires a synchronous schema. ' ,
156+ this [ kCollectionId ] ,
135157 )
136158
137- restoreProperties ( record )
159+ this . #defineInternals( record , initialValues [ kPrimaryKey ] )
160+ this . #records. push ( record )
138161
139- // Generate random primary key for every record.
140- const primaryKey =
141- ( isObject ( initialValues ) &&
142- initialValues [ kPrimaryKey as keyof typeof initialValues ] ) ||
143- crypto . randomUUID ( )
162+ return record
163+ }
144164
165+ /**
166+ * Validates the given initial values against the schema of this collection.
167+ * Returns the validated record synchronously if the schema allows it.
168+ */
169+ #validateInitialValues(
170+ initialValues : StandardSchemaV1 . InferInput < Schema > ,
171+ ) : RecordType | Promise < RecordType > {
172+ const { sanitizedInitialValues, restoreProperties } =
173+ sanitizeInitialValues ( initialValues )
174+
175+ const toRecord = (
176+ validationResult : StandardSchemaV1 . Result <
177+ StandardSchemaV1 . InferOutput < Schema >
178+ > ,
179+ ) : RecordType => {
180+ if ( validationResult . issues ) {
181+ console . error ( validationResult . issues )
182+
183+ throw new OperationError (
184+ 'Failed to create a new record with initial values: does not match the schema. Please see the schema validation errors above.' ,
185+ OperationErrorCodes . INVALID_INITIAL_VALUES ,
186+ )
187+ }
188+
189+ const record = validationResult . value as RecordType
190+
191+ invariant . as (
192+ OperationError . for ( OperationErrorCodes . INVALID_INITIAL_VALUES ) ,
193+ typeof record === 'object' ,
194+ 'Failed to create a record with initial values (%j): expected the record to be an object or an array' ,
195+ initialValues ,
196+ )
197+
198+ restoreProperties ( record )
199+
200+ return record
201+ }
202+
203+ const validationResult = this . options . schema [ '~standard' ] . validate (
204+ sanitizedInitialValues ,
205+ )
206+
207+ return validationResult instanceof Promise
208+ ? validationResult . then ( toRecord )
209+ : toRecord ( validationResult )
210+ }
211+
212+ #defineInternals( record : RecordType , primaryKey : string ) : void {
145213 Object . defineProperties ( record , {
146214 [ kPrimaryKey ] : {
147215 enumerable : false ,
@@ -154,21 +222,6 @@ export class Collection<Schema extends StandardSchemaV1> {
154222 value : new Map < string , Set < [ string , string ] > > ( ) ,
155223 } ,
156224 } )
157-
158- logger = logger . extend ( primaryKey )
159- logger . log ( 'symbols defined!' , record [ kRelationMap ] )
160-
161- if ( this . hooks . listenerCount ( 'create' ) > 0 ) {
162- await this . hooks . emitAsPromise (
163- new TypedEvent ( 'create' , { data : { record, initialValues } } ) ,
164- )
165- }
166- logger . log ( 'create hooks done!' )
167-
168- this . #records. push ( record )
169- logger . log ( 'create done!' , record )
170-
171- return record
172225 }
173226
174227 /**
@@ -524,99 +577,6 @@ export class Collection<Schema extends StandardSchemaV1> {
524577 } )
525578 }
526579
527- /**
528- * Sanitizes the given object so it can be accepted as the input to Standard Schema validation.
529- * This removes getters to prevent potentially infinite object references in self-referencing
530- * relations. This also drops the internal symbols but gives a function to restore them back.
531- */
532- #sanitizeInitialValues( initialValues : unknown ) {
533- const propertiesToRestore : Array < {
534- path : Array < string | number | symbol >
535- descriptor : PropertyDescriptor
536- } > = [ ]
537-
538- // Track visited records by primary key to detect cycles
539- // in self-referencing relations. Only strip relation values
540- // when revisiting a record (i.e. an actual cycle), not for
541- // all nested records indiscriminately.
542- const visited = new Set < string > ( )
543-
544- const sanitize = (
545- value : unknown ,
546- path : Array < string | number | symbol > = [ ] ,
547- ) : unknown => {
548- if ( Array . isArray ( value ) ) {
549- return value . map ( ( value , index ) => sanitize ( value , path . concat ( index ) ) )
550- }
551-
552- if ( isObject ( value ) ) {
553- const record = isRecord ( value ) ? value : undefined
554- const isRevisit = record != null && visited . has ( record [ kPrimaryKey ] )
555-
556- if ( record && ! isRevisit ) {
557- visited . add ( record [ kPrimaryKey ] )
558- }
559-
560- const relations = record ? record [ kRelationMap ] : undefined
561-
562- return Object . fromEntries (
563- Reflect . ownKeys ( value ) . map ( ( key ) => {
564- const childValue = value [ key as keyof typeof value ]
565- const childPath = path . concat ( key )
566-
567- if ( typeof key === 'symbol' ) {
568- /**
569- * @note Preserve primary keys on sanitized initial values.
570- * Otherwise, internal symbols are stripped off and record references are lost.
571- * This is curcial when handling relations for records that were created
572- * before the relation was defined.
573- */
574- if ( key === kPrimaryKey ) {
575- propertiesToRestore . push ( {
576- path : childPath ,
577- descriptor : Object . getOwnPropertyDescriptor ( value , key ) ! ,
578- } )
579- }
580- return [ key , childValue ]
581- }
582-
583- const relation = relations ?. get ( key )
584-
585- // Only strip relation values when revisiting a record
586- // to break self-referencing cycles. Non-circular nested
587- // relations are left intact for proper schema validation.
588- if ( isRevisit && relation && childValue != null ) {
589- propertiesToRestore . push ( {
590- path : childPath ,
591- descriptor : Object . getOwnPropertyDescriptor ( value , key ) ! ,
592- } )
593- return [ key , relation . getDefaultValue ( ) ]
594- }
595-
596- return [ key , sanitize ( childValue , childPath ) ]
597- } ) ,
598- )
599- }
600-
601- return value
602- }
603-
604- const sanitizedInitialValues = sanitize ( initialValues )
605-
606- return {
607- sanitizedInitialValues,
608- /**
609- * Restores record properties that were stripped off during the sanitization
610- * (e.g. relational properties, internal symbols of records given as initial value, etc).
611- */
612- restoreProperties ( record : RecordType ) : void {
613- for ( const { path, descriptor } of propertiesToRestore ) {
614- definePropertyAtPath ( record , path , descriptor )
615- }
616- } ,
617- }
618- }
619-
620580 * #query(
621581 query : Query < RecordType < StandardSchemaV1 . InferOutput < Schema > > > ,
622582 options : PaginationOptions < Schema > = { take : Infinity } ,
@@ -903,7 +863,7 @@ export class Collection<Schema extends StandardSchemaV1> {
903863 : maybeNextRecord
904864
905865 logger . log ( 're-applying the schema...' )
906- const { sanitizedInitialValues } = this . # sanitizeInitialValues( nextRecord )
866+ const { sanitizedInitialValues } = sanitizeInitialValues ( nextRecord )
907867 const validationResult = await this . options . schema [ '~standard' ] . validate (
908868 sanitizedInitialValues ,
909869 )
0 commit comments