Skip to content

Commit d19d39c

Browse files
Add helpers for creating ObjectMessage for LiveObject creation
Interface by me, code largely by Cursor and then tweaked by me. Haven't looked at its tests yet; may end up getting it to regenerate because they seem a bit anaemic. TODO we need to figure out how we can do an object operation that doesn't contain an ID
1 parent f614228 commit d19d39c

3 files changed

Lines changed: 362 additions & 0 deletions

File tree

Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift

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

9+
// MARK: - Representation in the Realtime protocol
10+
11+
// TODO: document — it's RTO11f4 (for createMap) and RTLM20e4 (for set)
12+
internal var toObjectData: ObjectData {
13+
// TOOD add
14+
// RTO11f4c1: Create an ObjectsMapEntry for the current value
15+
switch self {
16+
case let .primitive(primitiveValue):
17+
switch primitiveValue {
18+
case let .bool(value):
19+
.init(boolean: value)
20+
case let .data(value):
21+
.init(bytes: value)
22+
case let .number(value):
23+
.init(number: NSNumber(value: value))
24+
case let .string(value):
25+
.init(string: .string(value))
26+
}
27+
case let .liveMap(liveMap):
28+
// RTO11f4c1a: If the value is of type LiveMap, set ObjectsMapEntry.data.objectId to the objectId of that object
29+
.init(objectId: liveMap.objectID)
30+
case let .liveCounter(liveCounter):
31+
// RTO11f4c1a: If the value is of type LiveCounter, set ObjectsMapEntry.data.objectId to the objectId of that object
32+
.init(objectId: liveCounter.objectID)
33+
}
34+
}
35+
936
// MARK: - Convenience getters for associated values
1037

1138
/// If this `InternalLiveMapValue` has case `primitive`, this returns the associated value. Else, it returns `nil`.
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
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 a LiveObject creation method (e.g. `createCounter`) needs in order to request that Realtime create a LiveObject and to populate the local objects pool.
10+
internal struct ObjectCreationOperation {
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 LiveObject.
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+
/// Contains only the properties of `ObjectOperation` needed for creating the initial value JSON string. In particular, it does not have an `objectID`.
26+
internal struct LiveObjectInitialValue {
27+
internal var action: WireEnum<ObjectOperationAction>
28+
internal var map: ObjectsMap?
29+
internal var counter: WireObjectsCounter?
30+
}
31+
32+
/// Creates a `COUNTER_CREATE` `ObjectMessage` for the `RealtimeObjects.createCounter` method per RTO12f.
33+
///
34+
/// - Parameters:
35+
/// - count: The initial count for the new LiveCounter object
36+
/// - timestamp: The timestamp to use for the generated object ID.
37+
internal static func creationOperationForLiveCounter(
38+
count: Double,
39+
timestamp: Date,
40+
) -> ObjectCreationOperation {
41+
// RTO12f2: Create initial value for the new LiveCounter
42+
let initialValue = LiveObjectInitialValue(
43+
action: .known(.counterCreate),
44+
counter: WireObjectsCounter(count: NSNumber(value: count)),
45+
)
46+
47+
// RTO12f3: Create an initial value JSON string as described in RTO13
48+
let initialValueJSONString = createInitialValueJSONString(from: initialValue)
49+
50+
// RTO12f4: Create a unique nonce as a random string
51+
let nonce = generateNonce()
52+
53+
// RTO12f5: Get the current server time (using the provided timestamp)
54+
let serverTime = timestamp
55+
56+
// RTO12f6: Create an objectId for the new LiveCounter object as described in RTO14
57+
let objectId = createObjectID(
58+
type: "counter",
59+
initialValue: initialValueJSONString,
60+
nonce: nonce,
61+
timestamp: serverTime,
62+
)
63+
64+
// RTO12f7-12: Set ObjectMessage.operation fields
65+
let operation = ObjectOperation(
66+
action: .known(.counterCreate),
67+
objectId: objectId,
68+
counter: WireObjectsCounter(count: NSNumber(value: count)),
69+
nonce: nonce,
70+
initialValue: initialValueJSONString,
71+
)
72+
73+
// Create the OutboundObjectMessage
74+
let objectMessage = OutboundObjectMessage(
75+
operation: operation,
76+
)
77+
78+
return ObjectCreationOperation(
79+
objectID: objectId,
80+
operation: operation,
81+
objectMessage: objectMessage,
82+
)
83+
}
84+
85+
/// Creates a `MAP_CREATE` `ObjectMessage` for the `RealtimeObjects.createMap` method per RTO11f.
86+
///
87+
/// - Parameters:
88+
/// - entries: The initial entries for the new LiveMap object
89+
/// - timestamp: The timestamp to use for the generated object ID.
90+
internal static func creationOperationForLiveMap(
91+
entries: [String: InternalLiveMapValue],
92+
timestamp: Date,
93+
) -> ObjectCreationOperation {
94+
// RTO11f4: Create initial value for the new LiveMap
95+
let mapEntries = entries.mapValues { liveMapValue -> ObjectsMapEntry in
96+
ObjectsMapEntry(data: liveMapValue.toObjectData)
97+
}
98+
99+
let initialValue = LiveObjectInitialValue(
100+
action: .known(.mapCreate),
101+
map: ObjectsMap(
102+
semantics: .known(.lww),
103+
entries: mapEntries,
104+
),
105+
)
106+
107+
// RTO11f5: Create an initial value JSON string as described in RTO13
108+
let initialValueJSONString = createInitialValueJSONString(from: initialValue)
109+
110+
// RTO11f6: Create a unique nonce as a random string
111+
let nonce = generateNonce()
112+
113+
// RTO11f7: Get the current server time (using the provided timestamp)
114+
let serverTime = timestamp
115+
116+
// RTO11f8: Create an objectId for the new LiveMap object as described in RTO14
117+
let objectId = createObjectID(
118+
type: "map",
119+
initialValue: initialValueJSONString,
120+
nonce: nonce,
121+
timestamp: serverTime,
122+
)
123+
124+
// RTO11f9-13: Set ObjectMessage.operation fields
125+
let operation = ObjectOperation(
126+
action: .known(.mapCreate),
127+
objectId: objectId,
128+
map: ObjectsMap(
129+
semantics: .known(.lww),
130+
entries: mapEntries,
131+
),
132+
nonce: nonce,
133+
initialValue: initialValueJSONString,
134+
)
135+
136+
// Create the OutboundObjectMessage
137+
let objectMessage = OutboundObjectMessage(
138+
operation: operation,
139+
)
140+
141+
return ObjectCreationOperation(
142+
objectID: objectId,
143+
operation: operation,
144+
objectMessage: objectMessage,
145+
)
146+
}
147+
148+
// MARK: - Private Helper Methods
149+
150+
/// Creates an initial value JSON string from a LiveObjectInitialValue, per RTO13.
151+
private static func createInitialValueJSONString(from initialValue: LiveObjectInitialValue) -> String {
152+
// RTO13b: Encode the initial value using OM4 encoding
153+
let jsonObject = initialValue.toWire(format: .json).toWireObject.mapValues { wireValue in
154+
do {
155+
return try wireValue.toJSONValue
156+
} catch {
157+
// 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.)
158+
preconditionFailure("Failed to convert WireValue \(wireValue) to JSONValue when encoding initialValue")
159+
}
160+
}
161+
162+
// RTO13c
163+
return JSONObjectOrArray.object(jsonObject).toJSONString
164+
}
165+
166+
/// Creates an Object ID for a new LiveObject instance, per RTO14.
167+
private static func createObjectID(
168+
type: String,
169+
initialValue: String,
170+
nonce: String,
171+
timestamp: Date,
172+
) -> String {
173+
// RTO14b1: Generate a hash string for the Object ID
174+
let hashInput = "\(initialValue):\(nonce)"
175+
let hashData = Data(hashInput.utf8)
176+
177+
// RTO14b1: Generate a SHA-256 digest
178+
let hash = SHA256.hash(data: hashData)
179+
180+
// RTO14b2: Base64URL-encode the generated digest
181+
// TODO: check
182+
let base64URLHash = Data(hash).base64EncodedString()
183+
.replacingOccurrences(of: "+", with: "-")
184+
.replacingOccurrences(of: "/", with: "_")
185+
.replacingOccurrences(of: "=", with: "")
186+
187+
// RTO14c: Return an Object ID in the format [type]:[hash]@[timestamp]
188+
let timestampMillis = Int(timestamp.timeIntervalSince1970 * 1000)
189+
return "\(type):\(base64URLHash)@\(timestampMillis)"
190+
}
191+
192+
/// Generates a unique nonce as a random string, per RTO11f6 and RTO12f4.
193+
private static func generateNonce() -> String {
194+
// TODO: confirm if there's any specific rules here: https://github.com/ably/specification/pull/353/files#r2228252389
195+
let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
196+
return String((0 ..< 16).map { _ in letters.randomElement()! })
197+
}
198+
}
199+
200+
// MARK: - LiveObjectInitialValue Extensions
201+
202+
internal extension ObjectCreationHelpers.LiveObjectInitialValue {
203+
/// Converts this `LiveObjectInitialValue` to a `WireObjectOperation`, applying the data encoding rules of OD4 and OOP5.
204+
///
205+
/// - Parameters:
206+
/// - format: The format to use when applying the encoding rules of OD4 and OOP5.
207+
func toWire(format: AblyPlugin.EncodingFormat) -> WireObjectOperation {
208+
.init(
209+
action: action,
210+
// TODO: this is dodgy — we need a partial
211+
objectId: "", // Empty string since this is for initial value creation only
212+
mapOp: nil,
213+
counterOp: nil,
214+
map: map?.toWire(format: format),
215+
counter: counter,
216+
nonce: nil,
217+
initialValue: nil,
218+
)
219+
}
220+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
@testable import AblyLiveObjects
2+
import Foundation
3+
import Testing
4+
5+
final class ObjectCreationHelpersTests {
6+
@Test
7+
func creationOperationForLiveCounter_createsValidOperation() {
8+
let count = 42.0
9+
let timestamp = Date(timeIntervalSince1970: 1_234_567_890)
10+
11+
let operation = ObjectCreationHelpers.creationOperationForLiveCounter(
12+
count: count,
13+
timestamp: timestamp,
14+
)
15+
16+
// Verify the object ID format follows RTO14
17+
#expect(operation.objectID.hasPrefix("counter:"))
18+
#expect(operation.objectID.contains("@"))
19+
20+
// Verify the ObjectMessage structure
21+
#expect(operation.objectMessage.operation?.action == .known(.counterCreate))
22+
#expect(operation.objectMessage.operation?.objectId == operation.objectID)
23+
#expect(operation.objectMessage.operation?.counter?.count == NSNumber(value: count))
24+
#expect(operation.objectMessage.operation?.nonce != nil)
25+
#expect(operation.objectMessage.operation?.initialValue != nil)
26+
}
27+
28+
@Test
29+
func creationOperationForLiveMap_createsValidOperation() {
30+
let entries: [String: InternalLiveMapValue] = [
31+
"stringKey": .primitive(.string("stringValue")),
32+
"numberKey": .primitive(.number(123.45)),
33+
"boolKey": .primitive(.bool(true)),
34+
"dataKey": .primitive(.data(Data([1, 2, 3, 4]))),
35+
]
36+
let timestamp = Date(timeIntervalSince1970: 1_234_567_890)
37+
38+
let operation = ObjectCreationHelpers.creationOperationForLiveMap(
39+
entries: entries,
40+
timestamp: timestamp,
41+
)
42+
43+
// Verify the object ID format follows RTO14
44+
#expect(operation.objectID.hasPrefix("map:"))
45+
#expect(operation.objectID.contains("@"))
46+
47+
// Verify the ObjectMessage structure
48+
#expect(operation.objectMessage.operation?.action == .known(.mapCreate))
49+
#expect(operation.objectMessage.operation?.objectId == operation.objectID)
50+
#expect(operation.objectMessage.operation?.map?.semantics == .known(.lww))
51+
#expect(operation.objectMessage.operation?.nonce != nil)
52+
#expect(operation.objectMessage.operation?.initialValue != nil)
53+
54+
// Verify map entries
55+
let mapEntries = operation.objectMessage.operation?.map?.entries
56+
#expect(mapEntries?.count == 4)
57+
58+
// Check string value
59+
if case let .string(value) = mapEntries?["stringKey"]?.data.string {
60+
#expect(value == "stringValue")
61+
} else {
62+
Issue.record("Expected string value")
63+
}
64+
65+
#expect(mapEntries?["numberKey"]?.data.number == NSNumber(value: 123.45))
66+
#expect(mapEntries?["boolKey"]?.data.boolean == true)
67+
#expect(mapEntries?["dataKey"]?.data.bytes == Data([1, 2, 3, 4]))
68+
}
69+
70+
@Test
71+
func objectIDGeneration_followsRTO14Format() {
72+
let timestamp = Date(timeIntervalSince1970: 1_234_567_890)
73+
74+
let counterOperation = ObjectCreationHelpers.creationOperationForLiveCounter(
75+
count: 42.0,
76+
timestamp: timestamp,
77+
)
78+
79+
let mapOperation = ObjectCreationHelpers.creationOperationForLiveMap(
80+
entries: [:],
81+
timestamp: timestamp,
82+
)
83+
84+
// Verify object ID format: [type]:[hash]@[timestamp]
85+
let counterPattern = #/^counter:[A-Za-z0-9_-]+@\d+$/#
86+
let mapPattern = #/^map:[A-Za-z0-9_-]+@\d+$/#
87+
88+
#expect(counterOperation.objectID.wholeMatch(of: counterPattern) != nil)
89+
#expect(mapOperation.objectID.wholeMatch(of: mapPattern) != nil)
90+
91+
// Verify timestamp part
92+
let expectedTimestamp = Int(timestamp.timeIntervalSince1970 * 1000)
93+
#expect(counterOperation.objectID.hasSuffix("@\(expectedTimestamp)"))
94+
#expect(mapOperation.objectID.hasSuffix("@\(expectedTimestamp)"))
95+
}
96+
97+
@Test
98+
func nonceGeneration_createsUniqueValues() {
99+
let timestamp = Date()
100+
101+
let operation1 = ObjectCreationHelpers.creationOperationForLiveCounter(
102+
count: 42.0,
103+
timestamp: timestamp,
104+
)
105+
106+
let operation2 = ObjectCreationHelpers.creationOperationForLiveCounter(
107+
count: 42.0,
108+
timestamp: timestamp,
109+
)
110+
111+
// Nonces should be different even with same input
112+
#expect(operation1.objectMessage.operation?.nonce != operation2.objectMessage.operation?.nonce)
113+
#expect(operation1.objectID != operation2.objectID)
114+
}
115+
}

0 commit comments

Comments
 (0)