Skip to content

Commit b55f03b

Browse files
Implement the createMap and createCounter methods
Based on [1] at cb11ba8. Internal interfaces by me, implementation and tests by Cursor (both with some tweaking by me). Haven't implemented: - the RTO11e etc echoMessages check — deferred to #49 - the RTO11c etc channel mode checking - same reason as 392fae3 - using server time for generating object ID — deferred to #50 [1] ably/specification#353
1 parent 50bd6ad commit b55f03b

11 files changed

Lines changed: 999 additions & 18 deletions

Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift

Lines changed: 76 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -157,20 +157,88 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool
157157
}
158158
}
159159

160-
internal func createMap(entries _: [String: LiveMapValue]) async throws(ARTErrorInfo) -> any LiveMap {
161-
notYetImplemented()
160+
internal func createMap(entries: [String: InternalLiveMapValue], coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveMap {
161+
do throws(InternalError) {
162+
// RTO11d
163+
do {
164+
try coreSDK.validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "RealtimeObjects.createMap")
165+
} catch {
166+
throw error.toInternalError()
167+
}
168+
169+
// RTO11f
170+
// TODO: This is a stopgap; change to use server time per RTO11f5 (https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/50)
171+
let timestamp = clock.now
172+
let creationOperation = ObjectCreationHelpers.creationOperationForLiveMap(
173+
entries: entries,
174+
timestamp: timestamp,
175+
)
176+
177+
// RTO11g
178+
try await coreSDK.publish(objectMessages: [creationOperation.objectMessage])
179+
180+
// RTO11h
181+
return mutex.withLock {
182+
mutableState.objectsPool.getOrCreateMap(
183+
creationOperation: creationOperation,
184+
logger: logger,
185+
userCallbackQueue: userCallbackQueue,
186+
clock: clock,
187+
)
188+
}
189+
} catch {
190+
throw error.toARTErrorInfo()
191+
}
162192
}
163193

164-
internal func createMap() async throws(ARTErrorInfo) -> any LiveMap {
165-
notYetImplemented()
194+
internal func createMap(coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveMap {
195+
// RTO11f4b
196+
try await createMap(entries: [:], coreSDK: coreSDK)
166197
}
167198

168-
internal func createCounter(count _: Double) async throws(ARTErrorInfo) -> any LiveCounter {
169-
notYetImplemented()
199+
internal func createCounter(count: Double, coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveCounter {
200+
do throws(InternalError) {
201+
// RTO12d
202+
do {
203+
try coreSDK.validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "RealtimeObjects.createMap")
204+
} catch {
205+
throw error.toInternalError()
206+
}
207+
208+
// RTO12f1
209+
if !count.isFinite {
210+
throw LiveObjectsError.counterInitialValueInvalid(value: count).toARTErrorInfo().toInternalError()
211+
}
212+
213+
// RTO12f
214+
215+
// TODO: This is a stopgap; change to use server time per RTO12f5 (https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/50)
216+
let timestamp = clock.now
217+
let creationOperation = ObjectCreationHelpers.creationOperationForLiveCounter(
218+
count: count,
219+
timestamp: timestamp,
220+
)
221+
222+
// RTO12g
223+
try await coreSDK.publish(objectMessages: [creationOperation.objectMessage])
224+
225+
// RTO12h
226+
return mutex.withLock {
227+
mutableState.objectsPool.getOrCreateCounter(
228+
creationOperation: creationOperation,
229+
logger: logger,
230+
userCallbackQueue: userCallbackQueue,
231+
clock: clock,
232+
)
233+
}
234+
} catch {
235+
throw error.toARTErrorInfo()
236+
}
170237
}
171238

172-
internal func createCounter() async throws(ARTErrorInfo) -> any LiveCounter {
173-
notYetImplemented()
239+
internal func createCounter(coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveCounter {
240+
// RTO12f2a
241+
try await createCounter(count: 0, coreSDK: coreSDK)
174242
}
175243

176244
internal func batch(callback _: sending BatchCallback) async throws {

Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,60 @@ internal enum InternalLiveMapValue: Sendable, Equatable {
66
case liveMap(InternalDefaultLiveMap)
77
case liveCounter(InternalDefaultLiveCounter)
88

9+
// MARK: - Creating from a public LiveMapValue
10+
11+
/// Converts a public ``LiveMapValue`` into an ``InternalLiveMapValue``.
12+
///
13+
/// Needed in order to access the internals of user-provided LiveObject-valued LiveMap entries to extract their object ID.
14+
internal init(liveMapValue: LiveMapValue) {
15+
switch liveMapValue {
16+
case let .primitive(primitiveValue):
17+
self = .primitive(primitiveValue)
18+
case let .liveMap(publicLiveMap):
19+
guard let publicDefaultLiveMap = publicLiveMap as? PublicDefaultLiveMap else {
20+
// TODO: Try and remove this runtime check and know this type statically, see https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/37
21+
preconditionFailure("Expected PublicDefaultLiveMap, got \(publicLiveMap)")
22+
}
23+
self = .liveMap(publicDefaultLiveMap.proxied)
24+
case let .liveCounter(publicLiveCounter):
25+
guard let publicDefaultLiveCounter = publicLiveCounter as? PublicDefaultLiveCounter else {
26+
// TODO: Try and remove this runtime check and know this type statically, see https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/37
27+
preconditionFailure("Expected PublicDefaultLiveCounter, got \(publicLiveCounter)")
28+
}
29+
self = .liveCounter(publicDefaultLiveCounter.proxied)
30+
}
31+
}
32+
33+
// MARK: - Representation in the Realtime protocol
34+
35+
/// Converts an `InternalLiveMapValue` to the value that should be used when creating or updating a map entry in the Realtime protocol, per the rules of RTO11f4 and RTLM20e4.
36+
internal var toObjectData: ObjectData {
37+
// RTO11f4c1: Create an ObjectsMapEntry for the current value
38+
switch self {
39+
case let .primitive(primitiveValue):
40+
switch primitiveValue {
41+
case let .bool(value):
42+
.init(boolean: value)
43+
case let .data(value):
44+
.init(bytes: value)
45+
case let .number(value):
46+
.init(number: NSNumber(value: value))
47+
case let .string(value):
48+
.init(string: value)
49+
case let .jsonArray(value):
50+
.init(json: .array(value))
51+
case let .jsonObject(value):
52+
.init(json: .object(value))
53+
}
54+
case let .liveMap(liveMap):
55+
// RTO11f4c1a: If the value is of type LiveMap, set ObjectsMapEntry.data.objectId to the objectId of that object
56+
.init(objectId: liveMap.objectID)
57+
case let .liveCounter(liveCounter):
58+
// RTO11f4c1a: If the value is of type LiveCounter, set ObjectsMapEntry.data.objectId to the objectId of that object
59+
.init(objectId: liveCounter.objectID)
60+
}
61+
}
62+
963
// MARK: - Convenience getters for associated values
1064

1165
/// If this `InternalLiveMapValue` has case `primitive`, this returns the associated value. Else, it returns `nil`.

Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import Foundation
22

33
/// The entries stored in a `LiveMap`'s data. Same as an `ObjectsMapEntry` but with an additional `tombstonedAt` property, per RTLM3a.
4-
internal struct InternalObjectsMapEntry {
4+
internal struct InternalObjectsMapEntry: Equatable {
55
internal var tombstonedAt: Date? // RTLM3a
66
internal var tombstone: Bool {
77
// TODO: Confirm that we don't need to store this (https://github.com/ably/specification/pull/350/files#r2213895661)
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
internal import AblyPlugin
2+
import CryptoKit
3+
import Foundation
4+
5+
/// Helpers for creating a new LiveObject.
6+
///
7+
/// These generate an object ID and the `ObjectMessage` needed to create the LiveObject.
8+
internal enum ObjectCreationHelpers {
9+
/// The metadata that `createCounter` needs in order to request that Realtime create a LiveCounter and to populate the local objects pool.
10+
internal struct CounterCreationOperation {
11+
/// The generated object ID. Needed for populating the local objects pool.
12+
///
13+
/// We include this property separately as a non-nil value, instead of expecting the caller to fish the nullable value out of ``objectMessage``.
14+
internal var objectID: String
15+
16+
/// The operation that should be merged into any created LiveCounter.
17+
///
18+
/// We include this property separately as a non-nil value, instead of expecting the caller to fish the nullable value out of ``objectMessage``.
19+
internal var operation: ObjectOperation
20+
21+
/// The ObjectMessage that must be sent in order for Realtime to create the object.
22+
internal var objectMessage: OutboundObjectMessage
23+
}
24+
25+
/// The metadata that `createMap` needs in order to request that Realtime create a LiveMap and to populate the local objects pool.
26+
internal struct MapCreationOperation {
27+
/// The generated object ID. Needed for populating the local objects pool.
28+
///
29+
/// We include this property separately as a non-nil value, instead of expecting the caller to fish the nullable value out of ``objectMessage``.
30+
internal var objectID: String
31+
32+
/// The operation that should be merged into any created LiveMap.
33+
///
34+
/// We include this property separately as a non-nil value, instead of expecting the caller to fish the nullable value out of ``objectMessage``.
35+
internal var operation: ObjectOperation
36+
37+
/// The ObjectMessage that must be sent in order for Realtime to create the object.
38+
internal var objectMessage: OutboundObjectMessage
39+
40+
/// The semantics that should be used for the created LiveMap.
41+
///
42+
/// We include this property separately as a non-nil value, instead of expecting the caller to fish the nullable value out of ``objectMessage``.
43+
internal var semantics: ObjectsMapSemantics
44+
}
45+
46+
/// Creates a `COUNTER_CREATE` `ObjectMessage` for the `RealtimeObjects.createCounter` method per RTO12f.
47+
///
48+
/// - Parameters:
49+
/// - count: The initial count for the new LiveCounter object
50+
/// - timestamp: The timestamp to use for the generated object ID.
51+
internal static func creationOperationForLiveCounter(
52+
count: Double,
53+
timestamp: Date,
54+
) -> CounterCreationOperation {
55+
// RTO12f2: Create initial value for the new LiveCounter
56+
let initialValue = PartialObjectOperation(
57+
counter: WireObjectsCounter(count: NSNumber(value: count)),
58+
)
59+
60+
// RTO12f3: Create an initial value JSON string as described in RTO13
61+
let initialValueJSONString = createInitialValueJSONString(from: initialValue)
62+
63+
// RTO12f4: Create a unique nonce as a random string
64+
let nonce = generateNonce()
65+
66+
// RTO12f5: Get the current server time (using the provided timestamp)
67+
let serverTime = timestamp
68+
69+
// RTO12f6: Create an objectId for the new LiveCounter object as described in RTO14
70+
let objectId = createObjectID(
71+
type: "counter",
72+
initialValue: initialValueJSONString,
73+
nonce: nonce,
74+
timestamp: serverTime,
75+
)
76+
77+
// RTO12f7-12: Set ObjectMessage.operation fields
78+
let operation = ObjectOperation(
79+
action: .known(.counterCreate),
80+
objectId: objectId,
81+
counter: WireObjectsCounter(count: NSNumber(value: count)),
82+
nonce: nonce,
83+
initialValue: initialValueJSONString,
84+
)
85+
86+
// Create the OutboundObjectMessage
87+
let objectMessage = OutboundObjectMessage(
88+
operation: operation,
89+
)
90+
91+
return CounterCreationOperation(
92+
objectID: objectId,
93+
operation: operation,
94+
objectMessage: objectMessage,
95+
)
96+
}
97+
98+
/// Creates a `MAP_CREATE` `ObjectMessage` for the `RealtimeObjects.createMap` method per RTO11f.
99+
///
100+
/// - Parameters:
101+
/// - entries: The initial entries for the new LiveMap object
102+
/// - timestamp: The timestamp to use for the generated object ID.
103+
internal static func creationOperationForLiveMap(
104+
entries: [String: InternalLiveMapValue],
105+
timestamp: Date,
106+
) -> MapCreationOperation {
107+
// RTO11f4: Create initial value for the new LiveMap
108+
let mapEntries = entries.mapValues { liveMapValue -> ObjectsMapEntry in
109+
ObjectsMapEntry(data: liveMapValue.toObjectData)
110+
}
111+
112+
let initialValue = PartialObjectOperation(
113+
map: ObjectsMap(
114+
semantics: .known(.lww),
115+
entries: mapEntries,
116+
),
117+
)
118+
119+
// RTO11f5: Create an initial value JSON string as described in RTO13
120+
let initialValueJSONString = createInitialValueJSONString(from: initialValue)
121+
122+
// RTO11f6: Create a unique nonce as a random string
123+
let nonce = generateNonce()
124+
125+
// RTO11f7: Get the current server time (using the provided timestamp)
126+
let serverTime = timestamp
127+
128+
// RTO11f8: Create an objectId for the new LiveMap object as described in RTO14
129+
let objectId = createObjectID(
130+
type: "map",
131+
initialValue: initialValueJSONString,
132+
nonce: nonce,
133+
timestamp: serverTime,
134+
)
135+
136+
// RTO11f9-13: Set ObjectMessage.operation fields
137+
let semantics = ObjectsMapSemantics.lww
138+
let operation = ObjectOperation(
139+
action: .known(.mapCreate),
140+
objectId: objectId,
141+
map: ObjectsMap(
142+
semantics: .known(semantics),
143+
entries: mapEntries,
144+
),
145+
nonce: nonce,
146+
initialValue: initialValueJSONString,
147+
)
148+
149+
// Create the OutboundObjectMessage
150+
let objectMessage = OutboundObjectMessage(
151+
operation: operation,
152+
)
153+
154+
return MapCreationOperation(
155+
objectID: objectId,
156+
operation: operation,
157+
objectMessage: objectMessage,
158+
semantics: semantics,
159+
)
160+
}
161+
162+
// MARK: - Private Helper Methods
163+
164+
/// Creates an initial value JSON string from a PartialObjectOperation, per RTO13.
165+
private static func createInitialValueJSONString(from initialValue: PartialObjectOperation) -> String {
166+
// RTO13b: Encode the initial value using OM4 encoding
167+
let partialWireObjectOperation = initialValue.toWire(format: .json)
168+
let jsonObject = partialWireObjectOperation.toWireObject.mapValues { wireValue in
169+
do {
170+
return try wireValue.toJSONValue
171+
} catch {
172+
// By using `format: .json` we've requested a type that should be JSON-encodable, so if it isn't then it's a programmer error. (We can't reason about it statically though because of our choice to use a general-purpose WireValue type; maybe could improve upon this in the future.)
173+
preconditionFailure("Failed to convert WireValue \(wireValue) to JSONValue when encoding initialValue")
174+
}
175+
}
176+
177+
// RTO13c
178+
return JSONObjectOrArray.object(jsonObject).toJSONString
179+
}
180+
181+
/// Creates an Object ID for a new LiveObject instance, per RTO14.
182+
internal static func testsOnly_createObjectID(
183+
type: String,
184+
initialValue: String,
185+
nonce: String,
186+
timestamp: Date,
187+
) -> String {
188+
createObjectID(
189+
type: type,
190+
initialValue: initialValue,
191+
nonce: nonce,
192+
timestamp: timestamp,
193+
)
194+
}
195+
196+
/// Creates an Object ID for a new LiveObject instance, per RTO14.
197+
private static func createObjectID(
198+
type: String,
199+
initialValue: String,
200+
nonce: String,
201+
timestamp: Date,
202+
) -> String {
203+
// RTO14b1: Generate a SHA-256 digest
204+
let hash = SHA256.hash(data: Data("\(initialValue):\(nonce)".utf8))
205+
206+
// RTO14b2: Base64URL-encode the generated digest
207+
let base64URLHash = Data(hash).base64EncodedString()
208+
.replacingOccurrences(of: "+", with: "-")
209+
.replacingOccurrences(of: "/", with: "_")
210+
.replacingOccurrences(of: "=", with: "")
211+
212+
// RTO14c: Return an Object ID in the format [type]:[hash]@[timestamp]
213+
let timestampMillis = Int(timestamp.timeIntervalSince1970 * 1000)
214+
return "\(type):\(base64URLHash)@\(timestampMillis)"
215+
}
216+
217+
/// Generates a unique nonce as a random string, per RTO11f6 and RTO12f4.
218+
private static func generateNonce() -> String {
219+
// TODO: confirm if there's any specific rules here: https://github.com/ably/specification/pull/353/files#r2228252389
220+
let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
221+
return String((0 ..< 16).map { _ in letters.randomElement()! })
222+
}
223+
}

0 commit comments

Comments
 (0)