From a55e3012694e3fa0405c068665b28aea2f2cc047 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 19 Apr 2026 14:22:36 +0200 Subject: [PATCH 1/5] test: add failing test --- ...353-non-unique-one-relation-update.test.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 tests/regressions/353-non-unique-one-relation-update.test.ts diff --git a/tests/regressions/353-non-unique-one-relation-update.test.ts b/tests/regressions/353-non-unique-one-relation-update.test.ts new file mode 100644 index 0000000..91fc359 --- /dev/null +++ b/tests/regressions/353-non-unique-one-relation-update.test.ts @@ -0,0 +1,34 @@ +import { z } from 'zod' +import { Collection } from '#/src/collection.js' + +it('updates a non-unique one relation to a foreign record already associated with another owner', async () => { + const languageSchema = z.object({ code: z.string() }) + const userSchema = z.object({ + name: z.string(), + language: languageSchema, + }) + + const languages = new Collection({ schema: languageSchema }) + const users = new Collection({ schema: userSchema }) + + users.defineRelations(({ one }) => ({ + language: one(languages, { unique: false }), + })) + + const langPt = await languages.create({ code: 'pt' }) + const langEn = await languages.create({ code: 'en' }) + + const userOne = await users.create({ name: 'User One', language: langPt }) + await users.create({ name: 'User Two', language: langEn }) + + await users.update(userOne, { + data(user) { + user.language = langEn + }, + }) + + expect(users.all()).toEqual([ + { name: 'User One', language: { code: 'en' } }, + { name: 'User Two', language: { code: 'en' } }, + ]) +}) From 2f395aafa7bd10a31357e35a323d1379398b1ce3 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 19 Apr 2026 14:22:53 +0200 Subject: [PATCH 2/5] fix: check uniqueness of a relationship before guarding on update --- src/relation.ts | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/relation.ts b/src/relation.ts index 01bad50..fcf9fd9 100644 --- a/src/relation.ts +++ b/src/relation.ts @@ -269,18 +269,20 @@ export abstract class Relation { // 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('.'), - ) + if (this.options.unique) { + 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() } From b4eed050d77bc1df438d388ffd58ecf75f2ea4e2 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 19 Apr 2026 14:42:15 +0200 Subject: [PATCH 3/5] fix: scope relation updates to the targeted record --- src/relation.ts | 16 ++++++++++ ...353-update-non-unique-one-relation.test.ts | 31 +++++++++++++++++++ tests/relations/many-to-one.test.ts | 31 +++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 tests/regressions/353-update-non-unique-one-relation.test.ts diff --git a/src/relation.ts b/src/relation.ts index fcf9fd9..7bcf337 100644 --- a/src/relation.ts +++ b/src/relation.ts @@ -199,6 +199,14 @@ export abstract class Relation { path.every((key, index) => key === update.path[index]) && !isRecord(update.nextValue) ) { + /** + * @note Listeners are attached per-record but fire for every owner update. + * Skip events whose target record's relation isn't this instance. + */ + if (update.prevRecord[kRelationMap].get(serializedPath) !== this) { + return + } + event.preventDefault() event.stopImmediatePropagation() @@ -231,6 +239,14 @@ export abstract class Relation { const update = event.data if (isEqual(update.path, path) && isRecord(update.nextValue)) { + /** + * @note Listeners are attached per-record but fire for every owner update. + * Skip events whose target record's relation isn't this instance. + */ + if (update.prevRecord[kRelationMap].get(serializedPath) !== this) { + return + } + event.preventDefault() // If the owner relation is "one-of", multiple foreign records cannot own this record. diff --git a/tests/regressions/353-update-non-unique-one-relation.test.ts b/tests/regressions/353-update-non-unique-one-relation.test.ts new file mode 100644 index 0000000..1d17074 --- /dev/null +++ b/tests/regressions/353-update-non-unique-one-relation.test.ts @@ -0,0 +1,31 @@ +import { z } from 'zod' +import { Collection } from '#/src/collection.js' + +it("scopes nested updates to the updated owner's foreign record", async () => { + const countrySchema = z.object({ code: z.string() }) + const userSchema = z.object({ + name: z.string(), + country: countrySchema, + }) + + const countries = new Collection({ schema: countrySchema }) + const users = new Collection({ schema: userSchema }) + + users.defineRelations(({ one }) => ({ + country: one(countries), + })) + + const us = await countries.create({ code: 'us' }) + const ca = await countries.create({ code: 'ca' }) + + const userOne = await users.create({ name: 'John', country: us }) + await users.create({ name: 'Kate', country: ca }) + + await users.update(userOne, { + data(user) { + user.country.code = 'uk' + }, + }) + + expect(countries.all()).toEqual([{ code: 'uk' }, { code: 'ca' }]) +}) diff --git a/tests/relations/many-to-one.test.ts b/tests/relations/many-to-one.test.ts index 9c92194..a1cec50 100644 --- a/tests/relations/many-to-one.test.ts +++ b/tests/relations/many-to-one.test.ts @@ -35,3 +35,34 @@ it('supports many-to-one relations', async () => { .soft(posts.findFirst((q) => q.where({ title: 'Second' }))) .toEqual({ title: 'Second', author: { id: 1 } }) }) + +it('scopes a many-to-one relation update to the targeted record', async () => { + const users = new Collection({ schema: userSchema }) + const posts = new Collection({ schema: postSchema }) + + posts.defineRelations(({ one }) => ({ + author: one(users), + })) + + const userOne = await users.create({ id: 1 }) + const userTwo = await users.create({ id: 2 }) + + const firstPost = await posts.create({ title: 'First', author: userOne }) + await posts.create({ title: 'Second', author: userTwo }) + + await posts.update(firstPost, { + data(post) { + post.author = userTwo + }, + }) + await posts.update(firstPost, { + data(post) { + post.author = userOne + }, + }) + + expect(posts.all()).toEqual([ + { title: 'First', author: { id: 1 } }, + { title: 'Second', author: { id: 2 } }, + ]) +}) From a5fcaafda8c4a9e20f48378ba533b252d9c68856 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 19 Apr 2026 14:46:54 +0200 Subject: [PATCH 4/5] chore: move regression tests into relation test suite --- ...353-non-unique-one-relation-update.test.ts | 34 ----------- ...353-update-non-unique-one-relation.test.ts | 31 ---------- tests/relations/many-to-one.test.ts | 61 +++++++++++++++++++ 3 files changed, 61 insertions(+), 65 deletions(-) delete mode 100644 tests/regressions/353-non-unique-one-relation-update.test.ts delete mode 100644 tests/regressions/353-update-non-unique-one-relation.test.ts diff --git a/tests/regressions/353-non-unique-one-relation-update.test.ts b/tests/regressions/353-non-unique-one-relation-update.test.ts deleted file mode 100644 index 91fc359..0000000 --- a/tests/regressions/353-non-unique-one-relation-update.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { z } from 'zod' -import { Collection } from '#/src/collection.js' - -it('updates a non-unique one relation to a foreign record already associated with another owner', async () => { - const languageSchema = z.object({ code: z.string() }) - const userSchema = z.object({ - name: z.string(), - language: languageSchema, - }) - - const languages = new Collection({ schema: languageSchema }) - const users = new Collection({ schema: userSchema }) - - users.defineRelations(({ one }) => ({ - language: one(languages, { unique: false }), - })) - - const langPt = await languages.create({ code: 'pt' }) - const langEn = await languages.create({ code: 'en' }) - - const userOne = await users.create({ name: 'User One', language: langPt }) - await users.create({ name: 'User Two', language: langEn }) - - await users.update(userOne, { - data(user) { - user.language = langEn - }, - }) - - expect(users.all()).toEqual([ - { name: 'User One', language: { code: 'en' } }, - { name: 'User Two', language: { code: 'en' } }, - ]) -}) diff --git a/tests/regressions/353-update-non-unique-one-relation.test.ts b/tests/regressions/353-update-non-unique-one-relation.test.ts deleted file mode 100644 index 1d17074..0000000 --- a/tests/regressions/353-update-non-unique-one-relation.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { z } from 'zod' -import { Collection } from '#/src/collection.js' - -it("scopes nested updates to the updated owner's foreign record", async () => { - const countrySchema = z.object({ code: z.string() }) - const userSchema = z.object({ - name: z.string(), - country: countrySchema, - }) - - const countries = new Collection({ schema: countrySchema }) - const users = new Collection({ schema: userSchema }) - - users.defineRelations(({ one }) => ({ - country: one(countries), - })) - - const us = await countries.create({ code: 'us' }) - const ca = await countries.create({ code: 'ca' }) - - const userOne = await users.create({ name: 'John', country: us }) - await users.create({ name: 'Kate', country: ca }) - - await users.update(userOne, { - data(user) { - user.country.code = 'uk' - }, - }) - - expect(countries.all()).toEqual([{ code: 'uk' }, { code: 'ca' }]) -}) diff --git a/tests/relations/many-to-one.test.ts b/tests/relations/many-to-one.test.ts index a1cec50..4f5a08f 100644 --- a/tests/relations/many-to-one.test.ts +++ b/tests/relations/many-to-one.test.ts @@ -66,3 +66,64 @@ it('scopes a many-to-one relation update to the targeted record', async () => { { title: 'Second', author: { id: 2 } }, ]) }) + +it('updates a many-to-one relation to a foreign record already associated with another owner', async () => { + const languageSchema = z.object({ code: z.string() }) + const userSchema = z.object({ + name: z.string(), + language: languageSchema, + }) + + const languages = new Collection({ schema: languageSchema }) + const users = new Collection({ schema: userSchema }) + + users.defineRelations(({ one }) => ({ + language: one(languages, { unique: false }), + })) + + const langPt = await languages.create({ code: 'pt' }) + const langEn = await languages.create({ code: 'en' }) + + const userOne = await users.create({ name: 'John', language: langPt }) + await users.create({ name: 'Kate', language: langEn }) + + await users.update(userOne, { + data(user) { + user.language = langEn + }, + }) + + expect(users.all()).toEqual([ + { name: 'John', language: { code: 'en' } }, + { name: 'Kate', language: { code: 'en' } }, + ]) +}) + +it("scopes nested updates to the updated owner's foreign record", async () => { + const countrySchema = z.object({ code: z.string() }) + const userSchema = z.object({ + name: z.string(), + country: countrySchema, + }) + + const countries = new Collection({ schema: countrySchema }) + const users = new Collection({ schema: userSchema }) + + users.defineRelations(({ one }) => ({ + country: one(countries), + })) + + const us = await countries.create({ code: 'us' }) + const ca = await countries.create({ code: 'ca' }) + + const userOne = await users.create({ name: 'John', country: us }) + await users.create({ name: 'Kate', country: ca }) + + await users.update(userOne, { + data(user) { + user.country.code = 'uk' + }, + }) + + expect(countries.all()).toEqual([{ code: 'uk' }, { code: 'ca' }]) +}) From c3c3e186c93a7fe06975b9097110a881832cbaff Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 19 Apr 2026 14:54:18 +0200 Subject: [PATCH 5/5] test: add missing test cases --- tests/relations/many-to-many.test.ts | 26 +++++++++++++ tests/relations/one-to-many.test.ts | 26 +++++++++++++ tests/relations/one-to-one.test.ts | 55 ++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+) diff --git a/tests/relations/many-to-many.test.ts b/tests/relations/many-to-many.test.ts index b91a6f7..a36f714 100644 --- a/tests/relations/many-to-many.test.ts +++ b/tests/relations/many-to-many.test.ts @@ -96,3 +96,29 @@ it('respects updates of foreign records', async () => { expect(firstPost.authors).toEqual([firstUser, updatedSecondUser]) expect(secondPost.authors).toEqual([firstUser, updatedSecondUser]) }) + +it('scopes a nested many-to-many relation update to the targeted record', async () => { + const users = new Collection({ schema: userSchema }) + const posts = new Collection({ schema: postSchema }) + + users.defineRelations(({ many }) => ({ + posts: many(posts), + })) + posts.defineRelations(({ many }) => ({ + authors: many(users), + })) + + const firstPost = await posts.create({ title: 'First' }) + const secondPost = await posts.create({ title: 'Second' }) + + const firstUser = await users.create({ id: 1, posts: [firstPost] }) + await users.create({ id: 2, posts: [secondPost] }) + + await users.update(firstUser, { + data(user) { + user.posts[0]!.title = 'Updated' + }, + }) + + expect(posts.all().map((post) => post.title)).toEqual(['Updated', 'Second']) +}) diff --git a/tests/relations/one-to-many.test.ts b/tests/relations/one-to-many.test.ts index bcc6a2b..8525bf7 100644 --- a/tests/relations/one-to-many.test.ts +++ b/tests/relations/one-to-many.test.ts @@ -453,3 +453,29 @@ it('errors when creating a unique relation with already associated foreign recor ) } }) + +it('scopes a nested one-to-many relation update to the targeted record', async () => { + const users = new Collection({ schema: userSchema }) + const posts = new Collection({ schema: postSchema }) + + users.defineRelations(({ many }) => ({ + posts: many(posts), + })) + + const firstUser = await users.create({ + id: 1, + posts: [await posts.create({ title: 'First' })], + }) + await users.create({ + id: 2, + posts: [await posts.create({ title: 'Second' })], + }) + + await users.update(firstUser, { + data(user) { + user.posts[0]!.title = 'Updated' + }, + }) + + expect(posts.all().map((post) => post.title)).toEqual(['Updated', 'Second']) +}) diff --git a/tests/relations/one-to-one.test.ts b/tests/relations/one-to-one.test.ts index 7dc3626..2db9cd3 100644 --- a/tests/relations/one-to-one.test.ts +++ b/tests/relations/one-to-one.test.ts @@ -562,3 +562,58 @@ it('errors when updating a unique two-way relation referencing a taken foreign r ), ) }) + +it('scopes a one-to-one relation update to the targeted record', async () => { + const users = new Collection({ schema: userSchema }) + const countries = new Collection({ schema: countrySchema }) + + users.defineRelations(({ one }) => ({ + country: one(countries), + })) + + await users.create({ + id: 1, + country: await countries.create({ code: 'us' }), + }) + await users.create({ + id: 2, + country: await countries.create({ code: 'ca' }), + }) + + await users.update((q) => q.where({ id: 1 }), { + async data(user) { + user.country = await countries.create({ code: 'uk' }) + }, + }) + + expect(users.all()).toEqual([ + { id: 1, country: { code: 'uk' } }, + { id: 2, country: { code: 'ca' } }, + ]) +}) + +it('scopes a nested one-to-one relation update to the targeted record', async () => { + const users = new Collection({ schema: userSchema }) + const countries = new Collection({ schema: countrySchema }) + + users.defineRelations(({ one }) => ({ + country: one(countries), + })) + + await users.create({ + id: 1, + country: await countries.create({ code: 'us' }), + }) + await users.create({ + id: 2, + country: await countries.create({ code: 'ca' }), + }) + + await users.update((q) => q.where({ id: 1 }), { + data(user) { + user.country.code = 'uk' + }, + }) + + expect(countries.all()).toEqual([{ code: 'uk' }, { code: 'ca' }]) +})