Skip to content

Commit 8dcae07

Browse files
committed
fix: make clear() idempotent without dropping TTL index (plan#runtime)
1 parent 55c2454 commit 8dcae07

4 files changed

Lines changed: 39 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
- **Compatibility:** Supported/tested matrix: Node 20/22/24 + MongoDB driver 5.x–7.x + MongoDB server 4.4–8.0 (peer range remains `>=5.0.0`).
1111
- **Packaging:** npm package now ships dual ESM/CJS bundles via `tsdown`, with an explicit exports map and cleaned type declarations (`.d.ts`/`.d.cts`).
1212
- **Types:** `MongoStore` and option hooks are strongly typed to avoid `any` leaks.
13+
- **Fixed:** `store.clear()` now uses `deleteMany({})` instead of `collection.drop()`, preserving TTL indexes and treating `NamespaceNotFound` as success so clears are idempotent.
1314

1415
## [5.1.0] - 2023-10-14
1516

docs/PLANS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
- Runtime & API Quality
2121
- store.clear() issues collection.drop(), which wipes the TTL index required for autoRemove: 'native' and throws NamespaceNotFound for empty stores (src/lib/MongoStore.ts:530-535). Switch to deleteMany({}) (keeping indexes) and swallow the namespace error so clear() is idempotent.
22+
- [done 2025-11-18] Swapped drop() for deleteMany(), swallowing NamespaceNotFound and adding coverage to ensure TTL index survives clear() (agent: Codex)
2223
- Decryption failures call the callback twice because the rejection handler just invokes callback(err) and then execution continues to the success path (src/lib/MongoStore.ts:314-326). Re-throw inside the catch or guard against multiple invocations to avoid “callback was already called” regressions.
2324
- Session TTL math ignores cookie.maxAge; it only respects cookie.expires or a global default in both set() and touch(), so rolling sessions expire too early (src/lib/MongoStore.ts:355-368, 435-439). Mirror express-session's logic: prefer maxAge, fall back to expires, then to ttl.
2425
- close() always shuts down the underlying MongoClient, even if the user supplied their own client/promise, which can tear down the rest of the app's DB connections (src/lib/MongoStore.ts:188-210, 541-543). Track whether the store created the client and only close in that case; otherwise just clear timers.

src/lib/MongoStore.spec.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,25 @@ test.serial('set with no stringify', async (t) => {
173173
t.is(await storePromise.length(), 0)
174174
})
175175

176+
test.serial('clear preserves TTL index and is idempotent', async (t) => {
177+
;({ store, storePromise } = createStoreHelper({ autoRemove: 'native' }))
178+
const collection = await store.collectionP
179+
await collection.insertOne({
180+
_id: 'clear-ttl-index',
181+
session: makeData(),
182+
expires: new Date(Date.now() + 1000),
183+
})
184+
const indexesBefore = await collection.listIndexes().toArray()
185+
t.true(indexesBefore.some((idx) => idx.name === 'expires_1'))
186+
187+
await t.notThrowsAsync(() => storePromise.clear())
188+
189+
const indexesAfter = await collection.listIndexes().toArray()
190+
t.true(indexesAfter.some((idx) => idx.name === 'expires_1'))
191+
192+
await t.notThrowsAsync(() => storePromise.clear())
193+
})
194+
176195
test.serial('test destory event', async (t) => {
177196
;({ store, storePromise } = createStoreHelper())
178197
const orgSession = makeData()

src/lib/MongoStore.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -530,9 +530,25 @@ export default class MongoStore<
530530
clear(callback: (err: any) => void = noop): void {
531531
debug('MongoStore#clear()')
532532
this.collectionP
533-
.then((collection) => collection.drop())
533+
.then((collection) =>
534+
collection.deleteMany(
535+
{},
536+
{ writeConcern: this.options.writeOperationOptions }
537+
)
538+
)
534539
.then(() => callback(null))
535-
.catch((err) => callback(err))
540+
.catch((err: unknown) => {
541+
const message = (err as Error | undefined)?.message ?? ''
542+
// NamespaceNotFound (code 26) occurs if the collection was dropped earlier; treat as success to keep clear() idempotent.
543+
if (
544+
(err as { code?: number })?.code === 26 ||
545+
/ns not found/i.test(message)
546+
) {
547+
callback(null)
548+
return
549+
}
550+
callback(err)
551+
})
536552
}
537553

538554
/**

0 commit comments

Comments
 (0)