From 80a102c8b57fcf638187d4230d0cfd8fc426eff4 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 2 Oct 2025 16:34:40 +0200 Subject: [PATCH 1/6] docs: document `OperationError` --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index 7855568..04a50c5 100644 --- a/README.md +++ b/README.md @@ -426,6 +426,21 @@ videos.defineRelations(({ one }) => ({ > In this example, `post.attachments` is an array of either `images` or `videos`, where records from both collections are allowed. +## Error handling + +Data provides multiple different error classes to help you differentiate and handle different errors. + +### `OperationError` + +- `operationName` ``, the name of the errored operation (e.g. "create", "updateMany", etc.); +- `info` ``, additional operation information (often the operation arguments); +- `cause` ``, a reference to the original thrown error. + +Thrown whenever performing an operation fails. For example: + +- When creating a new record whose initial values do not match the collection's schema; +- When there are no records found for a strict query. + --- ## API From 768d4be73ea46a12045509fb61eb050dc4b9e703 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 2 Oct 2025 16:57:22 +0200 Subject: [PATCH 2/6] fix: implement `RelationError` --- README.md | 16 +++++++++++- src/errors.ts | 39 +++++++++++++++++++++++++++-- src/index.ts | 10 ++++++-- src/relation.ts | 65 +++++++++++++++++++++++++++++++++++++++---------- 4 files changed, 112 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 04a50c5..cab4f00 100644 --- a/README.md +++ b/README.md @@ -436,11 +436,25 @@ Data provides multiple different error classes to help you differentiate and han - `info` ``, additional operation information (often the operation arguments); - `cause` ``, a reference to the original thrown error. -Thrown whenever performing an operation fails. For example: +Thrown whenever performing a record operation fails. For example: - When creating a new record whose initial values do not match the collection's schema; - When there are no records found for a strict query. +### `RelationError` + +- `code` ``, the error code describing the relation operation; +- `info` ``, additional error information; + - `path` ``, path of the relational property; + - `ownerCollection` ``, a reference to the owner collection; + - `foreignCollection` `>`, an array of foreign collections referenced by this relation; + - `options` `RelationDefinitionOptions`, the options object passed upon decaring this relation. + +Thrown whenever performing a relation operation fails. For example: + +- When attempting to reference a foreign record that's already associated with another record in a unique relation; +- When directly assigning value to a relational property. + --- ## API diff --git a/src/errors.ts b/src/errors.ts index cac62d1..efe7595 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,5 +1,7 @@ import { InvariantError } from 'outvariant' -import type { Collection } from './collection.js' +import type { Collection } from '#/src/collection.js' +import type { PropertyPath } from '#/src/utils.js' +import type { RelationDeclarationOptions } from '#/src/relation.js' export interface OperationErrorMap { create: { @@ -44,7 +46,9 @@ export class OperationError< operationName: OperationName, info: OperationErrorMap[OperationName], ) { - return (message: string) => new OperationError(message, operationName, info) + return (message: string) => { + return new OperationError(message, operationName, info) + } } constructor( @@ -62,3 +66,34 @@ export class StrictOperationError< > extends OperationError { static for = OperationError.for } + +export enum RelationErrorCodes { + RELATION_NOT_READY = 'RELATION_NOT_READY', + UNEXPECTED_SET_EXPRESSION = 'UNEXPECTED_SET_EXPRESSION', + INVALID_FOREIGN_RECORD = 'INVALID_FOREIGN_RECORD', + FORBIDDEN_UNIQUE_CREATE = 'FORBIDDEN_UNIQUE_CREATE', + FORBIDDEN_UNIQUE_UPDATE = 'FORBIDDEN_UNIQUE_UPDATE', +} + +export interface RelationErrorDetails { + path: PropertyPath + ownerCollection: Collection + foreignCollections: Array> + options: RelationDeclarationOptions +} + +export class RelationError extends Error { + static for(code: RelationErrorCodes, details: RelationErrorDetails) { + return (message: string) => { + return new RelationError(message, code, details) + } + } + + constructor( + message: string, + public readonly code: RelationErrorCodes, + public readonly details: RelationErrorDetails, + ) { + super(message) + } +} diff --git a/src/index.ts b/src/index.ts index 8bddd04..fe3d248 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,11 @@ export { Collection, type CollectionOptions } from './collection.js' export { Query, type Condition, type PredicateFunction } from './query.js' -export { Relation } from './relation.js' +export { Relation, type RelationDeclarationOptions } from './relation.js' export type { HookEventMap, HookEventListener } from './hooks.js' -export { OperationError, StrictOperationError } from './errors.js' +export { + OperationError, + StrictOperationError, + RelationError, + RelationErrorCodes, + type RelationErrorDetails, +} from './errors.js' diff --git a/src/relation.ts b/src/relation.ts index c597248..de69560 100644 --- a/src/relation.ts +++ b/src/relation.ts @@ -1,5 +1,5 @@ import type { StandardSchemaV1 } from '@standard-schema/spec' -import { invariant, InvariantError } from 'outvariant' +import { invariant, format } from 'outvariant' import { get, isEqual, set, unset } from 'lodash-es' import { kPrimaryKey, @@ -9,9 +9,18 @@ import { type RecordType, } from '#/src/collection.js' import { Logger } from '#/src/logger.js' -import { definePropertyAtPath, isRecord } from '#/src/utils.js' +import { + definePropertyAtPath, + isRecord, + type PropertyPath, +} from '#/src/utils.js' +import { + RelationError, + RelationErrorCodes, + type RelationErrorDetails, +} from '#/src/errors.js' -interface RelationDeclarationOptions { +export interface RelationDeclarationOptions { /** * Unique relation role to disambiguate between multiple relations * to the same target collection. @@ -87,7 +96,7 @@ export const createRelationBuilder = >( export abstract class Relation { #logger: Logger - #path?: Array + #path?: PropertyPath public foreignKeys: Set @@ -107,11 +116,16 @@ export abstract class Relation { this.foreignKeys = new Set() } - get path(): Array { - invariant( + get path(): PropertyPath { + invariant.as( + RelationError.for( + RelationErrorCodes.RELATION_NOT_READY, + this.#createErrorDetails(), + ), this.#path != null, 'Failed to retrieve path for relation: relation is not initialized', ) + return this.#path } @@ -237,7 +251,11 @@ export abstract class Relation { // Throw if attempting to disassociate unique relations. if (this.options.unique) { - invariant( + invariant.as( + RelationError.for( + RelationErrorCodes.FORBIDDEN_UNIQUE_UPDATE, + this.#createErrorDetails(), + ), oldForeignRecords.length === 0, 'Failed to update a unique relation at "%s": record already associated with another foreign record', update.path.join('.'), @@ -331,7 +349,11 @@ export abstract class Relation { const recordLabel = this instanceof Many ? 'records' : 'record' - invariant( + invariant.as( + RelationError.for( + RelationErrorCodes.FORBIDDEN_UNIQUE_CREATE, + this.#createErrorDetails(), + ), isUnique, `Failed to create a unique relation at "%s": foreign ${recordLabel} already associated with another owner`, this.path.join('.'), @@ -341,7 +363,11 @@ export abstract class Relation { for (const foreignRecord of initialForeignEntries) { const foreignKey = foreignRecord[kPrimaryKey] - invariant( + invariant.as( + RelationError.for( + RelationErrorCodes.INVALID_FOREIGN_RECORD, + this.#createErrorDetails(), + ), foreignKey != null, 'Failed to store foreign record reference for "%s" relation: the referenced record (%j) is missing the primary key', serializedPath, @@ -387,10 +413,14 @@ export abstract class Relation { return this.getDefaultValue() }, set: () => { - throw new InvariantError( - 'Failed to set property "%s" on collection (%s): relational properties are read-only and can only be updated via collection updates', - serializedPath, - this.ownerCollection[kCollectionId], + throw new RelationError( + format( + 'Failed to set property "%s" on collection (%s): relational properties are read-only and can only be updated via collection updates', + serializedPath, + this.ownerCollection[kCollectionId], + ), + RelationErrorCodes.UNEXPECTED_SET_EXPRESSION, + this.#createErrorDetails(), ) }, }) @@ -421,6 +451,15 @@ export abstract class Relation { return result } + + #createErrorDetails(): RelationErrorDetails { + return { + path: this.path, + ownerCollection: this.ownerCollection, + foreignCollections: this.foreignCollections, + options: this.options, + } + } } class One extends Relation { From 6ac646a45e457221299c601e391a9086099ddf28 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 2 Oct 2025 17:23:36 +0200 Subject: [PATCH 3/6] fix: use `code` for `OperationError` --- README.md | 5 ++-- src/collection.ts | 27 +++++++++--------- src/errors.ts | 59 ++++++--------------------------------- src/extensions/persist.ts | 5 ++-- src/index.ts | 1 - src/relation.ts | 6 +--- 6 files changed, 27 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index cab4f00..c254828 100644 --- a/README.md +++ b/README.md @@ -432,9 +432,8 @@ Data provides multiple different error classes to help you differentiate and han ### `OperationError` -- `operationName` ``, the name of the errored operation (e.g. "create", "updateMany", etc.); -- `info` ``, additional operation information (often the operation arguments); -- `cause` ``, a reference to the original thrown error. +- `code` ``, the error code describing the failed operation; +- `cause` `` (_optional_), a reference to the original thrown error. Thrown whenever performing a record operation fails. For example: diff --git a/src/collection.ts b/src/collection.ts index b9cd013..817eedc 100644 --- a/src/collection.ts +++ b/src/collection.ts @@ -19,7 +19,7 @@ import { } from '#/src/utils.js' import { type SortOptions, sortResults } from '#/src/sort.js' import type { Extension } from '#/src/extensions/index.js' -import { OperationError, StrictOperationError } from '#/src/errors.js' +import { OperationError, OperationErrorCodes } from '#/src/errors.js' import { TypedEvent, type Emitter } from 'rettime' let collectionsCreated = 0 @@ -131,16 +131,16 @@ export class Collection { if (validationResult.issues) { console.error(validationResult.issues) - throw new InvariantError( - 'Failed to create a new record with initial values (%j): does not match the schema', - initialValues, + throw new OperationError( + 'Failed to create a new record with initial values (%j): does not match the schema. Please see the schema validation errors above.', + OperationErrorCodes.INVALID_INITIAL_VALUES, ) } let record = validationResult.value as RecordType invariant.as( - OperationError.for('create', { initialValues }), + 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, @@ -207,8 +207,7 @@ export class Collection { return await Promise.all(pendingPromises).catch((error) => { throw new OperationError( 'Failed to execute "createMany" on collection: unexpected error', - 'createMany', - { count, initialValuesFactory }, + OperationErrorCodes.UNEXPECTED_ERROR, error, ) }) @@ -232,7 +231,7 @@ export class Collection { const firstRecord = this.#records[0] invariant.as( - StrictOperationError.for('findFirst', { predicate, options }), + OperationError.for(OperationErrorCodes.STRICT_QUERY_WITHOUT_RESULTS), options?.strict ? firstRecord != null : true, 'Failed to execute "findFirst" on collection without a query: the collection is empty', ) @@ -245,7 +244,7 @@ export class Collection { ).next().value invariant.as( - StrictOperationError.for('findFirst', { predicate, options }), + OperationError.for(OperationErrorCodes.STRICT_QUERY_WITHOUT_RESULTS), options?.strict ? result != null : true, 'Failed to execute "findFirst" on collection: no record found matching the query', ) @@ -277,7 +276,7 @@ export class Collection { ) invariant.as( - StrictOperationError.for('findMany', { predicate, options }), + OperationError.for(OperationErrorCodes.STRICT_QUERY_WITHOUT_RESULTS), options?.strict ? results.length > 0 : true, 'Failed to execute "findMany" on collection: no records found matching the query', ) @@ -324,7 +323,7 @@ export class Collection { if (prevRecord == null) { invariant.as( - StrictOperationError.for('update', { predicate, options }), + OperationError.for(OperationErrorCodes.STRICT_QUERY_WITHOUT_RESULTS), !options.strict, 'Failed to execute "update" on collection: no record found matching the query', ) @@ -363,7 +362,7 @@ export class Collection { if (prevRecords.length === 0) { invariant.as( - StrictOperationError.for('updateMany', { predicate, options }), + OperationError.for(OperationErrorCodes.STRICT_QUERY_WITHOUT_RESULTS), !options.strict, 'Failed to execute "updateMany" on collection: no records found matching the query', ) @@ -409,7 +408,7 @@ export class Collection { if (record == null) { invariant.as( - StrictOperationError.for('delete', { predicate, options }), + OperationError.for(OperationErrorCodes.STRICT_QUERY_WITHOUT_RESULTS), !options?.strict, 'Failed to execute "delete" on collection: no record found matching the query', ) @@ -445,7 +444,7 @@ export class Collection { if (records.length === 0) { invariant.as( - StrictOperationError.for('deleteMany', { predicate, options }), + OperationError.for(OperationErrorCodes.STRICT_QUERY_WITHOUT_RESULTS), !options?.strict, 'Failed to execute "deleteMany" on collection: no records found matching the query', ) diff --git a/src/errors.ts b/src/errors.ts index efe7595..bce08cc 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,72 +1,29 @@ -import { InvariantError } from 'outvariant' import type { Collection } from '#/src/collection.js' import type { PropertyPath } from '#/src/utils.js' import type { RelationDeclarationOptions } from '#/src/relation.js' -export interface OperationErrorMap { - create: { - initialValues: Parameters['create']>[0] - } - createMany: { - count: number - initialValuesFactory: Parameters< - InstanceType['createMany'] - >[1] - } - findFirst: { - predicate: Parameters['findFirst']>[0] - options: Parameters['findFirst']>[1] - } - findMany: { - predicate: Parameters['findMany']>[0] - options: Parameters['findMany']>[1] - } - update: { - predicate: Parameters['update']>[0] - options: Parameters['update']>[1] - } - updateMany: { - predicate: Parameters['updateMany']>[0] - options: Parameters['updateMany']>[1] - } - delete: { - predicate: Parameters['delete']>[0] - options: Parameters['delete']>[1] - } - deleteMany: { - predicate: Parameters['deleteMany']>[0] - options: Parameters['deleteMany']>[1] - } +export enum OperationErrorCodes { + UNEXPECTED_ERROR = 'UNEXPECTED_ERROR', + INVALID_INITIAL_VALUES = 'INVALID_INITIAL_VALUES', + STRICT_QUERY_WITHOUT_RESULTS = 'STRICT_QUERY_WITHOUT_RESULTS', } -export class OperationError< - OperationName extends keyof OperationErrorMap, -> extends InvariantError { - static for( - operationName: OperationName, - info: OperationErrorMap[OperationName], - ) { +export class OperationError extends Error { + static for(code: OperationErrorCodes) { return (message: string) => { - return new OperationError(message, operationName, info) + return new OperationError(message, code) } } constructor( message: string, - public readonly operationName: OperationName, - public readonly info: OperationErrorMap[OperationName], + public readonly code: OperationErrorCodes, public readonly cause?: unknown, ) { super(message) } } -export class StrictOperationError< - OperationName extends keyof OperationErrorMap, -> extends OperationError { - static for = OperationError.for -} - export enum RelationErrorCodes { RELATION_NOT_READY = 'RELATION_NOT_READY', UNEXPECTED_SET_EXPRESSION = 'UNEXPECTED_SET_EXPRESSION', diff --git a/src/extensions/persist.ts b/src/extensions/persist.ts index 14f2d65..303384b 100644 --- a/src/extensions/persist.ts +++ b/src/extensions/persist.ts @@ -8,7 +8,8 @@ import { kRelationMap, type RecordType, } from '#/src/collection.js' -import { Logger } from '../logger.js' +import { Logger } from '#/src/logger.js' +import type { PropertyPath } from '#/src/utils.js' const STORAGE_KEY = 'msw/data/storage' const METADATA_KEY = '__metadata__' @@ -27,7 +28,7 @@ export interface SerializedRecord { interface RecordMetadata { primaryKey: string relations: Array<{ - path: Array + path: PropertyPath foreignKeys: Array }> } diff --git a/src/index.ts b/src/index.ts index fe3d248..6ebdbb2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,6 @@ export { Relation, type RelationDeclarationOptions } from './relation.js' export type { HookEventMap, HookEventListener } from './hooks.js' export { OperationError, - StrictOperationError, RelationError, RelationErrorCodes, type RelationErrorDetails, diff --git a/src/relation.ts b/src/relation.ts index de69560..7f861b3 100644 --- a/src/relation.ts +++ b/src/relation.ts @@ -117,11 +117,7 @@ export abstract class Relation { } get path(): PropertyPath { - invariant.as( - RelationError.for( - RelationErrorCodes.RELATION_NOT_READY, - this.#createErrorDetails(), - ), + invariant( this.#path != null, 'Failed to retrieve path for relation: relation is not initialized', ) From 2947486c35363292957043b8bb2060bad497041d Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 2 Oct 2025 20:18:04 +0200 Subject: [PATCH 4/6] test: add failing record update/create tests --- src/relation.ts | 2 +- tests/relations/one-to-one.test.ts | 69 +++++++++++++++++++++++++++--- 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/src/relation.ts b/src/relation.ts index 7f861b3..b0c5431 100644 --- a/src/relation.ts +++ b/src/relation.ts @@ -253,7 +253,7 @@ export abstract class Relation { this.#createErrorDetails(), ), oldForeignRecords.length === 0, - 'Failed to update a unique relation at "%s": record already associated with another foreign record', + 'Failed to update a unique relation at "%s": the foreign record is already associated with another owner', update.path.join('.'), ) } diff --git a/tests/relations/one-to-one.test.ts b/tests/relations/one-to-one.test.ts index e2ff906..63be147 100644 --- a/tests/relations/one-to-one.test.ts +++ b/tests/relations/one-to-one.test.ts @@ -1,5 +1,5 @@ import { kRelationMap } from '#/src/collection.js' -import { Collection } from '#/src/index.js' +import { Collection, RelationError, RelationErrorCodes } from '#/src/index.js' import { isRecord } from '#/src/utils.js' import z from 'zod' @@ -273,9 +273,24 @@ it('supports unique one-to-one relations', async () => { expect .soft(posts.findFirst((q) => q.where({ author: { id: updatedUser!.id } }))) .toEqual(post) + + const anotherUser = await users.create({ id: 2 }) + await expect( + posts.update(post, { + data(post) { + // This must not error since the provided foreign record (user) + // is not associated with any owners (posts). + post.author = anotherUser + }, + }), + 'Updates the unique relational property', + ).resolves.toEqual({ + title: 'First', + author: anotherUser, + }) }) -it('errors when updating a unique relation that has already been associated', async () => { +it('errors when creating a unique relation with a foreign record that has already been associated', async () => { const userSchema = z.object({ id: z.number() }) const postSchema = z.object({ title: z.string(), @@ -290,17 +305,57 @@ it('errors when updating a unique relation that has already been associated', as })) const user = await users.create({ id: 1 }) - const firstPost = await posts.create({ title: 'First', author: user }) + await posts.create({ title: 'First', author: user }) + + await expect(posts.create({ title: 'Second', author: user })).rejects.toThrow( + new RelationError( + `Failed to create a unique relation at "author": the foreign record is already associated with another owner`, + RelationErrorCodes.FORBIDDEN_UNIQUE_CREATE, + { + path: ['author'], + ownerCollection: posts, + foreignCollections: [users], + options: { unique: true }, + }, + ), + ) +}) + +it('errors when updating a unique relation with a foreign record that has already been associated', async () => { + const userSchema = z.object({ id: z.number() }) + const postSchema = z.object({ + title: z.string(), + author: userSchema, + }) + + const users = new Collection({ schema: userSchema }) + const posts = new Collection({ schema: postSchema }) - expect(firstPost.author).toEqual(user) + posts.defineRelations(({ one }) => ({ + author: one(users, { unique: true }), + })) + + const firstUser = await users.create({ id: 1 }) + const secondUser = await users.create({ id: 2 }) + await posts.create({ title: 'First', author: firstUser }) + await posts.create({ title: 'Second', author: secondUser }) await expect( - posts.update((q) => q.where({ title: 'First' }), { + posts.update((q) => q.where({ title: 'Second' }), { async data(post) { - post.author = await users.create({ id: 2 }) + post.author = firstUser }, }), ).rejects.toThrow( - `Failed to update a unique relation at "author": record already associated with another foreign record`, + new RelationError( + `Failed to update a unique relation at "author": the foreign record is already associated with another owner`, + RelationErrorCodes.FORBIDDEN_UNIQUE_UPDATE, + { + path: ['author'], + ownerCollection: posts, + foreignCollections: [users], + options: { unique: true }, + }, + ), ) }) From 0b4c4413b332f739da2c4f3553c1e02e0af14932 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 3 Oct 2025 10:52:02 +0200 Subject: [PATCH 5/6] fix: validate other owners in unique one-way relations --- src/relation.ts | 79 +++++++++++++++++++++++------- tests/relations/one-to-one.test.ts | 43 +++++++++++++++- 2 files changed, 101 insertions(+), 21 deletions(-) diff --git a/src/relation.ts b/src/relation.ts index b0c5431..19a73a9 100644 --- a/src/relation.ts +++ b/src/relation.ts @@ -245,6 +245,10 @@ export abstract class Relation { }, ) + const foreignRelationsToDisassociate = oldForeignRecords.flatMap( + (record) => this.getRelationsToOwner(record), + ) + // Throw if attempting to disassociate unique relations. if (this.options.unique) { invariant.as( @@ -252,20 +256,31 @@ export abstract class Relation { RelationErrorCodes.FORBIDDEN_UNIQUE_UPDATE, this.#createErrorDetails(), ), - oldForeignRecords.length === 0, + foreignRelationsToDisassociate.length === 0, 'Failed to update a unique relation at "%s": the foreign record is already associated with another owner', update.path.join('.'), ) } - const foreignRelationsToDisassociate = oldForeignRecords.flatMap( - (record) => this.getRelationsToOwner(record), - ) - for (const foreignRelation of foreignRelationsToDisassociate) { foreignRelation.foreignKeys.delete(update.prevRecord[kPrimaryKey]) } + // Check any other owners associated with the same foreign record. + // This is important since unique relations are not always two-way. + const otherOwnersAssociatedWithForeignRecord = + this.#getOtherOwnerForRecords([update.nextValue]) + + invariant.as( + RelationError.for( + RelationErrorCodes.FORBIDDEN_UNIQUE_UPDATE, + this.#createErrorDetails(), + ), + otherOwnersAssociatedWithForeignRecord == null, + 'Failed to update a unique relation at "%s": the foreign record is already associated with another owner', + update.path.join('.'), + ) + this.foreignKeys.clear() } @@ -316,7 +331,7 @@ export abstract class Relation { initialValue, ) - const initialForeignEntries: Array = Array.prototype + const initialForeignRecords: Array = Array.prototype .concat([], get(initialValues, path)) /** * @note If the initial value as an empty array, concatenating it above @@ -324,16 +339,11 @@ export abstract class Relation { */ .filter(Boolean) - logger.log('all foreign entries:', initialForeignEntries) + logger.log('all foreign entries:', initialForeignRecords) - /** - * @todo Check if: - * 1. Relation is unique; - * 2. Foreign entries already reference something! - * Then, throw. - */ if (this.options.unique) { - const foreignRelations = initialForeignEntries.flatMap( + // Check if the foreign record isn't associated with another owner. + const foreignRelations = initialForeignRecords.flatMap( (foreignRecord) => { return this.getRelationsToOwner(foreignRecord) }, @@ -343,20 +353,32 @@ export abstract class Relation { (relation) => relation.foreignKeys.size === 0, ) - const recordLabel = this instanceof Many ? 'records' : 'record' - invariant.as( RelationError.for( RelationErrorCodes.FORBIDDEN_UNIQUE_CREATE, this.#createErrorDetails(), ), isUnique, - `Failed to create a unique relation at "%s": foreign ${recordLabel} already associated with another owner`, - this.path.join('.'), + `Failed to create a unique relation at "%s": foreign ${this instanceof Many ? 'records' : 'record'} already associated with another owner`, + serializedPath, + ) + + // Check if another owner isn't associated with the foreign record. + const otherOwnersAssociatedWithForeignRecord = + this.#getOtherOwnerForRecords(initialForeignRecords) + + invariant.as( + RelationError.for( + RelationErrorCodes.FORBIDDEN_UNIQUE_CREATE, + this.#createErrorDetails(), + ), + otherOwnersAssociatedWithForeignRecord == null, + 'Failed to create a unique relation at "%s": the foreign record is already associated with another owner', + serializedPath, ) } - for (const foreignRecord of initialForeignEntries) { + for (const foreignRecord of initialForeignRecords) { const foreignKey = foreignRecord[kPrimaryKey] invariant.as( @@ -448,6 +470,25 @@ export abstract class Relation { return result } + #getOtherOwnerForRecords( + foreignRecords: Array, + ): RecordType | undefined { + const serializedPath = this.path.join('.') + + return this.ownerCollection.findFirst((q) => { + return q.where((otherOwner) => { + const otherOwnerRelations = otherOwner[kRelationMap] + const otherOwnerRelation = otherOwnerRelations.get(serializedPath) + + // Forego any other relation comparisons since the same collection + // shares the relation definition at the same property path. + return foreignRecords.some((foreignRecord) => { + return otherOwnerRelation.foreignKeys.has(foreignRecord[kPrimaryKey]) + }) + }) + }) + } + #createErrorDetails(): RelationErrorDetails { return { path: this.path, diff --git a/tests/relations/one-to-one.test.ts b/tests/relations/one-to-one.test.ts index 63be147..abfc10d 100644 --- a/tests/relations/one-to-one.test.ts +++ b/tests/relations/one-to-one.test.ts @@ -241,7 +241,7 @@ it('applies relation to records created before the relation is defined', async ( }) }) -it('supports unique one-to-one relations', async () => { +it('supports creating unique one-to-one relations', async () => { const userSchema = z.object({ id: z.number() }) const postSchema = z.object({ title: z.string(), @@ -274,7 +274,45 @@ it('supports unique one-to-one relations', async () => { .soft(posts.findFirst((q) => q.where({ author: { id: updatedUser!.id } }))) .toEqual(post) - const anotherUser = await users.create({ id: 2 }) + const anotherUser = await users.create({ id: 5 }) + await expect( + posts.update(post, { + data(post) { + // This must not error since the provided foreign record (user) + // is not associated with any owners (posts). + post.author = anotherUser + }, + }), + 'Updates the unique relational property', + ).resolves.toEqual({ + title: 'First', + author: anotherUser, + }) +}) + +it('supports updating unique one-to-one relations', async () => { + const userSchema = z.object({ id: z.number() }) + const postSchema = z.object({ + title: z.string(), + author: userSchema, + }) + + const users = new Collection({ schema: userSchema }) + const posts = new Collection({ schema: postSchema }) + + posts.defineRelations(({ one }) => ({ + author: one(users, { unique: true }), + })) + + const user = await users.create({ id: 1 }) + const post = await posts.create({ title: 'First', author: user }) + + expect.soft(post.author).toEqual(user) + expect + .soft(posts.findFirst((q) => q.where({ author: { id: user.id } }))) + .toEqual(post) + + const anotherUser = await users.create({ id: 5 }) await expect( posts.update(post, { data(post) { @@ -307,6 +345,7 @@ it('errors when creating a unique relation with a foreign record that has alread const user = await users.create({ id: 1 }) await posts.create({ title: 'First', author: user }) + // Cannot create another post referencing the same `user` as the author. await expect(posts.create({ title: 'Second', author: user })).rejects.toThrow( new RelationError( `Failed to create a unique relation at "author": the foreign record is already associated with another owner`, From ba28287cf857fda1acfeba9e2093e24afe9e8600 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 3 Oct 2025 11:08:24 +0200 Subject: [PATCH 6/6] test: add two-way unique one-to-one relations tests --- src/collection.ts | 3 +- src/relation.ts | 2 +- tests/relations/one-to-many.test.ts | 4 +- tests/relations/one-to-one.test.ts | 173 +++++++++++++++++++++++++++- 4 files changed, 174 insertions(+), 8 deletions(-) diff --git a/src/collection.ts b/src/collection.ts index 817eedc..115b52a 100644 --- a/src/collection.ts +++ b/src/collection.ts @@ -131,8 +131,9 @@ export class Collection { if (validationResult.issues) { console.error(validationResult.issues) + throw new OperationError( - 'Failed to create a new record with initial values (%j): does not match the schema. Please see the schema validation errors above.', + '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, ) } diff --git a/src/relation.ts b/src/relation.ts index 19a73a9..d7670e9 100644 --- a/src/relation.ts +++ b/src/relation.ts @@ -359,7 +359,7 @@ export abstract class Relation { this.#createErrorDetails(), ), isUnique, - `Failed to create a unique relation at "%s": foreign ${this instanceof Many ? 'records' : 'record'} already associated with another owner`, + `Failed to create a unique relation at "%s": the foreign record is already associated with another owner`, serializedPath, ) diff --git a/tests/relations/one-to-many.test.ts b/tests/relations/one-to-many.test.ts index 5f6e352..b3586ba 100644 --- a/tests/relations/one-to-many.test.ts +++ b/tests/relations/one-to-many.test.ts @@ -403,7 +403,7 @@ it('errors when creating a unique relation with already associated foreign recor await users.create({ id: 1, posts: [post] }) await expect(users.create({ id: 2, posts: [post] })).rejects.toThrow( - 'Failed to create a unique relation at "posts": foreign records already associated with another owner', + 'Failed to create a unique relation at "posts": the foreign record is already associated with another owner', ) } @@ -425,7 +425,7 @@ it('errors when creating a unique relation with already associated foreign recor await expect( posts.create({ title: 'Second', author: user }), ).rejects.toThrow( - 'Failed to create a unique relation at "author": foreign record already associated with another owner', + 'Failed to create a unique relation at "author": the foreign record is already associated with another owner', ) } }) diff --git a/tests/relations/one-to-one.test.ts b/tests/relations/one-to-one.test.ts index abfc10d..dcb9e8e 100644 --- a/tests/relations/one-to-one.test.ts +++ b/tests/relations/one-to-one.test.ts @@ -241,7 +241,7 @@ it('applies relation to records created before the relation is defined', async ( }) }) -it('supports creating unique one-to-one relations', async () => { +it('supports creating unique one-way one-to-one relations', async () => { const userSchema = z.object({ id: z.number() }) const postSchema = z.object({ title: z.string(), @@ -290,7 +290,7 @@ it('supports creating unique one-to-one relations', async () => { }) }) -it('supports updating unique one-to-one relations', async () => { +it('supports updating unique one-way one-to-one relations', async () => { const userSchema = z.object({ id: z.number() }) const postSchema = z.object({ title: z.string(), @@ -328,7 +328,7 @@ it('supports updating unique one-to-one relations', async () => { }) }) -it('errors when creating a unique relation with a foreign record that has already been associated', async () => { +it('errors when creating a unique one-way relation referencing a taken foreign record', async () => { const userSchema = z.object({ id: z.number() }) const postSchema = z.object({ title: z.string(), @@ -360,7 +360,7 @@ it('errors when creating a unique relation with a foreign record that has alread ) }) -it('errors when updating a unique relation with a foreign record that has already been associated', async () => { +it('errors when updating a unique one-way relation referencing a taken foreign record', async () => { const userSchema = z.object({ id: z.number() }) const postSchema = z.object({ title: z.string(), @@ -398,3 +398,168 @@ it('errors when updating a unique relation with a foreign record that has alread ), ) }) + +it('supports creating unique two-way one-to-one relations', async () => { + const userSchema = z.object({ + id: z.number(), + get favoritePost() { + return postSchema + }, + }) + const postSchema = z.object({ + title: z.string(), + get author() { + return userSchema.optional() + }, + }) + + const users = new Collection({ schema: userSchema }) + const posts = new Collection({ schema: postSchema }) + + users.defineRelations(({ one }) => ({ + favoritePost: one(posts, { unique: true }), + })) + posts.defineRelations(({ one }) => ({ + author: one(users, { unique: true }), + })) + + const user = await users.create({ + id: 1, + favoritePost: await posts.create({ title: 'First' }), + }) + expect(user.favoritePost).toEqual({ title: 'First', author: user }) + expect(posts.findFirst((q) => q.where({ author: { id: 1 } }))).toEqual({ + title: 'First', + author: user, + }) +}) + +it('errors when creating a unique two-way relation referencing a taken foreign record', async () => { + const userSchema = z.object({ + id: z.number(), + get favoritePost() { + return postSchema.optional() + }, + }) + const postSchema = z.object({ + title: z.string(), + get author() { + return userSchema.optional() + }, + }) + + const users = new Collection({ schema: userSchema }) + const posts = new Collection({ schema: postSchema }) + + users.defineRelations(({ one }) => ({ + favoritePost: one(posts, { unique: true }), + })) + posts.defineRelations(({ one }) => ({ + author: one(users, { unique: true }), + })) + + const user = await users.create({ + id: 1, + favoritePost: await posts.create({ title: 'First' }), + }) + + await expect( + users.create({ id: 2, favoritePost: user.favoritePost }), + ).rejects.toThrow( + new RelationError( + `Failed to create a unique relation at "favoritePost": the foreign record is already associated with another owner`, + RelationErrorCodes.FORBIDDEN_UNIQUE_CREATE, + { + path: ['favoritePost'], + ownerCollection: users, + foreignCollections: [posts], + options: { unique: true }, + }, + ), + ) + + await expect(posts.create({ title: 'Second', author: user })).rejects.toThrow( + new RelationError( + `Failed to create a unique relation at "author": the foreign record is already associated with another owner`, + RelationErrorCodes.FORBIDDEN_UNIQUE_CREATE, + { + path: ['author'], + ownerCollection: posts, + foreignCollections: [users], + options: { unique: true }, + }, + ), + ) +}) + +it('errors when updating a unique two-way relation referencing a taken foreign record', async () => { + const userSchema = z.object({ + id: z.number(), + get favoritePost() { + return postSchema + }, + }) + const postSchema = z.object({ + title: z.string(), + get author() { + return userSchema.optional() + }, + }) + + const users = new Collection({ schema: userSchema }) + const posts = new Collection({ schema: postSchema }) + + users.defineRelations(({ one }) => ({ + favoritePost: one(posts, { unique: true }), + })) + posts.defineRelations(({ one }) => ({ + author: one(users, { unique: true }), + })) + + const firstUser = await users.create({ + id: 1, + favoritePost: await posts.create({ title: 'First' }), + }) + const secondUser = await users.create({ + id: 2, + favoritePost: await posts.create({ title: 'Second' }), + }) + + await expect( + users.update(secondUser, { + data(user) { + user.favoritePost = firstUser.favoritePost + }, + }), + ).rejects.toThrow( + new RelationError( + `Failed to update a unique relation at "favoritePost": the foreign record is already associated with another owner`, + RelationErrorCodes.FORBIDDEN_UNIQUE_UPDATE, + { + path: ['favoritePost'], + ownerCollection: users, + foreignCollections: [posts], + options: { unique: true }, + }, + ), + ) + + await expect( + posts.update((q) => q.where({ author: { id: 2 } }), { + data(post) { + post.author = firstUser + }, + }), + ).rejects.toThrow( + new RelationError( + `Failed to update a unique relation at "author": the foreign record is already associated with another owner`, + RelationErrorCodes.FORBIDDEN_UNIQUE_UPDATE, + { + path: ['author'], + ownerCollection: posts, + foreignCollections: [users], + options: { unique: true }, + }, + ), + ) +})