Repository: meilisearch/strapi-plugin-meilisearch (js)

Issue:
Meilisearch update failure with draftAndPublish enabled: Transaction query already complete
**Description:**
When updating / adding an item in the Meilisearch plugin with the draftAndPublish option enabled for a content type, the item is removed from Meilisearch, and I receive the following error:

```shell
error: Meilisearch could not add entry with id: 9: Transaction query already complete, run with DEBUG=knex:tx for more info
```

This issue only occurs when the draftAndPublish feature is enabled. When I disable this option, the update works as expected, and the item is correctly uploaded to Meilisearch.

**Expected behavior:** 
The item should be updated in Meilisearch without issues.

**Current behavior**
The item is removed from Meilisearch, and the error is displayed. However, if the "Update" button in the plugin is pressed, the items are correctly synchronized. This likely happens because all items are deleted first and then added again during the synchronization process, bypassing the issue.

**Environment (please complete the following information):**
- node 
- Meilisearch version: 
- strapi-plugin-meilisearch version: 0.13.0 /  0.13.1
- Browser: brave
- Strapi version:  5.6.0


Pull request description:
fix: resolve issue when updating or adding content type
fix: resolve Meilisearch issue with draftAndPublish enabled

When updating or adding an item with draftAndPublish enabled, the item is removed from Meilisearch, and the following error occurs:

error: Meilisearch could not add entry with id: 9: Transaction query already complete

This issue is described in https://github.com/meilisearch/strapi-plugin-meilisearch/issues/997. The item should update correctly without errors when draftAndPublish is enabled.

# Pull Request

## Related issue
Fixes #997

## What does this PR do?
- Resolves the issue where items with draftAndPublish enabled are removed from Meilisearch and cause transaction errors.
- Ensures that updates to items are processed correctly when draftAndPublish is enabled.


Patch:
diff --git a/jest.config.js b/jest.config.js
index 3e5da93..8c46a0b 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -22,6 +22,7 @@ module.exports = {
     '**/__tests__/**/content-types.test.[jt]s?(x)',
     '**/__tests__/**/meilisearch.test.[jt]s?(x)',
     '**/__tests__/**/configuration.test.[jt]s?(x)',
+    '**/__tests__/**/lifecycle.test.[jt]s?(x)',
     '**/__tests__/**/configuration-validation.test.[jt]s?(x)',
   ],
 }
diff --git a/server/src/__mocks__/strapi.js b/server/src/__mocks__/strapi.js
index 57f4cbb..0a12bfe 100644
--- a/server/src/__mocks__/strapi.js
+++ b/server/src/__mocks__/strapi.js
@@ -32,6 +32,7 @@ function createStrapiMock({
   const mockPluginService = jest.fn(() => {
     return {
       getContentTypesUid: () => ['restaurant', 'about'],
+      getContentTypeUid: ({ contentType }) => contentType,
       getCollectionName: ({ contentType }) => contentType,
       getCredentials: () => ({
         host: 'http://localhost:7700',
@@ -48,6 +49,10 @@ function createStrapiMock({
       subscribeContentType: () => {
         return
       },
+      // Add methods for Meilisearch operations
+      addEntriesToMeilisearch: jest.fn(),
+      updateEntriesInMeilisearch: jest.fn(),
+      deleteEntriesFromMeiliSearch: jest.fn(),
     }
   })
 
@@ -64,6 +69,9 @@ function createStrapiMock({
     return 1
   })
   const mockDb = {
+    lifecycles: {
+      subscribe: jest.fn(),
+    },
     query: jest.fn(() => ({
       count: mockFindWithCount,
     })),
diff --git a/server/src/services/lifecycle/lifecycle.js b/server/src/services/lifecycle/lifecycle.js
index 0fb11b4..5545166 100644
--- a/server/src/services/lifecycle/lifecycle.js
+++ b/server/src/services/lifecycle/lifecycle.js
@@ -23,18 +23,10 @@ export default ({ strapi }) => {
             .plugin('meilisearch')
             .service('meilisearch')
 
-          // Fetch complete entry instead of using result that is possibly
-          // partial.
-          const entry = await contentTypeService.getEntry({
-            contentType: contentTypeUid,
-            documentId: result.documentId,
-            entriesQuery: meilisearch.entriesQuery({ contentType }),
-          })
-
-          meilisearch
+          await meilisearch
             .addEntriesToMeilisearch({
               contentType: contentTypeUid,
-              entries: [entry],
+              entries: [result],
             })
             .catch(e => {
               strapi.log.error(
@@ -84,18 +76,10 @@ export default ({ strapi }) => {
             .plugin('meilisearch')
             .service('meilisearch')
 
-          // Fetch complete entry instead of using result that is possibly
-          // partial.
-          const entry = await contentTypeService.getEntry({
-            contentType: contentTypeUid,
-            documentId: result.documentId,
-            entriesQuery: meilisearch.entriesQuery({ contentType }),
-          })
-
-          meilisearch
+          await meilisearch
             .updateEntriesInMeilisearch({
               contentType: contentTypeUid,
-              entries: [entry],
+              entries: [result],
             })
             .catch(e => {
               strapi.log.error(


Test patch:
diff --git a/server/src/__tests__/lifecycle.test.js b/server/src/__tests__/lifecycle.test.js
new file mode 100644
index 0000000..feb09cd
--- /dev/null
+++ b/server/src/__tests__/lifecycle.test.js
@@ -0,0 +1,355 @@
+import createLifecycle from '../services/lifecycle/lifecycle.js'
+import { MeiliSearch } from '../__mocks__/meilisearch'
+import { createStrapiMock } from '../__mocks__/strapi'
+
+global.meiliSearch = MeiliSearch
+
+const strapiMock = createStrapiMock({})
+global.strapi = strapiMock
+
+// Setup service mocks to handle lifecycle operations
+const meilisearchService = {
+  addEntriesToMeilisearch: jest.fn().mockReturnValue(Promise.resolve()),
+  updateEntriesInMeilisearch: jest.fn().mockReturnValue(Promise.resolve()),
+  deleteEntriesFromMeiliSearch: jest.fn().mockReturnValue(Promise.resolve()),
+  getContentTypesUid: () => ['restaurant', 'about'],
+  getContentTypeUid: ({ contentType }) => contentType,
+  getCollectionName: ({ contentType }) => contentType,
+  entriesQuery: jest.fn(() => ({})),
+}
+
+const storeService = {
+  addListenedContentType: jest.fn(() => ({})),
+}
+
+const contentTypeService = {
+  getContentTypeUid: ({ contentType }) => contentType,
+  getEntry: jest.fn(),
+}
+
+// Create a mock of the plugin service function
+const originalPlugin = strapiMock.plugin
+strapiMock.plugin = jest.fn(pluginName => {
+  if (pluginName === 'meilisearch') {
+    return {
+      service: jest.fn(serviceName => {
+        if (serviceName === 'store') return storeService
+        if (serviceName === 'meilisearch') return meilisearchService
+        if (serviceName === 'contentType') return contentTypeService
+        return originalPlugin().service()
+      }),
+    }
+  }
+  return originalPlugin(pluginName)
+})
+
+describe('Lifecycle Meilisearch integration', () => {
+  let lifecycleHandler
+
+  beforeEach(async () => {
+    jest.clearAllMocks()
+    jest.restoreAllMocks()
+
+    // Reset all mocks for clean state
+    meilisearchService.addEntriesToMeilisearch
+      .mockClear()
+      .mockReturnValue(Promise.resolve())
+    meilisearchService.updateEntriesInMeilisearch
+      .mockClear()
+      .mockReturnValue(Promise.resolve())
+    meilisearchService.deleteEntriesFromMeiliSearch
+      .mockClear()
+      .mockReturnValue(Promise.resolve())
+
+    contentTypeService.getEntries = jest
+      .fn()
+      .mockResolvedValue([{ id: '1', title: 'Test' }])
+    contentTypeService.numberOfEntries = jest.fn().mockResolvedValue(5)
+
+    lifecycleHandler = createLifecycle({ strapi: strapiMock })
+  })
+
+  test('should add entry to Meilisearch on afterCreate', async () => {
+    const contentTypeUid = 'api::restaurant.restaurant'
+    const result = { id: '123', title: 'Test Entry' }
+
+    await lifecycleHandler.subscribeContentType({ contentType: contentTypeUid })
+    await strapiMock.db.lifecycles.subscribe.mock.calls[0][0].afterCreate({
+      result,
+    })
+
+    expect(meilisearchService.addEntriesToMeilisearch).toHaveBeenCalledWith({
+      contentType: contentTypeUid,
+      entries: [result],
+    })
+    expect(storeService.addListenedContentType).toHaveBeenCalledWith({
+      contentType: contentTypeUid,
+    })
+  })
+
+  test('should handle error during afterCreate', async () => {
+    const contentTypeUid = 'api::restaurant.restaurant'
+    const result = { id: '123', title: 'Test Entry' }
+    const error = new Error('Connection failed')
+
+    // Mock error scenario
+    meilisearchService.addEntriesToMeilisearch.mockRejectedValueOnce(error)
+    jest.spyOn(strapiMock.log, 'error')
+
+    await lifecycleHandler.subscribeContentType({ contentType: contentTypeUid })
+    await strapiMock.db.lifecycles.subscribe.mock.calls[0][0].afterCreate({
+      result,
+    })
+
+    expect(strapiMock.log.error).toHaveBeenCalledWith(
+      `Meilisearch could not add entry with id: ${result.id}: ${error.message}`,
+    )
+  })
+
+  test('should process multiple entries on afterCreateMany', async () => {
+    const contentTypeUid = 'api::restaurant.restaurant'
+    const result = {
+      count: 3,
+      ids: ['1', '2', '3'],
+    }
+
+    const mockEntries = [
+      { id: '1', title: 'Entry 1' },
+      { id: '2', title: 'Entry 2' },
+      { id: '3', title: 'Entry 3' },
+    ]
+
+    contentTypeService.getEntries.mockResolvedValueOnce(mockEntries)
+
+    await lifecycleHandler.subscribeContentType({ contentType: contentTypeUid })
+    await strapiMock.db.lifecycles.subscribe.mock.calls[0][0].afterCreateMany({
+      result,
+    })
+
+    expect(contentTypeService.getEntries).toHaveBeenCalledWith({
+      contentType: contentTypeUid,
+      start: 0,
+      limit: 500,
+      filters: {
+        id: {
+          $in: result.ids,
+        },
+      },
+    })
+
+    expect(meilisearchService.updateEntriesInMeilisearch).toHaveBeenCalledWith({
+      contentType: contentTypeUid,
+      entries: mockEntries,
+    })
+  })
+
+  test('should handle error during afterCreateMany', async () => {
+    const contentTypeUid = 'api::restaurant.restaurant'
+    const result = {
+      count: 3,
+      ids: ['1', '2', '3'],
+    }
+
+    const mockEntries = [
+      { id: '1', title: 'Entry 1' },
+      { id: '2', title: 'Entry 2' },
+      { id: '3', title: 'Entry 3' },
+    ]
+
+    // Setup the mock to return entries but fail on updateEntriesInMeilisearch
+    contentTypeService.getEntries.mockResolvedValueOnce(mockEntries)
+    const error = new Error('Batch update failed')
+    meilisearchService.updateEntriesInMeilisearch.mockRejectedValueOnce(error)
+
+    jest.spyOn(strapiMock.log, 'error')
+
+    await lifecycleHandler.subscribeContentType({ contentType: contentTypeUid })
+    await strapiMock.db.lifecycles.subscribe.mock.calls[0][0].afterCreateMany({
+      result,
+    })
+
+    expect(strapiMock.log.error).toHaveBeenCalledWith(
+      `Meilisearch could not update the entries: ${error.message}`,
+    )
+  })
+
+  test('should update entry in Meilisearch on afterUpdate', async () => {
+    const contentTypeUid = 'api::restaurant.restaurant'
+    const result = { id: '123', title: 'Updated Entry' }
+
+    await lifecycleHandler.subscribeContentType({ contentType: contentTypeUid })
+    await strapiMock.db.lifecycles.subscribe.mock.calls[0][0].afterUpdate({
+      result,
+    })
+
+    expect(meilisearchService.updateEntriesInMeilisearch).toHaveBeenCalledWith({
+      contentType: contentTypeUid,
+      entries: [result],
+    })
+  })
+
+  test('should handle error during afterUpdate', async () => {
+    const contentTypeUid = 'api::restaurant.restaurant'
+    const result = { id: '123', title: 'Updated Entry' }
+    const error = new Error('Update failed')
+
+    meilisearchService.updateEntriesInMeilisearch.mockRejectedValueOnce(error)
+    jest.spyOn(strapiMock.log, 'error')
+
+    await lifecycleHandler.subscribeContentType({ contentType: contentTypeUid })
+    await strapiMock.db.lifecycles.subscribe.mock.calls[0][0].afterUpdate({
+      result,
+    })
+
+    expect(strapiMock.log.error).toHaveBeenCalledWith(
+      `Meilisearch could not update entry with id: ${result.id}: ${error.message}`,
+    )
+  })
+
+  test('should process multiple entries on afterUpdateMany', async () => {
+    const contentTypeUid = 'api::restaurant.restaurant'
+    const event = {
+      params: {
+        where: { type: 'restaurant' },
+      },
+    }
+
+    const mockEntries = [
+      { id: '1', title: 'Updated 1' },
+      { id: '2', title: 'Updated 2' },
+    ]
+
+    contentTypeService.getEntries.mockResolvedValueOnce(mockEntries)
+
+    await lifecycleHandler.subscribeContentType({ contentType: contentTypeUid })
+    await strapiMock.db.lifecycles.subscribe.mock.calls[0][0].afterUpdateMany(
+      event,
+    )
+
+    expect(contentTypeService.numberOfEntries).toHaveBeenCalledWith({
+      contentType: contentTypeUid,
+      filters: event.params.where,
+    })
+
+    expect(contentTypeService.getEntries).toHaveBeenCalledWith({
+      contentType: contentTypeUid,
+      filters: event.params.where,
+      start: 0,
+      limit: 500,
+    })
+
+    expect(meilisearchService.updateEntriesInMeilisearch).toHaveBeenCalledWith({
+      contentType: contentTypeUid,
+      entries: mockEntries,
+    })
+  })
+
+  test('should handle error during afterUpdateMany', async () => {
+    const contentTypeUid = 'api::restaurant.restaurant'
+    const event = {
+      params: {
+        where: { type: 'restaurant' },
+      },
+    }
+
+    const mockEntries = [
+      { id: '1', title: 'Updated 1' },
+      { id: '2', title: 'Updated 2' },
+    ]
+
+    // Setup mocks for the success path but failure during Meilisearch update
+    contentTypeService.getEntries.mockResolvedValueOnce(mockEntries)
+    const error = new Error('Batch update failed')
+    meilisearchService.updateEntriesInMeilisearch.mockRejectedValueOnce(error)
+
+    jest.spyOn(strapiMock.log, 'error')
+
+    await lifecycleHandler.subscribeContentType({ contentType: contentTypeUid })
+    await strapiMock.db.lifecycles.subscribe.mock.calls[0][0].afterUpdateMany(
+      event,
+    )
+
+    expect(strapiMock.log.error).toHaveBeenCalledWith(
+      `Meilisearch could not update the entries: ${error.message}`,
+    )
+  })
+
+  test('should delete entry from Meilisearch on afterDelete', async () => {
+    const contentTypeUid = 'api::restaurant.restaurant'
+    const result = { id: '123' }
+
+    await lifecycleHandler.subscribeContentType({ contentType: contentTypeUid })
+    await strapiMock.db.lifecycles.subscribe.mock.calls[0][0].afterDelete({
+      result,
+    })
+
+    expect(
+      meilisearchService.deleteEntriesFromMeiliSearch,
+    ).toHaveBeenCalledWith({
+      contentType: contentTypeUid,
+      entriesId: [result.id],
+    })
+  })
+
+  test('should handle multiple ids in afterDelete', async () => {
+    const contentTypeUid = 'api::restaurant.restaurant'
+    const result = { id: '123' }
+    const params = {
+      where: {
+        $and: [{ id: { $in: ['101', '102', '103'] } }],
+      },
+    }
+
+    await lifecycleHandler.subscribeContentType({ contentType: contentTypeUid })
+    await strapiMock.db.lifecycles.subscribe.mock.calls[0][0].afterDelete({
+      result,
+      params,
+    })
+
+    expect(
+      meilisearchService.deleteEntriesFromMeiliSearch,
+    ).toHaveBeenCalledWith({
+      contentType: contentTypeUid,
+      entriesId: ['101', '102', '103'],
+    })
+  })
+
+  test('should handle error during afterDelete', async () => {
+    const contentTypeUid = 'api::restaurant.restaurant'
+    const result = { id: '123' }
+    const error = new Error('Delete failed')
+
+    meilisearchService.deleteEntriesFromMeiliSearch.mockRejectedValueOnce(error)
+    jest.spyOn(strapiMock.log, 'error')
+
+    await lifecycleHandler.subscribeContentType({ contentType: contentTypeUid })
+    await strapiMock.db.lifecycles.subscribe.mock.calls[0][0].afterDelete({
+      result,
+    })
+
+    expect(strapiMock.log.error).toHaveBeenCalledWith(
+      `Meilisearch could not delete entry with id: ${result.id}: ${error.message}`,
+    )
+  })
+
+  test('should call afterDelete from afterDeleteMany', async () => {
+    const contentTypeUid = 'api::restaurant.restaurant'
+    const event = { result: { id: '123' } }
+
+    await lifecycleHandler.subscribeContentType({ contentType: contentTypeUid })
+
+    // Get a reference to the afterDelete handler
+    const afterDeleteSpy = jest.spyOn(
+      strapiMock.db.lifecycles.subscribe.mock.calls[0][0],
+      'afterDelete',
+    )
+
+    // Call afterDeleteMany
+    await strapiMock.db.lifecycles.subscribe.mock.calls[0][0].afterDeleteMany(
+      event,
+    )
+
+    // Verify it calls afterDelete with the same event
+    expect(afterDeleteSpy).toHaveBeenCalledWith(event)
+  })
+})