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

Local mutated bookmarks persistence & The new bookmarks persistence #686

Open
wants to merge 36 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
dc8f66a
Create PageBookmarkMutationsPersistence package
mohannad-hassan Feb 10, 2025
341c8d2
Create MutatedPageBookmarkModel
mohannad-hassan Feb 10, 2025
ff6a92b
Create GRDBPageBookmarkMutationsPersistence
mohannad-hassan Feb 10, 2025
4b8adc4
Define the interface of PageBookmarkMutationsPersistence
mohannad-hassan Feb 11, 2025
47a1cb6
Create PageBookmarkMutationsPersistenceTests
mohannad-hassan Feb 11, 2025
8490589
Handle creation
mohannad-hassan Feb 11, 2025
76f519f
Delete bookmarks
mohannad-hassan Feb 12, 2025
4e4ae58
Guard against creating a duplicated bookmark
mohannad-hassan Feb 13, 2025
8d8067b
Clear all records
mohannad-hassan Feb 13, 2025
4d12ce9
Publisher
mohannad-hassan Feb 13, 2025
c194111
Allow to change a synced bookmark's modification date via deleting an…
mohannad-hassan Feb 14, 2025
24ec42a
Handle some illegal states
mohannad-hassan Feb 14, 2025
175b3ad
Add some documentation
mohannad-hassan Feb 14, 2025
b87ec7e
Linting
mohannad-hassan Feb 14, 2025
0ca5d9e
Add an auto-incrementing primary key
mohannad-hassan Feb 14, 2025
bac7b75
Refactor to remove bookamrks by only page number and an optional remo…
mohannad-hassan Feb 17, 2025
146d588
Refactor deleted attribute and define a `Mutation` enumt
mohannad-hassan Feb 17, 2025
8255b71
Make types public
mohannad-hassan Feb 18, 2025
9022f6d
Add a function to fetch mutations for a single bookmark
mohannad-hassan Feb 20, 2025
e1315e5
Rename PageBookmarkMutationsPersistence to MutatedPageBookmarkPersist…
mohannad-hassan Feb 26, 2025
777361e
Documentation
mohannad-hassan Feb 27, 2025
d2cbbc6
Test individual page fetch function
mohannad-hassan Feb 27, 2025
c518b00
Create SynchronizedPageBookmarkPersistence
mohannad-hassan Feb 18, 2025
299feed
Create SynchronizedPageBookmarkPersistenceTests
mohannad-hassan Feb 18, 2025
0aa6923
Add `remoteID` to PageBookmarkPersistenceModel
mohannad-hassan Feb 18, 2025
3693340
Read synced database in SynchronizedPageBookmarkPersistence
mohannad-hassan Feb 18, 2025
033601a
Test updating the synced bookmarks
mohannad-hassan Feb 18, 2025
59ed238
Read updates from mutated bookmarks
mohannad-hassan Feb 18, 2025
efa1e6c
Merge synced and mutated bookmarks properly
mohannad-hassan Feb 19, 2025
b1ec970
Delete records in SynchronizedPageBookmarkPersistence
mohannad-hassan Feb 20, 2025
7ef67c6
Create bookmarks
mohannad-hassan Feb 20, 2025
2fa08b5
Update name of the mutated bookmarks persistence package
mohannad-hassan Feb 27, 2025
3d8d4a6
Fix insertion
mohannad-hassan Feb 27, 2025
a4019da
Refactor SynchronizedPageBookmarkPersistence
mohannad-hassan Feb 28, 2025
e10ea69
Remove some unneeded tests
mohannad-hassan Feb 28, 2025
4203309
Linting
mohannad-hassan Feb 28, 2025
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
//
// GRDBMutatedPageBookmarkPersistence.swift
// QuranEngine
//
// Created by Mohannad Hassan on 10/02/2025.
//

import Combine
import Foundation
import GRDB
import SQLitePersistence
import VLogging

public struct GRDBMutatedPageBookmarkPersistence: MutatedPageBookmarkPersistence {
// MARK: Lifecycle

init(db: DatabaseConnection) {
self.db = db
do {
try migrator.migrate(db)
} catch {
logger.error("Failed to to do Mutated Page Bookmarks migration: \(error)")
}
}

public init(directory: URL) {
let fileURL = directory.appendingPathComponent("local_pagebookmarks_mutations.db", isDirectory: false)
self.init(db: DatabaseConnection(url: fileURL, readonly: false))
}

// MARK: Public

public func bookmarksPublisher() throws -> AnyPublisher<[MutatedPageBookmarkModel], Never> {
try db.readPublisher { db in
try GRDBMutatedPageBookmark.fetchAll(db)
.map { $0.toMutatedBookmarkModel() }
}
.catch { error in
logger.error("Error in page bookmarks publisher: \(error)")
return Empty<[MutatedPageBookmarkModel], Never>()
}
.eraseToAnyPublisher()
}

public func bookmarkMutations(page: Int) async throws -> [MutatedPageBookmarkModel] {
try await db.read { db in
try GRDBMutatedPageBookmark.fetchAll(
db.makeStatement(sql: "SELECT * FROM \(GRDBMutatedPageBookmark.databaseTableName) WHERE page = ?"),
arguments: [page]
)
.map { $0.toMutatedBookmarkModel() }
}
}

public func bookmarks() async throws -> [MutatedPageBookmarkModel] {
try await db.read { db in
try GRDBMutatedPageBookmark.fetchAll(db)
.map { $0.toMutatedBookmarkModel() }
}
}

public func createBookmark(page: Int) async throws {
let persisted = try await fetchCreatedBookmark(forPage: page)
if persisted?.deleted == false {
logger.error("[PageBookamrksMutatiosn] Adding a duplicate page bookmark.")
throw MutatedPageBookmarkPersistenceError.bookmarkAlreadyExists(page: page)
}

// If `persisted` is still not nil, then it's been deleted, and it was a synced one.
// In this case, it's safe to create a new unsynced bookmark, as a way to update
// the bookmark's modification date.
// The other case for this code branch is that `persisted` is nil.
try await db.write { db in
var instance = GRDBMutatedPageBookmark(page: page)
try instance.insert(db)
}
}

public func removeBookmark(page: Int, remoteID: String?) async throws {
let hasCreatedRecord = try await fetchCreatedBookmark(forPage: page) != nil

if hasCreatedRecord && remoteID != nil {
logger.error("[PageBookamrksMutatiosn] Illegal state: Deleting a bookmark on page, while there's an unsynced bookmark on the same page.")
let reason = "Deleting a synced bookmark on a page, after creating an unsynced one."
throw MutatedPageBookmarkPersistenceError.illegalState(reason: reason, page: page)
} else if hasCreatedRecord && remoteID == nil {
logger.trace("[PageBookamrksMutatiosn] Removing records for a page bookmark, after deleting an unsynced bookmark.")
try await deleteAll(forPage: page)
} else if remoteID != nil {
logger.trace("[PageBookamrksMutatiosn] Adding a delete record for a synced page bookmark.")
try await createBookamrkMarkedForDelete(for: page, remoteID: remoteID!)
} else {
logger.error("[PageBookamrksMutatiosn] Illegal state: Deleting an unsynced page bookmark, while there's no record for it.")
let reason = "Deleting an unsynced bookmark on a page with no record of being created."
throw MutatedPageBookmarkPersistenceError.illegalState(reason: reason, page: page)
}
}

public func clear() async throws {
try await db.write { db in
let cnt = try GRDBMutatedPageBookmark.deleteAll(db)
logger.info("[PageBookmarkMutationsPersistence] Cleared \(cnt) bookmark records.")
}
}

// MARK: Private

private let db: DatabaseConnection

private var migrator: DatabaseMigrator {
var migrator = DatabaseMigrator()
migrator.registerMigration("createPageBookmarks") { db in
try db.create(table: GRDBMutatedPageBookmark.databaseTableName, options: .ifNotExists) { table in
table.column("page", .integer).notNull()
table.column("remote_id", .text)
table.column("deleted", .boolean).notNull().defaults(to: false)
table.column("modification_date", .datetime).notNull()
// See the documentation on GRDBMutatedPageBookmark.
table.column("id", .integer).primaryKey(autoincrement: true)
}
}
return migrator
}

/// Fetches a bookmark for the given page that isn't marked for deletion.
private func fetchCreatedBookmark(forPage page: Int) async throws -> GRDBMutatedPageBookmark? {
try await db.read { db in
try GRDBMutatedPageBookmark.fetchOne(db.makeStatement(sql: "SELECT * from \(GRDBMutatedPageBookmark.databaseTableName) WHERE page=? AND deleted=false"), arguments: ["\(page)"])
}
}

private func createBookamrkMarkedForDelete(for page: Int, remoteID: String) async throws {
try await db.write { db in
var instance = GRDBMutatedPageBookmark(
remoteID: remoteID,
page: page,
modificationDate: Date(),
deleted: true
)
try instance.insert(db)
}
}

private func deleteAll(forPage page: Int) async throws {
try await db.write { db in
try db.execute(sql: "DELETE FROM \(GRDBMutatedPageBookmark.databaseTableName) WHERE page = ?", arguments: [page])
}
}
}

/// Imperatives:
/// - If remote ID is not nil, then `deleted` must be true.
/// - If remote ID is nil, then `deleted` can't be true
/// - If there are two records with the same `page`, then the first must be a deletion for a synced bookmark, so the remote
/// ID must be nil, and the second must be a new unsynced one.
/// - Otherwise, there can't be two records for the same `page`.
private struct GRDBMutatedPageBookmark: Identifiable, Codable, FetchableRecord, MutablePersistableRecord {
enum CodingKeys: String, CodingKey {
case id
case remoteID = "remote_id"
case page
case modificationDate = "modification_date"
case deleted
}

static var databaseTableName: String { "mutated_page_bookmarks" }

var id: Int64?
var remoteID: String?
var page: Int
var modificationDate: Date
var deleted: Bool
}

private extension GRDBMutatedPageBookmark {
init(page: Int) {
self.init(page: page, modificationDate: Date(), deleted: false)
}

func toMutatedBookmarkModel() -> MutatedPageBookmarkModel {
.init(
remoteID: remoteID,
page: page,
modificationDate: modificationDate,
mutation: remoteID == nil ? .created : .deleted
)
}
}

private extension MutatedPageBookmarkModel {
var isSynced: Bool { remoteID != nil }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// MutatedPageBookmarkModel.swift
// QuranEngine
//
// Created by Mohannad Hassan on 10/02/2025.
//

import Foundation

/// Represents a page bookmark with mutation information.
///
/// This model is used to represent a bookmark that has been created or mutated locally. The model
/// may reference a remote bookmark that has been synced with the upstream server, in which case,
/// `remoteID` will be the ID of that bookmark. This means that the bookmark has been deleted
/// locally, but the deletion hasn't been marked as synced yet.
///
/// See `MutatedPageBookmarkPersistence` for more information regarding
/// the relationship between the local mutated bookmarks and the upstream-synced bookmarks.
public struct MutatedPageBookmarkModel {
public enum Mutation {
case created
case deleted
}

/// If `nil`, then this is a local bookmark that hasn't been synced upstream. Otherwise, this
/// is the remote ID of the upstream bookmark.
public let remoteID: String?
public let page: Int
public let modificationDate: Date

/// Represents the kind of mutation that casued this bookmark's current state.
///
/// If `remoteID` is `nil`, then this will be `.created`. For remote bookmarks, this will be
/// `.deleted`.
public let mutation: Mutation
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
//
// MutatedPageBookmarkPersistence.swift
// QuranEngine
//
// Created by Mohannad Hassan on 10/02/2025.
//

import Combine
import Foundation

public enum MutatedPageBookmarkPersistenceError: Error {
case bookmarkAlreadyExists(page: Int)
case illegalState(reason: String, page: Int)
}

/// Provides access for a mutable page bookmark persistence.
///
/// This persistence layer is aware of the difference between downstream and upstream-synced bookmarks. It can mark
/// a mutation record for an upstream-synced bookmark, while providing access to create and remove local-unsynced
/// bookmarks. If used in an unconnected state, this persistence can be can be seen as an local bookmarks persistence,
/// without reference to upstream resources.
///
/// The repository guarantees that it can only hold a single *created* bookmark for a page. It may hold a record of a
/// deleted upstream bookmark for a page holding a record for a created local unsynced bookmark. It's the responsibility
/// of the client of this persistence to guarantee any further data requirements with the upstream resources.
///
/// See `MutatedPageBookmarkModel` for more information on the state of the returend objects.
public protocol MutatedPageBookmarkPersistence {
func bookmarksPublisher() throws -> AnyPublisher<[MutatedPageBookmarkModel], Never>

func bookmarks() async throws -> [MutatedPageBookmarkModel]

func bookmarkMutations(page: Int) async throws -> [MutatedPageBookmarkModel]

/// Creates a new local bookmark for the given page.
///
/// - throws `MutatedPageBookmarkPersistenceError.bookmarkAlreadyExists`
func createBookmark(page: Int) async throws

/// Signals the removal of the bookmark for the given page.
///
/// If `remoteID` is given, this marks the record for deletion on the associated upstream resource.
/// If `remoteID` is `nil`, this will remove the records of the bookmark of the given page.
///
/// - throws `MutatedPageBookmarkPersistenceError.illegalState` if
/// `remoteID` is nil, and there's no local bookmark for that page.
func removeBookmark(page: Int, remoteID: String?) async throws

/// Clears all records.
///
/// Should only be called after a sync operation with the upstream source is done.
func clear() async throws
}
Loading