Skip to content

Commit 9f9845c

Browse files
authored
fix: clear relation listeners to prevent leaks (#356)
1 parent fa1c709 commit 9f9845c

3 files changed

Lines changed: 207 additions & 134 deletions

File tree

src/extensions/sync.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ export function sync() {
139139
logger.log('updating record...')
140140

141141
await performWithoutBroadcasting(async () => {
142-
collection.update(
142+
await collection.update(
143143
(q: Query<any>) =>
144144
q.where((record: RecordType) => {
145145
return record[kPrimaryKey] === data.primaryKey

src/relation.ts

Lines changed: 172 additions & 133 deletions
Original file line numberDiff line numberDiff line change
@@ -141,47 +141,75 @@ export abstract class Relation {
141141
// This way, each record has its own instance of a stateful relation.
142142
this.#initializeRelation(path, record, initialValues)
143143

144+
/**
145+
* @note Tear down all hook listeners registered below once the owner
146+
* record is deleted. Aborting the signal removes the listeners from
147+
* the underlying emitter — including the `delete` listener itself.
148+
*/
149+
const abortController = new AbortController()
150+
151+
this.ownerCollection.hooks.on(
152+
'delete',
153+
(event) => {
154+
if (
155+
event.data.deletedRecord[kRelationMap].get(serializedPath) === this
156+
) {
157+
abortController.abort()
158+
}
159+
},
160+
{ signal: abortController.signal },
161+
)
162+
144163
for (const foreignCollection of this.foreignCollections) {
145164
// Update the owner relations when a foreign record is created
146165
// referencing the owner record.
147-
foreignCollection.hooks.on('create', (event) => {
148-
const { record: foreignRecord } = event.data
149-
const foreignRelations = this.getRelationsToOwner(foreignRecord)
150-
151-
for (const foreignRelation of foreignRelations) {
152-
const ownerRecords = this.ownerCollection.findMany((q) =>
153-
q.where((record) => {
154-
return foreignRelation.foreignKeys.has(record[kPrimaryKey])
155-
}),
156-
)
166+
foreignCollection.hooks.on(
167+
'create',
168+
(event) => {
169+
const { record: foreignRecord } = event.data
170+
const foreignRelations = this.getRelationsToOwner(foreignRecord)
171+
172+
for (const foreignRelation of foreignRelations) {
173+
const ownerRecords = this.ownerCollection.findMany((q) =>
174+
q.where((record) => {
175+
return foreignRelation.foreignKeys.has(record[kPrimaryKey])
176+
}),
177+
)
157178

158-
for (const ownerRecord of ownerRecords) {
159-
const ownerRelation = ownerRecord[kRelationMap].get(serializedPath)
160-
ownerRelation.foreignKeys.add(foreignRecord[kPrimaryKey])
179+
for (const ownerRecord of ownerRecords) {
180+
const ownerRelation =
181+
ownerRecord[kRelationMap].get(serializedPath)
182+
ownerRelation.foreignKeys.add(foreignRecord[kPrimaryKey])
183+
}
161184
}
162-
}
163-
})
185+
},
186+
{ signal: abortController.signal },
187+
)
164188

165189
// Clear the references to deleted foreign records.
166-
foreignCollection.hooks.on('delete', (event) => {
167-
const { deletedRecord: deletedForeignRecord } = event.data
168-
this.foreignKeys.delete(deletedForeignRecord[kPrimaryKey])
169-
170-
// Delete all the owners referencing the deleted foreign record
171-
// if the relation is set to cascade on delete.
172-
if (this.options.onDelete === 'cascade') {
173-
const foreignRelations =
174-
this.getRelationsToOwner(deletedForeignRecord)
175-
176-
this.ownerCollection.deleteMany((q) => {
177-
return q.where((record) => {
178-
return foreignRelations.some((foreignRelation) => {
179-
return foreignRelation.foreignKeys.has(record[kPrimaryKey])
190+
foreignCollection.hooks.on(
191+
'delete',
192+
(event) => {
193+
const { deletedRecord: deletedForeignRecord } = event.data
194+
this.foreignKeys.delete(deletedForeignRecord[kPrimaryKey])
195+
196+
// Delete all the owners referencing the deleted foreign record
197+
// if the relation is set to cascade on delete.
198+
if (this.options.onDelete === 'cascade') {
199+
const foreignRelations =
200+
this.getRelationsToOwner(deletedForeignRecord)
201+
202+
this.ownerCollection.deleteMany((q) => {
203+
return q.where((record) => {
204+
return foreignRelations.some((foreignRelation) => {
205+
return foreignRelation.foreignKeys.has(record[kPrimaryKey])
206+
})
180207
})
181208
})
182-
})
183-
}
184-
})
209+
}
210+
},
211+
{ signal: abortController.signal },
212+
)
185213
}
186214

187215
/**
@@ -192,126 +220,137 @@ export abstract class Relation {
192220
* @example
193221
* await users.update(q, { data: { country: { code: 'uk' } } })
194222
*/
195-
this.ownerCollection.hooks.earlyOn('update', (event) => {
196-
const update = event.data
197-
198-
if (
199-
path.every((key, index) => key === update.path[index]) &&
200-
!isRecord(update.nextValue)
201-
) {
202-
/**
203-
* @note Listeners are attached per-record but fire for every owner update.
204-
* Skip events whose target record's relation isn't this instance.
205-
*/
206-
if (update.prevRecord[kRelationMap].get(serializedPath) !== this) {
207-
return
208-
}
223+
this.ownerCollection.hooks.earlyOn(
224+
'update',
225+
(event) => {
226+
const update = event.data
227+
228+
if (
229+
path.every((key, index) => key === update.path[index]) &&
230+
!isRecord(update.nextValue)
231+
) {
232+
/**
233+
* @note Listeners are attached per-record but fire for every owner update.
234+
* Skip events whose target record's relation isn't this instance.
235+
*/
236+
if (update.prevRecord[kRelationMap].get(serializedPath) !== this) {
237+
return
238+
}
209239

210-
event.preventDefault()
211-
event.stopImmediatePropagation()
240+
event.preventDefault()
241+
event.stopImmediatePropagation()
212242

213-
const foreignUpdatePath = update.path.slice(path.length)
243+
const foreignUpdatePath = update.path.slice(path.length)
214244

215-
for (const foreignCollection of this.foreignCollections) {
216-
foreignCollection.updateMany(
217-
(q) => {
218-
return q.where((record) => {
219-
return this.foreignKeys.has(record[kPrimaryKey])
220-
})
221-
},
222-
{
223-
data(foreignRecord) {
224-
set(foreignRecord, foreignUpdatePath, update.nextValue)
245+
for (const foreignCollection of this.foreignCollections) {
246+
foreignCollection.updateMany(
247+
(q) => {
248+
return q.where((record) => {
249+
return this.foreignKeys.has(record[kPrimaryKey])
250+
})
225251
},
226-
},
227-
)
252+
{
253+
data(foreignRecord) {
254+
set(foreignRecord, foreignUpdatePath, update.nextValue)
255+
},
256+
},
257+
)
258+
}
228259
}
229-
}
230-
})
260+
},
261+
{ signal: abortController.signal },
262+
)
231263

232264
/**
233265
* Handle owner updates where the relational property changes to another foreign record.
234266
*
235267
* @example
236268
* await users.update(q, { data: { country: await countries.create({}) } })
237269
*/
238-
this.ownerCollection.hooks.on('update', (event) => {
239-
const update = event.data
240-
241-
if (isEqual(update.path, path) && isRecord(update.nextValue)) {
242-
/**
243-
* @note Listeners are attached per-record but fire for every owner update.
244-
* Skip events whose target record's relation isn't this instance.
245-
*/
246-
if (update.prevRecord[kRelationMap].get(serializedPath) !== this) {
247-
return
248-
}
249-
250-
event.preventDefault()
251-
252-
// If the owner relation is "one-of", multiple foreign records cannot own this record.
253-
// Disassociate the old foreign records from pointing to the owner record.
254-
if (this instanceof One) {
255-
const oldForeignRecords = this.foreignCollections.flatMap<RecordType>(
256-
(foreignCollection) => {
257-
return foreignCollection.findMany((q) => {
258-
return q.where((record) => {
259-
return this.foreignKeys.has(record[kPrimaryKey])
260-
})
261-
})
262-
},
263-
)
264-
265-
const foreignRelationsToDisassociate = oldForeignRecords.flatMap(
266-
(record) => this.getRelationsToOwner(record),
267-
)
268-
269-
// Throw if attempting to disassociate unique relations.
270-
if (this.options.unique) {
271-
invariant.as(
272-
RelationError.for(
273-
RelationErrorCodes.FORBIDDEN_UNIQUE_UPDATE,
274-
this.#createErrorDetails(),
275-
),
276-
foreignRelationsToDisassociate.length === 0,
277-
'Failed to update a unique relation at "%s": the foreign record is already associated with another owner',
278-
update.path.join('.'),
279-
)
280-
}
281-
282-
for (const foreignRelation of foreignRelationsToDisassociate) {
283-
foreignRelation.foreignKeys.delete(update.prevRecord[kPrimaryKey])
270+
this.ownerCollection.hooks.on(
271+
'update',
272+
(event) => {
273+
const update = event.data
274+
275+
if (isEqual(update.path, path) && isRecord(update.nextValue)) {
276+
/**
277+
* @note Listeners are attached per-record but fire for every owner update.
278+
* Skip events whose target record's relation isn't this instance.
279+
*/
280+
if (update.prevRecord[kRelationMap].get(serializedPath) !== this) {
281+
return
284282
}
285283

286-
// Check any other owners associated with the same foreign record.
287-
// This is important since unique relations are not always two-way.
288-
if (this.options.unique) {
289-
const otherOwnersAssociatedWithForeignRecord =
290-
this.#getOtherOwnerForRecords([update.nextValue])
291-
292-
invariant.as(
293-
RelationError.for(
294-
RelationErrorCodes.FORBIDDEN_UNIQUE_UPDATE,
295-
this.#createErrorDetails(),
296-
),
297-
otherOwnersAssociatedWithForeignRecord == null,
298-
'Failed to update a unique relation at "%s": the foreign record is already associated with another owner',
299-
update.path.join('.'),
284+
event.preventDefault()
285+
286+
// If the owner relation is "one-of", multiple foreign records cannot own this record.
287+
// Disassociate the old foreign records from pointing to the owner record.
288+
if (this instanceof One) {
289+
const oldForeignRecords =
290+
this.foreignCollections.flatMap<RecordType>(
291+
(foreignCollection) => {
292+
return foreignCollection.findMany((q) => {
293+
return q.where((record) => {
294+
return this.foreignKeys.has(record[kPrimaryKey])
295+
})
296+
})
297+
},
298+
)
299+
300+
const foreignRelationsToDisassociate = oldForeignRecords.flatMap(
301+
(record) => this.getRelationsToOwner(record),
300302
)
301-
}
302303

303-
this.foreignKeys.clear()
304-
}
304+
// Throw if attempting to disassociate unique relations.
305+
if (this.options.unique) {
306+
invariant.as(
307+
RelationError.for(
308+
RelationErrorCodes.FORBIDDEN_UNIQUE_UPDATE,
309+
this.#createErrorDetails(),
310+
),
311+
foreignRelationsToDisassociate.length === 0,
312+
'Failed to update a unique relation at "%s": the foreign record is already associated with another owner',
313+
update.path.join('.'),
314+
)
315+
}
316+
317+
for (const foreignRelation of foreignRelationsToDisassociate) {
318+
foreignRelation.foreignKeys.delete(update.prevRecord[kPrimaryKey])
319+
}
320+
321+
// Check any other owners associated with the same foreign record.
322+
// This is important since unique relations are not always two-way.
323+
if (this.options.unique) {
324+
const otherOwnersAssociatedWithForeignRecord =
325+
this.#getOtherOwnerForRecords([update.nextValue])
326+
327+
invariant.as(
328+
RelationError.for(
329+
RelationErrorCodes.FORBIDDEN_UNIQUE_UPDATE,
330+
this.#createErrorDetails(),
331+
),
332+
otherOwnersAssociatedWithForeignRecord == null,
333+
'Failed to update a unique relation at "%s": the foreign record is already associated with another owner',
334+
update.path.join('.'),
335+
)
336+
}
337+
338+
this.foreignKeys.clear()
339+
}
305340

306-
// Associate the owner with a foreign record from the update data.
307-
const foreignRecord = update.nextValue
308-
this.foreignKeys.add(foreignRecord[kPrimaryKey])
341+
// Associate the owner with a foreign record from the update data.
342+
const foreignRecord = update.nextValue
343+
this.foreignKeys.add(foreignRecord[kPrimaryKey])
309344

310-
for (const foreignRelation of this.getRelationsToOwner(foreignRecord)) {
311-
foreignRelation.foreignKeys.add(update.prevRecord[kPrimaryKey])
345+
for (const foreignRelation of this.getRelationsToOwner(
346+
foreignRecord,
347+
)) {
348+
foreignRelation.foreignKeys.add(update.prevRecord[kPrimaryKey])
349+
}
312350
}
313-
}
314-
})
351+
},
352+
{ signal: abortController.signal },
353+
)
315354
}
316355

317356
public abstract resolve(foreignKeys: Set<string>): unknown

tests/relations/one-to-one.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,3 +617,37 @@ it('scopes a nested one-to-one relation update to the targeted record', async ()
617617

618618
expect(countries.all()).toEqual([{ code: 'uk' }, { code: 'ca' }])
619619
})
620+
621+
it('removes relation listeners when the owner record is deleted', async () => {
622+
const users = new Collection({ schema: userSchema })
623+
const countries = new Collection({ schema: countrySchema })
624+
625+
users.defineRelations(({ one }) => ({
626+
country: one(countries),
627+
}))
628+
629+
const totalListeners = () =>
630+
countries.hooks.listenerCount('create') +
631+
countries.hooks.listenerCount('delete') +
632+
users.hooks.listenerCount('update') +
633+
users.hooks.listenerCount('delete')
634+
635+
const baseline = totalListeners()
636+
637+
const user = await users.create({
638+
id: 1,
639+
country: await countries.create({ code: 'us' }),
640+
})
641+
642+
expect(
643+
totalListeners(),
644+
'Attaches relation listeners when the owner record is created',
645+
).toBeGreaterThan(baseline)
646+
647+
users.delete(user)
648+
649+
expect(
650+
totalListeners(),
651+
'Detaches relation listeners when the owner record is deleted',
652+
).toBe(baseline)
653+
})

0 commit comments

Comments
 (0)