From 97df8cdd8f324195ef628f0b74f1f53d32443a1a Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 12 Dec 2025 11:06:53 -0300 Subject: [PATCH 1/9] WIP experimenting with user-provided map shapes --- .../Public/UserDefinedTypeSupport.swift | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 Sources/AblyLiveObjects/Public/UserDefinedTypeSupport.swift diff --git a/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport.swift b/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport.swift new file mode 100644 index 00000000..ebb05b84 --- /dev/null +++ b/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport.swift @@ -0,0 +1,112 @@ +import Foundation +import Ably + +// MARK: - Public-facing types for shaped LiveMaps + +// TODO not sure this actually needs to be a protocol +protocol LiveMapShape { + // I'm unsure about this but I think that we want something like it so that we can do implicit member access: `.get(key: .topLevelCounter)`. but again it's not clear what this would inherit from. Also we might need this in order to see whether a key is a known key or not. But we may have to have one of these per Value type? e.g. LiveMapStringKey, LiveMapLiveCounterKey etc (no, that falls apart when you start having parameterisable types e.g. nested maps) — Hmm +// associatedtype LiveMapKey +} + +// TODO this name isn't great, it's not really a key, it's a key description (but I guess a KeyPath is not just a "key path") +// This is going to be something described by +protocol LiveMapKey: Sendable { + associatedtype Shape: LiveMapShape + associatedtype Value +} + +struct ShapedLiveMap: Sendable { + // TODO this needs a `create()` with constraints + +} + +// TODO: naming TBD +// TODO: we don't have any constraints on Value which makes things trickier +// TODO: I didn't actually do PrimitivePathObject in the non-typed API; we should have that +protocol TypedPrimitivePathObject { + associatedtype Value + + var value: Value? { get } +} + +protocol ShapedLiveMapPathObject { + associatedtype Shape: LiveMapShape + + // TODO: we need set, entries etc + // TODO: what do keys and entries return when it's not a known key? + // TODO: you should still be able to interact with this without shape too + + // For entries of each of the primitive types + func get(key: Key) -> any TypedPrimitivePathObject where Key.Shape == Shape, Key.Value == String + func get(key: Key) -> any TypedPrimitivePathObject where Key.Shape == Shape, Key.Value == Double + func get(key: Key) -> any TypedPrimitivePathObject where Key.Shape == Shape, Key.Value == Bool + func get(key: Key) -> any TypedPrimitivePathObject where Key.Shape == Shape, Key.Value == Data + func get(key: Key) -> any TypedPrimitivePathObject<[JSONValue]> where Key.Shape == Shape, Key.Value == [JSONValue] + func get(key: Key) -> any TypedPrimitivePathObject<[String: JSONValue]> where Key.Shape == Shape, Key.Value == [String: JSONValue] + + // For LiveMap entries + func get(key: Key) -> LiveMapPathObject where Key.Shape == Shape, Key.Value == LiveMap + func get(key: Key) -> any ShapedLiveMapPathObject where Key.Shape == Shape, Key.Value == ShapedLiveMap + + // For LiveCounter entries + func get(key: Key) -> LiveCounterPathObject where Key.Shape == Shape, Key.Value == LiveCounter +} + +// MARK: - RealtimeObject `get` implementation for shaped LiveMaps + +extension RealtimeObject { + func get(withShape shape: Shape.Type = Shape.self) async throws(ARTErrorInfo) -> any ShapedLiveMapPathObject { + // TODO + fatalError("Not implemented") + } +} + +// MARK: - Example + +struct MyChannelObject { + var topLevelCounter: LiveCounter + var topLevelMap: ShapedLiveMap + + struct TopLevelMap { + var nestedEntry: String + } +} + +func exampleWithChannel(_ channel: ARTRealtimeChannel) async throws { + // Note that we can't say `.get()` like in TypeScript; gives us "Cannot explicitly specialize instance method 'get()'" + let myChannelPathObject = try await channel.object.get(withShape: MyChannelObject.self) + + // TODO: this is a bit ugly; implicit member access would be nice but I'm not sure how that works when you might be fetching from one of various types depending on the value? + // TODO consider key paths instead of implicit member access + let topLevelCounter = myChannelPathObject.get(key: MyChannelObject.LiveMapKeys.topLevelCounter) + let topLevelMap = myChannelPathObject.get(key: MyChannelObject.LiveMapKeys.topLevelMap) + + let nestedEntry = topLevelMap.get(key: MyChannelObject.TopLevelMap.LiveMapKeys.nestedEntry) + let nestedEntryValue = nestedEntry.value +} + +// MARK: - Code that would be generated (for now we're just writing it out) + +// These would come from some sort of macro like @LiveMapShape +extension MyChannelObject: LiveMapShape {} +extension MyChannelObject.TopLevelMap: LiveMapShape {} + +extension MyChannelObject { + enum LiveMapKeys { + static let topLevelCounter: some LiveMapKey = DefaultLiveMapKey(rawKey: "topLevelCounter") + static let topLevelMap: some LiveMapKey> = DefaultLiveMapKey(rawKey: "topLevelCounter") + } +} + +extension MyChannelObject.TopLevelMap { + enum LiveMapKeys { + static let nestedEntry: some LiveMapKey = DefaultLiveMapKey(rawKey: "nestedEntry") + } +} + +// Not exactly clear where this would come from (because we don't really want this to be a public type, so the user would have to create it themselves) +struct DefaultLiveMapKey: LiveMapKey { + /// The underlying key to use for fetching this key from a map's entries + var rawKey: String +} From 374a7c973857d9bb94fbf7ba696c1818d0140355 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 12 Dec 2025 12:01:44 -0300 Subject: [PATCH 2/9] Add a key paths example --- .../Public/UserDefinedTypeSupport.swift | 59 +++++++++++++++++-- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport.swift b/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport.swift index ebb05b84..df6b898f 100644 --- a/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport.swift +++ b/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport.swift @@ -5,12 +5,13 @@ import Ably // TODO not sure this actually needs to be a protocol protocol LiveMapShape { - // I'm unsure about this but I think that we want something like it so that we can do implicit member access: `.get(key: .topLevelCounter)`. but again it's not clear what this would inherit from. Also we might need this in order to see whether a key is a known key or not. But we may have to have one of these per Value type? e.g. LiveMapStringKey, LiveMapLiveCounterKey etc (no, that falls apart when you start having parameterisable types e.g. nested maps) — Hmm -// associatedtype LiveMapKey + // I'm unsure about this but I think that we want something like it so that we can do implicit member access: `.get(key: .topLevelCounter)`. but again it's not clear what this would inherit from. Also we might need this in order to see whether a key is a known key or not. But we may have to have one of these per Value type? e.g. LiveMapStringKey, LiveMapLiveCounterKey etc (no, that falls apart when you start having parameterisable types e.g. nested maps) — Hmm. I think that `entries` might just not be possible because there's no obvious type to define. In that case we _would_ have to do codegen and list all of the possible types. we can still have a LiveMapEntry type here I guess + + // TODO: currently this is _only_ used for the convenience extension that allows key path lookups to make things neater + associatedtype LiveMapKeys } // TODO this name isn't great, it's not really a key, it's a key description (but I guess a KeyPath is not just a "key path") -// This is going to be something described by protocol LiveMapKey: Sendable { associatedtype Shape: LiveMapShape associatedtype Value @@ -36,6 +37,7 @@ protocol ShapedLiveMapPathObject { // TODO: we need set, entries etc // TODO: what do keys and entries return when it's not a known key? // TODO: you should still be able to interact with this without shape too + // I don't _think_ there is a less verbose way of figuring out the shape of the PathObject // For entries of each of the primitive types func get(key: Key) -> any TypedPrimitivePathObject where Key.Shape == Shape, Key.Value == String @@ -53,6 +55,42 @@ protocol ShapedLiveMapPathObject { func get(key: Key) -> LiveCounterPathObject where Key.Shape == Shape, Key.Value == LiveCounter } +// Convenience extensions for specifying a key by using a key path into a static member of Shape.LiveMapKeys. TODO improve naming: it's a bit confusing because it's a key path _into a set of keys_ (i.e. not into the shape itself). The reason we use key paths instead of implicit member access is because it doesn't require that the "member" actually have that type +extension ShapedLiveMapPathObject { + func get(keyAt keyPath: KeyPath) -> any TypedPrimitivePathObject where Key.Shape == Shape, Key.Value == String { + get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) + } + + func get(keyAt keyPath: KeyPath) -> any TypedPrimitivePathObject where Key.Shape == Shape, Key.Value == Double { + get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) + } + + func get(keyAt keyPath: KeyPath) -> any TypedPrimitivePathObject where Key.Shape == Shape, Key.Value == Bool { + get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) + } + + func get(keyAt keyPath: KeyPath) -> any TypedPrimitivePathObject where Key.Shape == Shape, Key.Value == Data { + get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) + } + + func get(keyAt keyPath: KeyPath) -> any TypedPrimitivePathObject<[JSONValue]> where Key.Shape == Shape, Key.Value == [JSONValue] { + get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) + } + + func get(keyAt keyPath: KeyPath) -> LiveMapPathObject where Key.Shape == Shape, Key.Value == LiveMap { + get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) + + } + + func get(keyAt keyPath: KeyPath) -> any ShapedLiveMapPathObject where Key.Shape == Shape, Key.Value == ShapedLiveMap { + get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) + } + + func get(keyAt keyPath: KeyPath) -> LiveCounterPathObject where Key.Shape == Shape, Key.Value == LiveCounter { + get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) + } +} + // MARK: - RealtimeObject `get` implementation for shaped LiveMaps extension RealtimeObject { @@ -77,15 +115,24 @@ func exampleWithChannel(_ channel: ARTRealtimeChannel) async throws { // Note that we can't say `.get()` like in TypeScript; gives us "Cannot explicitly specialize instance method 'get()'" let myChannelPathObject = try await channel.object.get(withShape: MyChannelObject.self) - // TODO: this is a bit ugly; implicit member access would be nice but I'm not sure how that works when you might be fetching from one of various types depending on the value? - // TODO consider key paths instead of implicit member access + // Note that fetching the keys is verbose; see the next example with key paths let topLevelCounter = myChannelPathObject.get(key: MyChannelObject.LiveMapKeys.topLevelCounter) let topLevelMap = myChannelPathObject.get(key: MyChannelObject.LiveMapKeys.topLevelMap) let nestedEntry = topLevelMap.get(key: MyChannelObject.TopLevelMap.LiveMapKeys.nestedEntry) - let nestedEntryValue = nestedEntry.value } +// Example that uses the key paths convenience methods for get() +func keyPathsExampleWithChannel(_ channel: ARTRealtimeChannel) async throws { + let myChannelPathObject = try await channel.object.get(withShape: MyChannelObject.self) + + let topLevelCounter = myChannelPathObject.get(keyAt: \.topLevelCounter) + let topLevelMap = myChannelPathObject.get(keyAt: \.topLevelMap) + + let nestedEntry = topLevelMap.get(keyAt: \.nestedEntry) +} + + // MARK: - Code that would be generated (for now we're just writing it out) // These would come from some sort of macro like @LiveMapShape From baa1a4ef76cd9c0d283665409e91d5ab28caaedc Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 12 Dec 2025 12:09:40 -0300 Subject: [PATCH 3/9] Further --- .../Public/UserDefinedTypeSupport.swift | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport.swift b/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport.swift index df6b898f..de624818 100644 --- a/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport.swift +++ b/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport.swift @@ -3,6 +3,8 @@ import Ably // MARK: - Public-facing types for shaped LiveMaps +// TODO assess how much LiveMapShape needs to be able to do, and if it's just a convenience, then remove some constraints + // TODO not sure this actually needs to be a protocol protocol LiveMapShape { // I'm unsure about this but I think that we want something like it so that we can do implicit member access: `.get(key: .topLevelCounter)`. but again it's not clear what this would inherit from. Also we might need this in order to see whether a key is a known key or not. But we may have to have one of these per Value type? e.g. LiveMapStringKey, LiveMapLiveCounterKey etc (no, that falls apart when you start having parameterisable types e.g. nested maps) — Hmm. I think that `entries` might just not be possible because there's no obvious type to define. In that case we _would_ have to do codegen and list all of the possible types. we can still have a LiveMapEntry type here I guess @@ -135,25 +137,39 @@ func keyPathsExampleWithChannel(_ channel: ARTRealtimeChannel) async throws { // MARK: - Code that would be generated (for now we're just writing it out) -// These would come from some sort of macro like @LiveMapShape -extension MyChannelObject: LiveMapShape {} -extension MyChannelObject.TopLevelMap: LiveMapShape {} +// These would come from some sort of macro like @LiveMapShape applied to MyChannelObject -extension MyChannelObject { +extension MyChannelObject: LiveMapShape { enum LiveMapKeys { - static let topLevelCounter: some LiveMapKey = DefaultLiveMapKey(rawKey: "topLevelCounter") - static let topLevelMap: some LiveMapKey> = DefaultLiveMapKey(rawKey: "topLevelCounter") + private struct Key: LiveMapKey { + typealias Shape = MyChannelObject + + /// The underlying key to use for fetching this key from a map's entries + var rawKey: String + } + + static let topLevelCounter: some LiveMapKey = Key(rawKey: "topLevelCounter") + static let topLevelMap: some LiveMapKey> = Key(rawKey: "topLevelCounter") } } -extension MyChannelObject.TopLevelMap { +extension MyChannelObject.TopLevelMap: LiveMapShape { enum LiveMapKeys { - static let nestedEntry: some LiveMapKey = DefaultLiveMapKey(rawKey: "nestedEntry") + private struct Key: LiveMapKey { + typealias Shape = MyChannelObject.TopLevelMap + + /// The underlying key to use for fetching this key from a map's entries + var rawKey: String + } + + static let nestedEntry: some LiveMapKey = Key(rawKey: "nestedEntry") } } -// Not exactly clear where this would come from (because we don't really want this to be a public type, so the user would have to create it themselves) +// Note that each `LiveMapKeys` declares their own `Key` type — this is so that we don't have to pollute the library's public types with something that's only used for generated code; i.e. else we'd have to have something like the following: + +/* struct DefaultLiveMapKey: LiveMapKey { - /// The underlying key to use for fetching this key from a map's entries var rawKey: String } +*/ From 535cb0925eabb8226840829b8de8a048ef535d17 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 12 Dec 2025 13:02:34 -0300 Subject: [PATCH 4/9] WIP adding set and remove --- .../Public/UserDefinedTypeSupport.swift | 39 +++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport.swift b/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport.swift index de624818..a1205f36 100644 --- a/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport.swift +++ b/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport.swift @@ -33,12 +33,39 @@ protocol TypedPrimitivePathObject { var value: Value? { get } } +// TODO: How is Instance going to work? is it actually going to check types? if so will it do it all the way down through nested maps etc? + protocol ShapedLiveMapPathObject { associatedtype Shape: LiveMapShape - // TODO: we need set, entries etc - // TODO: what do keys and entries return when it's not a known key? - // TODO: you should still be able to interact with this without shape too + // TODO: we need keys and entries (what does entries return, and how do they both handle an unknown key?). I think that perhaps `keys` could just return [String], and that the LiveMapShape will need to define a Entry associated type (most likely an enum in practice) that can create itself from a given key and PathObject (or fail to do so in which case we'll have to return some "unknown" type) + // TODO: you should still be able to interact with this without shape too — I think the best thing would be to make _this_ type only work with Key but have a way to turn it into a normal LiveMapPathObject + + // Variants of `set()` + + // All the set() operations that this needs to be able to support. (I don't think we can do better than this because this type isn't expected to be able to handle arbitrary values, even if a user can form a Key that has one; that is, we can't just have a single one that takes Key.Value); unless we end up being able to impose constraints on Key.Value somehow but I don't really want to start adding extensions to String etc + + // For entries of each of the primitive types + func set(key: Key, value: String) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == String + func set(key: Key, value: Double) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == Double + func set(key: Key, value: Bool) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == Bool + func set(key: Key, value: Data) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == Data + func set(key: Key, value: [JSONValue]) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == JSONValue + func set(key: Key, value: [String: JSONValue]) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == [String: JSONValue] + + // For LiveMap entries + func set(key: Key, value: LiveMap) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == LiveMap + func set(key: Key, value: ShapedLiveMap) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == ShapedLiveMap + + // For LiveCounter entries + func set(key: Key) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == LiveCounter + + // `remove()` + + func remove(key: Key) async throws(ARTErrorInfo) + + // Variants of `get()` + // I don't _think_ there is a less verbose way of figuring out the shape of the PathObject // For entries of each of the primitive types @@ -59,6 +86,8 @@ protocol ShapedLiveMapPathObject { // Convenience extensions for specifying a key by using a key path into a static member of Shape.LiveMapKeys. TODO improve naming: it's a bit confusing because it's a key path _into a set of keys_ (i.e. not into the shape itself). The reason we use key paths instead of implicit member access is because it doesn't require that the "member" actually have that type extension ShapedLiveMapPathObject { + // Getters + func get(keyAt keyPath: KeyPath) -> any TypedPrimitivePathObject where Key.Shape == Shape, Key.Value == String { get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) } @@ -91,6 +120,8 @@ extension ShapedLiveMapPathObject { func get(keyAt keyPath: KeyPath) -> LiveCounterPathObject where Key.Shape == Shape, Key.Value == LiveCounter { get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) } + + // TODO create `set()` variants with key paths } // MARK: - RealtimeObject `get` implementation for shaped LiveMaps @@ -124,6 +155,8 @@ func exampleWithChannel(_ channel: ARTRealtimeChannel) async throws { let nestedEntry = topLevelMap.get(key: MyChannelObject.TopLevelMap.LiveMapKeys.nestedEntry) } +// TODO create examples with `set()` + // Example that uses the key paths convenience methods for get() func keyPathsExampleWithChannel(_ channel: ARTRealtimeChannel) async throws { let myChannelPathObject = try await channel.object.get(withShape: MyChannelObject.self) From d58b5be3fa42e6ee89d77dfe3f3563cc9c3dead6 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 12 Dec 2025 15:53:02 -0300 Subject: [PATCH 5/9] fill in the set() and remove() key path methods --- .../Public/UserDefinedTypeSupport.swift | 58 +++++++++++++++++-- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport.swift b/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport.swift index a1205f36..bbe307db 100644 --- a/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport.swift +++ b/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport.swift @@ -58,7 +58,7 @@ protocol ShapedLiveMapPathObject { func set(key: Key, value: ShapedLiveMap) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == ShapedLiveMap // For LiveCounter entries - func set(key: Key) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == LiveCounter + func set(key: Key, value: LiveCounter) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == LiveCounter // `remove()` @@ -86,7 +86,51 @@ protocol ShapedLiveMapPathObject { // Convenience extensions for specifying a key by using a key path into a static member of Shape.LiveMapKeys. TODO improve naming: it's a bit confusing because it's a key path _into a set of keys_ (i.e. not into the shape itself). The reason we use key paths instead of implicit member access is because it doesn't require that the "member" actually have that type extension ShapedLiveMapPathObject { - // Getters + // `set()` + + func set(keyAt keyPath: KeyPath, value: String) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == String { + try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) + } + + func set(keyAt keyPath: KeyPath, value: Double) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == Double { + try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) + } + + func set(keyAt keyPath: KeyPath, value: Bool) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == Bool { + try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) + } + + func set(keyAt keyPath: KeyPath, value: Data) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == Data { + try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) + } + + func set(keyAt keyPath: KeyPath, value: [JSONValue]) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == JSONValue { + try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) + } + + func set(keyAt keyPath: KeyPath, value: [String: JSONValue]) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == [String: JSONValue] { + try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) + } + + func set(keyAt keyPath: KeyPath, value: LiveMap) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == LiveMap { + try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) + } + + func set(keyAt keyPath: KeyPath, value: ShapedLiveMap) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == ShapedLiveMap { + try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) + } + + func set(keyAt keyPath: KeyPath, value: LiveCounter) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == LiveCounter { + try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) + } + + // `remove()` + + func remove(keyAt keyPath: KeyPath) async throws(ARTErrorInfo) where Key.Shape == Shape { + try await remove(key: Shape.LiveMapKeys.self[keyPath: keyPath]) + } + + // `get()` func get(keyAt keyPath: KeyPath) -> any TypedPrimitivePathObject where Key.Shape == Shape, Key.Value == String { get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) @@ -155,9 +199,7 @@ func exampleWithChannel(_ channel: ARTRealtimeChannel) async throws { let nestedEntry = topLevelMap.get(key: MyChannelObject.TopLevelMap.LiveMapKeys.nestedEntry) } -// TODO create examples with `set()` - -// Example that uses the key paths convenience methods for get() +// Example that uses the key paths convenience methods for get(), set(), remove() func keyPathsExampleWithChannel(_ channel: ARTRealtimeChannel) async throws { let myChannelPathObject = try await channel.object.get(withShape: MyChannelObject.self) @@ -165,6 +207,12 @@ func keyPathsExampleWithChannel(_ channel: ARTRealtimeChannel) async throws { let topLevelMap = myChannelPathObject.get(keyAt: \.topLevelMap) let nestedEntry = topLevelMap.get(keyAt: \.nestedEntry) + + try await topLevelMap.set(keyAt: \.nestedEntry, value: "Hello") + try await topLevelMap.remove(keyAt: \.nestedEntry) + + try await myChannelPathObject.set(keyAt: \.topLevelCounter, value: LiveCounter.create(initialCount: 3)) + try await topLevelCounter.increment(amount: 4) } From 3236a3cf97ee56f7af52658ad8a2f9309c473263 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 12 Dec 2025 16:33:43 -0300 Subject: [PATCH 6/9] Demonstrate LiveMap creation in shaped world --- .../Public/UserDefinedTypeSupport.swift | 61 ++++++++++++++++++- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport.swift b/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport.swift index bbe307db..921767ba 100644 --- a/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport.swift +++ b/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport.swift @@ -11,6 +11,9 @@ protocol LiveMapShape { // TODO: currently this is _only_ used for the convenience extension that allows key path lookups to make things neater associatedtype LiveMapKeys + + /// An entry that can be passed to `ShapedLiveMap.create()`. + associatedtype InitialEntry: LiveMapInitialEntry } // TODO this name isn't great, it's not really a key, it's a key description (but I guess a KeyPath is not just a "key path") @@ -19,9 +22,26 @@ protocol LiveMapKey: Sendable { associatedtype Value } +protocol LiveMapInitialEntry { + /// A key-value pair to use when creating the LiveMap. + var toKeyValuePair: (String, Value) { get } +} + struct ShapedLiveMap: Sendable { - // TODO this needs a `create()` with constraints + private let liveMap: LiveMap + + public static func create(initialEntries: [Shape.InitialEntry] = []) -> Self { + // TODO: There's a mismatch here between this using an array and LiveMap using a dictionary + let liveMap = LiveMap.create(initialEntries: .init(uniqueKeysWithValues: initialEntries.map(\.toKeyValuePair))) + return .init(liveMap: liveMap) + } + // TODO: we don't _really_ want this to have to be public + + /// A type-erased representation of this ShapedLiveMap. + public var toLiveMap: LiveMap { + return liveMap + } } // TODO: naming TBD @@ -164,8 +184,6 @@ extension ShapedLiveMapPathObject { func get(keyAt keyPath: KeyPath) -> LiveCounterPathObject where Key.Shape == Shape, Key.Value == LiveCounter { get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) } - - // TODO create `set()` variants with key paths } // MARK: - RealtimeObject `get` implementation for shaped LiveMaps @@ -213,6 +231,16 @@ func keyPathsExampleWithChannel(_ channel: ARTRealtimeChannel) async throws { try await myChannelPathObject.set(keyAt: \.topLevelCounter, value: LiveCounter.create(initialCount: 3)) try await topLevelCounter.increment(amount: 4) + + try await myChannelPathObject.set( + keyAt: \.topLevelMap, + value: .create( + // TODO not decided if this is the API I want yet (that is, `Entry` being an enum); see the other places where I need entries and figure it out + initialEntries: [ + .nestedEntry("Goodbye") + ] + ) + ) } @@ -232,6 +260,22 @@ extension MyChannelObject: LiveMapShape { static let topLevelCounter: some LiveMapKey = Key(rawKey: "topLevelCounter") static let topLevelMap: some LiveMapKey> = Key(rawKey: "topLevelCounter") } + + enum InitialEntry: LiveMapInitialEntry { + case topLevelCounter(LiveCounter) + case topLevelMap(ShapedLiveMap) + + // TODO: this might be a bit tricky for codegen as-is, because ideally we wouldn't have to understand the meaning of the shape's properties; we just want to copy and paste their types. Might be better to have an init(containerCreationValue:) on Value, overloaded for all of the supported types. Although according to ChatGPT you can perform full type resolution inside a macro expansion now: https://chatgpt.com/c/693c6ec0-32d0-8333-8776-1145397c263f + + var toKeyValuePair: (String, Value) { + switch self { + case .topLevelCounter(let liveCounter): + ("topLevelCounter", .liveCounter(liveCounter)) + case .topLevelMap(let shapedLiveMap): + ("topLevelMap", .liveMap(shapedLiveMap.toLiveMap)) + } + } + } } extension MyChannelObject.TopLevelMap: LiveMapShape { @@ -245,6 +289,17 @@ extension MyChannelObject.TopLevelMap: LiveMapShape { static let nestedEntry: some LiveMapKey = Key(rawKey: "nestedEntry") } + + enum InitialEntry: LiveMapInitialEntry { + case nestedEntry(String) + + var toKeyValuePair: (String, Value) { + switch self { + case .nestedEntry(let string): + ("nestedEntry", .primitive(.string(string))) + } + } + } } // Note that each `LiveMapKeys` declares their own `Key` type — this is so that we don't have to pollute the library's public types with something that's only used for generated code; i.e. else we'd have to have something like the following: From d69a0d1628d15636bb4f271108848cd17f1ee040 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 12 Dec 2025 17:38:29 -0300 Subject: [PATCH 7/9] WIP on `entries()` Not sure of best approach here yet --- .../Public/UserDefinedTypeSupport.swift | 71 ++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport.swift b/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport.swift index 921767ba..678a1215 100644 --- a/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport.swift +++ b/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport.swift @@ -14,6 +14,9 @@ protocol LiveMapShape { /// An entry that can be passed to `ShapedLiveMap.create()`. associatedtype InitialEntry: LiveMapInitialEntry + + /// An entry that can be returned from `ShapedLiveMapPathObject.entries()`. + associatedtype PathObjectKnownEntry: LiveMapPathObjectKnownEntry } // TODO this name isn't great, it's not really a key, it's a key description (but I guess a KeyPath is not just a "key path") @@ -27,6 +30,11 @@ protocol LiveMapInitialEntry { var toKeyValuePair: (String, Value) { get } } +protocol LiveMapPathObjectKnownEntry { + /// Should return `nil` if the key does not correspond to a known entry. + init?(key: String, pathObject: PathObject) +} + struct ShapedLiveMap: Sendable { private let liveMap: LiveMap @@ -55,10 +63,25 @@ protocol TypedPrimitivePathObject { // TODO: How is Instance going to work? is it actually going to check types? if so will it do it all the way down through nested maps etc? +/// An element of `ShapedLiveMapPathObject.entries`. +enum ShapedLiveMapPathObjectEntry { + /// A known key-value pair. + case known(Known) + + /// An unknown key-value pair; the best we can do is return a String key and an untyped PathObject. + case unknown(key: String, value: PathObject) +} + protocol ShapedLiveMapPathObject { associatedtype Shape: LiveMapShape - // TODO: we need keys and entries (what does entries return, and how do they both handle an unknown key?). I think that perhaps `keys` could just return [String], and that the LiveMapShape will need to define a Entry associated type (most likely an enum in practice) that can create itself from a given key and PathObject (or fail to do so in which case we'll have to return some "unknown" type) + // This is my proposal for `entries`; I think its return value should be consistent with `keys` and `values`; that is, it should be able to represent things that were found at runtime even when they aren't in the known set of keys. + var entries: [ShapedLiveMapPathObjectEntry] { get } + + // I think that we'll just keep `keys` and `values` as String and PathObject (same as LiveMapPathObject), given that shapes only matter when considering the relationship between a key and a value + var keys: [String] { get } + var values: [PathObject] { get } + // TODO: you should still be able to interact with this without shape too — I think the best thing would be to make _this_ type only work with Key but have a way to turn it into a normal LiveMapPathObject // Variants of `set()` @@ -241,6 +264,33 @@ func keyPathsExampleWithChannel(_ channel: ARTRealtimeChannel) async throws { ] ) ) + + for entry in myChannelPathObject.entries { + switch entry { + case .known(let known): + switch known { + case .topLevelCounter(let liveCounterPathObject): + break + case .topLevelMap(let shapedLiveMapPathObject): + break + } + case .unknown(let key, let value): + break + } + } + + for entry in topLevelMap.entries { + switch entry { + case .known(let known): + switch known { + case .nestedEntry(let typedPrimitivePathObject): + break + } + case .unknown(let key, let value): + break + } + + } } @@ -276,6 +326,17 @@ extension MyChannelObject: LiveMapShape { } } } + + enum PathObjectKnownEntry: LiveMapPathObjectKnownEntry { + case topLevelCounter(LiveCounterPathObject) + case topLevelMap(any ShapedLiveMapPathObject) + + // TODO: I think that this is going to be another one that's tricky for codegen, again might require us to actually interpret the type because we need to turn a ShapedLiveMap property into a ShapedLiveMapPathObject. Perhaps what we actually want to do here is to let the caller be in charge of creating the object itself, i.e. return some sort of enum result from here instead, but I'm still not sure that fully helps us. + // (note that the `get` variants don't have to handle this problem because they perform the conversion via the compiler picking the correct overload; maybe we need to see what we can do along those lines, maybe we can lean on the Key type more again) + init?(key: String, pathObject: any PathObject) { + fatalError("TODO: Not implemented") + } + } } extension MyChannelObject.TopLevelMap: LiveMapShape { @@ -300,6 +361,14 @@ extension MyChannelObject.TopLevelMap: LiveMapShape { } } } + + enum PathObjectKnownEntry: LiveMapPathObjectKnownEntry { + case nestedEntry(any TypedPrimitivePathObject) + + init?(key: String, pathObject: any PathObject) { + fatalError("TODO: Not implemented") + } + } } // Note that each `LiveMapKeys` declares their own `Key` type — this is so that we don't have to pollute the library's public types with something that's only used for generated code; i.e. else we'd have to have something like the following: From 9d01b2802877251fa88ac5c2a9a2ee97d11cc13b Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 11 May 2026 15:49:20 -0300 Subject: [PATCH 8/9] Split user-defined types stuff into separate files for clarity --- .../Public/UserDefinedTypeSupport.swift | 380 ------------------ .../Example/GeneratedCode.swift | 87 ++++ .../Example/Shape.swift | 77 ++++ .../KeyPathConvenience.swift | 84 ++++ .../PublicShapedTypes.swift | 137 +++++++ 5 files changed, 385 insertions(+), 380 deletions(-) delete mode 100644 Sources/AblyLiveObjects/Public/UserDefinedTypeSupport.swift create mode 100644 Sources/AblyLiveObjects/Public/UserDefinedTypeSupport/Example/GeneratedCode.swift create mode 100644 Sources/AblyLiveObjects/Public/UserDefinedTypeSupport/Example/Shape.swift create mode 100644 Sources/AblyLiveObjects/Public/UserDefinedTypeSupport/KeyPathConvenience.swift create mode 100644 Sources/AblyLiveObjects/Public/UserDefinedTypeSupport/PublicShapedTypes.swift diff --git a/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport.swift b/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport.swift deleted file mode 100644 index 678a1215..00000000 --- a/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport.swift +++ /dev/null @@ -1,380 +0,0 @@ -import Foundation -import Ably - -// MARK: - Public-facing types for shaped LiveMaps - -// TODO assess how much LiveMapShape needs to be able to do, and if it's just a convenience, then remove some constraints - -// TODO not sure this actually needs to be a protocol -protocol LiveMapShape { - // I'm unsure about this but I think that we want something like it so that we can do implicit member access: `.get(key: .topLevelCounter)`. but again it's not clear what this would inherit from. Also we might need this in order to see whether a key is a known key or not. But we may have to have one of these per Value type? e.g. LiveMapStringKey, LiveMapLiveCounterKey etc (no, that falls apart when you start having parameterisable types e.g. nested maps) — Hmm. I think that `entries` might just not be possible because there's no obvious type to define. In that case we _would_ have to do codegen and list all of the possible types. we can still have a LiveMapEntry type here I guess - - // TODO: currently this is _only_ used for the convenience extension that allows key path lookups to make things neater - associatedtype LiveMapKeys - - /// An entry that can be passed to `ShapedLiveMap.create()`. - associatedtype InitialEntry: LiveMapInitialEntry - - /// An entry that can be returned from `ShapedLiveMapPathObject.entries()`. - associatedtype PathObjectKnownEntry: LiveMapPathObjectKnownEntry -} - -// TODO this name isn't great, it's not really a key, it's a key description (but I guess a KeyPath is not just a "key path") -protocol LiveMapKey: Sendable { - associatedtype Shape: LiveMapShape - associatedtype Value -} - -protocol LiveMapInitialEntry { - /// A key-value pair to use when creating the LiveMap. - var toKeyValuePair: (String, Value) { get } -} - -protocol LiveMapPathObjectKnownEntry { - /// Should return `nil` if the key does not correspond to a known entry. - init?(key: String, pathObject: PathObject) -} - -struct ShapedLiveMap: Sendable { - private let liveMap: LiveMap - - public static func create(initialEntries: [Shape.InitialEntry] = []) -> Self { - // TODO: There's a mismatch here between this using an array and LiveMap using a dictionary - let liveMap = LiveMap.create(initialEntries: .init(uniqueKeysWithValues: initialEntries.map(\.toKeyValuePair))) - return .init(liveMap: liveMap) - } - - // TODO: we don't _really_ want this to have to be public - - /// A type-erased representation of this ShapedLiveMap. - public var toLiveMap: LiveMap { - return liveMap - } -} - -// TODO: naming TBD -// TODO: we don't have any constraints on Value which makes things trickier -// TODO: I didn't actually do PrimitivePathObject in the non-typed API; we should have that -protocol TypedPrimitivePathObject { - associatedtype Value - - var value: Value? { get } -} - -// TODO: How is Instance going to work? is it actually going to check types? if so will it do it all the way down through nested maps etc? - -/// An element of `ShapedLiveMapPathObject.entries`. -enum ShapedLiveMapPathObjectEntry { - /// A known key-value pair. - case known(Known) - - /// An unknown key-value pair; the best we can do is return a String key and an untyped PathObject. - case unknown(key: String, value: PathObject) -} - -protocol ShapedLiveMapPathObject { - associatedtype Shape: LiveMapShape - - // This is my proposal for `entries`; I think its return value should be consistent with `keys` and `values`; that is, it should be able to represent things that were found at runtime even when they aren't in the known set of keys. - var entries: [ShapedLiveMapPathObjectEntry] { get } - - // I think that we'll just keep `keys` and `values` as String and PathObject (same as LiveMapPathObject), given that shapes only matter when considering the relationship between a key and a value - var keys: [String] { get } - var values: [PathObject] { get } - - // TODO: you should still be able to interact with this without shape too — I think the best thing would be to make _this_ type only work with Key but have a way to turn it into a normal LiveMapPathObject - - // Variants of `set()` - - // All the set() operations that this needs to be able to support. (I don't think we can do better than this because this type isn't expected to be able to handle arbitrary values, even if a user can form a Key that has one; that is, we can't just have a single one that takes Key.Value); unless we end up being able to impose constraints on Key.Value somehow but I don't really want to start adding extensions to String etc - - // For entries of each of the primitive types - func set(key: Key, value: String) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == String - func set(key: Key, value: Double) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == Double - func set(key: Key, value: Bool) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == Bool - func set(key: Key, value: Data) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == Data - func set(key: Key, value: [JSONValue]) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == JSONValue - func set(key: Key, value: [String: JSONValue]) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == [String: JSONValue] - - // For LiveMap entries - func set(key: Key, value: LiveMap) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == LiveMap - func set(key: Key, value: ShapedLiveMap) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == ShapedLiveMap - - // For LiveCounter entries - func set(key: Key, value: LiveCounter) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == LiveCounter - - // `remove()` - - func remove(key: Key) async throws(ARTErrorInfo) - - // Variants of `get()` - - // I don't _think_ there is a less verbose way of figuring out the shape of the PathObject - - // For entries of each of the primitive types - func get(key: Key) -> any TypedPrimitivePathObject where Key.Shape == Shape, Key.Value == String - func get(key: Key) -> any TypedPrimitivePathObject where Key.Shape == Shape, Key.Value == Double - func get(key: Key) -> any TypedPrimitivePathObject where Key.Shape == Shape, Key.Value == Bool - func get(key: Key) -> any TypedPrimitivePathObject where Key.Shape == Shape, Key.Value == Data - func get(key: Key) -> any TypedPrimitivePathObject<[JSONValue]> where Key.Shape == Shape, Key.Value == [JSONValue] - func get(key: Key) -> any TypedPrimitivePathObject<[String: JSONValue]> where Key.Shape == Shape, Key.Value == [String: JSONValue] - - // For LiveMap entries - func get(key: Key) -> LiveMapPathObject where Key.Shape == Shape, Key.Value == LiveMap - func get(key: Key) -> any ShapedLiveMapPathObject where Key.Shape == Shape, Key.Value == ShapedLiveMap - - // For LiveCounter entries - func get(key: Key) -> LiveCounterPathObject where Key.Shape == Shape, Key.Value == LiveCounter -} - -// Convenience extensions for specifying a key by using a key path into a static member of Shape.LiveMapKeys. TODO improve naming: it's a bit confusing because it's a key path _into a set of keys_ (i.e. not into the shape itself). The reason we use key paths instead of implicit member access is because it doesn't require that the "member" actually have that type -extension ShapedLiveMapPathObject { - // `set()` - - func set(keyAt keyPath: KeyPath, value: String) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == String { - try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) - } - - func set(keyAt keyPath: KeyPath, value: Double) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == Double { - try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) - } - - func set(keyAt keyPath: KeyPath, value: Bool) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == Bool { - try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) - } - - func set(keyAt keyPath: KeyPath, value: Data) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == Data { - try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) - } - - func set(keyAt keyPath: KeyPath, value: [JSONValue]) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == JSONValue { - try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) - } - - func set(keyAt keyPath: KeyPath, value: [String: JSONValue]) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == [String: JSONValue] { - try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) - } - - func set(keyAt keyPath: KeyPath, value: LiveMap) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == LiveMap { - try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) - } - - func set(keyAt keyPath: KeyPath, value: ShapedLiveMap) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == ShapedLiveMap { - try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) - } - - func set(keyAt keyPath: KeyPath, value: LiveCounter) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == LiveCounter { - try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) - } - - // `remove()` - - func remove(keyAt keyPath: KeyPath) async throws(ARTErrorInfo) where Key.Shape == Shape { - try await remove(key: Shape.LiveMapKeys.self[keyPath: keyPath]) - } - - // `get()` - - func get(keyAt keyPath: KeyPath) -> any TypedPrimitivePathObject where Key.Shape == Shape, Key.Value == String { - get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) - } - - func get(keyAt keyPath: KeyPath) -> any TypedPrimitivePathObject where Key.Shape == Shape, Key.Value == Double { - get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) - } - - func get(keyAt keyPath: KeyPath) -> any TypedPrimitivePathObject where Key.Shape == Shape, Key.Value == Bool { - get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) - } - - func get(keyAt keyPath: KeyPath) -> any TypedPrimitivePathObject where Key.Shape == Shape, Key.Value == Data { - get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) - } - - func get(keyAt keyPath: KeyPath) -> any TypedPrimitivePathObject<[JSONValue]> where Key.Shape == Shape, Key.Value == [JSONValue] { - get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) - } - - func get(keyAt keyPath: KeyPath) -> LiveMapPathObject where Key.Shape == Shape, Key.Value == LiveMap { - get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) - - } - - func get(keyAt keyPath: KeyPath) -> any ShapedLiveMapPathObject where Key.Shape == Shape, Key.Value == ShapedLiveMap { - get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) - } - - func get(keyAt keyPath: KeyPath) -> LiveCounterPathObject where Key.Shape == Shape, Key.Value == LiveCounter { - get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) - } -} - -// MARK: - RealtimeObject `get` implementation for shaped LiveMaps - -extension RealtimeObject { - func get(withShape shape: Shape.Type = Shape.self) async throws(ARTErrorInfo) -> any ShapedLiveMapPathObject { - // TODO - fatalError("Not implemented") - } -} - -// MARK: - Example - -struct MyChannelObject { - var topLevelCounter: LiveCounter - var topLevelMap: ShapedLiveMap - - struct TopLevelMap { - var nestedEntry: String - } -} - -func exampleWithChannel(_ channel: ARTRealtimeChannel) async throws { - // Note that we can't say `.get()` like in TypeScript; gives us "Cannot explicitly specialize instance method 'get()'" - let myChannelPathObject = try await channel.object.get(withShape: MyChannelObject.self) - - // Note that fetching the keys is verbose; see the next example with key paths - let topLevelCounter = myChannelPathObject.get(key: MyChannelObject.LiveMapKeys.topLevelCounter) - let topLevelMap = myChannelPathObject.get(key: MyChannelObject.LiveMapKeys.topLevelMap) - - let nestedEntry = topLevelMap.get(key: MyChannelObject.TopLevelMap.LiveMapKeys.nestedEntry) -} - -// Example that uses the key paths convenience methods for get(), set(), remove() -func keyPathsExampleWithChannel(_ channel: ARTRealtimeChannel) async throws { - let myChannelPathObject = try await channel.object.get(withShape: MyChannelObject.self) - - let topLevelCounter = myChannelPathObject.get(keyAt: \.topLevelCounter) - let topLevelMap = myChannelPathObject.get(keyAt: \.topLevelMap) - - let nestedEntry = topLevelMap.get(keyAt: \.nestedEntry) - - try await topLevelMap.set(keyAt: \.nestedEntry, value: "Hello") - try await topLevelMap.remove(keyAt: \.nestedEntry) - - try await myChannelPathObject.set(keyAt: \.topLevelCounter, value: LiveCounter.create(initialCount: 3)) - try await topLevelCounter.increment(amount: 4) - - try await myChannelPathObject.set( - keyAt: \.topLevelMap, - value: .create( - // TODO not decided if this is the API I want yet (that is, `Entry` being an enum); see the other places where I need entries and figure it out - initialEntries: [ - .nestedEntry("Goodbye") - ] - ) - ) - - for entry in myChannelPathObject.entries { - switch entry { - case .known(let known): - switch known { - case .topLevelCounter(let liveCounterPathObject): - break - case .topLevelMap(let shapedLiveMapPathObject): - break - } - case .unknown(let key, let value): - break - } - } - - for entry in topLevelMap.entries { - switch entry { - case .known(let known): - switch known { - case .nestedEntry(let typedPrimitivePathObject): - break - } - case .unknown(let key, let value): - break - } - - } -} - - -// MARK: - Code that would be generated (for now we're just writing it out) - -// These would come from some sort of macro like @LiveMapShape applied to MyChannelObject - -extension MyChannelObject: LiveMapShape { - enum LiveMapKeys { - private struct Key: LiveMapKey { - typealias Shape = MyChannelObject - - /// The underlying key to use for fetching this key from a map's entries - var rawKey: String - } - - static let topLevelCounter: some LiveMapKey = Key(rawKey: "topLevelCounter") - static let topLevelMap: some LiveMapKey> = Key(rawKey: "topLevelCounter") - } - - enum InitialEntry: LiveMapInitialEntry { - case topLevelCounter(LiveCounter) - case topLevelMap(ShapedLiveMap) - - // TODO: this might be a bit tricky for codegen as-is, because ideally we wouldn't have to understand the meaning of the shape's properties; we just want to copy and paste their types. Might be better to have an init(containerCreationValue:) on Value, overloaded for all of the supported types. Although according to ChatGPT you can perform full type resolution inside a macro expansion now: https://chatgpt.com/c/693c6ec0-32d0-8333-8776-1145397c263f - - var toKeyValuePair: (String, Value) { - switch self { - case .topLevelCounter(let liveCounter): - ("topLevelCounter", .liveCounter(liveCounter)) - case .topLevelMap(let shapedLiveMap): - ("topLevelMap", .liveMap(shapedLiveMap.toLiveMap)) - } - } - } - - enum PathObjectKnownEntry: LiveMapPathObjectKnownEntry { - case topLevelCounter(LiveCounterPathObject) - case topLevelMap(any ShapedLiveMapPathObject) - - // TODO: I think that this is going to be another one that's tricky for codegen, again might require us to actually interpret the type because we need to turn a ShapedLiveMap property into a ShapedLiveMapPathObject. Perhaps what we actually want to do here is to let the caller be in charge of creating the object itself, i.e. return some sort of enum result from here instead, but I'm still not sure that fully helps us. - // (note that the `get` variants don't have to handle this problem because they perform the conversion via the compiler picking the correct overload; maybe we need to see what we can do along those lines, maybe we can lean on the Key type more again) - init?(key: String, pathObject: any PathObject) { - fatalError("TODO: Not implemented") - } - } -} - -extension MyChannelObject.TopLevelMap: LiveMapShape { - enum LiveMapKeys { - private struct Key: LiveMapKey { - typealias Shape = MyChannelObject.TopLevelMap - - /// The underlying key to use for fetching this key from a map's entries - var rawKey: String - } - - static let nestedEntry: some LiveMapKey = Key(rawKey: "nestedEntry") - } - - enum InitialEntry: LiveMapInitialEntry { - case nestedEntry(String) - - var toKeyValuePair: (String, Value) { - switch self { - case .nestedEntry(let string): - ("nestedEntry", .primitive(.string(string))) - } - } - } - - enum PathObjectKnownEntry: LiveMapPathObjectKnownEntry { - case nestedEntry(any TypedPrimitivePathObject) - - init?(key: String, pathObject: any PathObject) { - fatalError("TODO: Not implemented") - } - } -} - -// Note that each `LiveMapKeys` declares their own `Key` type — this is so that we don't have to pollute the library's public types with something that's only used for generated code; i.e. else we'd have to have something like the following: - -/* -struct DefaultLiveMapKey: LiveMapKey { - var rawKey: String -} -*/ diff --git a/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport/Example/GeneratedCode.swift b/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport/Example/GeneratedCode.swift new file mode 100644 index 00000000..15f4c531 --- /dev/null +++ b/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport/Example/GeneratedCode.swift @@ -0,0 +1,87 @@ +import Foundation +import Ably + +// MARK: - Code that would be generated (for now we're just writing it out) + +// These would come from some sort of macro like @LiveMapShape applied to MyChannelObject + +extension MyChannelObject: LiveMapShape { + enum LiveMapKeys { + private struct Key: LiveMapKey { + typealias Shape = MyChannelObject + + /// The underlying key to use for fetching this key from a map's entries + var rawKey: String + } + + static let topLevelCounter: some LiveMapKey = Key(rawKey: "topLevelCounter") + static let topLevelMap: some LiveMapKey> = Key(rawKey: "topLevelCounter") + } + + enum InitialEntry: LiveMapInitialEntry { + case topLevelCounter(LiveCounter) + case topLevelMap(ShapedLiveMap) + + // TODO: this might be a bit tricky for codegen as-is, because ideally we wouldn't have to understand the meaning of the shape's properties; we just want to copy and paste their types. Might be better to have an init(containerCreationValue:) on Value, overloaded for all of the supported types. Although according to ChatGPT you can perform full type resolution inside a macro expansion now: https://chatgpt.com/c/693c6ec0-32d0-8333-8776-1145397c263f + + var toKeyValuePair: (String, Value) { + switch self { + case .topLevelCounter(let liveCounter): + ("topLevelCounter", .liveCounter(liveCounter)) + case .topLevelMap(let shapedLiveMap): + ("topLevelMap", .liveMap(shapedLiveMap.toLiveMap)) + } + } + } + + enum PathObjectKnownEntry: LiveMapPathObjectKnownEntry { + case topLevelCounter(LiveCounterPathObject) + case topLevelMap(any ShapedLiveMapPathObject) + + // TODO: I think that this is going to be another one that's tricky for codegen, again might require us to actually interpret the type because we need to turn a ShapedLiveMap property into a ShapedLiveMapPathObject. Perhaps what we actually want to do here is to let the caller be in charge of creating the object itself, i.e. return some sort of enum result from here instead, but I'm still not sure that fully helps us. + // (note that the `get` variants don't have to handle this problem because they perform the conversion via the compiler picking the correct overload; maybe we need to see what we can do along those lines, maybe we can lean on the Key type more again) + init?(key: String, pathObject: any PathObject) { + fatalError("TODO: Not implemented") + } + } +} + +extension MyChannelObject.TopLevelMap: LiveMapShape { + enum LiveMapKeys { + private struct Key: LiveMapKey { + typealias Shape = MyChannelObject.TopLevelMap + + /// The underlying key to use for fetching this key from a map's entries + var rawKey: String + } + + static let nestedEntry: some LiveMapKey = Key(rawKey: "nestedEntry") + } + + enum InitialEntry: LiveMapInitialEntry { + case nestedEntry(String) + + var toKeyValuePair: (String, Value) { + switch self { + case .nestedEntry(let string): + ("nestedEntry", .primitive(.string(string))) + } + } + } + + enum PathObjectKnownEntry: LiveMapPathObjectKnownEntry { + case nestedEntry(any TypedPrimitivePathObject) + + init?(key: String, pathObject: any PathObject) { + fatalError("TODO: Not implemented") + } + } +} + +// Note that each `LiveMapKeys` declares their own `Key` type — this is so that we don't have to pollute the library's public types with something that's only used for generated code; i.e. else we'd have to have something like the following: + +/* +struct DefaultLiveMapKey: LiveMapKey { + var rawKey: String +} +*/ diff --git a/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport/Example/Shape.swift b/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport/Example/Shape.swift new file mode 100644 index 00000000..8a94d6c1 --- /dev/null +++ b/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport/Example/Shape.swift @@ -0,0 +1,77 @@ +import Foundation +import Ably + +// MARK: - Example + +struct MyChannelObject { + var topLevelCounter: LiveCounter + var topLevelMap: ShapedLiveMap + + struct TopLevelMap { + var nestedEntry: String + } +} + +func exampleWithChannel(_ channel: ARTRealtimeChannel) async throws { + // Note that we can't say `.get()` like in TypeScript; gives us "Cannot explicitly specialize instance method 'get()'" + let myChannelPathObject = try await channel.object.get(withShape: MyChannelObject.self) + + // Note that fetching the keys is verbose; see the next example with key paths + let topLevelCounter = myChannelPathObject.get(key: MyChannelObject.LiveMapKeys.topLevelCounter) + let topLevelMap = myChannelPathObject.get(key: MyChannelObject.LiveMapKeys.topLevelMap) + + let nestedEntry = topLevelMap.get(key: MyChannelObject.TopLevelMap.LiveMapKeys.nestedEntry) +} + +// Example that uses the key paths convenience methods for get(), set(), remove() +func keyPathsExampleWithChannel(_ channel: ARTRealtimeChannel) async throws { + let myChannelPathObject = try await channel.object.get(withShape: MyChannelObject.self) + + let topLevelCounter = myChannelPathObject.get(keyAt: \.topLevelCounter) + let topLevelMap = myChannelPathObject.get(keyAt: \.topLevelMap) + + let nestedEntry = topLevelMap.get(keyAt: \.nestedEntry) + + try await topLevelMap.set(keyAt: \.nestedEntry, value: "Hello") + try await topLevelMap.remove(keyAt: \.nestedEntry) + + try await myChannelPathObject.set(keyAt: \.topLevelCounter, value: LiveCounter.create(initialCount: 3)) + try await topLevelCounter.increment(amount: 4) + + try await myChannelPathObject.set( + keyAt: \.topLevelMap, + value: .create( + // TODO not decided if this is the API I want yet (that is, `Entry` being an enum); see the other places where I need entries and figure it out + initialEntries: [ + .nestedEntry("Goodbye") + ] + ) + ) + + for entry in myChannelPathObject.entries { + switch entry { + case .known(let known): + switch known { + case .topLevelCounter(let liveCounterPathObject): + break + case .topLevelMap(let shapedLiveMapPathObject): + break + } + case .unknown(let key, let value): + break + } + } + + for entry in topLevelMap.entries { + switch entry { + case .known(let known): + switch known { + case .nestedEntry(let typedPrimitivePathObject): + break + } + case .unknown(let key, let value): + break + } + + } +} diff --git a/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport/KeyPathConvenience.swift b/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport/KeyPathConvenience.swift new file mode 100644 index 00000000..eeb58f76 --- /dev/null +++ b/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport/KeyPathConvenience.swift @@ -0,0 +1,84 @@ +import Foundation +import Ably + +// Convenience extensions for specifying a key by using a key path into a static member of Shape.LiveMapKeys. TODO improve naming: it's a bit confusing because it's a key path _into a set of keys_ (i.e. not into the shape itself). The reason we use key paths instead of implicit member access is because it doesn't require that the "member" actually have that type +extension ShapedLiveMapPathObject { + // `set()` + + func set(keyAt keyPath: KeyPath, value: String) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == String { + try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) + } + + func set(keyAt keyPath: KeyPath, value: Double) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == Double { + try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) + } + + func set(keyAt keyPath: KeyPath, value: Bool) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == Bool { + try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) + } + + func set(keyAt keyPath: KeyPath, value: Data) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == Data { + try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) + } + + func set(keyAt keyPath: KeyPath, value: [JSONValue]) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == JSONValue { + try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) + } + + func set(keyAt keyPath: KeyPath, value: [String: JSONValue]) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == [String: JSONValue] { + try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) + } + + func set(keyAt keyPath: KeyPath, value: LiveMap) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == LiveMap { + try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) + } + + func set(keyAt keyPath: KeyPath, value: ShapedLiveMap) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == ShapedLiveMap { + try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) + } + + func set(keyAt keyPath: KeyPath, value: LiveCounter) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == LiveCounter { + try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) + } + + // `remove()` + + func remove(keyAt keyPath: KeyPath) async throws(ARTErrorInfo) where Key.Shape == Shape { + try await remove(key: Shape.LiveMapKeys.self[keyPath: keyPath]) + } + + // `get()` + + func get(keyAt keyPath: KeyPath) -> any TypedPrimitivePathObject where Key.Shape == Shape, Key.Value == String { + get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) + } + + func get(keyAt keyPath: KeyPath) -> any TypedPrimitivePathObject where Key.Shape == Shape, Key.Value == Double { + get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) + } + + func get(keyAt keyPath: KeyPath) -> any TypedPrimitivePathObject where Key.Shape == Shape, Key.Value == Bool { + get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) + } + + func get(keyAt keyPath: KeyPath) -> any TypedPrimitivePathObject where Key.Shape == Shape, Key.Value == Data { + get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) + } + + func get(keyAt keyPath: KeyPath) -> any TypedPrimitivePathObject<[JSONValue]> where Key.Shape == Shape, Key.Value == [JSONValue] { + get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) + } + + func get(keyAt keyPath: KeyPath) -> LiveMapPathObject where Key.Shape == Shape, Key.Value == LiveMap { + get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) + + } + + func get(keyAt keyPath: KeyPath) -> any ShapedLiveMapPathObject where Key.Shape == Shape, Key.Value == ShapedLiveMap { + get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) + } + + func get(keyAt keyPath: KeyPath) -> LiveCounterPathObject where Key.Shape == Shape, Key.Value == LiveCounter { + get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) + } +} diff --git a/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport/PublicShapedTypes.swift b/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport/PublicShapedTypes.swift new file mode 100644 index 00000000..15f74b65 --- /dev/null +++ b/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport/PublicShapedTypes.swift @@ -0,0 +1,137 @@ +import Foundation +import Ably + +// MARK: - Public-facing types for shaped LiveMaps + +// TODO assess how much LiveMapShape needs to be able to do, and if it's just a convenience, then remove some constraints + +// TODO not sure this actually needs to be a protocol +protocol LiveMapShape { + // I'm unsure about this but I think that we want something like it so that we can do implicit member access: `.get(key: .topLevelCounter)`. but again it's not clear what this would inherit from. Also we might need this in order to see whether a key is a known key or not. But we may have to have one of these per Value type? e.g. LiveMapStringKey, LiveMapLiveCounterKey etc (no, that falls apart when you start having parameterisable types e.g. nested maps) — Hmm. I think that `entries` might just not be possible because there's no obvious type to define. In that case we _would_ have to do codegen and list all of the possible types. we can still have a LiveMapEntry type here I guess + + // TODO: currently this is _only_ used for the convenience extension that allows key path lookups to make things neater + associatedtype LiveMapKeys + + /// An entry that can be passed to `ShapedLiveMap.create()`. + associatedtype InitialEntry: LiveMapInitialEntry + + /// An entry that can be returned from `ShapedLiveMapPathObject.entries()`. + associatedtype PathObjectKnownEntry: LiveMapPathObjectKnownEntry +} + +// TODO this name isn't great, it's not really a key, it's a key description (but I guess a KeyPath is not just a "key path") +protocol LiveMapKey: Sendable { + associatedtype Shape: LiveMapShape + associatedtype Value +} + +protocol LiveMapInitialEntry { + /// A key-value pair to use when creating the LiveMap. + var toKeyValuePair: (String, Value) { get } +} + +protocol LiveMapPathObjectKnownEntry { + /// Should return `nil` if the key does not correspond to a known entry. + init?(key: String, pathObject: PathObject) +} + +struct ShapedLiveMap: Sendable { + private let liveMap: LiveMap + + public static func create(initialEntries: [Shape.InitialEntry] = []) -> Self { + // TODO: There's a mismatch here between this using an array and LiveMap using a dictionary + let liveMap = LiveMap.create(initialEntries: .init(uniqueKeysWithValues: initialEntries.map(\.toKeyValuePair))) + return .init(liveMap: liveMap) + } + + // TODO: we don't _really_ want this to have to be public + + /// A type-erased representation of this ShapedLiveMap. + public var toLiveMap: LiveMap { + return liveMap + } +} + +// TODO: naming TBD +// TODO: we don't have any constraints on Value which makes things trickier +// TODO: I didn't actually do PrimitivePathObject in the non-typed API; we should have that +protocol TypedPrimitivePathObject { + associatedtype Value + + var value: Value? { get } +} + +// TODO: How is Instance going to work? is it actually going to check types? if so will it do it all the way down through nested maps etc? + +/// An element of `ShapedLiveMapPathObject.entries`. +enum ShapedLiveMapPathObjectEntry { + /// A known key-value pair. + case known(Known) + + /// An unknown key-value pair; the best we can do is return a String key and an untyped PathObject. + case unknown(key: String, value: PathObject) +} + +protocol ShapedLiveMapPathObject { + associatedtype Shape: LiveMapShape + + // This is my proposal for `entries`; I think its return value should be consistent with `keys` and `values`; that is, it should be able to represent things that were found at runtime even when they aren't in the known set of keys. + var entries: [ShapedLiveMapPathObjectEntry] { get } + + // I think that we'll just keep `keys` and `values` as String and PathObject (same as LiveMapPathObject), given that shapes only matter when considering the relationship between a key and a value + var keys: [String] { get } + var values: [PathObject] { get } + + // TODO: you should still be able to interact with this without shape too — I think the best thing would be to make _this_ type only work with Key but have a way to turn it into a normal LiveMapPathObject + + // Variants of `set()` + + // All the set() operations that this needs to be able to support. (I don't think we can do better than this because this type isn't expected to be able to handle arbitrary values, even if a user can form a Key that has one; that is, we can't just have a single one that takes Key.Value); unless we end up being able to impose constraints on Key.Value somehow but I don't really want to start adding extensions to String etc + + // For entries of each of the primitive types + func set(key: Key, value: String) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == String + func set(key: Key, value: Double) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == Double + func set(key: Key, value: Bool) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == Bool + func set(key: Key, value: Data) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == Data + func set(key: Key, value: [JSONValue]) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == JSONValue + func set(key: Key, value: [String: JSONValue]) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == [String: JSONValue] + + // For LiveMap entries + func set(key: Key, value: LiveMap) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == LiveMap + func set(key: Key, value: ShapedLiveMap) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == ShapedLiveMap + + // For LiveCounter entries + func set(key: Key, value: LiveCounter) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == LiveCounter + + // `remove()` + + func remove(key: Key) async throws(ARTErrorInfo) + + // Variants of `get()` + + // I don't _think_ there is a less verbose way of figuring out the shape of the PathObject + + // For entries of each of the primitive types + func get(key: Key) -> any TypedPrimitivePathObject where Key.Shape == Shape, Key.Value == String + func get(key: Key) -> any TypedPrimitivePathObject where Key.Shape == Shape, Key.Value == Double + func get(key: Key) -> any TypedPrimitivePathObject where Key.Shape == Shape, Key.Value == Bool + func get(key: Key) -> any TypedPrimitivePathObject where Key.Shape == Shape, Key.Value == Data + func get(key: Key) -> any TypedPrimitivePathObject<[JSONValue]> where Key.Shape == Shape, Key.Value == [JSONValue] + func get(key: Key) -> any TypedPrimitivePathObject<[String: JSONValue]> where Key.Shape == Shape, Key.Value == [String: JSONValue] + + // For LiveMap entries + func get(key: Key) -> LiveMapPathObject where Key.Shape == Shape, Key.Value == LiveMap + func get(key: Key) -> any ShapedLiveMapPathObject where Key.Shape == Shape, Key.Value == ShapedLiveMap + + // For LiveCounter entries + func get(key: Key) -> LiveCounterPathObject where Key.Shape == Shape, Key.Value == LiveCounter +} + +// MARK: - RealtimeObject `get` implementation for shaped LiveMaps + +extension RealtimeObject { + func get(withShape shape: Shape.Type = Shape.self) async throws(ARTErrorInfo) -> any ShapedLiveMapPathObject { + // TODO + fatalError("Not implemented") + } +} From c4b3a69e659ce55e58fb658785ebcb89efad69ff Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 11 May 2026 15:56:55 -0300 Subject: [PATCH 9/9] Add a README for the user-defined types experiment Briefly explains what the directory is for (proving we could later layer shape support on top of the unshaped path-based API), names the files, points readers who only want to read one file at Example/Shape.swift, and notes that the proof is still incomplete: the example generated code isn't fully filled in and no real codegen has been demonstrated yet. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Public/UserDefinedTypeSupport/README.md | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 Sources/AblyLiveObjects/Public/UserDefinedTypeSupport/README.md diff --git a/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport/README.md b/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport/README.md new file mode 100644 index 00000000..27105946 --- /dev/null +++ b/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport/README.md @@ -0,0 +1,60 @@ +# UserDefinedTypeSupport + +An experiment to convince ourselves that, if we later wanted to, we could +layer support for user-specified object shapes (the Swift analogue of +ably-js's `channel.object.get()`) on top of the unshaped +path-based API, without breaking it. + +If you only want to read one file, read +[`Example/Shape.swift`](./Example/Shape.swift) — it shows what the API would +ideally look like to a user. Everything else here is the supporting +machinery that makes those call sites compile. + +Nothing here is intended to ship as part of the initial GA — the plan is to +ship the unshaped API first. This directory only exists to prove the +additive-layering claim. See the discussion in +[`PATH-BASED-API.md`](../../../../PATH-BASED-API.md) for the design notes +that informed it. + +It's also not yet a complete proof. Specifically: + +- The hand-written stand-in in `Example/GeneratedCode.swift` still has + unfilled pieces — the `PathObjectKnownEntry.init?(key:pathObject:)` + implementations are `fatalError("TODO")`, and there are flagged TODOs in + the surrounding code about how the conversions would actually need to + work. +- No real codegen has been demonstrated. There is no `@LiveMapShape` macro + yet; the "generated" code is written by hand to prove the call-site + shape compiles. The hardest parts of the codegen story (interpreting the + user's property types so that, say, a `ShapedLiveMap` + property turns into a `case` carrying `any + ShapedLiveMapPathObject`) are noted in TODOs but not + actually exercised. + +So we haven't yet convinced ourselves that the additive-layering claim +holds. This directory shows that the *shape* of the public API is +expressible in Swift and that example call sites compile, but the +codegen-and-runtime-glue layer underneath remains unproven. + +## Files + +- **[`PublicShapedTypes.swift`](./PublicShapedTypes.swift)** — the public + protocol family and types: `LiveMapShape`, `LiveMapKey`, + `LiveMapInitialEntry`, `LiveMapPathObjectKnownEntry`, `ShapedLiveMap`, + `TypedPrimitivePathObject`, `ShapedLiveMapPathObjectEntry`, + `ShapedLiveMapPathObject`, plus the `RealtimeObject.get(withShape:)` + entry point. +- **[`KeyPathConvenience.swift`](./KeyPathConvenience.swift)** — the + `extension ShapedLiveMapPathObject` adding `keyAt:` variants of `get`, + `set` and `remove`, so call sites can write `\.topLevelCounter` instead of + `MyChannelObject.LiveMapKeys.topLevelCounter`. +- **[`Example/Shape.swift`](./Example/Shape.swift)** — the user-written side + of the example: a `MyChannelObject` struct that would be annotated with a + hypothetical `@LiveMapShape` macro, plus two example functions + demonstrating use of the API (one using explicit `LiveMapKeys` + references, the other using the key-path convenience). +- **[`Example/GeneratedCode.swift`](./Example/GeneratedCode.swift)** — the + hand-written stand-in for what a `@LiveMapShape` macro would generate from + `MyChannelObject`: the `LiveMapShape` conformance and the three + associated-type enums (`LiveMapKeys`, `InitialEntry`, + `PathObjectKnownEntry`).