Skip to content

Commit 09ea6b4

Browse files
authored
fix: hydrate relational values (#377)
1 parent cb8fa69 commit 09ea6b4

9 files changed

Lines changed: 646 additions & 249 deletions

File tree

src/collection.ts

Lines changed: 92 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ import {
1212
} from '#/src/relation.js'
1313
import {
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 & {
7171
export const kCollectionId = Symbol('kCollectionId')
7272
export const kPrimaryKey = Symbol('kPrimaryKey')
7373
export 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
)

src/errors.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export enum OperationErrorCodes {
66
UNEXPECTED_ERROR = 'UNEXPECTED_ERROR',
77
INVALID_INITIAL_VALUES = 'INVALID_INITIAL_VALUES',
88
STRICT_QUERY_WITHOUT_RESULTS = 'STRICT_QUERY_WITHOUT_RESULTS',
9+
ASYNCHRONOUS_SCHEMA = 'ASYNCHRONOUS_SCHEMA',
910
}
1011

1112
export class OperationError extends Error {

0 commit comments

Comments
 (0)