Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add LocalStorageKey; rework LocalStorage API #30

Open
wants to merge 12 commits into
base: main
Choose a base branch
from

Conversation

lukaskollmer
Copy link
Member

@lukaskollmer lukaskollmer commented Jan 28, 2025

add LocalStorageKey; rework LocalStorage API

♻️ Current situation & Problem

LocalStorage currently identifies entries in the storage via either optional, raw untyped strings, or via the static type of the to-be-stored/loaded value.
While this approach does lead to a nice-looking API, it is problematic for several reasons:

  • identifying stored entries using string literals makes it easy to misspel something, or to accidentally use the wrong key
  • the fact that there's no static coupling between a key and the type it's used to store/load, means that there's nothing that would prevent someone from acidentally decoding a value into an incorrect type (i.e., a different type than what was encoded into the LocalStorage entry)
  • the fact that parameters such as e.g. the encoder/decoder or the storage setting, both of which determine how a value is stored/loaded, need to be explicitly specified at both calls to store() as well as calls to read(), means that it is easy to mismatch these parameters
    • eg: if you use a PropertyListEncoder when storing some value into the LocalStorage, you need to make sure that you also use the matching PropertyListDecoder when reading the stored value.
      • since the current API defaults both the encoder in store() and the decoder in read() to the JSONEncoder (and `JSONDecoder, respectively), makes it too easy to miss the parameter somewhere
    • the same applies to the storageSetting parameter: all matching calls to store() and read() that operate on the same storage key must specify the same storage setting.
      • this is currently not enforced, and with the current API there is no way of enforcing this
  • the fact that storage keys are optional (if the key is nil (the default), the unqualified typename of the to-be-stored/loaded type will be used instead) is also likely to lead to unexpected issues:
    • since the unqualified typename doesn't take nesting / access control into account, it can easily happen that two calls to store()/read() that operate on completely different types (potentially even in completely different modules!) end up operating on the same LocalStorage entry, because the unqualified typename will be the same in both cases

This PR attempts to address these issues, by restructuring and redesigning the LocalStorage API:

  • Entries in the storage are no longer identified by raw strings which are passed to the store()/load() calls
  • Entries are no longer identified by the typename of the value being stored/loaded
  • We instead have a LocalStorageKey<Value> type, which are intended to be long-lived objects that are used to access the LocalStorage
    • the LocalStorageKey encapsulates all information required to store data into the storage / load data from it
    • since every store/load for an entry will use the same LocalStorageKey instance, we can ensure that every store/load for the same entry will use matching decoders/encoders, and will use the same storageSetting.
    • it also improves the typing, by associating every storage key with its underlying stored type
    • in order to prevent race conditions and other potential concurrency issues, the LocalStorageKey also acts as a read-write lock when accessing the storage entry identified by it
  • In addition to these changes, the LocalStorageKey-based API also makes it easier to support storing additional currently not supported types, such as e.g. NSSecureCoding-compliant types
  • Note this PR removes support for CodableWithConfiguration, which previously was supported but (AFAICT) not used.
  • Note: SpeziSecureStorage is currently untouched; it might make sense to have something similar there as well, but it'd of course require a separate, dedicated key type and would be out-of-scope for this PR.
  • This PR also adds a small convenience property wrapper, @LocalStorageEntry, which makes it easier to use the LocalStorage API from within a SwiftUI View, and is e.g. able to observe a key's value and update the view in response.

⚙️ Release Notes

  • Completely reworked the LocalStorage API:
    • Values are stored/loaded via LocalStorageKeys, which define an entry in the storage.
    • LocalStorageKeys need to explicitly specify their underlying raw keys (a String), and are encouraged to use reverse-DNS-notation in order to avoid collisions

📚 Documentation

The documentation has been adapted to the new API.

✅ Testing

The tests have been adapted to the new API.

📝 Code of Conduct & Contributing Guidelines

By submitting creating this pull request, you agree to follow our Code of Conduct and Contributing Guidelines:

Copy link

codecov bot commented Jan 28, 2025

Codecov Report

Attention: Patch coverage is 90.00000% with 12 lines in your changes missing coverage. Please review.

Project coverage is 90.04%. Comparing base (935a7e1) to head (6a9522e).

Files with missing lines Patch % Lines
Sources/SpeziLocalStorage/LocalStorageKey.swift 75.00% 10 Missing ⚠️
Sources/SpeziLocalStorage/LocalStorageEntry.swift 93.11% 2 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main      #30      +/-   ##
==========================================
+ Coverage   88.75%   90.04%   +1.29%     
==========================================
  Files           6        8       +2     
  Lines         471      552      +81     
==========================================
+ Hits          418      497      +79     
- Misses         53       55       +2     
Files with missing lines Coverage Δ
Sources/SpeziLocalStorage/LocalStorage.swift 92.26% <100.00%> (+8.41%) ⬆️
...ources/SpeziLocalStorage/LocalStorageSetting.swift 97.30% <100.00%> (ø)
Sources/SpeziLocalStorage/LocalStorageEntry.swift 93.11% <93.11%> (ø)
Sources/SpeziLocalStorage/LocalStorageKey.swift 75.00% <75.00%> (ø)

Continue to review full report in Codecov by Sentry.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 935a7e1...6a9522e. Read the comment docs.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm not overly experienced when it comes to using Combine, so please let me know whether what i'm doing here makes sense / is the correct approach

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks alright to me, even tho I'm also rather inexperienced with Combine.

@lukaskollmer lukaskollmer marked this pull request as ready for review January 29, 2025 06:50
Copy link
Member

@philippzagar philippzagar left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the great PR @lukaskollmer! 🚀
Mostly had some smaller nits and long-shot ideas. What's important is to update the rest of the documentation.
And yes, a similar PR in the SecureStorage would be great as well!

}


struct LocalStorageLiveUpdateTestView: View { // swiftlint:disable:this file_types_order
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool test, thanks!

let numbers = (0..<17).map { _ in Int.random(in: 0..<5) }
for number in numbers {
app.buttons["\(number)"].tap()
XCTAssertTrue(app.staticTexts.matching(NSPredicate(format: "label MATCHES %@", "number.*\(number)")).element.waitForExistence(timeout: 0.5))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timeouts might be a bit low for the CI (we usually have around 2 sec), but we can evolve that if needed in the future.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i added the small delay since not having any delay would in some cases not catch the update (afaik the update happens on the next run loop invocation, so there is a small delay we need to account for)

Comment on lines +70 to +83
### Defining Storage Keys

`LocalStorage` uses unique ``LocalStorageKey``s to .

You define storage keys by placing a static non-computed properties of type ``LocalStorageKey`` into an extension on the ``LocalStorageKeys`` type:

```swift
extension LocalStorageKeys {
static let note = LocalStorageKey
}
```


### Storing and Loading Data
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the updated docs! 🚀 I feel there are quite some other doc parts that we need to updated in the README and DocC

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch; i'll look over the docs and make sure everything is in sync and up to date

Comment on lines +81 to +82
encode: @Sendable @escaping (Value) throws -> Data,
decode: @Sendable @escaping (Data) throws -> Value?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool idea, I like it!

let setting: LocalStorageSetting
let encode: @Sendable (Value) throws -> Data
let decode: @Sendable (Data) throws -> Value?
private let lock = RWLock()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just as a long shot: Should we aim to move away from manual locking and go towards an async/await actor-based implementation here? This would of course then also propagate to the public layer, which probably is not wanted. Might be overkill, just putting it out there.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that was @PSchmiedmayer's idea as well, but there's, from what i can tell, some issues with that approach:

  • with actors, we wouldn't be able to allow concurrent reads while enforcing exclusive writes (which currently is allowed).
    whether this is something we actually need is a different question of course (the answer is almost definitely no, but i'd argue it does feel more elegant this way)
  • were we to use actors, we'd need to make all store() / load() calls async (that'd be the entire point, of course), which might be overkill, since the likelihood of parallel reads is already pretty low (plus, with the current implementation parallel reads would be fine anyway), and parallel writes are already synchronized currently.
  • more importantly, we wouldn't be able to have a property-wrapper for accessing storage entries anymore, since the wrappedValue can't be async.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep I share these concerns as well, I think the downsides of actor isolation outway the benefits in this case, so from my side I'm fine with keeping the RWLock, even tho it doesn't feel super swifty.



/// Access ``LocalStorage`` entries within a SwiftUI View.
@propertyWrapper
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great idea to introduce that! 🚀
Would be great to get a small usage doc in here for the wrapper as well as writing down the need for the LocalStorage module in the env.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure what you mean / how that would would work. @Property is a property wrapper which gets its value externally injected (as part of handling the LLM function call), which isn't something we can do with SwiftUI (if that's what you're talking about).

or are you referring to some other aspect of @Parameter?

/// If the closure returns `nil`, the entry will be removed from the `LocalStorage`.
///
/// - throws: if `transform` throws,
public func modify<Value>(_ key: LocalStorageKey<Value>, transform: (Value?) throws -> Value?) throws {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Would be great to test this function

if let value {
try storeImp(value, for: key)
} else {
try delete(key)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Would be great to test this branch

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks alright to me, even tho I'm also rather inexperienced with Combine.

@LocalStorageEntry(.number) private var number

var body: some View {
LabeledContent("number", value: number.map(String.init) ?? "")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe test a small write to the property wrapper as well?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants