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

swift-client: initial sketch. #1958

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
24 changes: 24 additions & 0 deletions packages/swift-client/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Electric Swift client

WIP: initial sketch of a Swift client.

## Notes

- `ElectricShape` **Class**: This class manages the synchronization and data handling for a single ElectricSQL shape.
- `@Published` **Property Wrapper**: This makes the `data` property observable, allowing you to update UI elements or other parts of your application whenever the shape data changes.
- **Initialization**: The `init` method sets up the base URL, table name, and optional where clause for the shape.
- `subscribe` **Method**: Allows other parts of your application to subscribe to data changes.
- `sync` **Method**: Starts an asynchronous task that continuously polls for updates from the ElectricSQL server.
- `request` **Method**: Constructs the API request URL and handles the HTTP request/response cycle.
- `processMessages` **Method**: Parses and applies the incoming changes from the server to the local data dictionary.
- `applyOperation` **Method**: Handles the different operation types ("insert", "update", "delete") received from the server.
- `notifySubscribers` **Method**: Notifies any subscribers about changes in the data.
- `buildUrl` **Method**: Dynamically constructs the URL for the API request based on the current state of the ElectricShape object.

N.b.:

- includes basic error handling, but a production-ready client should have more robust error handling, including:
- Retrying failed requests with exponential backoff.
- Handling network connectivity issues.
- Gracefully handling different HTTP error codes.
- sync method runs indefinitely; implement a mechanism to stop the sync when done
166 changes: 166 additions & 0 deletions packages/swift-client/client.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import Foundation

class ElectricShape: ObservableObject {
@Published var data: [String: Any] = [:]
private var offset: String = "-1"
private var handle: String?
private var cursor: String?
private var live: Bool = false
private let baseUrl: String
private let table: String
private let whereClause: String?
private var messages: [[[String: Any]]] = []
private var subscribers: [(Data) -> Void] = []

init(baseUrl: String = "http://localhost:3000", table: String, whereClause: String? = nil) {
self.baseUrl = baseUrl
self.table = table
self.whereClause = whereClause
}

func subscribe(callback: @escaping (Data) -> Void) {
subscribers.append(callback)
}

func sync() {
Task {
while true {
await request()
}
}
}

private func request() async {
guard let url = buildUrl() else {
print("Error building URL")
return
}

do {
var request = URLRequest(url: url)
request.httpMethod = "GET"

let (data, response) = try await URLSession.shared.data(for: request)

guard let httpResponse = response as? HTTPURLResponse else {
print("Invalid response")
return
}

if httpResponse.statusCode > 204 {
print("Error: \(httpResponse.statusCode)")
return
}

if httpResponse.statusCode == 200 {
if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]] {
messages.append(json)
} else {
print("Failed to decode JSON")
return
}

if httpResponse.allHeaderFields["electric-up-to-date"] != nil {
live = true
processMessages()
}
}

handle = httpResponse.allHeaderFields["electric-handle"] as? String
offset = httpResponse.allHeaderFields["electric-offset"] as? String
cursor = httpResponse.allHeaderFields["electric-cursor"] as? String

} catch {
print("Error fetching data: \(error)")
}
}

private func processMessages() {
var hasChanged = false

for batch in messages {
for message in batch {
if let opChanged = applyOperation(message), opChanged {
hasChanged = true
}
}
}

messages = []

if hasChanged {
notifySubscribers()
}
}

private func applyOperation(_ message: [String: Any]) -> Bool? {
guard let headers = message["headers"] as? [String: String],
let operation = headers["operation"],
let key = message["key"] as? String else { return nil }

let cleanKey = key.replacingOccurrences(of: "\"", with: "").split(separator: "/").last!
let value = message["value"] as? [String: Any]

switch operation {
case "insert":
data[String(cleanKey)] = value
return true
case "update":
guard var currentValue = data[String(cleanKey)] as? [String: Any] else { return false }
var hasChanged = false

if let value = value {
for (k, v) in value {
if currentValue[k] != v {
hasChanged = true
currentValue[k] = v
}
}
}

data[String(cleanKey)] = currentValue
return hasChanged
case "delete":
if data.keys.contains(String(cleanKey)) {
data.removeValue(forKey: String(cleanKey))
return true
}
return false
default:
return nil
}
}

private func notifySubscribers() {
for callback in subscribers {
callback(try! JSONSerialization.data(withJSONObject: data, options: .prettyPrinted))
}
}

private func buildUrl() -> URL? {
var components = URLComponents(string: "\(baseUrl)/v1/shape")
var queryItems: [URLQueryItem] = [
URLQueryItem(name: "table", value: table),
URLQueryItem(name: "offset", value: offset)
]

if let cursor = cursor {
queryItems.append(URLQueryItem(name: "cursor", value: cursor))
}

if let handle = handle {
queryItems.append(URLQueryItem(name: "handle", value: handle))
}

if live {
queryItems.append(URLQueryItem(name: "live", value: "true"))
}

if let whereClause = whereClause {
queryItems.append(URLQueryItem(name: "where", value: whereClause))
}

components?.queryItems = queryItems
return components?.url
}
}