Skip to content
Merged
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
545 changes: 545 additions & 0 deletions Docs/2026-04-12-persistent-color-cache-list-rows.md

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions Packages/PlayaDB/Sources/PlayaDB/Models/ListRow.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import Foundation

/// Fully-inflated row for list views. Bundles the data object with all metadata
/// needed for rendering, fetched in a single GRDB read transaction.
public struct ListRow<T> {
public let object: T
public let metadata: ObjectMetadata?
public let thumbnailColors: ThumbnailColors?

/// Convenience: whether this object is favorited.
public var isFavorite: Bool { metadata?.isFavorite ?? false }

public init(object: T, metadata: ObjectMetadata?, thumbnailColors: ThumbnailColors?) {
self.object = object
self.metadata = metadata
self.thumbnailColors = thumbnailColors
}
}
78 changes: 78 additions & 0 deletions Packages/PlayaDB/Sources/PlayaDB/Models/ThumbnailColors.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import Foundation
import GRDB

/// Cached thumbnail-extracted colors for data objects.
/// Stores RGBA components for four semantic colors: background, primary, secondary, detail.
public struct ThumbnailColors: Codable, FetchableRecord, MutablePersistableRecord, Equatable {
public static let databaseTableName = "thumbnail_colors"

public enum Columns: String, CodingKey, ColumnExpression {
case objectId = "object_id"
case bgRed = "bg_red"
case bgGreen = "bg_green"
case bgBlue = "bg_blue"
case bgAlpha = "bg_alpha"
case primaryRed = "primary_red"
case primaryGreen = "primary_green"
case primaryBlue = "primary_blue"
case primaryAlpha = "primary_alpha"
case secondaryRed = "secondary_red"
case secondaryGreen = "secondary_green"
case secondaryBlue = "secondary_blue"
case secondaryAlpha = "secondary_alpha"
case detailRed = "detail_red"
case detailGreen = "detail_green"
case detailBlue = "detail_blue"
case detailAlpha = "detail_alpha"
}

private typealias CodingKeys = Columns

// MARK: - Properties

public var objectId: String
public var bgRed: Double
public var bgGreen: Double
public var bgBlue: Double
public var bgAlpha: Double
public var primaryRed: Double
public var primaryGreen: Double
public var primaryBlue: Double
public var primaryAlpha: Double
public var secondaryRed: Double
public var secondaryGreen: Double
public var secondaryBlue: Double
public var secondaryAlpha: Double
public var detailRed: Double
public var detailGreen: Double
public var detailBlue: Double
public var detailAlpha: Double

// MARK: - Init

public init(
objectId: String,
bgRed: Double, bgGreen: Double, bgBlue: Double, bgAlpha: Double,
primaryRed: Double, primaryGreen: Double, primaryBlue: Double, primaryAlpha: Double,
secondaryRed: Double, secondaryGreen: Double, secondaryBlue: Double, secondaryAlpha: Double,
detailRed: Double, detailGreen: Double, detailBlue: Double, detailAlpha: Double
) {
self.objectId = objectId
self.bgRed = bgRed
self.bgGreen = bgGreen
self.bgBlue = bgBlue
self.bgAlpha = bgAlpha
self.primaryRed = primaryRed
self.primaryGreen = primaryGreen
self.primaryBlue = primaryBlue
self.primaryAlpha = primaryAlpha
self.secondaryRed = secondaryRed
self.secondaryGreen = secondaryGreen
self.secondaryBlue = secondaryBlue
self.secondaryAlpha = secondaryAlpha
self.detailRed = detailRed
self.detailGreen = detailGreen
self.detailBlue = detailBlue
self.detailAlpha = detailAlpha
}
}
40 changes: 26 additions & 14 deletions Packages/PlayaDB/Sources/PlayaDB/PlayaDB.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,11 @@ public protocol PlayaDB {
func fetchMutantVehicle(uid: String) async throws -> MutantVehicleObject?

/// Observe mutant vehicles matching the specified filter criteria.
/// Returns fully-inflated `ListRow`s with metadata and thumbnail colors.
@discardableResult
func observeMutantVehicles(
filter: MutantVehicleFilter,
onChange: @escaping ([MutantVehicleObject]) -> Void,
onChange: @escaping ([ListRow<MutantVehicleObject>]) -> Void,
onError: @escaping (Error) -> Void
) -> PlayaDBObservationToken

Expand Down Expand Up @@ -98,32 +99,29 @@ public protocol PlayaDB {
func fetchEvents(filter: EventFilter) async throws -> [EventObjectOccurrence]

/// Observe art objects matching the specified filter criteria.
///
/// - Parameters:
/// - filter: Filter options for art objects.
/// - onChange: Called each time the underlying query results change.
/// - onError: Called if the observation encounters an error.
/// - Returns: Token for cancelling the observation.
/// Returns fully-inflated `ListRow`s with metadata and thumbnail colors.
@discardableResult
func observeArt(
filter: ArtFilter,
onChange: @escaping ([ArtObject]) -> Void,
onChange: @escaping ([ListRow<ArtObject>]) -> Void,
onError: @escaping (Error) -> Void
) -> PlayaDBObservationToken

/// Observe camp objects matching the specified filter criteria.
/// Returns fully-inflated `ListRow`s with metadata and thumbnail colors.
@discardableResult
func observeCamps(
filter: CampFilter,
onChange: @escaping ([CampObject]) -> Void,
onChange: @escaping ([ListRow<CampObject>]) -> Void,
onError: @escaping (Error) -> Void
) -> PlayaDBObservationToken

/// Observe event occurrences matching the specified filter criteria.
/// Returns fully-inflated `ListRow`s with metadata and thumbnail colors.
@discardableResult
func observeEvents(
filter: EventFilter,
onChange: @escaping ([EventObjectOccurrence]) -> Void,
onChange: @escaping ([ListRow<EventObjectOccurrence>]) -> Void,
onError: @escaping (Error) -> Void
) -> PlayaDBObservationToken

Expand Down Expand Up @@ -188,6 +186,20 @@ public protocol PlayaDB {
/// Batch fetch objects of any type by their UIDs (4 queries total, one per type)
func fetchObjects(byUIDs uids: [String]) async throws -> [any DataObject]

// MARK: - Thumbnail Colors

/// Save (insert or replace) a single thumbnail color entry.
func saveThumbnailColors(_ colors: ThumbnailColors) async throws

/// Save a batch of thumbnail color entries in a single transaction.
func saveThumbnailColorsBatch(_ batch: [ThumbnailColors]) async throws

/// Fetch cached thumbnail colors for an object.
func fetchThumbnailColors(objectId: String) async throws -> ThumbnailColors?

/// Fetch all object IDs that have cached thumbnail colors.
func fetchCachedColorObjectIDs() async throws -> Set<String>

// MARK: - User Map Pins

/// Save (insert or update) a user map pin.
Expand Down Expand Up @@ -242,31 +254,31 @@ public extension PlayaDB {
@discardableResult
func observeArt(
filter: ArtFilter,
onChange: @escaping ([ArtObject]) -> Void
onChange: @escaping ([ListRow<ArtObject>]) -> Void
) -> PlayaDBObservationToken {
observeArt(filter: filter, onChange: onChange, onError: { _ in })
}

@discardableResult
func observeCamps(
filter: CampFilter,
onChange: @escaping ([CampObject]) -> Void
onChange: @escaping ([ListRow<CampObject>]) -> Void
) -> PlayaDBObservationToken {
observeCamps(filter: filter, onChange: onChange, onError: { _ in })
}

@discardableResult
func observeEvents(
filter: EventFilter,
onChange: @escaping ([EventObjectOccurrence]) -> Void
onChange: @escaping ([ListRow<EventObjectOccurrence>]) -> Void
) -> PlayaDBObservationToken {
observeEvents(filter: filter, onChange: onChange, onError: { _ in })
}

@discardableResult
func observeMutantVehicles(
filter: MutantVehicleFilter,
onChange: @escaping ([MutantVehicleObject]) -> Void
onChange: @escaping ([ListRow<MutantVehicleObject>]) -> Void
) -> PlayaDBObservationToken {
observeMutantVehicles(filter: filter, onChange: onChange, onError: { _ in })
}
Expand Down
110 changes: 90 additions & 20 deletions Packages/PlayaDB/Sources/PlayaDB/PlayaDBImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,17 @@ internal class PlayaDBImpl: PlayaDB {
)
""")

// Create thumbnail_colors table for cached extracted colors
try db.execute(sql: """
CREATE TABLE IF NOT EXISTS thumbnail_colors (
object_id TEXT PRIMARY KEY,
bg_red REAL NOT NULL, bg_green REAL NOT NULL, bg_blue REAL NOT NULL, bg_alpha REAL NOT NULL,
primary_red REAL NOT NULL, primary_green REAL NOT NULL, primary_blue REAL NOT NULL, primary_alpha REAL NOT NULL,
secondary_red REAL NOT NULL, secondary_green REAL NOT NULL, secondary_blue REAL NOT NULL, secondary_alpha REAL NOT NULL,
detail_red REAL NOT NULL, detail_green REAL NOT NULL, detail_blue REAL NOT NULL, detail_alpha REAL NOT NULL
)
""")

// Create user_map_pins table
try db.execute(sql: """
CREATE TABLE IF NOT EXISTS user_map_pins (
Expand Down Expand Up @@ -1070,38 +1081,65 @@ internal class PlayaDBImpl: PlayaDB {

// MARK: - Filtered Observation Helpers

private func observe<T>(
type: DataObjectType?,
/// Observe objects as fully-inflated ListRows. Fetches objects, metadata, and
/// thumbnail colors in a single read transaction.
private func observeListRows<T>(
type: DataObjectType,
ids: @escaping ([T]) -> [String],
value: @escaping @Sendable (Database) throws -> [T],
onChange: @escaping ([T]) -> Void,
onChange: @escaping ([ListRow<T>]) -> Void,
onError: @escaping (Error) -> Void
) -> PlayaDBObservationToken {
let observation = ValueObservation.tracking(value)
let typeRaw = type.rawValue
let observation = ValueObservation.tracking { db -> [ListRow<T>] in
let objects = try value(db)
let objectIDs = ids(objects)
guard !objectIDs.isEmpty else { return [] }

// Batch fetch full metadata in same transaction
let allMeta = try ObjectMetadata
.filter(ObjectMetadata.Columns.objectType == typeRaw)
.filter(objectIDs.contains(ObjectMetadata.Columns.objectId))
.fetchAll(db)
let metaByID = Dictionary(uniqueKeysWithValues: allMeta.map { ($0.objectId, $0) })

// Batch fetch thumbnail colors in same transaction
let allColors = try ThumbnailColors
.filter(objectIDs.contains(ThumbnailColors.Columns.objectId))
.fetchAll(db)
let colorsByID = Dictionary(uniqueKeysWithValues: allColors.map { ($0.objectId, $0) })

return objects.map { obj in
let uid = ids([obj]).first ?? ""
return ListRow(
object: obj,
metadata: metaByID[uid],
thumbnailColors: colorsByID[uid]
)
}
}
let cancellable = observation.start(
in: dbQueue,
onError: onError,
onChange: { [weak self] values in
if let type = type {
let identifiers = ids(values)
if !identifiers.isEmpty {
Task {
try? await self?.ensureMetadata(for: type, ids: identifiers)
}
onChange: { [weak self] rows in
let identifiers = ids(rows.map(\.object))
if !identifiers.isEmpty {
Task {
try? await self?.ensureMetadata(for: type, ids: identifiers)
}
}
onChange(values)
onChange(rows)
}
)
return PlayaDBObservationToken(cancellable)
}

func observeArt(
filter: ArtFilter,
onChange: @escaping ([ArtObject]) -> Void,
onChange: @escaping ([ListRow<ArtObject>]) -> Void,
onError: @escaping (Error) -> Void
) -> PlayaDBObservationToken {
observe(
observeListRows(
type: .art,
ids: { $0.map(\.uid) },
value: { [weak self, filter] db in
Expand All @@ -1115,10 +1153,10 @@ internal class PlayaDBImpl: PlayaDB {

func observeCamps(
filter: CampFilter,
onChange: @escaping ([CampObject]) -> Void,
onChange: @escaping ([ListRow<CampObject>]) -> Void,
onError: @escaping (Error) -> Void
) -> PlayaDBObservationToken {
observe(
observeListRows(
type: .camp,
ids: { $0.map(\.uid) },
value: { [weak self, filter] db in
Expand All @@ -1132,10 +1170,10 @@ internal class PlayaDBImpl: PlayaDB {

func observeEvents(
filter: EventFilter,
onChange: @escaping ([EventObjectOccurrence]) -> Void,
onChange: @escaping ([ListRow<EventObjectOccurrence>]) -> Void,
onError: @escaping (Error) -> Void
) -> PlayaDBObservationToken {
observe(
observeListRows(
type: .event,
ids: { $0.map { $0.event.uid } },
value: { [weak self, filter] db in
Expand All @@ -1149,10 +1187,10 @@ internal class PlayaDBImpl: PlayaDB {

func observeMutantVehicles(
filter: MutantVehicleFilter,
onChange: @escaping ([MutantVehicleObject]) -> Void,
onChange: @escaping ([ListRow<MutantVehicleObject>]) -> Void,
onError: @escaping (Error) -> Void
) -> PlayaDBObservationToken {
observe(
observeListRows(
type: .mutantVehicle,
ids: { $0.map(\.uid) },
value: { [weak self, filter] db in
Expand All @@ -1164,6 +1202,38 @@ internal class PlayaDBImpl: PlayaDB {
)
}

// MARK: - Thumbnail Colors

func saveThumbnailColors(_ colors: ThumbnailColors) async throws {
try await dbQueue.write { db in
var colors = colors
try colors.save(db, onConflict: .replace)
}
}

func saveThumbnailColorsBatch(_ batch: [ThumbnailColors]) async throws {
try await dbQueue.write { db in
for var colors in batch {
try colors.save(db, onConflict: .replace)
}
}
}

func fetchThumbnailColors(objectId: String) async throws -> ThumbnailColors? {
try await dbQueue.read { db in
try ThumbnailColors
.filter(ThumbnailColors.Columns.objectId == objectId)
.fetchOne(db)
}
}

func fetchCachedColorObjectIDs() async throws -> Set<String> {
try await dbQueue.read { db in
let ids = try String.fetchAll(db, sql: "SELECT object_id FROM thumbnail_colors")
return Set(ids)
}
}

// MARK: - User Map Pins

func saveUserMapPin(_ pin: UserMapPin) async throws {
Expand Down
Loading
Loading