diff --git a/ably.d.ts b/ably.d.ts index 225fc806c..5f375b1ec 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -2039,25 +2039,76 @@ export declare interface LiveObjects { /** * Retrieves the root {@link LiveMap} object for state on a channel. * + * A type parameter can be provided to describe the structure of the LiveObjects state on the channel. By default, it uses types from the globally defined `LiveObjectsTypes` interface. + * + * You can specify custom types for LiveObjects by defining a global `LiveObjectsTypes` interface with a `root` property that conforms to {@link LiveMapType}. + * + * Example: + * + * ```typescript + * import { LiveCounter } from 'ably'; + * + * type MyRoot = { + * myTypedKey: LiveCounter; + * }; + * + * declare global { + * export interface LiveObjectsTypes { + * root: MyRoot; + * } + * } + * ``` + * * @returns A promise which, upon success, will be fulfilled with a {@link LiveMap} object. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. */ - getRoot(): Promise; + getRoot(): Promise>; } +declare global { + /** + * A globally defined interface that allows users to define custom types for LiveObjects. + */ + export interface LiveObjectsTypes { + [key: string]: unknown; + } +} + +/** + * Represents the type of data stored in a {@link LiveMap}. + * It maps string keys to scalar values ({@link StateValue}), or other LiveObjects. + */ +export type LiveMapType = { [key: string]: StateValue | LiveMap | LiveCounter | undefined }; + +/** + * The default type for the `root` object in the LiveObjects, based on the globally defined {@link LiveObjectsTypes} interface. + * + * - If no custom types are provided in `LiveObjectsTypes`, defaults to an untyped root map representation using the {@link LiveMapType} interface. + * - If a `root` type exists in `LiveObjectsTypes` and conforms to the {@link LiveMapType} interface, it is used as the type for the `root` object. + * - If the provided `root` type does not match {@link LiveMapType}, a type error message is returned. + */ +export type DefaultRoot = + // we need a way to know when no types were provided by the user. + // we expect a "root" property to be set on LiveObjectsTypes interface, e.g. it won't be "unknown" anymore + unknown extends LiveObjectsTypes['root'] + ? LiveMapType // no custom types provided; use the default untyped map representation for the root + : LiveObjectsTypes['root'] extends LiveMapType + ? LiveObjectsTypes['root'] // "root" property exists, and it is of an expected type, we can use this interface for the root object in LiveObjects. + : `Provided type definition for the "root" object in LiveObjectsTypes is not of an expected LiveMapType`; + /** * The `LiveMap` class represents a key/value map data structure, similar to a JavaScript Map, where all changes are synchronized across clients in realtime. * Conflict-free resolution for updates follows Last Write Wins (LWW) semantics, meaning that if two clients update the same key in the map, the update with the most recent timestamp wins. * * Keys must be strings. Values can be another Live Object, or a primitive type, such as a string, number, boolean, or binary data (see {@link StateValue}). */ -export declare interface LiveMap extends LiveObject { +export declare interface LiveMap extends LiveObject { /** * Returns the value associated with a given key. Returns `undefined` if the key doesn't exist in a map. * * @param key - The key to retrieve the value for. * @returns A {@link LiveObject}, a primitive type (string, number, boolean, or binary data) or `undefined` if the key doesn't exist in a map. */ - get(key: string): LiveObject | StateValue | undefined; + get(key: TKey): T[TKey]; /** * Returns the number of key/value pairs in the map. diff --git a/src/plugins/liveobjects/livemap.ts b/src/plugins/liveobjects/livemap.ts index b29654c02..07577d4b2 100644 --- a/src/plugins/liveobjects/livemap.ts +++ b/src/plugins/liveobjects/livemap.ts @@ -1,5 +1,6 @@ import deepEqual from 'deep-equal'; +import type * as API from '../../../ably'; import { LiveObject, LiveObjectData, LiveObjectUpdate, LiveObjectUpdateNoop } from './liveobject'; import { LiveObjects } from './liveobjects'; import { @@ -45,7 +46,7 @@ export interface LiveMapUpdate extends LiveObjectUpdate { update: { [keyName: string]: 'updated' | 'removed' }; } -export class LiveMap extends LiveObject { +export class LiveMap extends LiveObject { constructor( liveObjects: LiveObjects, private _semantics: MapSemantics, @@ -59,8 +60,8 @@ export class LiveMap extends LiveObject { * * @internal */ - static zeroValue(liveobjects: LiveObjects, objectId: string): LiveMap { - return new LiveMap(liveobjects, MapSemantics.LWW, objectId); + static zeroValue(liveobjects: LiveObjects, objectId: string): LiveMap { + return new LiveMap(liveobjects, MapSemantics.LWW, objectId); } /** @@ -69,8 +70,8 @@ export class LiveMap extends LiveObject { * * @internal */ - static fromStateObject(liveobjects: LiveObjects, stateObject: StateObject): LiveMap { - const obj = new LiveMap(liveobjects, stateObject.map?.semantics!, stateObject.objectId); + static fromStateObject(liveobjects: LiveObjects, stateObject: StateObject): LiveMap { + const obj = new LiveMap(liveobjects, stateObject.map?.semantics!, stateObject.objectId); obj.overrideWithStateObject(stateObject); return obj; } @@ -82,24 +83,25 @@ export class LiveMap extends LiveObject { * then you will get a reference to that Live Object if it exists in the local pool, or undefined otherwise. * If the value is not an objectId, then you will get that value. */ - get(key: string): LiveObject | StateValue | undefined { + // force the key to be of type string as we only allow strings as key in a map + get(key: TKey): T[TKey] { const element = this._dataRef.data.get(key); if (element === undefined) { - return undefined; + return undefined as T[TKey]; } if (element.tombstone === true) { - return undefined; + return undefined as T[TKey]; } // data exists for non-tombstoned elements const data = element.data!; if ('value' in data) { - return data.value; + return data.value as T[TKey]; } else { - return this._liveObjects.getPool().get(data.objectId); + return this._liveObjects.getPool().get(data.objectId) as T[TKey]; } } diff --git a/src/plugins/liveobjects/liveobjects.ts b/src/plugins/liveobjects/liveobjects.ts index 5ba586fe4..75b743d5b 100644 --- a/src/plugins/liveobjects/liveobjects.ts +++ b/src/plugins/liveobjects/liveobjects.ts @@ -36,13 +36,18 @@ export class LiveObjects { this._bufferedStateOperations = []; } - async getRoot(): Promise { + /** + * When called without a type variable, we return a default root type which is based on globally defined LiveObjects interface. + * A user can provide an explicit type for the getRoot method to explicitly set the LiveObjects type structure on this particular channel. + * This is useful when working with LiveObjects on multiple channels with different underlying data. + */ + async getRoot(): Promise> { // SYNC is currently in progress, wait for SYNC sequence to finish if (this._syncInProgress) { await this._eventEmitter.once(LiveObjectsEvents.SyncCompleted); } - return this._liveObjectsPool.get(ROOT_OBJECT_ID) as LiveMap; + return this._liveObjectsPool.get(ROOT_OBJECT_ID) as LiveMap; } /** diff --git a/test/package/browser/template/src/ably.config.d.ts b/test/package/browser/template/src/ably.config.d.ts new file mode 100644 index 000000000..e5bca7718 --- /dev/null +++ b/test/package/browser/template/src/ably.config.d.ts @@ -0,0 +1,21 @@ +import { LiveCounter, LiveMap } from 'ably'; + +type CustomRoot = { + numberKey: number; + stringKey: string; + booleanKey: boolean; + couldBeUndefined?: string; + mapKey?: LiveMap<{ + foo: 'bar'; + nestedMap?: LiveMap<{ + baz: 'qux'; + }>; + }>; + counterKey?: LiveCounter; +}; + +declare global { + export interface LiveObjectsTypes { + root: CustomRoot; + } +} diff --git a/test/package/browser/template/src/index-liveobjects.ts b/test/package/browser/template/src/index-liveobjects.ts index 9334aadf4..1cd27b021 100644 --- a/test/package/browser/template/src/index-liveobjects.ts +++ b/test/package/browser/template/src/index-liveobjects.ts @@ -1,7 +1,12 @@ import * as Ably from 'ably'; import LiveObjects from 'ably/liveobjects'; +import { CustomRoot } from './ably.config'; import { createSandboxAblyAPIKey } from './sandbox'; +type ExplicitRootType = { + someOtherKey: string; +}; + globalThis.testAblyPackage = async function () { const key = await createSandboxAblyAPIKey({ featureFlags: ['enableChannelState'] }); @@ -11,12 +16,27 @@ globalThis.testAblyPackage = async function () { // check liveObjects can be accessed const liveObjects = channel.liveObjects; await channel.attach(); - // root should be a LiveMap object - const root: Ably.LiveMap = await liveObjects.getRoot(); + // expect root to be a LiveMap instance with LiveObjects types defined via the global LiveObjectsTypes interface + // also checks that we can refer to the LiveObjects types exported from 'ably' by referencing a LiveMap interface + const root: Ably.LiveMap = await liveObjects.getRoot(); + + // check root has expected LiveMap TypeScript type methods + const size: number = root.size(); - // check root is recognized as LiveMap TypeScript type - root.get('someKey'); - root.size(); + // check custom user provided typings via LiveObjectsTypes are working: + // keys on a root: + const aNumber: number = root.get('numberKey'); + const aString: string = root.get('stringKey'); + const aBoolean: boolean = root.get('booleanKey'); + const couldBeUndefined: string | undefined = root.get('couldBeUndefined'); + // live objects on a root: + const counter: Ably.LiveCounter | undefined = root.get('counterKey'); + const map: LiveObjectsTypes['root']['mapKey'] = root.get('mapKey'); + // check string literal types works + // need to use nullish coalescing as we didn't actually create any data on the root, + // so the next calls would fail. we only need to check that TypeScript types work + const foo: 'bar' = map?.get('foo')!; + const baz: 'qux' = map?.get('nestedMap')?.get('baz')!; // check LiveMap subscription callback has correct TypeScript types const { unsubscribe } = root.subscribe(({ update }) => { @@ -31,13 +51,15 @@ globalThis.testAblyPackage = async function () { }); unsubscribe(); - // check LiveCounter types also behave as expected - const counter = root.get('randomKey') as Ably.LiveCounter | undefined; - // use nullish coalescing as we didn't actually create a counter object on the root, - // so the next calls would fail. we only need to check that TypeScript types work - const value: number = counter?.value(); + // check LiveCounter type also behaves as expected + // same deal with nullish coalescing + const value: number = counter?.value()!; const counterSubscribeResponse = counter?.subscribe(({ update }) => { const shouldBeANumber: number = update.inc; }); counterSubscribeResponse?.unsubscribe(); + + // check can provide custom types for the getRoot method, ignoring global LiveObjectsTypes interface + const explicitRoot: Ably.LiveMap = await liveObjects.getRoot(); + const someOtherKey: string = explicitRoot.get('someOtherKey'); }; diff --git a/test/package/browser/template/src/tsconfig.json b/test/package/browser/template/src/tsconfig.json index b206a6399..3230e8697 100644 --- a/test/package/browser/template/src/tsconfig.json +++ b/test/package/browser/template/src/tsconfig.json @@ -1,6 +1,7 @@ { "include": ["**/*.ts", "**/*.tsx"], "compilerOptions": { + "strictNullChecks": true, "resolveJsonModule": true, "esModuleInterop": true, "module": "esnext", diff --git a/typedoc.json b/typedoc.json index 24faf3bad..7c9c17476 100644 --- a/typedoc.json +++ b/typedoc.json @@ -20,5 +20,6 @@ "TypeAlias", "Variable", "Namespace" - ] + ], + "intentionallyNotExported": ["__global.LiveObjectsTypes"] }