Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
b5052c7
Require CoreModel 2.10.0 and gate SQLite/URLSession off wasm
colemancda Jul 23, 2026
955444e
Update Package.resolved for CoreModel 2.10.0
colemancda Jul 23, 2026
b484c93
Exclude the URLSession HTTPClient conformance on wasm
colemancda Jul 23, 2026
42bd765
Exclude the URLSession-backed service initializer on wasm
colemancda Jul 23, 2026
a8e73af
Guard the SQLite store behind canImport(CoreModelSQLite)
colemancda Jul 23, 2026
3c90bea
Add an in-memory ModelStorage and ViewContext backend
colemancda Jul 23, 2026
430f52a
Add the Store(inMemory:) convenience initializer
colemancda Jul 23, 2026
e96295e
Add tests for the in-memory store
colemancda Jul 23, 2026
95e14ae
Vendor the ElementaryUI browser runtime
colemancda Jul 23, 2026
50b9c32
Add the FuelingWeb Swift package manifest
colemancda Jul 23, 2026
72701ac
Add the FuelingWeb Package.resolved
colemancda Jul 23, 2026
cdaf5c5
Add a fetch-based HTTPClient transport for the browser
colemancda Jul 23, 2026
feb6a2d
Add the ElementaryUI reactive coordinator
colemancda Jul 23, 2026
f11af47
Add the root view and screen switcher
colemancda Jul 23, 2026
cf66384
Add the locations list and card views
colemancda Jul 23, 2026
6eeda65
Add the location detail view
colemancda Jul 23, 2026
6d02d29
Add the FuelingWeb app entry point
colemancda Jul 23, 2026
7b5d22c
Add the web app HTML shell
colemancda Jul 23, 2026
52bf4fd
Add the web app bootstrap script
colemancda Jul 23, 2026
f041c76
Add the TypeScript configuration
colemancda Jul 23, 2026
c181754
Add the Vite swift-wasm build configuration
colemancda Jul 23, 2026
089ed96
Add the npm package manifest
colemancda Jul 23, 2026
aa2257a
Add the npm lockfile
colemancda Jul 23, 2026
6d8b624
Ignore build artifacts and node_modules in Web
colemancda Jul 23, 2026
fa5242f
Document the FuelingWeb package
colemancda Jul 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 17 additions & 5 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,17 @@ import PackageDescription
import class Foundation.ProcessInfo

let darwin: [Platform] = [.macOS, .iOS, .tvOS, .watchOS, .visionOS, .macCatalyst]
let nonAndroidPlatforms: [Platform] = darwin + [.linux, .windows, .wasi, .openbsd]
// Platforms whose Foundation ships a usable `URLSession` networking stack, so
// `HTTPTypesFoundation`'s `URLSession: HTTPClient` bridge can be linked.
// Excludes Android (JNI-callback transport, see `FuelingAndroid`) and wasm
// (JavaScriptKit `fetch` transport, see `Web/`) — both provide their own
// `HTTPClient` and must not drag in `FoundationNetworking`.
let urlSessionPlatforms: [Platform] = darwin + [.linux, .windows, .openbsd]
// Platforms the SQLite persistence backend supports. Excludes wasm: the SQLite
// package's SQLCipher/libtomcrypt C code fails to compile for `wasm32-unknown-wasip1`
// (it needs `_WASI_EMULATED_SIGNAL`), so the browser app uses the in-memory
// store (`Store(inMemory:)`) instead of `Store(sqliteDatabase:)`.
let sqlitePlatforms: [Platform] = darwin + [.linux, .windows, .android, .openbsd]

// The Android jextract/JNI build (see Android/fueling-jni/build.gradle.kts) cross-compiles
// this package with this flag set, so every library in the graph — including this package's
Expand Down Expand Up @@ -47,7 +57,7 @@ let package = Package(
dependencies: [
.package(
url: "https://github.com/PureSwift/CoreModel.git",
from: "2.8.0"
from: "2.10.0"
),
.package(
url: "https://github.com/apple/swift-http-types",
Expand Down Expand Up @@ -90,7 +100,7 @@ let package = Package(
.product(
name: "HTTPTypesFoundation",
package: "swift-http-types",
condition: .when(platforms: nonAndroidPlatforms)
condition: .when(platforms: urlSessionPlatforms)
)
]
),
Expand All @@ -114,11 +124,13 @@ let package = Package(
),
.product(
name: "CoreModelSQLite",
package: "CoreModel-SQLite"
package: "CoreModel-SQLite",
condition: .when(platforms: sqlitePlatforms)
),
.product(
name: "SQLite",
package: "SQLite"
package: "SQLite",
condition: .when(platforms: sqlitePlatforms)
)
]
),
Expand Down
12 changes: 7 additions & 5 deletions Sources/FuelingAPI/URLSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
// FuelingAPI
//

// Excluded on Android: the app there supplies its own JNI-callback transport
// (see `FuelingAndroid`), and merely linking `HTTPTypesFoundation`'s
// `URLSession` bridge would pull `FoundationNetworking` + its ~42 MB ICU
// dependency chain into every Android build.
#if canImport(Foundation) && !os(Android)
// Excluded on Android and wasm: each supplies its own transport (a JNI-callback
// client in `FuelingAndroid`, a JavaScriptKit `fetch` client in `Web/`), and
// `HTTPTypesFoundation` isn't linked there anyway — on Android it would drag in
// `FoundationNetworking` + its ~42 MB ICU chain, and on wasm `URLSession` has
// no networking backend. The manifest drops the product on both (see
// `urlSessionPlatforms`); the `!os(WASI)` guard keeps this file from importing it.
#if canImport(Foundation) && !os(Android) && !os(WASI)
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
Expand Down
7 changes: 4 additions & 3 deletions Sources/FuelingModel/APILocationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,10 @@ public struct APILocationService<Client: HTTPClient>: LocationService {
// non-Darwin platforms), not `FoundationEssentials` — the top-level import
// above prefers the lean subset whenever it's importable, so this extension
// imports what it actually needs directly rather than relying on that.
// Excluded on Android, where the transport is a JNI-callback client instead
// (see `FuelingAndroid`) and `URLSession: HTTPClient` does not exist.
#if canImport(Foundation) && !os(Android)
// Excluded on Android and wasm, where the transport is a JNI-callback client
// (see `FuelingAndroid`) / a JavaScriptKit `fetch` client (see `Web/`) and
// `URLSession: HTTPClient` does not exist.
#if canImport(Foundation) && !os(Android) && !os(WASI)
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
Expand Down
162 changes: 162 additions & 0 deletions Sources/FuelingModel/InMemoryStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
//
// InMemoryStore.swift
// FuelingModel
//

import CoreModel

/// An in-memory `ModelStorage` **and** `ViewContext` backed by a plain
/// dictionary, using CoreModel's pure-Swift `FetchRequest.evaluate` engine.
///
/// The cross-platform, dependency-free storage backend — used where neither
/// `CoreDataModel` (Darwin) nor the SQLite backend are available. In
/// particular the browser/wasm target: the SQLite package pulls in SQLCipher's
/// libtomcrypt C code, which doesn't compile for `wasm32-unknown-wasip1`
/// (it needs `_WASI_EMULATED_SIGNAL`), so `Store+SQLite` is off the table there.
///
/// Single-object design: the *same* instance is handed to `Store` as both its
/// asynchronous `storage` and its synchronous `viewContext`, so UI reads
/// always observe prior writes. This is sound because the whole app runs on
/// the main actor — the class is `@MainActor`, so its `ModelStorage` async
/// methods and its `ViewContext` sync methods share one isolation domain and
/// one copy of the data.
@MainActor
public final class InMemoryStore {

/// The schema this store validates entities against.
public let model: Model

/// `entity name -> (object id -> object)`.
private var objects = [EntityName: [ObjectID: ModelData]]()

/// Custom functions registered for predicate/sort evaluation.
private var functions = [String: DatabaseFunction]()

/// Initialize an empty store validating entities against the given schema.
public init(model: Model) {
self.model = model
}

// MARK: - Core (synchronous)

private func read(_ entity: EntityName, for id: ObjectID) throws -> ModelData? {
try validate(entity)
return objects[entity]?[id]
}

private func read(_ fetchRequest: FetchRequest) throws -> [ModelData] {
try validate(fetchRequest.entity)
let all = objects[fetchRequest.entity].map { Array($0.values) } ?? []
return fetchRequest.evaluate(all, functions: functions)
}

private func write(_ value: ModelData) throws {
try validate(value.entity)
// Upsert as a *partial* update, merging the provided keys over any
// existing object rather than replacing it wholesale. This matches the
// SQL/CoreData backends and preserves relationships a caller left out
// on purpose (e.g. a location refresh that deliberately omits
// `fuelProducts` so it never severs cached price links).
var merged = objects[value.entity]?[value.id] ?? ModelData(entity: value.entity, id: value.id)
merged.attributes.merge(value.attributes) { _, new in new }
merged.relationships.merge(value.relationships) { _, new in new }
// Fill in any schema-declared property still missing with a null/empty
// default, so a freshly-inserted object decodes the same way it would
// after a round trip through a database (unset attribute → null, unset
// to-many → empty, unset to-one → null) instead of throwing
// `keyNotFound`. Callers that map DTOs to `ModelData` routinely omit
// optional attributes (`lastViewed`, `brand`, …) and relationships they
// don't want to overwrite.
if let description = model[value.entity] {
for attribute in description.attributes where merged.attributes[attribute.id] == nil {
merged.attributes[attribute.id] = .null
}
for relationship in description.relationships where merged.relationships[relationship.id] == nil {
switch relationship.type {
case .toMany:
merged.relationships[relationship.id] = .toMany([])
case .toOne:
merged.relationships[relationship.id] = .null
}
}
}
objects[value.entity, default: [:]][value.id] = merged
}

private func remove(_ entity: EntityName, for id: ObjectID) throws {
try validate(entity)
objects[entity]?[id] = nil
}

private func validate(_ entity: EntityName) throws {
guard model[entity] != nil else {
throw CoreModelError.invalidEntity(entity)
}
}
}

// MARK: - ViewContext (synchronous, main-actor UI reads)

extension InMemoryStore: ViewContext {

public func fetch(_ entity: EntityName, for id: ObjectID) throws -> ModelData? {
try read(entity, for: id)
}

public func fetch(_ fetchRequest: FetchRequest) throws -> [ModelData] {
try read(fetchRequest)
}

public func fetchID(_ fetchRequest: FetchRequest) throws -> [ObjectID] {
try read(fetchRequest).map { $0.id }
}

public func count(_ fetchRequest: FetchRequest) throws -> UInt {
try UInt(read(fetchRequest).count)
}
}

// MARK: - ModelStorage (asynchronous writes + reads)

extension InMemoryStore: ModelStorage {

public func fetch(_ entity: EntityName, for id: ObjectID) async throws -> ModelData? {
try read(entity, for: id)
}

public func fetch(_ fetchRequest: FetchRequest) async throws -> [ModelData] {
try read(fetchRequest)
}

public func fetchID(_ fetchRequest: FetchRequest) async throws -> [ObjectID] {
try read(fetchRequest).map { $0.id }
}

public func count(_ fetchRequest: FetchRequest) async throws -> UInt {
try UInt(read(fetchRequest).count)
}

public func insert(_ value: ModelData) async throws {
try write(value)
}

public func insert(_ values: [ModelData]) async throws {
for value in values {
try write(value)
}
}

public func delete(_ entity: EntityName, for id: ObjectID) async throws {
try remove(entity, for: id)
}

public func delete(_ entity: EntityName, for ids: [ObjectID]) async throws {
for id in ids {
try remove(entity, for: id)
}
}

public func register(function: DatabaseFunction) async throws {
functions[function.name] = function
}
}
38 changes: 38 additions & 0 deletions Sources/FuelingModel/Store+InMemory.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// Store+InMemory.swift
// FuelingModel
//

import CoreModel
import CoreFueling

public extension Store {

/// Build a store backed entirely by an in-memory dictionary.
///
/// No filesystem, no C dependencies — the storage backend for platforms
/// without CoreData or SQLite (notably the browser/wasm target), and a
/// convenient one for previews and tests everywhere.
///
/// The same ``InMemoryStore`` instance serves as both the store's
/// asynchronous `storage` and its synchronous `viewContext`, so UI reads
/// see prior writes immediately.
///
/// - Parameters:
/// - model: The schema to validate entities against. Defaults to `.fueling`.
/// - locationService: Network transport, or `nil` for offline use.
/// - userLocation: Current user location, if known.
convenience init(
inMemory model: Model = .fueling,
locationService: (any LocationService)? = nil,
userLocation: LocationCoordinate? = nil
) {
let store = InMemoryStore(model: model)
self.init(
storage: store,
viewContext: store,
locationService: locationService,
userLocation: userLocation
)
}
}
6 changes: 6 additions & 0 deletions Sources/FuelingModel/Store+SQLite.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
// FuelingModel
//

// Gated to platforms where the SQLite backend is available (see
// `sqlitePlatforms` in Package.swift). On wasm the SQLite package's C code
// doesn't compile, so the modules aren't in the graph there and this whole
// file compiles away — the browser app uses `Store(inMemory:)` instead.
#if canImport(CoreModelSQLite)
import CoreModel
import CoreModelSQLite
import SQLite
Expand Down Expand Up @@ -31,3 +36,4 @@ public extension Store {
)
}
}
#endif
Loading
Loading