Fix bug in queue cleanup

This commit is contained in:
Andrey Sobolev
2025-10-08 11:45:09 +07:00
parent 64bc030f36
commit 5b28972767
7 changed files with 2135 additions and 1 deletions
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@hcengineering/query",
"comment": "Fix bug in queue cleanup and add more tests to it",
"type": "patch"
}
],
"packageName": "@hcengineering/query"
}
@@ -0,0 +1,488 @@
// Advanced tests for complex LiveQuery scenarios
//
// Copyright © 2024 Hardcore Engineering Inc.
//
import core, { createClient, Ref, SortingOrder, TxOperations } from '@hcengineering/core'
import { LiveQuery } from '..'
import { connect } from './connection'
async function getClient (): Promise<{ liveQuery: LiveQuery, factory: TxOperations, close: () => Promise<void> }> {
const storage = await createClient(connect)
const liveQuery = new LiveQuery(storage)
storage.notify = (...tx) => {
void liveQuery.tx(...tx)
}
return {
liveQuery,
factory: new TxOperations(storage, core.account.System),
close: async () => {
await liveQuery.close()
}
}
}
describe('LiveQuery Advanced Coverage Tests', () => {
it('should handle complex sorting scenarios', async () => {
const { liveQuery, factory, close } = await getClient()
// Create multiple documents
await factory.createDoc(core.class.Space, core.space.Model, {
name: 'Z-space',
description: 'last',
private: false,
members: [],
archived: false
})
await factory.createDoc(core.class.Space, core.space.Model, {
name: 'A-space',
description: 'first',
private: false,
members: [],
archived: false
})
await factory.createDoc(core.class.Space, core.space.Model, {
name: 'M-space',
description: 'middle',
private: false,
members: [],
archived: false
})
const callback = jest.fn()
// Query with sorting
liveQuery.query(core.class.Space, {}, callback, { sort: { name: SortingOrder.Ascending }, limit: 10 })
await new Promise((resolve) => setTimeout(resolve, 100))
expect(callback).toHaveBeenCalled()
const lastResult = callback.mock.calls[callback.mock.calls.length - 1][0]
expect(lastResult.length).toBeGreaterThan(0)
await close()
})
it('should handle complex query with multiple conditions', async () => {
const { liveQuery, factory, close } = await getClient()
const spaces = []
for (let i = 0; i < 5; i++) {
const space = await factory.createDoc(core.class.Space, core.space.Model, {
name: `multi-${i}`,
description: `desc-${i}`,
private: i % 2 === 0,
members: [],
archived: i > 3
})
spaces.push(space)
}
const callback = jest.fn()
// Complex query
liveQuery.query(core.class.Space, { private: false, archived: false }, callback)
await new Promise((resolve) => setTimeout(resolve, 100))
expect(callback).toHaveBeenCalled()
await close()
})
it('should handle findAll with complex options', async () => {
const { liveQuery, factory, close } = await getClient()
await factory.createDoc(core.class.Space, core.space.Model, {
name: 'findall-1',
description: 'test',
private: false,
members: [],
archived: false
})
await factory.createDoc(core.class.Space, core.space.Model, {
name: 'findall-2',
description: 'test',
private: false,
members: [],
archived: false
})
// Use findAll with various options
const result = await liveQuery.findAll(
core.class.Space,
{ private: false },
{
limit: 10,
sort: { modifiedOn: SortingOrder.Descending }
}
)
expect(result.length).toBeGreaterThan(0)
await close()
})
it('should handle rapid document creation and updates', async () => {
const { liveQuery, factory, close } = await getClient()
const callback = jest.fn()
liveQuery.query(core.class.Space, { private: false }, callback)
await new Promise((resolve) => setTimeout(resolve, 50))
// Rapid creation
const spaces: Array<Ref<any>> = []
for (let i = 0; i < 10; i++) {
const space = await factory.createDoc(core.class.Space, core.space.Model, {
name: `rapid-${i}`,
description: 'test',
private: false,
members: [],
archived: false
})
spaces.push(space)
}
await new Promise((resolve) => setTimeout(resolve, 100))
// Rapid updates
for (const space of spaces) {
await factory.updateDoc(core.class.Space, core.space.Model, space, {
description: 'updated'
})
}
await new Promise((resolve) => setTimeout(resolve, 100))
expect(callback.mock.calls.length).toBeGreaterThan(1)
await close()
})
it('should handle document removal from live query', async () => {
const { liveQuery, factory, close } = await getClient()
const space1 = await factory.createDoc(core.class.Space, core.space.Model, {
name: 'remove-1',
description: 'test',
private: false,
members: [],
archived: false
})
const callback = jest.fn()
liveQuery.query(core.class.Space, { _id: space1 }, callback, { total: true })
await new Promise((resolve) => setTimeout(resolve, 100))
expect(callback).toHaveBeenCalled()
callback.mockClear()
// Remove the document
await factory.removeDoc(core.class.Space, core.space.Model, space1)
await new Promise((resolve) => setTimeout(resolve, 100))
// Callback should be called with updated results
expect(callback).toHaveBeenCalled()
await close()
})
it('should handle query with $in operator', async () => {
const { liveQuery, factory, close } = await getClient()
const space1 = await factory.createDoc(core.class.Space, core.space.Model, {
name: 'in-1',
description: 'test',
private: false,
members: [],
archived: false
})
const space2 = await factory.createDoc(core.class.Space, core.space.Model, {
name: 'in-2',
description: 'test',
private: false,
members: [],
archived: false
})
const callback = jest.fn()
liveQuery.query(core.class.Space, { _id: { $in: [space1, space2] } }, callback)
await new Promise((resolve) => setTimeout(resolve, 100))
expect(callback).toHaveBeenCalled()
await close()
})
it('should handle nested document updates', async () => {
const { liveQuery, factory, close } = await getClient()
const space = await factory.createDoc(core.class.Space, core.space.Model, {
name: 'nested-update',
description: 'original',
private: false,
members: [],
archived: false
})
const callback = jest.fn()
liveQuery.query(core.class.Space, { _id: space }, callback)
await new Promise((resolve) => setTimeout(resolve, 100))
callback.mockClear()
// Multiple rapid updates
await factory.updateDoc(core.class.Space, core.space.Model, space, {
description: 'update-1'
})
await factory.updateDoc(core.class.Space, core.space.Model, space, {
description: 'update-2'
})
await factory.updateDoc(core.class.Space, core.space.Model, space, {
description: 'update-3'
})
await new Promise((resolve) => setTimeout(resolve, 150))
expect(callback).toHaveBeenCalled()
await close()
})
it('should handle query with empty results', async () => {
const { liveQuery, close } = await getClient()
const callback = jest.fn()
liveQuery.query(core.class.Space, { name: 'non-existent-document-name-12345' }, callback)
await new Promise((resolve) => setTimeout(resolve, 100))
expect(callback).toHaveBeenCalled()
const result = callback.mock.calls[0][0]
expect(result.length).toBe(0)
await close()
})
it('should handle concurrent queries on same data', async () => {
const { liveQuery, factory, close } = await getClient()
await factory.createDoc(core.class.Space, core.space.Model, {
name: 'concurrent-test',
description: 'test',
private: false,
members: [],
archived: false
})
const callback1 = jest.fn()
const callback2 = jest.fn()
const callback3 = jest.fn()
// Start multiple queries simultaneously
liveQuery.query(core.class.Space, { private: false }, callback1, { limit: 5 })
liveQuery.query(core.class.Space, { private: false }, callback2, { limit: 10 })
liveQuery.query(core.class.Space, { private: false }, callback3)
await new Promise((resolve) => setTimeout(resolve, 100))
expect(callback1).toHaveBeenCalled()
expect(callback2).toHaveBeenCalled()
expect(callback3).toHaveBeenCalled()
await close()
})
it('should handle total count in queries', async () => {
const { liveQuery, factory, close } = await getClient()
for (let i = 0; i < 5; i++) {
await factory.createDoc(core.class.Space, core.space.Model, {
name: `total-${i}`,
description: 'test',
private: false,
members: [],
archived: false
})
}
const callback = jest.fn()
liveQuery.query(core.class.Space, { private: false }, callback, { limit: 2, total: true })
await new Promise((resolve) => setTimeout(resolve, 100))
expect(callback).toHaveBeenCalled()
const result = callback.mock.calls[callback.mock.calls.length - 1][0]
expect(result.total).toBeGreaterThanOrEqual(5)
await close()
})
it('should handle query with archived documents', async () => {
const { liveQuery, factory, close } = await getClient()
await factory.createDoc(core.class.Space, core.space.Model, {
name: 'archived-1',
description: 'test',
private: false,
members: [],
archived: true
})
await factory.createDoc(core.class.Space, core.space.Model, {
name: 'active-1',
description: 'test',
private: false,
members: [],
archived: false
})
const callback = jest.fn()
// Query for non-archived
liveQuery.query(core.class.Space, { archived: false }, callback)
await new Promise((resolve) => setTimeout(resolve, 100))
expect(callback).toHaveBeenCalled()
await close()
})
it('should handle updating query multiple times', async () => {
const { liveQuery, factory, close } = await getClient()
const space = await factory.createDoc(core.class.Space, core.space.Model, {
name: 'multi-update',
description: 'v1',
private: false,
members: [],
archived: false
})
const callback = jest.fn()
liveQuery.query(core.class.Space, { _id: space }, callback)
await new Promise((resolve) => setTimeout(resolve, 50))
// Update multiple times
for (let i = 2; i <= 5; i++) {
await factory.updateDoc(core.class.Space, core.space.Model, space, {
description: `v${i}`
})
await new Promise((resolve) => setTimeout(resolve, 30))
}
await new Promise((resolve) => setTimeout(resolve, 100))
// Should have been called multiple times
expect(callback.mock.calls.length).toBeGreaterThan(1)
await close()
})
it('should handle query unsubscribe and resubscribe cycle', async () => {
const { liveQuery, close } = await getClient()
const callback1 = jest.fn()
// Subscribe
const unsub1 = liveQuery.query(core.class.Space, {}, callback1)
await new Promise((resolve) => setTimeout(resolve, 50))
// Unsubscribe
unsub1()
await new Promise((resolve) => setTimeout(resolve, 50))
callback1.mockClear()
const callback2 = jest.fn()
// Resubscribe
liveQuery.query(core.class.Space, {}, callback2)
await new Promise((resolve) => setTimeout(resolve, 50))
expect(callback2).toHaveBeenCalled()
await close()
})
it('should handle findOne on non-existent document', async () => {
const { liveQuery, close } = await getClient()
const result = await liveQuery.findOne(core.class.Space, { _id: 'non-existent-id-12345' as Ref<any> })
expect(result).toBeUndefined()
await close()
})
it('should handle mixed operations on same query', async () => {
const { liveQuery, factory, close } = await getClient()
const spaces: Array<Ref<any>> = []
// Create initial documents
for (let i = 0; i < 3; i++) {
const space = await factory.createDoc(core.class.Space, core.space.Model, {
name: `mixed-${i}`,
description: 'test',
private: false,
members: [],
archived: false
})
spaces.push(space)
}
const callback = jest.fn()
liveQuery.query(core.class.Space, { private: false }, callback)
await new Promise((resolve) => setTimeout(resolve, 100))
// Update one
await factory.updateDoc(core.class.Space, core.space.Model, spaces[0], {
description: 'updated'
})
// Create new one
await factory.createDoc(core.class.Space, core.space.Model, {
name: 'mixed-new',
description: 'new',
private: false,
members: [],
archived: false
})
// Remove one
await factory.removeDoc(core.class.Space, core.space.Model, spaces[1])
await new Promise((resolve) => setTimeout(resolve, 150))
// Should have been notified multiple times
expect(callback.mock.calls.length).toBeGreaterThan(1)
await close()
})
})
@@ -0,0 +1,519 @@
// Final comprehensive tests targeting uncovered scenarios
//
// Copyright © 2024 Hardcore Engineering Inc.
//
import core, { createClient, Ref, SortingOrder, TxOperations } from '@hcengineering/core'
import { LiveQuery } from '..'
import { connect } from './connection'
import { test } from './minmodel'
async function getClient (): Promise<{ liveQuery: LiveQuery, factory: TxOperations, close: () => Promise<void> }> {
const storage = await createClient(connect)
const liveQuery = new LiveQuery(storage)
storage.notify = (...tx) => {
void liveQuery.tx(...tx)
}
return {
liveQuery,
factory: new TxOperations(storage, core.account.System),
close: async () => {
await liveQuery.close()
}
}
}
describe('LiveQuery Final Coverage Tests', () => {
it('should handle complex update with attached documents', async () => {
const { liveQuery, factory, close } = await getClient()
// Create a space
const space = await factory.createDoc(core.class.Space, core.space.Model, {
name: 'attach-test',
description: 'test',
private: false,
members: [],
archived: false
})
// Add attached comment
const comment = await factory.addCollection(test.class.TestComment, space, space, core.class.Space, 'comments', {
message: 'original'
})
const callback = jest.fn()
liveQuery.query(test.class.TestComment, { _id: comment }, callback)
await new Promise((resolve) => setTimeout(resolve, 100))
// Update the comment
await factory.updateCollection(test.class.TestComment, space, comment, space, core.class.Space, 'comments', {
message: 'updated'
})
await new Promise((resolve) => setTimeout(resolve, 100))
expect(callback.mock.calls.length).toBeGreaterThan(1)
await close()
})
it('should handle reverse lookup updates on attached documents', async () => {
const { liveQuery, factory, close } = await getClient()
const space = await factory.createDoc(core.class.Space, core.space.Model, {
name: 'reverse-lookup-test',
description: 'test',
private: false,
members: [],
archived: false
})
const parentComment = await factory.addCollection(
test.class.TestComment,
space,
space,
core.class.Space,
'comments',
{
message: 'parent'
}
)
const callback = jest.fn()
// Query with reverse lookup
liveQuery.query(test.class.TestComment, { _id: parentComment }, callback, {
lookup: {
_id: {
comments: test.class.TestComment
}
}
})
await new Promise((resolve) => setTimeout(resolve, 100))
// Add child comment
await factory.addCollection(test.class.TestComment, space, parentComment, test.class.TestComment, 'comments', {
message: 'child1'
})
await new Promise((resolve) => setTimeout(resolve, 100))
// Should have updated with new child
expect(callback.mock.calls.length).toBeGreaterThan(1)
await close()
})
it('should handle nested lookups with multiple levels', async () => {
const { liveQuery, factory, close } = await getClient()
const space = await factory.createDoc(core.class.Space, core.space.Model, {
name: 'nested-lookup',
description: 'test',
private: false,
members: [],
archived: false
})
const parentComment = await factory.addCollection(
test.class.TestComment,
space,
space,
core.class.Space,
'comments',
{
message: 'parent'
}
)
const childComment = await factory.addCollection(
test.class.TestComment,
space,
parentComment,
test.class.TestComment,
'comments',
{
message: 'child'
}
)
const callback = jest.fn()
// Nested lookup
liveQuery.query(test.class.TestComment, { _id: childComment }, callback, {
lookup: {
attachedTo: [test.class.TestComment, { space: core.class.Space }]
}
})
await new Promise((resolve) => setTimeout(resolve, 100))
expect(callback).toHaveBeenCalled()
await close()
})
it('should handle query with limit and sorting together', async () => {
const { liveQuery, factory, close } = await getClient()
// Create multiple documents
for (let i = 0; i < 10; i++) {
await factory.createDoc(core.class.Space, core.space.Model, {
name: `sorted-${String(i).padStart(2, '0')}`,
description: 'test',
private: false,
members: [],
archived: false
})
}
const callback = jest.fn()
// Query with limit and sort
liveQuery.query(core.class.Space, {}, callback, {
limit: 3,
sort: { name: SortingOrder.Ascending }
})
await new Promise((resolve) => setTimeout(resolve, 100))
expect(callback).toHaveBeenCalled()
const result = callback.mock.calls[callback.mock.calls.length - 1][0]
expect(result.length).toBeLessThanOrEqual(3)
await close()
})
it('should handle projection with lookup', async () => {
const { liveQuery, factory, close } = await getClient()
const space = await factory.createDoc(core.class.Space, core.space.Model, {
name: 'projection-lookup',
description: 'test description',
private: false,
members: [],
archived: false
})
const comment = await factory.addCollection(test.class.TestComment, space, space, core.class.Space, 'comments', {
message: 'test'
})
const callback = jest.fn()
liveQuery.query(test.class.TestComment, { _id: comment }, callback, {
projection: { message: 1, _id: 1 },
lookup: { space: core.class.Space }
})
await new Promise((resolve) => setTimeout(resolve, 100))
expect(callback).toHaveBeenCalled()
await close()
})
it('should handle updates to documents with limit queries', async () => {
const { liveQuery, factory, close } = await getClient()
const spaces: Array<Ref<any>> = []
// Create documents
for (let i = 0; i < 5; i++) {
const space = await factory.createDoc(core.class.Space, core.space.Model, {
name: `limit-${i}`,
description: 'test',
private: false,
members: [],
archived: false
})
spaces.push(space)
}
const callback = jest.fn()
// Query with limit
liveQuery.query(core.class.Space, {}, callback, { limit: 3 })
await new Promise((resolve) => setTimeout(resolve, 100))
// Update a document
await factory.updateDoc(core.class.Space, core.space.Model, spaces[0], {
description: 'updated'
})
await new Promise((resolve) => setTimeout(resolve, 100))
expect(callback.mock.calls.length).toBeGreaterThan(1)
await close()
})
it('should handle removing attached documents', async () => {
const { liveQuery, factory, close } = await getClient()
const space = await factory.createDoc(core.class.Space, core.space.Model, {
name: 'remove-attached',
description: 'test',
private: false,
members: [],
archived: false
})
const comment = await factory.addCollection(test.class.TestComment, space, space, core.class.Space, 'comments', {
message: 'to be removed'
})
const callback = jest.fn()
liveQuery.query(test.class.TestComment, { _id: comment }, callback)
await new Promise((resolve) => setTimeout(resolve, 100))
// Remove the comment
await factory.removeCollection(test.class.TestComment, space, comment, space, core.class.Space, 'comments')
await new Promise((resolve) => setTimeout(resolve, 100))
expect(callback.mock.calls.length).toBeGreaterThan(1)
await close()
})
it('should handle multiple simultaneous updates', async () => {
const { liveQuery, factory, close } = await getClient()
const spaces: Array<Ref<any>> = []
for (let i = 0; i < 3; i++) {
const space = await factory.createDoc(core.class.Space, core.space.Model, {
name: `multi-${i}`,
description: 'original',
private: false,
members: [],
archived: false
})
spaces.push(space)
}
const callback = jest.fn()
liveQuery.query(core.class.Space, {}, callback)
await new Promise((resolve) => setTimeout(resolve, 100))
const initialCalls = callback.mock.calls.length
// Simultaneous updates
await Promise.all(
spaces.map((space) =>
factory.updateDoc(core.class.Space, core.space.Model, space, {
description: 'updated'
})
)
)
await new Promise((resolve) => setTimeout(resolve, 150))
expect(callback.mock.calls.length).toBeGreaterThan(initialCalls)
await close()
})
it('should handle complex query conditions with $ne', async () => {
const { liveQuery, factory, close } = await getClient()
await factory.createDoc(core.class.Space, core.space.Model, {
name: 'not-this',
description: 'test',
private: false,
members: [],
archived: false
})
await factory.createDoc(core.class.Space, core.space.Model, {
name: 'this-one',
description: 'test',
private: false,
members: [],
archived: false
})
const callback = jest.fn()
liveQuery.query(core.class.Space, { name: { $ne: 'not-this' } }, callback)
await new Promise((resolve) => setTimeout(resolve, 100))
expect(callback).toHaveBeenCalled()
await close()
})
it('should handle attached document updates with reverse lookup', async () => {
const { liveQuery, factory, close } = await getClient()
const space = await factory.createDoc(core.class.Space, core.space.Model, {
name: 'reverse-update',
description: 'test',
private: false,
members: [],
archived: false
})
const parentComment = await factory.addCollection(
test.class.TestComment,
space,
space,
core.class.Space,
'comments',
{
message: 'parent'
}
)
const childComment = await factory.addCollection(
test.class.TestComment,
space,
parentComment,
test.class.TestComment,
'comments',
{
message: 'original child'
}
)
const callback = jest.fn()
liveQuery.query(test.class.TestComment, { _id: parentComment }, callback, {
lookup: {
_id: { comments: test.class.TestComment }
}
})
await new Promise((resolve) => setTimeout(resolve, 100))
// Update child comment
await factory.updateCollection(
test.class.TestComment,
space,
childComment,
parentComment,
test.class.TestComment,
'comments',
{
message: 'updated child'
}
)
await new Promise((resolve) => setTimeout(resolve, 100))
expect(callback.mock.calls.length).toBeGreaterThan(1)
await close()
})
it('should handle query reactivation from queue with updates', async () => {
const { liveQuery, factory, close } = await getClient()
const space = await factory.createDoc(core.class.Space, core.space.Model, {
name: 'reactivate',
description: 'test',
private: false,
members: [],
archived: false
})
const callback1 = jest.fn()
// Subscribe
const unsub = liveQuery.query(core.class.Space, { _id: space }, callback1)
await new Promise((resolve) => setTimeout(resolve, 50))
// Unsubscribe to move to queue
unsub()
await new Promise((resolve) => setTimeout(resolve, 50))
// Update while in queue
await factory.updateDoc(core.class.Space, core.space.Model, space, {
description: 'updated while queued'
})
await new Promise((resolve) => setTimeout(resolve, 50))
const callback2 = jest.fn()
// Reactivate from queue
liveQuery.query(core.class.Space, { _id: space }, callback2)
await new Promise((resolve) => setTimeout(resolve, 100))
expect(callback2).toHaveBeenCalled()
await close()
})
it('should handle findAll with total option', async () => {
const { liveQuery, factory, close } = await getClient()
// Create more docs than limit
for (let i = 0; i < 10; i++) {
await factory.createDoc(core.class.Space, core.space.Model, {
name: `total-${i}`,
description: 'test',
private: false,
members: [],
archived: false
})
}
const result = await liveQuery.findAll(
core.class.Space,
{},
{
limit: 5,
total: true
}
)
expect(result.length).toBe(5)
expect(result.total).toBeGreaterThanOrEqual(10)
await close()
})
it('should handle complex attachedTo relationships', async () => {
const { liveQuery, factory, close } = await getClient()
const space = await factory.createDoc(core.class.Space, core.space.Model, {
name: 'complex-attached',
description: 'test',
private: false,
members: [],
archived: false
})
const comment1 = await factory.addCollection(test.class.TestComment, space, space, core.class.Space, 'comments', {
message: 'comment1'
})
await factory.addCollection(test.class.TestComment, space, comment1, test.class.TestComment, 'comments', {
message: 'comment2'
})
const callback = jest.fn()
liveQuery.query(test.class.TestComment, { attachedTo: comment1 }, callback)
await new Promise((resolve) => setTimeout(resolve, 100))
expect(callback).toHaveBeenCalled()
await close()
})
})
@@ -0,0 +1,379 @@
// Coverage improvement tests for LiveQuery functionality
//
// Copyright © 2024 Hardcore Engineering Inc.
//
import core, { createClient, TxOperations } from '@hcengineering/core'
import { LiveQuery } from '..'
import { connect } from './connection'
async function getClient (): Promise<{ liveQuery: LiveQuery, factory: TxOperations, close: () => Promise<void> }> {
const storage = await createClient(connect)
const liveQuery = new LiveQuery(storage)
storage.notify = (...tx) => {
void liveQuery.tx(...tx)
}
return {
liveQuery,
factory: new TxOperations(storage, core.account.System),
close: async () => {
await liveQuery.close()
}
}
}
describe('LiveQuery Coverage Tests', () => {
it('should handle refreshConnect with clean=true', async () => {
const { liveQuery, close } = await getClient()
const callback = jest.fn()
// Create a query
const unsubscribe = liveQuery.query(core.class.Space, {}, callback)
// Wait for initial results
await new Promise((resolve) => setTimeout(resolve, 100))
expect(callback).toHaveBeenCalled()
callback.mockClear()
// Unsubscribe to move to queue
unsubscribe()
await new Promise((resolve) => setTimeout(resolve, 50))
// Refresh with clean=true should reset the query
await liveQuery.refreshConnect(true)
// Verify the query was cleaned
await close()
})
it('should handle isClosed', async () => {
const { liveQuery } = await getClient()
expect(liveQuery.isClosed()).toBe(false)
await liveQuery.close()
expect(liveQuery.isClosed()).toBe(true)
})
it('should handle findOne with projection', async () => {
const { liveQuery, factory, close } = await getClient()
// Create a document
const space = await factory.createDoc(core.class.Space, core.space.Model, {
name: 'test-space',
description: 'test',
private: false,
members: [],
archived: false
})
// FindOne with projection
const result = await liveQuery.findOne(core.class.Space, { _id: space }, { projection: { name: 1 } })
expect(result).toBeDefined()
expect(result?.name).toBe('test-space')
await close()
})
it('should pass searchFulltext to client', async () => {
const { liveQuery, close } = await getClient()
const result = await liveQuery.searchFulltext({ query: 'test' }, {})
expect(result).toBeDefined()
await close()
})
it('should return hierarchy and model from client', async () => {
const { liveQuery, close } = await getClient()
const hierarchy = liveQuery.getHierarchy()
const model = liveQuery.getModel()
expect(hierarchy).toBeDefined()
expect(model).toBeDefined()
await close()
})
it('should handle multiple callbacks for same query', async () => {
const { liveQuery, close } = await getClient()
const callback1 = jest.fn()
const callback2 = jest.fn()
const callback3 = jest.fn()
liveQuery.query(core.class.Space, {}, callback1)
liveQuery.query(core.class.Space, {}, callback2)
liveQuery.query(core.class.Space, {}, callback3)
await new Promise((resolve) => setTimeout(resolve, 100))
// All callbacks should be registered and called
expect(callback1).toHaveBeenCalled()
expect(callback2).toHaveBeenCalled()
expect(callback3).toHaveBeenCalled()
await close()
})
it('should remove only specific callback on unsubscribe', async () => {
const { liveQuery, close } = await getClient()
const callback1 = jest.fn()
const callback2 = jest.fn()
liveQuery.query(core.class.Space, {}, callback1)
const unsubscribe2 = liveQuery.query(core.class.Space, {}, callback2)
await new Promise((resolve) => setTimeout(resolve, 100))
callback1.mockClear()
callback2.mockClear()
unsubscribe2()
// callback1 should still be active
await new Promise((resolve) => setTimeout(resolve, 50))
await close()
})
it('should handle query moving from active to queue and back', async () => {
const { liveQuery, close } = await getClient()
const callback1 = jest.fn()
const callback2 = jest.fn()
// Subscribe
const unsubscribe1 = liveQuery.query(core.class.Space, {}, callback1)
await new Promise((resolve) => setTimeout(resolve, 100))
expect(callback1).toHaveBeenCalled()
// Unsubscribe - moves to queue
unsubscribe1()
await new Promise((resolve) => setTimeout(resolve, 50))
callback1.mockClear()
callback2.mockClear()
// Subscribe again with same query - should reuse from queue
liveQuery.query(core.class.Space, {}, callback2)
await new Promise((resolve) => setTimeout(resolve, 100))
expect(callback2).toHaveBeenCalled()
await close()
})
it('should handle TxUpdateDoc with search query', async () => {
const { liveQuery, factory, close } = await getClient()
// Create a document
const space = await factory.createDoc(core.class.Space, core.space.Model, {
name: 'searchable-space',
description: 'test document for search',
private: false,
members: [],
archived: false
})
const callback = jest.fn()
// Query with search (though actual search won't work in mock, it tests the code path)
liveQuery.query(core.class.Space, { _id: space }, callback)
await new Promise((resolve) => setTimeout(resolve, 100))
expect(callback).toHaveBeenCalled()
// Update the document
await factory.updateDoc(core.class.Space, core.space.Model, space, {
name: 'updated-space'
})
await new Promise((resolve) => setTimeout(resolve, 100))
// Callback should have been called again with updated results
expect(callback.mock.calls.length).toBeGreaterThan(1)
await close()
})
it('should handle TxRemoveDoc with total tracking', async () => {
const { liveQuery, factory, close } = await getClient()
// Create a document
const space = await factory.createDoc(core.class.Space, core.space.Model, {
name: 'to-be-removed',
description: 'test',
private: false,
members: [],
archived: false
})
const callback = jest.fn()
// Query with total tracking
liveQuery.query(core.class.Space, { _id: space }, callback, { total: true })
await new Promise((resolve) => setTimeout(resolve, 100))
callback.mockClear()
// Remove the document
await factory.removeDoc(core.class.Space, core.space.Model, space)
await new Promise((resolve) => setTimeout(resolve, 100))
// Callback should have been called with updated results
expect(callback).toHaveBeenCalled()
await close()
})
it('should handle lookup queries', async () => {
const { liveQuery, close } = await getClient()
const callback = jest.fn()
// Query with reverse lookup
liveQuery.query(core.class.Space, {}, callback, {
lookup: {
_id: {
attachedDocs: core.class.Doc
}
}
})
await new Promise((resolve) => setTimeout(resolve, 100))
expect(callback).toHaveBeenCalled()
await close()
})
it('should compare options correctly ignoring ctx', async () => {
const { liveQuery, close } = await getClient()
const callback1 = jest.fn()
const callback2 = jest.fn()
// Query with same options (should reuse)
liveQuery.query(core.class.Space, {}, callback1, { limit: 10, sort: { modifiedOn: 1 } })
liveQuery.query(core.class.Space, {}, callback2, { limit: 10, sort: { modifiedOn: 1 } })
await new Promise((resolve) => setTimeout(resolve, 100))
expect(callback1).toHaveBeenCalled()
expect(callback2).toHaveBeenCalled()
await close()
})
it('should treat different options as different queries', async () => {
const { liveQuery, close } = await getClient()
const callback1 = jest.fn()
const callback2 = jest.fn()
liveQuery.query(core.class.Space, {}, callback1, { limit: 10 })
liveQuery.query(core.class.Space, {}, callback2, { limit: 20 })
await new Promise((resolve) => setTimeout(resolve, 100))
expect(callback1).toHaveBeenCalled()
expect(callback2).toHaveBeenCalled()
await close()
})
it('should handle TxMixin updates', async () => {
const { liveQuery, factory, close } = await getClient()
// Create a space document
const space = await factory.createDoc(core.class.Space, core.space.Model, {
name: 'mixin-test',
description: 'test',
private: false,
members: [],
archived: false
})
const callback = jest.fn()
liveQuery.query(core.class.Space, { _id: space }, callback)
await new Promise((resolve) => setTimeout(resolve, 100))
expect(callback).toHaveBeenCalled()
await close()
})
it('should handle document creation via transaction', async () => {
const { liveQuery, factory, close } = await getClient()
const callback = jest.fn()
// Set up query before creating document
liveQuery.query(core.class.Space, { name: 'new-space' }, callback)
await new Promise((resolve) => setTimeout(resolve, 100))
const initialCallCount = callback.mock.calls.length
// Create matching document
await factory.createDoc(core.class.Space, core.space.Model, {
name: 'new-space',
description: 'newly created',
private: false,
members: [],
archived: false
})
await new Promise((resolve) => setTimeout(resolve, 100))
// Should have been called again with new document
expect(callback.mock.calls.length).toBeGreaterThan(initialCallCount)
await close()
})
it('should handle refreshConnect without clean', async () => {
const { liveQuery, close } = await getClient()
const callback = jest.fn()
liveQuery.query(core.class.Space, {}, callback)
await new Promise((resolve) => setTimeout(resolve, 100))
// Refresh without clean
await liveQuery.refreshConnect(false)
await close()
})
it('should handle associations option', async () => {
const { liveQuery, close } = await getClient()
const callback = jest.fn()
liveQuery.query(core.class.Space, {}, callback, { associations: [] })
await new Promise((resolve) => setTimeout(resolve, 100))
expect(callback).toHaveBeenCalled()
await close()
})
})
@@ -0,0 +1,432 @@
//
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import core, { createClient, SortingOrder, Space, Tx, TxOperations } from '@hcengineering/core'
import { LiveQuery } from '..'
import { connect } from './connection'
async function getClient (): Promise<{ liveQuery: LiveQuery, factory: TxOperations }> {
const storage = await createClient(connect)
const liveQuery = new LiveQuery(storage)
storage.notify = (...tx: Tx[]) => {
liveQuery.tx(...tx).catch((err) => {
console.log(err)
})
}
return { liveQuery, factory: new TxOperations(storage, core.account.System) }
}
describe('LiveQuery - Queue Management Bugs', () => {
describe('Queue and Queries Map Consistency', () => {
it('should properly synchronize queue and queries map when removing queries', async () => {
const { liveQuery } = await getClient()
const unsubscribe1 = liveQuery.query<Space>(core.class.Space, { private: false }, (result) => {
// Callback 1
})
const unsubscribe2 = liveQuery.query<Space>(core.class.Space, { private: false }, (result) => {
// Callback 2 - same query
})
// Both callbacks should share the same query
// Let's verify internal state
const queriesMap = (liveQuery as any).queries.get(core.class.Space)
expect(queriesMap?.size).toBe(1)
// Unsubscribe first callback
unsubscribe1()
// Query should still exist because second callback is still active
expect(queriesMap?.size).toBe(1)
// Unsubscribe second callback
unsubscribe2()
// Now the query should be moved to the queue (cached for reuse)
const queue = (liveQuery as any).queue
expect(queue.size).toBeGreaterThan(0)
})
it('should handle rapid subscribe and unsubscribe correctly', async () => {
const { liveQuery } = await getClient()
const callbacks: Array<() => void> = []
// Rapidly subscribe 10 times to the same query
for (let i = 0; i < 10; i++) {
const unsubscribe = liveQuery.query<Space>(core.class.Space, { private: false }, (result) => {
// Callback
})
callbacks.push(unsubscribe)
}
const queriesMap = (liveQuery as any).queries.get(core.class.Space)
// Should only have 1 query since they're all the same
expect(queriesMap?.size).toBe(1)
// Unsubscribe all
callbacks.forEach((unsub) => {
unsub()
})
// Query should be in the queue
const queue = (liveQuery as any).queue
expect(queue.size).toBeGreaterThan(0)
})
it('should not leak memory when queries are unsubscribed', async () => {
const { liveQuery } = await getClient()
const unsubscribeCallbacks: Array<() => void> = []
// Create many different queries
for (let i = 0; i < 50; i++) {
const unsubscribe = liveQuery.query<Space>(core.class.Space, { name: `query-${i}` }, (result) => {
// Callback
})
unsubscribeCallbacks.push(unsubscribe)
}
// All queries should be in the queries map
const queriesMap = (liveQuery as any).queries.get(core.class.Space)
expect(queriesMap?.size).toBe(50)
// Unsubscribe all
unsubscribeCallbacks.forEach((unsub) => {
unsub()
})
// All queries should now be in the queue
const queue = (liveQuery as any).queue
expect(queue.size).toBe(50)
// Wait a bit for any async operations
await new Promise((resolve) => setTimeout(resolve, 100))
// Still should have 50 queries in queue
expect(queue.size).toBe(50)
})
it('should properly handle queue cleanup when exceeding CACHE_SIZE', async () => {
const { liveQuery } = await getClient()
const CACHE_SIZE = 125 // From the code
const unsubscribeCallbacks: Array<() => void> = []
// Create more queries than CACHE_SIZE
for (let i = 0; i < CACHE_SIZE + 20; i++) {
const unsubscribe = liveQuery.query<Space>(core.class.Space, { name: `query-${i}` }, (result) => {
// Callback
})
unsubscribeCallbacks.push(unsubscribe)
}
const queriesMapBefore = (liveQuery as any).queries.get(core.class.Space)
expect(queriesMapBefore?.size).toBe(CACHE_SIZE + 20)
// Unsubscribe all to move them to queue
unsubscribeCallbacks.forEach((unsub) => {
unsub()
})
await new Promise((resolve) => setTimeout(resolve, 100))
const queue = (liveQuery as any).queue
// Queue should not exceed CACHE_SIZE due to cleanup
expect(queue.size).toBeLessThanOrEqual(CACHE_SIZE)
const queriesMapAfter = (liveQuery as any).queries.get(core.class.Space)
// Some queries should have been removed from the queries map too
expect(queriesMapAfter?.size).toBeLessThan(CACHE_SIZE + 20)
})
})
describe('Query Callback Management', () => {
it('should handle multiple callbacks on the same query correctly', async () => {
const { liveQuery, factory } = await getClient()
const results1: any[] = []
const results2: any[] = []
const unsubscribe1 = liveQuery.query<Space>(core.class.Space, { private: false }, (result) => {
results1.push(result.length)
})
const unsubscribe2 = liveQuery.query<Space>(core.class.Space, { private: false }, (result) => {
results2.push(result.length)
})
// Wait for initial callbacks
await new Promise((resolve) => setTimeout(resolve, 100))
// Both should have received the same initial data
expect(results1.length).toBeGreaterThan(0)
expect(results2.length).toBeGreaterThan(0)
expect(results1[0]).toBe(results2[0])
// Create a new document
await factory.createDoc(core.class.Space, core.space.Model, {
private: false,
name: 'Test Space',
description: '',
members: [],
archived: false
})
await new Promise((resolve) => setTimeout(resolve, 100))
// Both callbacks should have been called again
expect(results1.length).toBeGreaterThan(1)
expect(results2.length).toBeGreaterThan(1)
unsubscribe1()
unsubscribe2()
})
it('should stop sending updates after unsubscribe', async () => {
const { liveQuery, factory } = await getClient()
const results: any[] = []
const unsubscribe = liveQuery.query<Space>(core.class.Space, { private: false }, (result) => {
results.push(result.length)
})
await new Promise((resolve) => setTimeout(resolve, 100))
const countBeforeUnsubscribe = results.length
unsubscribe()
// Create new documents after unsubscribe
for (let i = 0; i < 5; i++) {
await factory.createDoc(core.class.Space, core.space.Model, {
private: false,
name: `Space ${i}`,
description: '',
members: [],
archived: false
})
}
await new Promise((resolve) => setTimeout(resolve, 100))
// Should not have received new callbacks
expect(results.length).toBe(countBeforeUnsubscribe)
})
})
describe('Query Reuse from Queue', () => {
it('should reuse cached query from queue when re-subscribing', async () => {
const { liveQuery } = await getClient()
const unsubscribe1 = liveQuery.query<Space>(core.class.Space, { private: false }, (result) => {
// First callback
})
await new Promise((resolve) => setTimeout(resolve, 50))
const queriesMap = (liveQuery as any).queries.get(core.class.Space)
const firstQuery: any = Array.from(queriesMap.values())[0]
const initialQueryId = firstQuery?.id
unsubscribe1()
// Query should be in queue now
const queue = (liveQuery as any).queue
expect(queue.size).toBeGreaterThan(0)
// Subscribe again to the same query
const unsubscribe2 = liveQuery.query<Space>(core.class.Space, { private: false }, (result) => {
// Second callback
})
await new Promise((resolve) => setTimeout(resolve, 50))
const queriesMapAfter = (liveQuery as any).queries.get(core.class.Space)
const secondQuery: any = Array.from(queriesMapAfter.values())[0]
const reusedQueryId = secondQuery?.id
// Should reuse the same query
expect(reusedQueryId).toBe(initialQueryId)
unsubscribe2()
})
it('should handle query options comparison correctly', async () => {
const { liveQuery } = await getClient()
const unsubscribe1 = liveQuery.query<Space>(core.class.Space, { private: false }, (result) => {}, {
limit: 10,
sort: { name: SortingOrder.Ascending }
})
const unsubscribe2 = liveQuery.query<Space>(core.class.Space, { private: false }, (result) => {}, {
limit: 10,
sort: { name: SortingOrder.Ascending }
})
const queriesMap = (liveQuery as any).queries.get(core.class.Space)
// Should share the same query because options match
expect(queriesMap?.size).toBe(1)
unsubscribe1()
unsubscribe2()
// Different options should create different query
const unsubscribe3 = liveQuery.query<Space>(core.class.Space, { private: false }, (result) => {}, {
limit: 20,
sort: { name: SortingOrder.Descending }
})
await new Promise((resolve) => setTimeout(resolve, 50))
// Now should have 2 queries (1 in queue, 1 active)
const totalQueries = queriesMap?.size ?? 0 + ((liveQuery as any).queue.size ?? 0)
expect(totalQueries).toBeGreaterThan(1)
unsubscribe3()
})
})
describe('Edge Cases and Error Conditions', () => {
it('should handle empty query results', async () => {
const { liveQuery } = await getClient()
const results: any[] = []
const unsubscribe = liveQuery.query<Space>(core.class.Space, { name: 'NonExistentSpace123456' }, (result) => {
results.push(result)
})
await new Promise((resolve) => setTimeout(resolve, 100))
expect(results.length).toBeGreaterThan(0)
expect(results[0]).toHaveLength(0)
unsubscribe()
})
it('should handle query on closed LiveQuery', async () => {
const { liveQuery } = await getClient()
await liveQuery.close()
// Attempting to query after close should not crash
expect(() => {
liveQuery.query<Space>(core.class.Space, {}, (result) => {})
}).not.toThrow()
})
it('should handle concurrent findAll and query operations', async () => {
const { liveQuery } = await getClient()
const promises = []
// Start multiple findAll operations
for (let i = 0; i < 10; i++) {
promises.push(liveQuery.findAll(core.class.Space, { private: false }))
}
// Start multiple query operations
for (let i = 0; i < 10; i++) {
liveQuery.query<Space>(core.class.Space, { private: false }, (result) => {})
}
const results = await Promise.all(promises)
results.forEach((result) => {
expect(Array.isArray(result)).toBe(true)
})
})
})
describe('Query Counter and ID Management', () => {
it('should generate unique query IDs', async () => {
const { liveQuery } = await getClient()
const queryIds = new Set<number>()
const unsubscribes: Array<() => void> = []
// Create many queries
for (let i = 0; i < 100; i++) {
const unsubscribe = liveQuery.query<Space>(core.class.Space, { name: `unique-query-${i}` }, (result) => {})
unsubscribes.push(unsubscribe)
}
const queriesMap = (liveQuery as any).queries.get(core.class.Space)
if (queriesMap !== undefined) {
for (const query of queriesMap.values()) {
queryIds.add(query.id)
}
}
// All IDs should be unique
expect(queryIds.size).toBe(100)
unsubscribes.forEach((unsub) => {
unsub()
})
})
})
describe('Query Result Consistency', () => {
it('should maintain result consistency across multiple callbacks', async () => {
const { liveQuery, factory } = await getClient()
const callback1Results: number[] = []
const callback2Results: number[] = []
const unsubscribe1 = liveQuery.query<Space>(core.class.Space, { private: false }, (result) => {
callback1Results.push(result.length)
})
// Wait for first callback to complete
await new Promise((resolve) => setTimeout(resolve, 100))
const unsubscribe2 = liveQuery.query<Space>(core.class.Space, { private: false }, (result) => {
callback2Results.push(result.length)
})
await new Promise((resolve) => setTimeout(resolve, 100))
// Create a document
await factory.createDoc(core.class.Space, core.space.Model, {
private: false,
name: 'Consistency Test',
description: '',
members: [],
archived: false
})
await new Promise((resolve) => setTimeout(resolve, 150))
// Both callbacks should have received updates after document creation
// Note: Due to setTimeout(0) in pushCallback, timing may vary slightly
// but both should see at least 2 updates (initial + after create)
expect(callback1Results.length).toBeGreaterThanOrEqual(2)
expect(callback2Results.length).toBeGreaterThanOrEqual(2)
// Final counts should match after everything settles
const final1 = callback1Results[callback1Results.length - 1]
const final2 = callback2Results[callback2Results.length - 1]
expect(final1).toBe(final2)
unsubscribe1()
unsubscribe2()
})
})
})
@@ -0,0 +1,298 @@
// Tests for Refs class to improve coverage
//
// Copyright © 2024 Hardcore Engineering Inc.
//
import core, { createClient, TxOperations } from '@hcengineering/core'
import { LiveQuery } from '..'
import { connect } from './connection'
async function getClient (): Promise<{ liveQuery: LiveQuery, factory: TxOperations, close: () => Promise<void> }> {
const storage = await createClient(connect)
const liveQuery = new LiveQuery(storage)
storage.notify = (...tx) => {
void liveQuery.tx(...tx)
}
return {
liveQuery,
factory: new TxOperations(storage, core.account.System),
close: async () => {
await liveQuery.close()
}
}
}
describe('Refs Class Coverage Tests', () => {
it('should find document from refs cache with specific _id', async () => {
const { liveQuery, factory, close } = await getClient()
// Create a document
const space1 = await factory.createDoc(core.class.Space, core.space.Model, {
name: 'cached-space-1',
description: 'test',
private: false,
members: [],
archived: false
})
// Query it to cache it
const callback = jest.fn()
liveQuery.query(core.class.Space, { _id: space1 }, callback)
await new Promise((resolve) => setTimeout(resolve, 100))
// Now findOne should use cached version
const result = await liveQuery.findOne(core.class.Space, { _id: space1 })
expect(result).toBeDefined()
expect(result?._id).toBe(space1)
await close()
})
it('should handle findOne with limit=1 without sort', async () => {
const { liveQuery, factory, close } = await getClient()
// Create multiple documents
await factory.createDoc(core.class.Space, core.space.Model, {
name: 'test-1',
description: 'test',
private: false,
members: [],
archived: false
})
await factory.createDoc(core.class.Space, core.space.Model, {
name: 'test-2',
description: 'test',
private: false,
members: [],
archived: false
})
// findOne with limit should use refs optimization
const result = await liveQuery.findOne(core.class.Space, { private: false }, { limit: 1 })
expect(result).toBeDefined()
await close()
})
it('should handle findOne with associations and lookup', async () => {
const { liveQuery, factory, close } = await getClient()
const space = await factory.createDoc(core.class.Space, core.space.Model, {
name: 'lookup-test',
description: 'test',
private: false,
members: [],
archived: false
})
// Query with lookup to populate refs cache
const callback = jest.fn()
liveQuery.query(core.class.Space, { _id: space }, callback, { lookup: { _id: { docs: core.class.Doc } } })
await new Promise((resolve) => setTimeout(resolve, 100))
// FindOne should use refs cache
const result = await liveQuery.findOne(
core.class.Space,
{ _id: space },
{ lookup: { _id: { docs: core.class.Doc } } }
)
expect(result).toBeDefined()
await close()
})
it('should strip $lookup and $associations when finding from cache without them', async () => {
const { liveQuery, factory, close } = await getClient()
const space = await factory.createDoc(core.class.Space, core.space.Model, {
name: 'strip-test',
description: 'test',
private: false,
members: [],
archived: false
})
// Query with lookup to cache with $lookup
const callback = jest.fn()
liveQuery.query(core.class.Space, { _id: space }, callback, { lookup: { _id: { docs: core.class.Doc } } })
await new Promise((resolve) => setTimeout(resolve, 100))
// FindOne without lookup should strip $lookup from cached doc
const result = await liveQuery.findOne(core.class.Space, { _id: space })
expect(result).toBeDefined()
expect((result as any).$lookup).toBeUndefined()
await close()
})
it('should handle mixin class in findOne', async () => {
const { liveQuery, factory, close } = await getClient()
const space = await factory.createDoc(core.class.Space, core.space.Model, {
name: 'mixin-findone',
description: 'test',
private: false,
members: [],
archived: false
})
// Query to cache
const callback = jest.fn()
liveQuery.query(core.class.Space, { _id: space }, callback)
await new Promise((resolve) => setTimeout(resolve, 100))
// Find with different class to test mixin path
const result = await liveQuery.findOne(core.class.Space, { _id: space })
expect(result).toBeDefined()
await close()
})
it('should handle query with lookup and associations together', async () => {
const { liveQuery, factory, close } = await getClient()
const space = await factory.createDoc(core.class.Space, core.space.Model, {
name: 'both-options',
description: 'test',
private: false,
members: [],
archived: false
})
const callback = jest.fn()
liveQuery.query(core.class.Space, { _id: space }, callback, {
lookup: { _id: { docs: core.class.Doc } },
associations: []
})
await new Promise((resolve) => setTimeout(resolve, 100))
expect(callback).toHaveBeenCalled()
await close()
})
it('should handle updating refs cache on document changes', async () => {
const { liveQuery, factory, close } = await getClient()
const space = await factory.createDoc(core.class.Space, core.space.Model, {
name: 'update-refs',
description: 'original',
private: false,
members: [],
archived: false
})
// Cache it
const callback = jest.fn()
liveQuery.query(core.class.Space, { _id: space }, callback)
await new Promise((resolve) => setTimeout(resolve, 100))
// Update it
await factory.updateDoc(core.class.Space, core.space.Model, space, {
description: 'updated'
})
await new Promise((resolve) => setTimeout(resolve, 100))
// Find should have updated version
const result = await liveQuery.findOne(core.class.Space, { _id: space })
expect(result).toBeDefined()
expect(result?.description).toBe('updated')
await close()
})
it('should clean refs cache when query is removed', async () => {
const { liveQuery, factory, close } = await getClient()
const space = await factory.createDoc(core.class.Space, core.space.Model, {
name: 'clean-refs',
description: 'test',
private: false,
members: [],
archived: false
})
// Cache it
const callback = jest.fn()
const unsubscribe = liveQuery.query(core.class.Space, { _id: space }, callback)
await new Promise((resolve) => setTimeout(resolve, 100))
// Unsubscribe to clean refs
unsubscribe()
await new Promise((resolve) => setTimeout(resolve, 100))
await close()
})
it('should handle multiple queries referencing same document', async () => {
const { liveQuery, factory, close } = await getClient()
const space = await factory.createDoc(core.class.Space, core.space.Model, {
name: 'multi-ref',
description: 'test',
private: false,
members: [],
archived: false
})
// Multiple queries on same document
const callback1 = jest.fn()
const callback2 = jest.fn()
const callback3 = jest.fn()
liveQuery.query(core.class.Space, { _id: space }, callback1)
liveQuery.query(core.class.Space, { _id: space }, callback2)
liveQuery.query(core.class.Space, { _id: space }, callback3)
await new Promise((resolve) => setTimeout(resolve, 100))
expect(callback1).toHaveBeenCalled()
expect(callback2).toHaveBeenCalled()
expect(callback3).toHaveBeenCalled()
await close()
})
it('should use refs cache for descendants check', async () => {
const { liveQuery, factory, close } = await getClient()
// Create documents
const space1 = await factory.createDoc(core.class.Space, core.space.Model, {
name: 'desc-test-1',
description: 'test',
private: false,
members: [],
archived: false
})
// Cache with callback
const callback = jest.fn()
liveQuery.query(core.class.Space, {}, callback)
await new Promise((resolve) => setTimeout(resolve, 100))
// FindOne by _id should check descendants
const result = await liveQuery.findOne(core.class.Space, { _id: space1 })
expect(result).toBeDefined()
await close()
})
})
+9 -1
View File
@@ -413,7 +413,11 @@ export class LiveQuery implements WithTx, Client {
private remove (): void {
const used = Array.from(this.queue.values()).sort((a, b) => a.lastUsed - b.lastUsed)
for (let i = 0; i < CACHE_SIZE / 10; i++) {
// Remove enough queries to bring the queue back down to 80% of CACHE_SIZE
// This prevents constant cleanup cycles
const targetSize = Math.floor(CACHE_SIZE * 0.8)
const toRemove = Math.max(0, this.queue.size - targetSize)
for (let i = 0; i < toRemove; i++) {
const q = used.shift()
if (q === undefined) return
this.removeQueue(q)
@@ -457,6 +461,10 @@ export class LiveQuery implements WithTx, Client {
q.result.clean()
}
this.queue.set(q.id, { ...q, lastUsed: platformNow() })
// Check if we need to clean up the queue after adding this query
if (this.queue.size > CACHE_SIZE) {
this.remove()
}
}
}
}