Repository: maxnowack/signaldb
Pull request #1526 (2025-03-19 12:25:24)

feat: implement replaceOne method on collection

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/maxnowack/signaldb/pull/1526?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Nowack) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.00%. Comparing base [(`cdfac64`)](https://app.codecov.io/gh/maxnowack/signaldb/commit/cdfac64850df09b145eee8c173973e10c750acaa?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Nowack) to head [(`2f3b681`)](https://app.codecov.io/gh/maxnowack/signaldb/commit/2f3b6815e1bb2d9f1ad38c6999eb1632281ee35b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Nowack).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##              main     #1526   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           55        55           
  Lines         1749      1771   +22     
  Branches       401       411   +10     
=========================================
+ Hits          1749      1771   +22     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/maxnowack/signaldb/pull/1526?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Nowack).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Nowack).

<details><summary>🚀 New features to boost your workflow: </summary>

- ❄ [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- 📦 [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>

Patch:
diff --git a/docs/data-manipulation/index.md b/docs/data-manipulation/index.md
index 7f78f6c..e1ceb2e 100644
--- a/docs/data-manipulation/index.md
+++ b/docs/data-manipulation/index.md
@@ -45,6 +45,14 @@ collection.updateMany({ title: 'Hello World' }, {
 })
 ```
 
+## Replacing items
+
+To replace an item in a collection, use the [`.replaceOne()`](/reference/core/collection/#replaceone-selector-selector-t-replacement-omit-t-id-partial-pick-t-id-options-upsert-boolean) method.
+
+```js
+collection.replaceOne({ id: 'xyz' }, { title: 'Hello SignalDB' })
+```
+
 ## Deleting data
 
 To delete data from a collection, use the [`.removeOne()`](/reference/core/collection/#removemanyselector-selector-t) or [`.removeMany()`](/reference/core/collection/#removemanyselector-selector-t) method.
diff --git a/docs/reference/core/collection/index.md b/docs/reference/core/collection/index.md
index c42ebfe..904041e 100644
--- a/docs/reference/core/collection/index.md
+++ b/docs/reference/core/collection/index.md
@@ -126,6 +126,16 @@ Parameters
 
 Behaves the same like `.updateMany()` but only updates the first found document.
 
+### `replaceOne(selector: Selector<T>, replacement: Omit<T, 'id'> & Partial<Pick<T, 'id'>>, options?: { upsert?: boolean })`
+
+Replaces a single item in the collection that matches a given selector with the specified replacement.
+Also check out the [data manipulation section](/data-manipulation/).
+
+Parameters
+* `selector`: A function to filter items in the collection.
+* `replacement`: The new item that should replace the existing one.
+* `options`: An object with additional options. Currently only `upsert` is supported, which will insert a document based on the replacement, if the selector doesn't match any documents.
+
 ### `removeMany(selector: Selector<T>)`
 
 Removes multiple items from the collection that match a given selector.
@@ -172,6 +182,7 @@ In addition to that, the collection will fire events for each executed method. F
 * `insert`: Fired when the `insert` method is called. The event handler receives the inserted item as an argument.
 * `updateMany`: Emitted when the `updateMany` method is called. The event handler receives the selector and the modifier as arguments.
 * `updateOne`: Triggered when the `updateOne` method is called. The event handler receives the selector and the modifier as arguments.
+* `replaceOne`: Emitted when the `replaceOne` method is called. The event handler receives the selector and the replacement as arguments.
 * `removeMany`: Emitted when the `removeMany` method is called. The event handler receives the selector as an argument.
 * `removeOne`: Triggered when the `removeOne` method is called. The event handler receives the selector as an argument.
 
diff --git a/packages/base/core/CHANGELOG.md b/packages/base/core/CHANGELOG.md
index df73347..1d5383b 100644
--- a/packages/base/core/CHANGELOG.md
+++ b/packages/base/core/CHANGELOG.md
@@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ### Added
 
-* Implemented `{ upsert: true }` option for `updateOne` and `updateMany` methods
+* Implemented `{ upsert: true }` option for `updateOne` and `updateMany` methods.
+* Implemented `replaceOne` method.
 
 ### Changed
 
diff --git a/packages/base/core/src/Collection/index.ts b/packages/base/core/src/Collection/index.ts
index 98bc5fc..ffffb66 100644
--- a/packages/base/core/src/Collection/index.ts
+++ b/packages/base/core/src/Collection/index.ts
@@ -38,7 +38,7 @@ export interface CollectionOptions<T extends BaseItem<I>, I, U = T> {
 
 interface CollectionEvents<T extends BaseItem, U = T> {
   'added': (item: T) => void,
-  'changed': (item: T, modifier: Modifier<T>) => void,
+  'changed': (item: T, modifier?: Modifier<T>) => void,
   'removed': (item: T) => void,
 
   'persistence.init': () => void,
@@ -67,6 +67,7 @@ interface CollectionEvents<T extends BaseItem, U = T> {
   'insert': (item: Omit<T, 'id'> & Partial<Pick<T, 'id'>>) => void,
   'updateOne': (selector: Selector<T>, modifier: Modifier<T>) => void,
   'updateMany': (selector: Selector<T>, modifier: Modifier<T>) => void,
+  'replaceOne': (selector: Selector<T>, item: Omit<T, 'id'> & Partial<Pick<T, 'id'>>) => void,
   'removeOne': (selector: Selector<T>) => void,
   'removeMany': (selector: Selector<T>) => void,
 
@@ -76,6 +77,7 @@ interface CollectionEvents<T extends BaseItem, U = T> {
   '_debug.insert': (callstack: string, item: Omit<T, 'id'> & Partial<Pick<T, 'id'>>) => void,
   '_debug.updateOne': (callstack: string, selector: Selector<T>, modifier: Modifier<T>) => void,
   '_debug.updateMany': (callstack: string, selector: Selector<T>, modifier: Modifier<T>) => void,
+  '_debug.replaceOne': (callstack: string, selector: Selector<T>, item: Omit<T, 'id'> & Partial<Pick<T, 'id'>>) => void,
   '_debug.removeOne': (callstack: string, selector: Selector<T>) => void,
   '_debug.removeMany': (callstack: string, selector: Selector<T>) => void,
 }
@@ -851,6 +853,49 @@ export default class Collection<
     return changes.length === 0 && options?.upsert ? 1 : changes.length
   }
 
+  /**
+   * Replaces a single item in the collection that matches the given selector.
+   * @param selector - The criteria to select the item to replace.
+   * @param replacement - The item to replace the selected item with.
+   * @param [options] - Optional settings for the replace operation.
+   * @param [options.upsert] - If `true`, creates a new item if no item matches the selector.
+   * @returns The number of items replaced (0 or 1).
+   * @throws {Error} If the collection is disposed or invalid arguments are provided.
+   */
+  public replaceOne(
+    selector: Selector<T>,
+    replacement: Omit<T, 'id'> & Partial<Pick<T, 'id'>>,
+    options?: { upsert?: boolean },
+  ) {
+    if (this.isDisposed) throw new Error('Collection is disposed')
+    if (!selector) throw new Error('Invalid selector')
+
+    const { item, index } = this.getItemAndIndex(selector)
+    if (item == null) {
+      if (options?.upsert) {
+        // if upsert is enabled, insert a new item
+        if (replacement.id != null
+          && this.getItemAndIndex({ id: replacement.id } as Selector<T>).item != null) {
+          throw new Error('Item with same id already exists')
+        }
+        this.insert(replacement)
+      }
+    } else {
+      if (item.id !== replacement.id
+        && this.getItemAndIndex({ id: replacement.id } as Selector<T>).item != null) {
+        throw new Error('Item with same id already exists')
+      }
+      const modifiedItem = { id: item.id, ...replacement } as T
+      this.memory().splice(index, 1, modifiedItem)
+      this.rebuildIndices()
+      this.emit('changed', modifiedItem)
+    }
+    this.emit('replaceOne', selector, replacement)
+    this.executeInDebugMode(callstack => this.emit('_debug.replaceOne', callstack, selector, replacement))
+    if (item == null && !options?.upsert) return 0
+    return 1
+  }
+
   /**
    * Removes a single item from the collection that matches the given selector.
    * @param selector - The criteria to select the item to remove.


Test patch:
diff --git a/packages/base/core/__tests__/Collection.spec.ts b/packages/base/core/__tests__/Collection.spec.ts
index 5c6583b..e39ec2b 100644
--- a/packages/base/core/__tests__/Collection.spec.ts
+++ b/packages/base/core/__tests__/Collection.spec.ts
@@ -282,6 +282,70 @@ describe('Collection', () => {
     })
   })
 
+  describe('replaceOne', () => {
+    it('should replace a single item that matches the selector', () => {
+      collection.insert({ id: '1', name: 'John' })
+
+      collection.replaceOne({ id: '1' }, { name: 'Jack' })
+
+      expect(collection.findOne({ id: '1' })).toEqual({ id: '1', name: 'Jack' })
+    })
+
+    it('should emit "changed" event when an item was replaced', () => {
+      collection.insert({ id: '1', name: 'John' })
+      const eventHandler = vi.fn()
+      collection.on('changed', eventHandler)
+
+      collection.replaceOne({ id: '1' }, { name: 'Jack' })
+
+      expect(eventHandler).toHaveBeenCalledWith({ id: '1', name: 'Jack' })
+    })
+
+    it('should not throw an error if no item matches the selector', () => {
+      expect(collection.replaceOne({ id: '1' }, { name: 'Jack' })).toBe(0)
+    })
+
+    it('should throw an error if trying to update the item id to a value that already exists', () => {
+      collection.insert({ id: '1', name: 'John' })
+      collection.insert({ id: '2', name: 'Jane' })
+
+      expect(() => collection.replaceOne({ id: '1' }, { id: '1', name: 'Jack' })).not.toThrow()
+      expect(() => collection.replaceOne({ id: '1' }, { id: '2', name: 'Jack' })).toThrow()
+      expect(() => collection.replaceOne({ id: '1' }, { id: '3', name: 'Jack' })).not.toThrow()
+    })
+
+    it('should emit "replaceOne" event', () => {
+      collection.insert({ id: '1', name: 'John' })
+      const eventHandler = vi.fn()
+      collection.on('replaceOne', eventHandler)
+
+      collection.replaceOne({ id: '1' }, { name: 'Jack' })
+
+      expect(eventHandler).toHaveBeenCalledWith({ id: '1' }, { name: 'Jack' })
+    })
+
+    it('should not upsert items if upsert option was not specified', () => {
+      expect(collection.replaceOne({ id: 'asdf' }, { name: 'Upsert' })).toEqual(0)
+      expect(collection.findOne({ name: 'Upsert' })).toEqual(undefined)
+    })
+
+    it('should upsert items if upsert option is true', () => {
+      expect(collection.replaceOne({ id: 'asdf' }, {
+        name: 'Upsert',
+      }, { upsert: true })).toEqual(1)
+      expect(collection.findOne({ name: 'Upsert' })).toMatchObject({ name: 'Upsert' })
+    })
+
+    it('should fail if there is an id conflict during upsert', () => {
+      collection.insert({ id: '1', name: 'John' })
+
+      expect(() => collection.replaceOne({ name: 'Jane' }, {
+        id: '1',
+        name: 'Jane',
+      }, { upsert: true })).toThrow()
+    })
+  })
+
   describe('removeOne', () => {
     it('should remove an item that match the selector', () => {
       collection.insert({ id: '1', name: 'John' })