From 5b28972767bafccb9aac169e69f6281fabfbe766 Mon Sep 17 00:00:00 2001 From: Andrey Sobolev Date: Wed, 8 Oct 2025 11:44:40 +0700 Subject: [PATCH] Fix bug in queue cleanup --- .../query/main_2025-10-08-04-45.json | 10 + .../src/__tests__/advanced-coverage.test.ts | 488 ++++++++++++++++ .../src/__tests__/final-coverage.test.ts | 519 ++++++++++++++++++ .../src/__tests__/livequery-coverage.test.ts | 379 +++++++++++++ .../query/src/__tests__/queue-bugs.test.ts | 432 +++++++++++++++ .../query/src/__tests__/refs-coverage.test.ts | 298 ++++++++++ packages/query/src/index.ts | 10 +- 7 files changed, 2135 insertions(+), 1 deletion(-) create mode 100644 common/changes/@hcengineering/query/main_2025-10-08-04-45.json create mode 100644 packages/query/src/__tests__/advanced-coverage.test.ts create mode 100644 packages/query/src/__tests__/final-coverage.test.ts create mode 100644 packages/query/src/__tests__/livequery-coverage.test.ts create mode 100644 packages/query/src/__tests__/queue-bugs.test.ts create mode 100644 packages/query/src/__tests__/refs-coverage.test.ts diff --git a/common/changes/@hcengineering/query/main_2025-10-08-04-45.json b/common/changes/@hcengineering/query/main_2025-10-08-04-45.json new file mode 100644 index 0000000000..606a54074b --- /dev/null +++ b/common/changes/@hcengineering/query/main_2025-10-08-04-45.json @@ -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" +} \ No newline at end of file diff --git a/packages/query/src/__tests__/advanced-coverage.test.ts b/packages/query/src/__tests__/advanced-coverage.test.ts new file mode 100644 index 0000000000..bcb1046eaa --- /dev/null +++ b/packages/query/src/__tests__/advanced-coverage.test.ts @@ -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 }> { + 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> = [] + 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 }) + + expect(result).toBeUndefined() + + await close() + }) + + it('should handle mixed operations on same query', async () => { + const { liveQuery, factory, close } = await getClient() + + const spaces: Array> = [] + + // 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() + }) +}) diff --git a/packages/query/src/__tests__/final-coverage.test.ts b/packages/query/src/__tests__/final-coverage.test.ts new file mode 100644 index 0000000000..1d239bb045 --- /dev/null +++ b/packages/query/src/__tests__/final-coverage.test.ts @@ -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 }> { + 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> = [] + + // 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> = [] + + 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() + }) +}) diff --git a/packages/query/src/__tests__/livequery-coverage.test.ts b/packages/query/src/__tests__/livequery-coverage.test.ts new file mode 100644 index 0000000000..0d71440532 --- /dev/null +++ b/packages/query/src/__tests__/livequery-coverage.test.ts @@ -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 }> { + 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() + }) +}) diff --git a/packages/query/src/__tests__/queue-bugs.test.ts b/packages/query/src/__tests__/queue-bugs.test.ts new file mode 100644 index 0000000000..d40cac51ba --- /dev/null +++ b/packages/query/src/__tests__/queue-bugs.test.ts @@ -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(core.class.Space, { private: false }, (result) => { + // Callback 1 + }) + + const unsubscribe2 = liveQuery.query(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(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(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(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(core.class.Space, { private: false }, (result) => { + results1.push(result.length) + }) + + const unsubscribe2 = liveQuery.query(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(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(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(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(core.class.Space, { private: false }, (result) => {}, { + limit: 10, + sort: { name: SortingOrder.Ascending } + }) + + const unsubscribe2 = liveQuery.query(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(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(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(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(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() + const unsubscribes: Array<() => void> = [] + + // Create many queries + for (let i = 0; i < 100; i++) { + const unsubscribe = liveQuery.query(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(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(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() + }) + }) +}) diff --git a/packages/query/src/__tests__/refs-coverage.test.ts b/packages/query/src/__tests__/refs-coverage.test.ts new file mode 100644 index 0000000000..fbf27df928 --- /dev/null +++ b/packages/query/src/__tests__/refs-coverage.test.ts @@ -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 }> { + 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() + }) +}) diff --git a/packages/query/src/index.ts b/packages/query/src/index.ts index d119cdfa5a..1ebe6bb662 100644 --- a/packages/query/src/index.ts +++ b/packages/query/src/index.ts @@ -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() + } } } }