Skip to content

Commit ea2b2dc

Browse files
committed
refactor: remove emitInitial flag from publishers
Simplify publisher APIs by removing the emitInitial flag. Publishers now consistently emit the current value upon subscription and only future updates are optionally filtered with `.dropFirst()`. This unification reduces complexity and aligns behavior across different cache strategies. Updated tests to reflect the new behavior. Also added specific tests for the namespace-limited clearing functions in the core Pandora API to enhance flexibility in data management.
1 parent 2d3acb5 commit ea2b2dc

18 files changed

Lines changed: 570 additions & 935 deletions

README.md

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
[![Swift](https://img.shields.io/badge/Swift-5.9%2B-orange.svg?style=flat)](https://swift.org)
1313
[![SPM ready](https://img.shields.io/badge/SPM-ready-brightgreen.svg?style=flat-square)](https://swift.org/package-manager/)
14-
[![Coverage](https://img.shields.io/badge/Coverage-97.1%25-brightgreen.svg?style=flat)](#)
14+
[![Coverage](https://img.shields.io/badge/Coverage-98%2B%25-brightgreen.svg?style=flat)](#)
1515
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
1616
[![Size](https://img.shields.io/badge/Package_Size-1.5MB-purple.svg?style=flat-square)](#)
1717

@@ -59,7 +59,7 @@ A powerful, type-safe caching library for Swift that provides multiple storage s
5959
- Built on **Swift Concurrency** (`async/await`)
6060
- **Actor isolation** for safe persistence without manual locks
6161
- Generic, type-safe APIs
62-
- **Combine** publishers for reactive data flow
62+
- **Combine** publishers for reactive data flow with simplified API
6363

6464
**Performance**
6565
- LRU eviction in memory & disk
@@ -283,8 +283,8 @@ cache.publisher(for: "current_user")
283283
}
284284
.store(in: &cancellables)
285285

286-
// Observe only future changes (skip current value)
287-
cache.publisher(for: "current_user", emitInitial: false)
286+
// Observe changes (current value is always emitted)
287+
cache.publisher(for: "current_user")
288288
.compactMap { $0 }
289289
.sink { user in
290290
print("User changed: \(user.name)")
@@ -301,31 +301,41 @@ cache.publisher(for: "user_id")
301301
// Handle user details
302302
}
303303
.store(in: &cancellables)
304+
305+
// Skip initial value if you only want future updates
306+
cache.publisher(for: "current_user")
307+
.dropFirst() // Skip the immediate current value emission
308+
.compactMap { $0 }
309+
.sink { user in
310+
print("User changed: \(user.name)")
311+
}
312+
.store(in: &cancellables)
304313
```
305314

306-
#### Publisher Options
315+
#### Publisher Behavior
307316

308-
All Pandora publishers support an `emitInitial` parameter to control whether the current value is emitted immediately upon subscription:
317+
All Pandora publishers emit the current value immediately upon subscription, followed by any future changes:
309318

310-
- `publisher(for: "key")` - Emits current value immediately (default behavior)
311-
- `publisher(for: "key", emitInitial: true)` - Explicitly emit current value
312-
- `publisher(for: "key", emitInitial: false)` - Only emit future changes
319+
- `publisher(for: "key")` - Emits current value immediately, then future changes
320+
- Use `.dropFirst()` if you only want to observe future changes, not the current value
313321

314322
### Cache Cleanup
315323

316324
```swift
317-
// Clear specific cache
318-
cache.clear()
319-
await diskCache.clear()
320-
321-
// Remove all Pandora disk caches for this app
322-
Pandora.clearAllDiskData()
323-
324-
// Remove all keys from this app's UserDefaults and iCloud KVS
325-
Pandora.clearUserDefaults()
326-
327-
// Remove everything above (nuclear option)
328-
Pandora.deleteAllLocalStorage()
325+
// Clear specific cache instances
326+
memoryCache.clear() // Synchronous for MemoryBox
327+
await diskCache.clear() // Asynchronous for DiskBox
328+
await hybridCache.clear() // Asynchronous for HybridBox
329+
await userDefaultsCache.clear() // Asynchronous for UserDefaultsBox
330+
331+
// Clear specific namespaces
332+
Pandora.clearUserDefaults(for: "my_settings") // Clear specific UserDefaults namespace
333+
Pandora.clearDiskData(for: "my_cache") // Clear specific disk namespace
334+
335+
// Clear all data
336+
Pandora.clearAllUserDefaults() // Clear all Pandora UserDefaults data
337+
Pandora.clearAllDiskData() // Clear all Pandora disk caches
338+
Pandora.deleteAllLocalStorage() // Nuclear option - clear everything
329339
```
330340

331341
## Thread Safety

Sources/Pandora/HybridBox/PandoraHybridBox.swift

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -47,17 +47,13 @@ public final class PandoraHybridBox<Key: Hashable & Codable & Sendable, Value: C
4747
self.diskExpiresAfter = diskExpiresAfter
4848
}
4949

50-
public func publisher(for key: Key, emitInitial: Bool = true) -> AnyPublisher<Value?, Never> {
51-
if emitInitial {
52-
let currentValue = syncLock.withLock { memory.get(key) }
53-
return Publishers.Merge(
54-
Just(currentValue).eraseToAnyPublisher(),
55-
memory.publisher(for: key, emitInitial: false)
56-
)
57-
.eraseToAnyPublisher()
58-
} else {
59-
return memory.publisher(for: key, emitInitial: false)
60-
}
50+
public func publisher(for key: Key) -> AnyPublisher<Value?, Never> {
51+
let currentValue = syncLock.withLock { memory.get(key) }
52+
return Publishers.Merge(
53+
Just(currentValue).eraseToAnyPublisher(),
54+
memory.publisher(for: key).dropFirst()
55+
)
56+
.eraseToAnyPublisher()
6157
}
6258

6359
public func get(_ key: Key) async -> Value? {
@@ -122,15 +118,13 @@ public final class PandoraHybridBox<Key: Hashable & Codable & Sendable, Value: C
122118
}
123119
}
124120

125-
public func clear() {
121+
public func clear() async {
126122
// Clear memory immediately
127123
syncLock.withLock {
128124
memory.clear()
129125
}
130126

131-
// Clear disk asynchronously
132-
Task {
133-
await disk.clear()
134-
}
127+
// Clear disk
128+
await disk.clear()
135129
}
136130
}

Sources/Pandora/HybridBox/PandoraHybridBoxProtocol.swift

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public protocol PandoraHybridBoxProtocol: Sendable {
1515
associatedtype Key: Hashable & Codable
1616
associatedtype Value: Codable
1717

18-
/// The unique namespace isolating this caches memory and disk entries.
18+
/// The unique namespace isolating this cache's memory and disk entries.
1919
var namespace: String { get }
2020

2121
/// A publisher emitting the current and subsequent value for the given key.
@@ -24,8 +24,7 @@ public protocol PandoraHybridBoxProtocol: Sendable {
2424
/// - Events are sent immediately for memory changes.
2525
/// - Parameters:
2626
/// - key: The cache key to observe.
27-
/// - emitInitial: Whether to emit the current value immediately upon subscription. Defaults to `true`.
28-
func publisher(for key: Key, emitInitial: Bool) -> AnyPublisher<Value?, Never>
27+
func publisher(for key: Key) -> AnyPublisher<Value?, Never>
2928

3029
/// Reads a value for the given key, checking memory first, then disk.
3130
///
@@ -45,5 +44,5 @@ public protocol PandoraHybridBoxProtocol: Sendable {
4544
func remove(_ key: Key)
4645

4746
/// Clears all values from memory and disk for this instance.
48-
func clear()
47+
func clear() async
4948
}

Sources/Pandora/MemoryBox/PandoraMemoryBox.swift

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -102,30 +102,35 @@ public class PandoraMemoryBox<Key: Hashable & Sendable, Value: Sendable>: Pandor
102102
removedSubject?.send(nil)
103103
}
104104

105-
public func publisher(for key: Key, emitInitial: Bool = true) -> AnyPublisher<Value?, Never> {
105+
public func publisher(for key: Key) -> AnyPublisher<Value?, Never> {
106+
let currentValue: Value? = lock.withLock {
107+
if let entry = storage[key], !isEntryExpired(entry) {
108+
return entry.value
109+
} else {
110+
return nil
111+
}
112+
}
113+
114+
return Publishers.Merge(
115+
Just(currentValue).eraseToAnyPublisher(),
116+
getOrCreateMemoryPublisher(for: key).dropFirst()
117+
)
118+
.eraseToAnyPublisher()
119+
}
120+
121+
private func getOrCreateMemoryPublisher(for key: Key) -> AnyPublisher<Value?, Never> {
106122
lock.lock()
107123
let subject: CurrentValueSubject<Value?, Never>
108124
if let existing = subjects[key] {
109125
subject = existing
110126
} else {
111-
let value: Value? = {
112-
if let entry = storage[key], !isEntryExpired(entry) {
113-
return entry.value
114-
} else {
115-
return nil
116-
}
117-
}()
118-
subject = .init(value)
127+
subject = .init(nil) // Start with nil, will be updated by put/remove
119128
subjects[key] = subject
120129
}
121130
let publisher = subject.eraseToAnyPublisher()
122131
lock.unlock()
123132

124-
if emitInitial {
125-
return publisher
126-
} else {
127-
return publisher.dropFirst().eraseToAnyPublisher()
128-
}
133+
return publisher
129134
}
130135

131136
public func clear() {

Sources/Pandora/MemoryBox/PandoraMemoryBoxProtocol.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,5 @@ public protocol PandoraMemoryBoxProtocol {
3131
/// Returns a publisher that emits the current and subsequent values for the given key.
3232
/// - Parameters:
3333
/// - key: The cache key to observe.
34-
/// - emitInitial: Whether to emit the current value immediately upon subscription. Defaults to `true`.
35-
func publisher(for key: Key, emitInitial: Bool) -> AnyPublisher<Value?, Never>
34+
func publisher(for key: Key) -> AnyPublisher<Value?, Never>
3635
}

Sources/Pandora/Pandora.swift

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -320,33 +320,74 @@ public enum Pandora {
320320

321321
// MARK: - Utilities
322322

323-
/// Deletes all disk cache data created by `Pandora.Disk` and `Pandora.Hybrid`.
323+
/// Clears all UserDefaults and iCloud data for a specific namespace.
324+
///
325+
/// - Parameter namespace: The namespace to clear.
326+
public static func clearUserDefaults(for namespace: String) {
327+
let defaults = Foundation.UserDefaults.standard
328+
let store = NSUbiquitousKeyValueStore.default
329+
330+
// Get all keys and filter by namespace
331+
let allKeys = Array(defaults.dictionaryRepresentation().keys)
332+
let namespaceKeys = allKeys.filter { $0.hasPrefix("\(namespace).") }
333+
334+
// Remove from UserDefaults
335+
for key in namespaceKeys {
336+
defaults.removeObject(forKey: key)
337+
}
338+
339+
// Remove from iCloud
340+
let allICloudKeys = Array(store.dictionaryRepresentation.keys)
341+
let namespaceICloudKeys = allICloudKeys.filter { $0.hasPrefix("\(namespace).") }
342+
343+
for key in namespaceICloudKeys {
344+
store.removeObject(forKey: key)
345+
}
346+
347+
defaults.synchronize()
348+
store.synchronize()
349+
}
350+
351+
/// Clears all disk data for a specific namespace.
352+
///
353+
/// - Parameter namespace: The namespace to clear.
354+
public static func clearDiskData(for namespace: String) {
355+
let diskPath = PandoraDiskBoxPath.sharedRoot.appendingPathComponent(namespace)
356+
try? FileManager.default.removeItem(at: diskPath)
357+
}
358+
359+
/// Clears all disk cache data created by `Pandora.Disk` and `Pandora.Hybrid`.
324360
///
325361
/// - Note: This is a destructive and irreversible operation.
326362
/// - Warning: This will delete **all** Pandora-managed disk caches.
327363
public static func clearAllDiskData() {
328364
try? FileManager.default.removeItem(at: PandoraDiskBoxPath.sharedRoot)
329365
}
330366

331-
/// Removes all keys from the standard `UserDefaults` and `NSUbiquitousKeyValueStore`.
332-
///
333-
/// - Warning: This will clear **all keys**, not just Pandora-related ones.
334-
public static func clearUserDefaults() {
367+
/// Clears all UserDefaults and iCloud data for all Pandora namespaces.
368+
public static func clearAllUserDefaults() {
335369
let defaults = Foundation.UserDefaults.standard
370+
let store = NSUbiquitousKeyValueStore.default
371+
372+
// Get all keys and filter by Pandora namespaces
373+
let allKeys = Array(defaults.dictionaryRepresentation().keys)
374+
let pandoraKeys = allKeys.filter { $0.hasPrefix("pandora.") }
336375

337-
for key in defaults.dictionaryRepresentation().keys {
376+
// Remove from UserDefaults
377+
for key in pandoraKeys {
338378
defaults.removeObject(forKey: key)
339379
}
340380

341-
let store = NSUbiquitousKeyValueStore.default
342-
343-
for key in store.dictionaryRepresentation.keys {
381+
// Remove from iCloud
382+
let allICloudKeys = Array(store.dictionaryRepresentation.keys)
383+
let pandoraICloudKeys = allICloudKeys.filter { $0.hasPrefix("pandora.") }
384+
385+
for key in pandoraICloudKeys {
344386
store.removeObject(forKey: key)
345387
}
346388

347389
defaults.synchronize()
348390
store.synchronize()
349-
350391
}
351392

352393
/// Deletes all local and iCloud-backed data for this app:
@@ -355,7 +396,7 @@ public enum Pandora {
355396
/// - Warning: This will clear **all keys**, not just Pandora-related ones.
356397
public static func deleteAllLocalStorage() {
357398
clearAllDiskData()
358-
clearUserDefaults()
399+
clearAllUserDefaults()
359400
}
360401

361402
}

Sources/Pandora/UserDefaultsBox/PandoraUserDefaultsBox.swift

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -115,17 +115,13 @@ public final class PandoraUserDefaultsBox<Value: Codable & Sendable>: PandoraDef
115115
// MARK: - Publisher
116116

117117
/// Returns a publisher that emits the current and future values for the specified key.
118-
public func publisher(for key: String, emitInitial: Bool = true) -> AnyPublisher<Value?, Never> {
119-
if emitInitial {
120-
let currentValue = syncLock.withLock { memory.get(key) }
121-
return Publishers.Merge(
122-
Just(currentValue).eraseToAnyPublisher(),
123-
memory.publisher(for: key, emitInitial: false)
124-
)
125-
.eraseToAnyPublisher()
126-
} else {
127-
return memory.publisher(for: key, emitInitial: false)
128-
}
118+
public func publisher(for key: String) -> AnyPublisher<Value?, Never> {
119+
let currentValue = syncLock.withLock { memory.get(key) }
120+
return Publishers.Merge(
121+
Just(currentValue).eraseToAnyPublisher(),
122+
memory.publisher(for: key).dropFirst()
123+
)
124+
.eraseToAnyPublisher()
129125
}
130126

131127
// MARK: - Get
@@ -243,7 +239,7 @@ public final class PandoraUserDefaultsBox<Value: Codable & Sendable>: PandoraDef
243239
// MARK: - Clear
244240

245241
/// Removes all keys in the current namespace from all storage layers.
246-
public func clear() {
242+
public func clear() async {
247243
let prefix = "\(namespace)."
248244

249245
syncLock.withLock {

Sources/Pandora/UserDefaultsBox/PandoraUserDefaultsBoxProtocol.swift

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,7 @@ public protocol PandoraDefaultsBoxProtocol {
2020
/// Emits the current and future values for the given key.
2121
/// - Parameters:
2222
/// - key: The cache key to observe.
23-
/// - emitInitial: Whether to emit the current value immediately upon subscription. Defaults to `true`.
24-
func publisher(for key: String, emitInitial: Bool) -> AnyPublisher<Value?, Never>
23+
func publisher(for key: String) -> AnyPublisher<Value?, Never>
2524

2625
/// Reads the value for the given key, checking memory first, then UserDefaults,
2726
/// and iCloud if enabled.
@@ -37,7 +36,6 @@ public protocol PandoraDefaultsBoxProtocol {
3736

3837
/// Clears all values in the current namespace from memory, UserDefaults,
3938
/// and iCloud if enabled.
40-
func clear()
39+
func clear() async
4140
}
4241

43-

0 commit comments

Comments
 (0)