diff --git a/src/collection.ts b/src/collection.ts index e3c2b16..f2672b0 100644 --- a/src/collection.ts +++ b/src/collection.ts @@ -12,8 +12,8 @@ import { } from '#/src/relation.js' import { cloneWithInternals, + sanitizeInitialValues, definePropertyAtPath, - isObject, isRecord, toDeepEntries, } from '#/src/utils.js' @@ -71,6 +71,7 @@ export type RecordType> = V & { export const kCollectionId = Symbol('kCollectionId') export const kPrimaryKey = Symbol('kPrimaryKey') export const kRelationMap = Symbol('kRelationMap') +export const kRestore = Symbol('kRestore') /** * A collection of data. @@ -106,42 +107,109 @@ export class Collection { public async create( initialValues: StandardSchemaV1.InferInput, ): Promise>> { - let logger = this.#logger.extend('create') + const logger = this.#logger.extend('create') logger.log('initial values:', initialValues) - const { sanitizedInitialValues, restoreProperties } = - this.#sanitizeInitialValues(initialValues) + const record = await this.#validateInitialValues(initialValues) - const validationResult = await this.options.schema['~standard'].validate( - sanitizedInitialValues, - ) + /** + * @note Initial values that are already a record mean that an existing record + * is being restored (e.g. synced from another tab). + * Restored records keep their primary key. + */ + const restored = isRecord(initialValues) + const primaryKey = restored + ? initialValues[kPrimaryKey] + : crypto.randomUUID() - if (validationResult.issues) { - console.error(validationResult.issues) + this.#defineInternals(record, primaryKey) - throw new OperationError( - 'Failed to create a new record with initial values: does not match the schema. Please see the schema validation errors above.', - OperationErrorCodes.INVALID_INITIAL_VALUES, + if (this.hooks.listenerCount('create') > 0) { + await this.hooks.emitAsPromise( + new TypedEvent('create', { + data: { record, initialValues, restored }, + }), ) } + logger.log('create hooks done!') + + this.#records.push(record) + logger.log('create done!', record) - let record = validationResult.value as RecordType + return record + } + + /** + * Restores an existing record synchronously, without emitting any hooks. + * Meant for extensions hydrating the collection during its construction, + * before any hooks can be attached. Requires the schema to validate synchronously. + */ + public [kRestore]( + initialValues: RecordType>, + ): RecordType> { + const record = this.#validateInitialValues(initialValues) invariant.as( - OperationError.for(OperationErrorCodes.INVALID_INITIAL_VALUES), - typeof record === 'object', - 'Failed to create a record with initial values (%j): expected the record to be an object or an array', - initialValues, + OperationError.for(OperationErrorCodes.ASYNCHRONOUS_SCHEMA), + !(record instanceof Promise), + 'Failed to restore a record in collection "%s": the schema validates asynchronously. Restoring records requires a synchronous schema.', + this[kCollectionId], ) - restoreProperties(record) + this.#defineInternals(record, initialValues[kPrimaryKey]) + this.#records.push(record) - // Generate random primary key for every record. - const primaryKey = - (isObject(initialValues) && - initialValues[kPrimaryKey as keyof typeof initialValues]) || - crypto.randomUUID() + return record + } + /** + * Validates the given initial values against the schema of this collection. + * Returns the validated record synchronously if the schema allows it. + */ + #validateInitialValues( + initialValues: StandardSchemaV1.InferInput, + ): RecordType | Promise { + const { sanitizedInitialValues, restoreProperties } = + sanitizeInitialValues(initialValues) + + const toRecord = ( + validationResult: StandardSchemaV1.Result< + StandardSchemaV1.InferOutput + >, + ): RecordType => { + if (validationResult.issues) { + console.error(validationResult.issues) + + throw new OperationError( + 'Failed to create a new record with initial values: does not match the schema. Please see the schema validation errors above.', + OperationErrorCodes.INVALID_INITIAL_VALUES, + ) + } + + const record = validationResult.value as RecordType + + invariant.as( + OperationError.for(OperationErrorCodes.INVALID_INITIAL_VALUES), + typeof record === 'object', + 'Failed to create a record with initial values (%j): expected the record to be an object or an array', + initialValues, + ) + + restoreProperties(record) + + return record + } + + const validationResult = this.options.schema['~standard'].validate( + sanitizedInitialValues, + ) + + return validationResult instanceof Promise + ? validationResult.then(toRecord) + : toRecord(validationResult) + } + + #defineInternals(record: RecordType, primaryKey: string): void { Object.defineProperties(record, { [kPrimaryKey]: { enumerable: false, @@ -154,21 +222,6 @@ export class Collection { value: new Map>(), }, }) - - logger = logger.extend(primaryKey) - logger.log('symbols defined!', record[kRelationMap]) - - if (this.hooks.listenerCount('create') > 0) { - await this.hooks.emitAsPromise( - new TypedEvent('create', { data: { record, initialValues } }), - ) - } - logger.log('create hooks done!') - - this.#records.push(record) - logger.log('create done!', record) - - return record } /** @@ -524,99 +577,6 @@ export class Collection { }) } - /** - * Sanitizes the given object so it can be accepted as the input to Standard Schema validation. - * This removes getters to prevent potentially infinite object references in self-referencing - * relations. This also drops the internal symbols but gives a function to restore them back. - */ - #sanitizeInitialValues(initialValues: unknown) { - const propertiesToRestore: Array<{ - path: Array - descriptor: PropertyDescriptor - }> = [] - - // Track visited records by primary key to detect cycles - // in self-referencing relations. Only strip relation values - // when revisiting a record (i.e. an actual cycle), not for - // all nested records indiscriminately. - const visited = new Set() - - const sanitize = ( - value: unknown, - path: Array = [], - ): unknown => { - if (Array.isArray(value)) { - return value.map((value, index) => sanitize(value, path.concat(index))) - } - - if (isObject(value)) { - const record = isRecord(value) ? value : undefined - const isRevisit = record != null && visited.has(record[kPrimaryKey]) - - if (record && !isRevisit) { - visited.add(record[kPrimaryKey]) - } - - const relations = record ? record[kRelationMap] : undefined - - return Object.fromEntries( - Reflect.ownKeys(value).map((key) => { - const childValue = value[key as keyof typeof value] - const childPath = path.concat(key) - - if (typeof key === 'symbol') { - /** - * @note Preserve primary keys on sanitized initial values. - * Otherwise, internal symbols are stripped off and record references are lost. - * This is curcial when handling relations for records that were created - * before the relation was defined. - */ - if (key === kPrimaryKey) { - propertiesToRestore.push({ - path: childPath, - descriptor: Object.getOwnPropertyDescriptor(value, key)!, - }) - } - return [key, childValue] - } - - const relation = relations?.get(key) - - // Only strip relation values when revisiting a record - // to break self-referencing cycles. Non-circular nested - // relations are left intact for proper schema validation. - if (isRevisit && relation && childValue != null) { - propertiesToRestore.push({ - path: childPath, - descriptor: Object.getOwnPropertyDescriptor(value, key)!, - }) - return [key, relation.getDefaultValue()] - } - - return [key, sanitize(childValue, childPath)] - }), - ) - } - - return value - } - - const sanitizedInitialValues = sanitize(initialValues) - - return { - sanitizedInitialValues, - /** - * Restores record properties that were stripped off during the sanitization - * (e.g. relational properties, internal symbols of records given as initial value, etc). - */ - restoreProperties(record: RecordType): void { - for (const { path, descriptor } of propertiesToRestore) { - definePropertyAtPath(record, path, descriptor) - } - }, - } - } - *#query( query: Query>>, options: PaginationOptions = { take: Infinity }, @@ -903,7 +863,7 @@ export class Collection { : maybeNextRecord logger.log('re-applying the schema...') - const { sanitizedInitialValues } = this.#sanitizeInitialValues(nextRecord) + const { sanitizedInitialValues } = sanitizeInitialValues(nextRecord) const validationResult = await this.options.schema['~standard'].validate( sanitizedInitialValues, ) diff --git a/src/errors.ts b/src/errors.ts index bce08cc..019f2ba 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -6,6 +6,7 @@ export enum OperationErrorCodes { UNEXPECTED_ERROR = 'UNEXPECTED_ERROR', INVALID_INITIAL_VALUES = 'INVALID_INITIAL_VALUES', STRICT_QUERY_WITHOUT_RESULTS = 'STRICT_QUERY_WITHOUT_RESULTS', + ASYNCHRONOUS_SCHEMA = 'ASYNCHRONOUS_SCHEMA', } export class OperationError extends Error { diff --git a/src/extensions/persist.ts b/src/extensions/persist.ts index 70ade94..1afb590 100644 --- a/src/extensions/persist.ts +++ b/src/extensions/persist.ts @@ -1,17 +1,18 @@ import { invariant } from 'outvariant' -import { unset } from 'es-toolkit/compat' import { defineExtension } from '#/src/extensions/index.js' import { kCollectionId, kPrimaryKey, kRelationMap, + kRestore, type Collection, type RecordType, } from '#/src/collection.js' import { Logger } from '#/src/logger.js' -import type { PropertyPath } from '#/src/utils.js' +import { isObject, isRecord, sanitizeInitialValues } from '#/src/utils.js' const STORAGE_KEY = 'msw/data/storage' +const STORAGE_VERSION = 2 const METADATA_KEY = '__metadata__' interface SerializedCollection { @@ -27,10 +28,10 @@ export interface SerializedRecord { interface RecordMetadata { primaryKey: string - relations: Array<{ - path: PropertyPath - foreignKeys: Array - }> +} + +function isSerializedRecord(value: unknown): value is SerializedRecord { + return isObject(value) && METADATA_KEY in value } /** @@ -39,7 +40,7 @@ interface RecordMetadata { export function persist() { return defineExtension({ name: 'persist', - async extend(collection) { + extend(collection) { if ( typeof window === 'undefined' || typeof localStorage === 'undefined' @@ -62,13 +63,8 @@ export function persist() { localStorage.setItem( COLLECTION_KEY, - /** - * @fixme Stringifying relations errors because they produce - * circular structures. Relations have to be stripped out of the records. - * Maybe preserved in the metadata? - */ JSON.stringify({ - version: 1, + version: STORAGE_VERSION, collectionId: collection[kCollectionId], records: collection.all().map(serializeRecord), } satisfies SerializedCollection), @@ -82,6 +78,13 @@ export function persist() { } const persistedData = JSON.parse(rawPersistedData) as SerializedCollection + if (persistedData.version !== STORAGE_VERSION) { + logger.warn( + `skipping hydration: persisted data version (${persistedData.version}) is incompatible with the current version (${STORAGE_VERSION})`, + ) + return + } + invariant( persistedData.collectionId === collection[kCollectionId], 'Failed to hydrate data for collection "%s": parsed a state of an unknown collection "%s"', @@ -91,100 +94,116 @@ export function persist() { logger.log(`found (${persistedData.records.length}) records to hydrate!`) - await Promise.all( - persistedData.records.map(async (serializedRecord) => { - logger.log('hydrating record...', { serializedRecord }) - await createFromSerializedRecord(collection, serializedRecord) - }), - ) + /** + * @note Hydrate synchronously so the records are available + * as soon as the collection is constructed. + */ + for (const serializedRecord of persistedData.records) { + logger.log('hydrating record...', { serializedRecord }) + collection[kRestore](deserializeRecord(serializedRecord)) + } logger.log('hydration done!', collection.all()) }, }) } +/** + * Serializes the given record into a plain structure that is a valid input + * to the schema of its collection. Relational properties are resolved into + * snapshots of the foreign records, breaking self-referencing cycles + * the same way the collection does when validating records. + * Primary keys of the record and all the nested records are preserved in the metadata. + */ export function serializeRecord(record: RecordType): SerializedRecord { - const result = structuredClone(record) as any as SerializedRecord + const { sanitizedInitialValues } = sanitizeInitialValues(record) + const serializedRecord = attachMetadata(sanitizedInitialValues) - const metadata: RecordMetadata = { - primaryKey: record[kPrimaryKey], - relations: [], - } + invariant( + isSerializedRecord(serializedRecord), + 'Failed to serialize record "%s": serialized value is not a record', + record[kPrimaryKey], + ) - // Delete relational keys since they can produce non-serializable structures. - const relations = record[kRelationMap] - for (const [path, relation] of relations) { - metadata.relations.push({ - path: relation.path, - foreignKeys: Array.from(relation.foreignKeys), - }) + return serializedRecord +} - unset(result, path) +function attachMetadata(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(attachMetadata) } - result[METADATA_KEY] = metadata + if (isObject(value)) { + for (const key of Object.keys(value)) { + value[key] = attachMetadata(value[key]) + } + + if (isRecord(value)) { + const metadata: RecordMetadata = { primaryKey: value[kPrimaryKey] } + value[METADATA_KEY] = metadata + } + } - return result + return value } +/** + * Restores the internal properties of the serialized record and all the nested records. + */ export function deserializeRecord( - record: SerializedRecord, -): Record { - const metadata = record[METADATA_KEY] + serializedRecord: SerializedRecord, +): RecordType { + restoreInternals(serializedRecord) invariant( - metadata, - 'Failed to deserialize record (%j): metadata is missing', - record, + isRecord(serializedRecord), + 'Failed to deserialize record: primary key is missing', ) - // Restore the primary key for this record so it's preserved across reloads. - Object.defineProperties(record, { - [kPrimaryKey]: { - enumerable: false, - configurable: false, - value: metadata.primaryKey, - }, - }) + return serializedRecord +} - delete record[METADATA_KEY as keyof typeof record] +function restoreInternals(value: unknown): void { + if (Array.isArray(value)) { + value.forEach(restoreInternals) + return + } - invariant( - !(METADATA_KEY in record), - 'Failed to deserialize record (%j): metadata not cleared', - record, - ) + if (!isObject(value)) { + return + } - return record + for (const key of Object.keys(value)) { + restoreInternals(value[key]) + } + + if (isSerializedRecord(value)) { + const { primaryKey } = value[METADATA_KEY] + Reflect.deleteProperty(value, METADATA_KEY) + + Object.defineProperties(value, { + [kPrimaryKey]: { + enumerable: false, + configurable: false, + value: primaryKey, + }, + /** + * @note Snapshots of foreign records have no relations of their own. + * Define an empty relation map so they are treated as records + * (e.g. when checking unique relations). + */ + [kRelationMap]: { + enumerable: false, + configurable: true, + value: new Map(), + }, + }) + } } export async function createFromSerializedRecord( collection: Collection, serializedRecord: SerializedRecord, ): Promise { - const metadata = serializedRecord[METADATA_KEY] - const initialValues = deserializeRecord(serializedRecord) - - invariant( - !(METADATA_KEY in initialValues), - 'Failed to create record from deserialized record (%j): metadata not cleared', - initialValues, - ) - - const record: RecordType = await collection.create(initialValues) - const relationMap = record[kRelationMap] - - for (const serializedRelation of metadata.relations) { - const relation = relationMap.get(serializedRelation.path.join('.')) - - if (relation == null) { - continue - } - - for (const foreignKey of serializedRelation.foreignKeys) { - relation.foreignKeys.add(foreignKey) - } - } - - return record + return collection.create(deserializeRecord(serializedRecord)) } diff --git a/src/extensions/sync.ts b/src/extensions/sync.ts index 44a0420..7a22915 100644 --- a/src/extensions/sync.ts +++ b/src/extensions/sync.ts @@ -101,34 +101,30 @@ export function sync() { * This way, non-serializable schemas can survive sync as long as * the initial values are serializable. */ - await performWithoutBroadcasting(async () => { - const record = await createFromSerializedRecord( - collection, - data.record, - ) + const record = await createFromSerializedRecord( + collection, + data.record, + ) + + /** + * @note Extraneous records might not have been associated with their owners + * at the time of sync. Manually ensure the owner is referenced in those relations. + */ + record[kRelationMap].forEach((relation) => { + relation.foreignCollections.forEach((foreignCollection) => { + const foreignRecords = foreignCollection.findMany((q) => + q.where((foreignRecord) => { + return relation.foreignKeys.has(foreignRecord[kPrimaryKey]) + }), + ) - /** - * @note Extraneous records might not have been associated with their owners - * at the time of sync. Manually ensure the owner is referenced in those relations. - */ - record[kRelationMap].forEach((relation) => { - relation.foreignCollections.forEach((foreignCollection) => { - const foreignRecords = foreignCollection.findMany((q) => - q.where((foreignRecord) => { - return relation.foreignKeys.has( - foreignRecord[kPrimaryKey], - ) - }), - ) - - const foreignRelations = foreignRecords.flatMap( - (foreignRecord) => { - return relation.getRelationsToOwner(foreignRecord) - }, - ) - foreignRelations.forEach((foreignRelation) => { - foreignRelation.foreignKeys.add(record[kPrimaryKey]) - }) + const foreignRelations = foreignRecords.flatMap( + (foreignRecord) => { + return relation.getRelationsToOwner(foreignRecord) + }, + ) + foreignRelations.forEach((foreignRelation) => { + foreignRelation.foreignKeys.add(record[kPrimaryKey]) }) }) }) @@ -184,15 +180,14 @@ export function sync() { } collection.hooks.on('create', (event) => { - const { record, initialValues } = event.data - - logger.warn( - 'record created, should broadcast?', - { record, initialValues }, - !hookContext.skip, - ) - - if (!hookContext.skip) { + const { record, restored } = event.data + logger.log('record created, should broadcast?', !restored) + + /** + * @note Restored records (synced from another tab or hydrated + * from the storage) already exist elsewhere. Never broadcast them. + */ + if (!restored) { broadcastOperation({ type: 'create', senderId: collection[kCollectionId], diff --git a/src/hooks.ts b/src/hooks.ts index 82f5793..877c7fc 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -6,6 +6,11 @@ export type HookEventMap = { create: TypedEvent<{ record: RecordType> initialValues?: StandardSchemaV1.InferInput + /** + * Whether an existing record is being restored (e.g. hydrated + * from the storage or synced from another tab) instead of created anew. + */ + restored: boolean }> update: TypedEvent<{ prevRecord: RecordType> diff --git a/src/utils.ts b/src/utils.ts index 860857e..a77f4df 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,6 @@ import { invariant } from 'outvariant' import { isPlainObject } from 'es-toolkit' -import { kPrimaryKey, type RecordType } from '#/src/collection.js' +import { kPrimaryKey, kRelationMap, type RecordType } from '#/src/collection.js' /** * Checks if the given value is a plain object. @@ -88,3 +88,97 @@ export function cloneWithInternals( return clone } + +/** + * Sanitizes the given object so it can be accepted as the input to Standard Schema validation. + * This resolves relational getters into plain values and breaks self-referencing cycles + * by replacing the relations of a revisited record with their default values. + * This also drops the internal symbols but gives a function to restore them back. + */ +export function sanitizeInitialValues(initialValues: unknown) { + const propertiesToRestore: Array<{ + path: Array + descriptor: PropertyDescriptor + }> = [] + + // Track visited records by primary key to detect cycles + // in self-referencing relations. Only strip relation values + // when revisiting a record (i.e. an actual cycle), not for + // all nested records indiscriminately. + const visited = new Set() + + const sanitize = ( + value: unknown, + path: Array = [], + ): unknown => { + if (Array.isArray(value)) { + return value.map((value, index) => sanitize(value, path.concat(index))) + } + + if (isObject(value)) { + const record = isRecord(value) ? value : undefined + const isRevisit = record != null && visited.has(record[kPrimaryKey]) + + if (record && !isRevisit) { + visited.add(record[kPrimaryKey]) + } + + const relations = record ? record[kRelationMap] : undefined + + return Object.fromEntries( + Reflect.ownKeys(value).map((key) => { + const childValue = value[key as keyof typeof value] + const childPath = path.concat(key) + + if (typeof key === 'symbol') { + /** + * @note Preserve the primary key of the root record and all the internal + * symbols of the nested records. Otherwise, record references are lost + * after the validation. This is crucial when handling relations for + * records that were created before the relation was defined. + */ + if (key === kPrimaryKey || path.length > 0) { + propertiesToRestore.push({ + path: childPath, + descriptor: Object.getOwnPropertyDescriptor(value, key)!, + }) + } + return [key, childValue] + } + + const relation = relations?.get(key) + + // Only strip relation values when revisiting a record + // to break self-referencing cycles. Non-circular nested + // relations are left intact for proper schema validation. + if (isRevisit && relation && childValue != null) { + propertiesToRestore.push({ + path: childPath, + descriptor: Object.getOwnPropertyDescriptor(value, key)!, + }) + return [key, relation.getDefaultValue()] + } + + return [key, sanitize(childValue, childPath)] + }), + ) + } + + return value + } + + const sanitizedInitialValues = sanitize(initialValues) + + return { + sanitizedInitialValues, + /** + * Restores record properties that were stripped off during the sanitization + * (e.g. relational properties, internal symbols of records given as initial value, etc). + */ + restoreProperties(record: RecordType): void { + for (const { path, descriptor } of propertiesToRestore) { + definePropertyAtPath(record, path, descriptor) + } + }, + } +} diff --git a/tests/extensions/persist.browser.test.ts b/tests/extensions/persist.browser.test.ts index 41d108f..97fd6d3 100644 --- a/tests/extensions/persist.browser.test.ts +++ b/tests/extensions/persist.browser.test.ts @@ -301,3 +301,269 @@ test('works in combination with `sync`', async ({ context, serve, page }) => { ], }) }) + +test('passes hydrated records through the schema', async ({ serve, page }) => { + const { url, evaluate } = await serve(async () => { + const z = await import('zod') + const { Collection } = await import('#/src/collection.js') + const { persist } = await import('#/src/extensions/persist.js') + + const schema = z.object({ + id: z.number(), + createdAt: z.coerce.date(), + }) + + const users = new Collection({ schema, extensions: [persist()] }) + return { users } + }) + + await page.goto(url.href, { waitUntil: 'networkidle' }) + + await evaluate(async ({ users }) => { + await users.create({ id: 1, createdAt: '2024-01-01T00:00:00.000Z' }) + }) + + await page.reload({ waitUntil: 'networkidle' }) + + await expect( + evaluate(({ users }) => { + const user = users.findFirst((q) => q.where({ id: 1 })) + return { + isDate: user?.createdAt instanceof Date, + createdAt: user?.createdAt.toISOString(), + } + }), + 'Coerces the persisted string into a Date instance', + ).resolves.toEqual({ + isDate: true, + createdAt: '2024-01-01T00:00:00.000Z', + }) +}) + +test('persists a required relation', async ({ serve, page }) => { + const { url, evaluate } = await serve(async () => { + const z = await import('zod') + const { Collection } = await import('#/src/collection.js') + const { persist } = await import('#/src/extensions/persist.js') + + const userSchema = z.object({ + id: z.number(), + createdAt: z.coerce.date(), + }) + const wishlistSchema = z.object({ + id: z.number(), + get user() { + return userSchema + }, + }) + + const users = new Collection({ + schema: userSchema, + extensions: [persist()], + }) + const wishlists = new Collection({ + schema: wishlistSchema, + extensions: [persist()], + }) + + wishlists.defineRelations(({ one }) => ({ + user: one(users), + })) + + return { users, wishlists } + }) + + await page.goto(url.href, { waitUntil: 'networkidle' }) + + await evaluate(async ({ users, wishlists }) => { + const user = await users.create({ + id: 1, + createdAt: '2024-01-01T00:00:00.000Z', + }) + await wishlists.create({ id: 1, user }) + }) + + await page.reload({ waitUntil: 'networkidle' }) + + await expect( + evaluate(({ wishlists }) => { + const wishlist = wishlists.findFirst((q) => q.where({ id: 1 })) + return { + id: wishlist?.id, + userId: wishlist?.user.id, + isUserCreatedAtDate: wishlist?.user.createdAt instanceof Date, + } + }), + 'Restores the required relation and passes the foreign record through its schema', + ).resolves.toEqual({ + id: 1, + userId: 1, + isUserCreatedAtDate: true, + }) +}) + +test('persists a unique relation', async ({ serve, page }) => { + const { url, evaluate } = await serve(async () => { + const z = await import('zod') + const { Collection } = await import('#/src/collection.js') + const { persist } = await import('#/src/extensions/persist.js') + + const userSchema = z.object({ + id: z.number(), + }) + const wishlistSchema = z.object({ + id: z.number(), + get user() { + return userSchema + }, + }) + + const users = new Collection({ + schema: userSchema, + extensions: [persist()], + }) + const wishlists = new Collection({ + schema: wishlistSchema, + extensions: [persist()], + }) + + wishlists.defineRelations(({ one }) => ({ + user: one(users, { unique: true }), + })) + + return { users, wishlists } + }) + + await page.goto(url.href, { waitUntil: 'networkidle' }) + + await evaluate(async ({ users, wishlists }) => { + const user = await users.create({ id: 1 }) + await wishlists.create({ id: 1, user }) + }) + + await page.reload({ waitUntil: 'networkidle' }) + + await expect( + evaluate(({ wishlists }) => { + return wishlists.all() + }), + ).resolves.toEqual([{ id: 1, user: { id: 1 } }]) +}) + +test('does not duplicate hydrated records in other tabs when combined with `sync`', async ({ + context, + serve, + page, +}) => { + const { url, evaluate } = await serve(async () => { + const z = await import('zod') + const { Collection } = await import('#/src/collection.js') + const { persist } = await import('#/src/extensions/persist.js') + const { sync } = await import('#/src/extensions/sync.js') + + const schema = z.object({ + id: z.number(), + name: z.string(), + }) + + const users = new Collection({ + schema, + extensions: [sync(), persist()], + }) + + return { users } + }) + + await page.goto(url.href, { waitUntil: 'networkidle' }) + const secondPage = await context.newPage() + await secondPage.goto(url.href, { waitUntil: 'networkidle' }) + + await evaluate(async ({ users }) => { + await users.create({ id: 1, name: 'John' }) + }) + + await expect( + evaluate(({ users }) => users.all(), { page: secondPage }), + 'Synchronizes the record with another tab', + ).resolves.toEqual([{ id: 1, name: 'John' }]) + + // Reloading hydrates the record from the storage. + await page.reload({ waitUntil: 'networkidle' }) + + await expect( + evaluate(({ users }) => users.all()), + 'Hydrates the record once', + ).resolves.toEqual([{ id: 1, name: 'John' }]) + + await expect( + evaluate(({ users }) => users.all(), { page: secondPage }), + 'Does not broadcast the hydrated record to other tabs', + ).resolves.toEqual([{ id: 1, name: 'John' }]) +}) + +test('hydrates the collection synchronously', async ({ serve, page }) => { + const { url, evaluate } = await serve(async () => { + const z = await import('zod') + const { Collection } = await import('#/src/collection.js') + const { persist } = await import('#/src/extensions/persist.js') + + const schema = z.object({ + id: z.number(), + }) + + const users = new Collection({ schema, extensions: [persist()] }) + + // Query the collection immediately after its construction. + const recordsAfterConstruction = users.all().length + + return { users, recordsAfterConstruction } + }) + + await page.goto(url.href, { waitUntil: 'networkidle' }) + + await evaluate(async ({ users }) => { + await users.create({ id: 1 }) + }) + + await page.reload({ waitUntil: 'networkidle' }) + + await expect( + evaluate(({ recordsAfterConstruction }) => recordsAfterConstruction), + 'Hydrated records are available synchronously after construction', + ).resolves.toBe(1) +}) + +test('throws when hydrating with an asynchronous schema', async ({ + serve, + page, +}) => { + const { url, evaluate } = await serve(async () => { + const z = await import('zod') + const { Collection } = await import('#/src/collection.js') + const { persist } = await import('#/src/extensions/persist.js') + + const schema = z.object({ + id: z.number().refine(async () => true), + }) + + try { + const users = new Collection({ schema, extensions: [persist()] }) + return { users, error: undefined } + } catch (error) { + return { users: undefined, error: String(error) } + } + }) + + await page.goto(url.href, { waitUntil: 'networkidle' }) + + await evaluate(async ({ users }) => { + await users?.create({ id: 1 }) + }) + + await page.reload({ waitUntil: 'networkidle' }) + + await expect( + evaluate(({ error }) => error), + 'Fails the collection construction with a descriptive error', + ).resolves.toMatch(/asynchronous/) +}) diff --git a/tests/extensions/sync.browser.test.ts b/tests/extensions/sync.browser.test.ts index 985f105..ec059b1 100644 --- a/tests/extensions/sync.browser.test.ts +++ b/tests/extensions/sync.browser.test.ts @@ -226,3 +226,55 @@ test('syncs record deletion across tabs', async ({ serve, context, page }) => { }), ).resolves.toEqual([]) }) + +test('syncs a required relation across tabs', async ({ + serve, + context, + page, +}) => { + const { url, evaluate } = await serve(async () => { + const z = await import('zod') + const { Collection } = await import('#/src/collection.js') + const { sync } = await import('#/src/extensions/sync.js') + + const userSchema = z.object({ + id: z.number(), + }) + const wishlistSchema = z.object({ + id: z.number(), + get user() { + return userSchema + }, + }) + + const users = new Collection({ schema: userSchema, extensions: [sync()] }) + const wishlists = new Collection({ + schema: wishlistSchema, + extensions: [sync()], + }) + + wishlists.defineRelations(({ one }) => ({ + user: one(users), + })) + + return { users, wishlists } + }) + + await page.goto(url.href, { waitUntil: 'networkidle' }) + const secondPage = await context.newPage() + await secondPage.goto(url.href, { waitUntil: 'networkidle' }) + + await evaluate(async ({ users, wishlists }) => { + const user = await users.create({ id: 1 }) + await wishlists.create({ id: 1, user }) + }) + + await expect( + evaluate( + ({ wishlists }) => { + return wishlists.all() + }, + { page: secondPage }, + ), + ).resolves.toEqual([{ id: 1, user: { id: 1 } }]) +}) diff --git a/tests/hooks/create.test.ts b/tests/hooks/create.test.ts index c8c6769..5d1fff7 100644 --- a/tests/hooks/create.test.ts +++ b/tests/hooks/create.test.ts @@ -22,6 +22,7 @@ it('invokes the create hook when a new record is created', async () => { data: { initialValues: { id: 1 }, record: { id: 1 }, + restored: false, }, }), ) @@ -31,6 +32,7 @@ it('invokes the create hook when a new record is created', async () => { data: { initialValues: { id: 2 }, record: { id: 2 }, + restored: false, }, }), ) @@ -59,6 +61,7 @@ it('differentiates between initial values and the created record', async () => { data: { initialValues: { id: 1 }, record: { id: 1, subscribed: false }, + restored: false, }, }), ) @@ -68,6 +71,7 @@ it('differentiates between initial values and the created record', async () => { data: { initialValues: { id: 2, name: 'John' }, record: { id: 2, name: 'John', subscribed: false }, + restored: false, }, }), ) @@ -77,6 +81,7 @@ it('differentiates between initial values and the created record', async () => { data: { initialValues: { id: 3, subscribed: true }, record: { id: 3, subscribed: true }, + restored: false, }, }), )