Skip to content

Commit 2d973c6

Browse files
committed
Add RestObject.generateObjectId() helper
As suggested in REST SDK usage docs PR [1], provide a helper for generating object IDs needed for *CreateWithObjectId operations. Constructing IDs manually is error-prone, and the SDK already has the internal machinery for this (used by the realtime client), so exposing it as a public method is straightforward. [1] ably/docs#3258 (comment)
1 parent 8d25ece commit 2d973c6

File tree

3 files changed

+267
-48
lines changed

3 files changed

+267
-48
lines changed

liveobjects.d.ts

Lines changed: 106 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,23 @@ export declare interface RestObject {
145145
* @returns A promise which, upon success, will be fulfilled with a {@link RestObjectPublishResult} containing information about the published operations. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error.
146146
*/
147147
publish(op: RestObjectOperation | RestObjectOperation[]): Promise<RestObjectPublishResult>;
148+
149+
/**
150+
* Generates an object ID for a create operation. The returned ID, nonce, and initial value
151+
* can be used to construct a {@link RestObjectOperationMapCreateWithObjectId} or
152+
* {@link RestObjectOperationCounterCreateWithObjectId} operation for use with {@link publish}.
153+
*
154+
* Client-generated object IDs enable atomic batch operations with cross-references between
155+
* newly created objects. When publishing a batch of operations using {@link publish}, you
156+
* can reference an object by its pre-computed ID in the same batch - for example, creating
157+
* a map and assigning it to a key in another map in a single atomic publish call.
158+
*
159+
* @param createBody - The create operation body, either a {@link RestObjectOperationMapCreateBody} or {@link RestObjectOperationCounterCreateBody}.
160+
* @returns A promise which, upon success, will be fulfilled with a {@link RestObjectGenerateIdResult}. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error.
161+
*/
162+
generateObjectId(
163+
createBody: RestObjectOperationMapCreateBody | RestObjectOperationCounterCreateBody,
164+
): Promise<RestObjectGenerateIdResult>;
148165
}
149166

150167
/**
@@ -252,44 +269,58 @@ export type PublishObjectData =
252269
/** Not applicable. */ json?: never;
253270
};
254271

272+
/**
273+
* The map creation payload, specifying the semantics and initial entries for a new map object.
274+
* Used as the body of {@link RestObjectOperationMapCreate} and as input to {@link RestObject.generateObjectId}.
275+
*/
276+
export interface RestObjectOperationMapCreateBody {
277+
/** The map creation parameters. */
278+
mapCreate: {
279+
/** The conflict-resolution semantics for the map. */
280+
semantics: Exclude<ObjectsMapSemantics, ObjectsMapSemanticsNamespace.UNKNOWN>;
281+
/** Initial key-value pairs for the map. */
282+
entries: Record<
283+
string,
284+
{
285+
/**
286+
* The initial value for this key, which is either a primitive value or the ID of another LiveObject.
287+
*/
288+
data: PublishObjectData;
289+
}
290+
>;
291+
};
292+
}
293+
255294
/**
256295
* Operation to create a new map object at the specified path with initial entries.
257296
*/
258297
export type RestObjectOperationMapCreate = RestObjectOperationBase &
259-
Partial<TargetByPath> & {
260-
/** The map creation parameters. */
261-
mapCreate: {
262-
/** The conflict-resolution semantics for the map. */
263-
semantics: Exclude<ObjectsMapSemantics, ObjectsMapSemanticsNamespace.UNKNOWN>;
264-
/** Initial key-value pairs for the map. */
265-
entries: Record<
266-
string,
267-
{
268-
/**
269-
* The initial value for this key, which is either a primitive value or the ID of another LiveObject.
270-
*/
271-
data: PublishObjectData;
272-
}
273-
>;
274-
};
275-
};
298+
Partial<TargetByPath> &
299+
RestObjectOperationMapCreateBody;
276300

277301
/**
278302
* Operation to create a new map object with a client-generated object ID and initial entries.
303+
* Use {@link RestObject.generateObjectId} to generate the object ID, nonce, and initial value
304+
* needed for this operation.
279305
*/
280-
export type RestObjectOperationMapCreateWithObjectId = RestObjectOperationBase &
281-
TargetByObjectId & {
282-
/** The map creation parameters for a pre-computed object ID. */
283-
mapCreateWithObjectId: {
284-
/**
285-
* JSON-encoded string representation of the {@link RestObjectOperationMapCreate.mapCreate} object.
286-
* For example: `'{"semantics":"lww","entries":{"name":{"data":{"string":"Alice"}}}}'`.
287-
*/
288-
initialValue: string;
289-
/** Random string used to generate the object ID */
290-
nonce: string;
291-
};
306+
export type RestObjectOperationMapCreateWithObjectId = RestObjectOperationBase & {
307+
/**
308+
* The object ID for the new map object.
309+
* Use {@link RestObject.generateObjectId} to generate this value along with the matching nonce and initial value.
310+
*/
311+
objectId: string;
312+
/** The map creation parameters for a pre-computed object ID. */
313+
mapCreateWithObjectId: {
314+
/**
315+
* JSON-encoded string representation of the {@link RestObjectOperationMapCreate.mapCreate} object.
316+
* Binary values in entries must be Base64-encoded in this JSON string.
317+
* For example: `'{"semantics":"lww","entries":{"name":{"data":{"string":"Alice"}}}}'`.
318+
*/
319+
initialValue: string;
320+
/** Random string used to generate the object ID. */
321+
nonce: string;
292322
};
323+
};
293324

294325
/**
295326
* Operation to set a key to a specified value in an existing map object.
@@ -317,34 +348,47 @@ export type RestObjectOperationMapRemove = AnyTargetRestObjectOperationBase & {
317348
};
318349
};
319350

351+
/**
352+
* The counter creation payload, specifying the initial count for a new counter object.
353+
* Used as the body of {@link RestObjectOperationCounterCreate} and as input to {@link RestObject.generateObjectId}.
354+
*/
355+
export interface RestObjectOperationCounterCreateBody {
356+
/** The counter creation parameters. */
357+
counterCreate: {
358+
/** The initial value of the counter. */
359+
count: number;
360+
};
361+
}
362+
320363
/**
321364
* Operation to create a new counter object at the specified path with an initial count value.
322365
*/
323366
export type RestObjectOperationCounterCreate = RestObjectOperationBase &
324-
Partial<TargetByPath> & {
325-
/** The counter creation parameters. */
326-
counterCreate: {
327-
/** The initial value of the counter. */
328-
count: number;
329-
};
330-
};
367+
Partial<TargetByPath> &
368+
RestObjectOperationCounterCreateBody;
331369

332370
/**
333371
* Operation to create a new counter object with a client-generated object ID and an initial count value.
372+
* Use {@link RestObject.generateObjectId} to generate the object ID, nonce, and initial value
373+
* needed for this operation.
334374
*/
335-
export type RestObjectOperationCounterCreateWithObjectId = RestObjectOperationBase &
336-
TargetByObjectId & {
337-
/** The counter creation parameters for a pre-computed object ID. */
338-
counterCreateWithObjectId: {
339-
/**
340-
* JSON-encoded string representation of the {@link RestObjectOperationCounterCreate.counterCreate} object.
341-
* For example: `'{"counter":0}'`.
342-
*/
343-
initialValue: string;
344-
/** Random string used to generate the object ID */
345-
nonce: string;
346-
};
375+
export type RestObjectOperationCounterCreateWithObjectId = RestObjectOperationBase & {
376+
/**
377+
* The object ID for the new counter object.
378+
* Use {@link RestObject.generateObjectId} to generate this value along with the matching nonce and initial value.
379+
*/
380+
objectId: string;
381+
/** The counter creation parameters for a pre-computed object ID. */
382+
counterCreateWithObjectId: {
383+
/**
384+
* JSON-encoded string representation of the {@link RestObjectOperationCounterCreate.counterCreate} object.
385+
* For example: `'{"count":0}'`.
386+
*/
387+
initialValue: string;
388+
/** Random string used to generate the object ID. */
389+
nonce: string;
347390
};
391+
};
348392

349393
/**
350394
* Operation to increment (or decrement with negative values) an existing counter object.
@@ -386,6 +430,20 @@ export interface RestObjectPublishResult {
386430
objectIds: string[];
387431
}
388432

433+
/**
434+
* Result returned by {@link RestObject.generateObjectId}, containing the generated object ID
435+
* and the values needed to construct a {@link RestObjectOperationMapCreateWithObjectId}
436+
* or {@link RestObjectOperationCounterCreateWithObjectId} operation.
437+
*/
438+
export interface RestObjectGenerateIdResult {
439+
/** The generated object ID. */
440+
objectId: string;
441+
/** The nonce used in ID generation. */
442+
nonce: string;
443+
/** The JSON-encoded initial value used in ID generation. */
444+
initialValue: string;
445+
}
446+
389447
/**
390448
* Request parameters for {@link RestObject.get}.
391449
*/

src/plugins/liveobjects/restobject.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,18 @@ import type {
77
RestLiveObject,
88
RestObject as PublicRestObject,
99
RestObjectData,
10+
RestObjectGenerateIdResult,
1011
RestObjectGetCompactParams,
1112
RestObjectGetCompactResult,
1213
RestObjectGetFullParams,
1314
RestObjectGetFullResult,
1415
RestObjectGetParams,
1516
RestObjectOperation,
17+
RestObjectOperationCounterCreateBody,
18+
RestObjectOperationMapCreateBody,
1619
RestObjectPublishResult,
1720
} from '../../../liveobjects';
21+
import { ObjectId } from './objectid';
1822
import {
1923
CounterCreate,
2024
CounterCreateWithObjectId,
@@ -158,6 +162,50 @@ export class RestObject implements PublicRestObject {
158162
return unpacked ? body! : client.Utils.decodeBody(body, client._MsgPack, format);
159163
}
160164

165+
async generateObjectId(
166+
createBody: RestObjectOperationMapCreateBody | RestObjectOperationCounterCreateBody,
167+
): Promise<RestObjectGenerateIdResult> {
168+
const client = this._channel.client;
169+
// operations for initialValue string are always encoded as JSON format
170+
const format = client.Utils.Format.json;
171+
172+
let objectType: 'map' | 'counter';
173+
let initialValueJSONString: string;
174+
175+
if ('mapCreate' in createBody && createBody.mapCreate) {
176+
objectType = 'map';
177+
const mapCreate: MapCreate<ObjectData> = {
178+
...createBody.mapCreate,
179+
semantics: encodeMapSemantics(createBody.mapCreate.semantics, client),
180+
};
181+
const { mapCreate: encodedMapCreate } = encodePartialObjectOperationForWire({ mapCreate }, client, format);
182+
initialValueJSONString = JSON.stringify(encodedMapCreate);
183+
} else if ('counterCreate' in createBody && createBody.counterCreate) {
184+
objectType = 'counter';
185+
const { counterCreate: encodedCounterCreate } = encodePartialObjectOperationForWire(createBody, client, format);
186+
initialValueJSONString = JSON.stringify(encodedCounterCreate);
187+
} else {
188+
throw new client.ErrorInfo('generateObjectId requires a mapCreate or counterCreate property', 40003, 400);
189+
}
190+
191+
const nonce = client.Utils.cheapRandStr();
192+
const msTimestamp = await client.getTimestamp(true);
193+
194+
const objectId = ObjectId.fromInitialValue(
195+
client.Platform,
196+
objectType,
197+
initialValueJSONString,
198+
nonce,
199+
msTimestamp,
200+
).toString();
201+
202+
return {
203+
objectId,
204+
nonce,
205+
initialValue: initialValueJSONString,
206+
};
207+
}
208+
161209
private _basePath(objectId?: string): string {
162210
return (
163211
this._channel.client.rest.channelMixin.basePath(this._channel) +

test/rest/liveobjects.test.js

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -893,6 +893,119 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f
893893
await scenario.action({ helper, options, client, channel, channelName, objectsHelper });
894894
});
895895
});
896+
897+
describe('RestObject.generateObjectId()', () => {
898+
/** @nospec */
899+
const generateObjectIdScenarios = [
900+
{
901+
description: 'returns result with valid objectId, nonce and initialValue for mapCreate',
902+
action: async ({ channel }) => {
903+
const result = await channel.object.generateObjectId({
904+
mapCreate: {
905+
semantics: 'lww',
906+
entries: {
907+
name: { data: { string: 'Alice' } },
908+
},
909+
},
910+
});
911+
912+
expect(result.objectId).to.be.a('string');
913+
expect(result.objectId).to.match(/^map:/);
914+
expect(result.nonce).to.be.a('string');
915+
expect(result.initialValue).to.be.a('string');
916+
expect(() => JSON.parse(result.initialValue)).to.not.throw();
917+
},
918+
},
919+
{
920+
description: 'returns result with valid objectId, nonce and initialValue for counterCreate',
921+
action: async ({ channel }) => {
922+
const result = await channel.object.generateObjectId({
923+
counterCreate: { count: 42 },
924+
});
925+
926+
expect(result.objectId).to.be.a('string');
927+
expect(result.objectId).to.match(/^counter:/);
928+
expect(result.nonce).to.be.a('string');
929+
expect(result.initialValue).to.be.a('string');
930+
expect(() => JSON.parse(result.initialValue)).to.not.throw();
931+
},
932+
},
933+
{
934+
description: 'generates different IDs for the same payload on each call',
935+
action: async ({ channel }) => {
936+
const payload = { counterCreate: { count: 0 } };
937+
const result1 = await channel.object.generateObjectId(payload);
938+
const result2 = await channel.object.generateObjectId(payload);
939+
940+
expect(result1.objectId).to.not.equal(result2.objectId);
941+
expect(result1.nonce).to.not.equal(result2.nonce);
942+
expect(result1.initialValue).to.equal(result2.initialValue);
943+
},
944+
},
945+
{
946+
description: 'throws an error if neither mapCreate nor counterCreate is provided',
947+
action: async ({ channel }) => {
948+
await Helper.expectToThrowAsync(
949+
() => channel.object.generateObjectId({}),
950+
'generateObjectId requires a mapCreate or counterCreate property',
951+
{ withCode: 40003, withStatusCode: 400 },
952+
);
953+
},
954+
},
955+
{
956+
jsonMsgpack: true,
957+
description: 'generated map ID can be used with mapCreateWithObjectId in publish',
958+
action: async ({ channel }) => {
959+
const { objectId, nonce, initialValue } = await channel.object.generateObjectId({
960+
mapCreate: {
961+
semantics: 'lww',
962+
entries: {
963+
key1: { data: { string: 'value1' } },
964+
},
965+
},
966+
});
967+
968+
const publishResult = await channel.object.publish({
969+
objectId,
970+
mapCreateWithObjectId: { initialValue, nonce },
971+
});
972+
973+
expect(publishResult.objectIds).to.include(objectId);
974+
975+
const obj = await channel.object.get({ objectId, compact: false });
976+
expect(obj.objectId).to.equal(objectId);
977+
expect(obj.map.entries.key1.data.string).to.equal('value1');
978+
},
979+
},
980+
{
981+
jsonMsgpack: true,
982+
description: 'generated counter ID can be used with counterCreateWithObjectId in publish',
983+
action: async ({ channel }) => {
984+
const { objectId, nonce, initialValue } = await channel.object.generateObjectId({
985+
counterCreate: { count: 100 },
986+
});
987+
988+
const publishResult = await channel.object.publish({
989+
objectId,
990+
counterCreateWithObjectId: { initialValue, nonce },
991+
});
992+
993+
expect(publishResult.objectIds).to.include(objectId);
994+
995+
const obj = await channel.object.get({ objectId, compact: false });
996+
expect(obj.objectId).to.equal(objectId);
997+
expect(obj.counter.data.number).to.equal(100);
998+
},
999+
},
1000+
];
1001+
1002+
forScenarios(generateObjectIdScenarios, async (helper, scenario, options, channelName) => {
1003+
const client = RestWithLiveObjects(helper, options);
1004+
const channel = client.channels.get(channelName);
1005+
1006+
await scenario.action({ helper, channel });
1007+
});
1008+
});
8961009
});
8971010
});
8981011
});

0 commit comments

Comments
 (0)