diff --git a/PATH-BASED-API-JAVA-PYTHON-COMPARISON.md b/PATH-BASED-API-JAVA-PYTHON-COMPARISON.md new file mode 100644 index 00000000..bdbbca68 --- /dev/null +++ b/PATH-BASED-API-JAVA-PYTHON-COMPARISON.md @@ -0,0 +1,177 @@ +# Comparison with the Java / Python path-based API proposal + +This document compares the Swift path-based API proposal in this repo to the +parallel Java / Python proposal authored by Evgenii in +[ably/ably-java#1190][1]. The comparison was carried out against the state +of the PR at commit [`be13cdc`][2] (branch `draft-new-lo-api`, file +`liveobjects/PATH_BASED_API_JAVA_PYTHON.md`). It is concerned only with the +*conversion* decisions — the ways the cross-SDK path-based model has been +adapted for Java and Python — and how those compare to what we have done in +Swift. + +## Provenance + +Evgenii wrote his document *after* the Swift design discussion in which the +type-erased-`PathObject` + `asLiveMap` / `asLiveCounter` pattern was proposed; +the `as*` cast pattern in the Java / Python doc is presumably directly inspired by that +discussion. However, Evgenii hadn't seen the exact Swift API surface in +detail when writing his proposal, so the places where the two designs diverge +are not necessarily intentional statements of preference — some of them may +simply reflect parallel evolution from the same starting point. + +## How the Java / Python document is structured + +Evgenii's document doesn't separate the cross-SDK design decisions inherited +from ably-js (deferred resolution, single root object per channel, atomic +deep creation via nested `*.create()`, path-based subscriptions with depth, +no object ID exposure outside the `Instance` API) from the Java/Python-specific +conversion decisions that are new in the doc. The rest of this document picks +out the latter and compares them to what we have in Swift. + +## Conversion decisions + +### Type-erased `PathObject` plus `as*` casts + +**Both:** the base `PathObject` doesn't try to expose the union of all +possible types; callers reach the type-specific surface via +`asLiveMap` / `asLiveCounter` / etc. This is the central shared design choice. + +### How primitives are represented + +**Java / Python:** four distinct primitive path-object types +(`StringPathObject`, `NumberPathObject`, `BooleanPathObject`, +`BinaryPathObject`), each exposing its own natively-typed `value()` returning +`String` / `Double` / `Boolean` / `byte[]` respectively. There is no +language-level enum for "any primitive". The type emerges from *which* +`as*Primitive()` cast you called. + +**Swift:** no per-primitive path-object type at all — primitives are read via +`var value: Primitive?` on `PathObject` directly, where `Primitive` is a +single Swift enum. The rationale for not porting `PrimitivePathObject` from +the ably-js basis is recorded in [`PATH-BASED-API.md`](./PATH-BASED-API.md) +(the basis type carried +only `value()` and `compact()`, both of which already sit naturally on +`PathObject`). The decision to expose a single `Primitive`-returning getter +rather than per-primitive-case path-object types is a design choice — Swift +could perfectly well have followed evgenii's per-case split, since nothing +about Swift forces the collapsed shape. The argument for keeping it +collapsed is simply that primitive path objects don't have any operations of +their own that distinguish a string from a number from a binary blob — they +all just expose `value()` and `compact()` — so introducing four separate +types per primitive case mostly buys typed-by-construction return values, at +the cost of multiplying the API surface. If we did decide to introduce a +per-primitive variant later, the natural shape would be a *single* +`PrimitivePathObject` whose `value()` returns the `Primitive` enum, rather +than four per-case types. + +### Whether `value()` and `instance()` are on the base `PathObject` + +**Java / Python:** they are not. The base `PathObject` exposes only `path()`, +`compact()`, the seven `as*` cast methods, and `subscribe()`. To read any +value or grab any instance, you must first cast. + +**Swift:** the base `PathObject` exposes `var value: Primitive?` and +`var instance: Instance?` directly, in addition to `asLiveMap` / +`asLiveCounter`. A caller can take a primitive-typed read off any +`PathObject` without going through a cast. + +### Which path-object types carry `instance()` + +**Java / Python:** only `LiveMapPathObject` has `instance()`. Neither +`LiveCounterPathObject` nor the primitive path objects expose it. (This is +probably an oversight rather than a deliberate choice — there's no positive +rationale given for it in the doc, the use case described under "Design +Decision #6" applies equally to counters, and ably-js exposes `instance()` on +both `LiveCounterPathObject` and `LiveMapPathObject`.) + +**Swift:** `instance` is exposed on `PathObject` (returning the type-erased +`Instance?`), on `LiveMapPathObject` (returning `LiveMapInstance?`), and on +`LiveCounterPathObject` (returning `LiveCounterInstance?`). + +### Failure mode when a path doesn't resolve to the expected type + +**Java:** every `value()` plus `LiveMapPathObject.instance()` is declared +`throws AblyException` and returns a non-nullable type (`String`, `Double`, +`LiveMap`, etc.). The `as*` cast itself is non-throwing, so the type-mismatch +failure is deferred to the leaf accessor and surfaces as an exception. The +doc does not explicitly explain this; it falls out of Design Decision #2 +("fails fast if the type doesn't match") combined with the absence of any +`Optional` returns on these accessors. + +**Python:** mixed. `LiveMapPathObject.instance()` is typed as +`Optional['LiveMap']`, so it can legitimately return `None` rather than +raising. The primitive `value()` methods (e.g. `def value(self) -> str:`) +return plain non-`Optional` types, so for those a type mismatch presumably +has to surface by raising. This isn't called out in the doc and reads more +like an inconsistency between the Java and Python sketches than a deliberate +split. + +**Swift:** the corresponding accessors return optionals +(`var value: Primitive?`, `var instance: LiveMapInstance?`, etc.) and don't +throw on type mismatch. This matches the ably-js semantics of returning +`undefined` for the same condition. + +### Mutation methods + +**Java / Python:** `set` / `remove` / `increment` / `decrement` all take a +`MessageOptions` parameter, allowing callers to set an explicit message id +and `extras` on the published operation. + +**Swift:** no `MessageOptions` analogue is currently exposed on the +path-based mutation methods. (Not exposed on the ably-js path-based API +either.) + +### Creating primitive values for mutations + +**Java / Python:** primitives are wrapped via `Primitive.create("...")` / +`Primitive.create(10)` etc. when passing them to `set()`. + +**Swift:** primitive `Value` cases (`.string`, `.number`, `.bool`, `.data`, +`.jsonArray`, `.jsonObject`) plus the `ExpressibleBy*Literal` conformances +let callers write the literal directly inside the `[String: Value]` argument +to `set` / `createMap`. + +### `compact()` return type + +**Java / Python:** exposes a single `compact()` returning plain `JsonValue` +(the existing JSON-shaped type in the SDK). What evgenii calls `compact()` +here is structurally the JSON-serialisable form — equivalent to ably-js +main's `compactJson()`, with binary encoded as base64 strings and cycles +represented via `{ objectId }` references. + +There is no in-memory analogue. The ably-js split between an in-memory +`compact()` (cycles as shared in-memory references, binary preserved as raw +bytes, type information unambiguous) and a JSON-serialisable `compactJson()` +isn't engaged with in the doc. evgenii's doc post-dates the split landing +in ably-js main (the split is commit `3887fe9` on 2025-12-18; evgenii's +proposal is commit `be13cdc` on 2026-02-11), so this isn't a case of him +working from a pre-split snapshot — but the doc doesn't acknowledge or +discuss the trade-off, for unknown reasons. The consequence is that callers +can't obtain an unambiguous in-memory representation, can't tell a raw +binary value apart from a string that happens to look base64-shaped, and +can't traverse cycles by following in-memory pointers. + +**Swift:** currently has `CompactedValue` as a class-backed enum supporting +in-memory cycles — closer in spirit to ably-js main's in-memory `compact()`. +The Swift proposal *does* predate the ably-js split: it's based on `ably.d.ts` +at `0bdd674` (2025-12-09), nine days before the split landed, so the single +`compact()` it inherits is just the pre-split shape. Adding the +JSON-serialisable form alongside it is captured as a follow-up in +[`PATH-BASED-API-MAIN-DELTA.md`](./PATH-BASED-API-MAIN-DELTA.md), where two +shapes for the *JSON-form* return type are weighed: option A (introduce a +dedicated `CompactedJsonValue` type) and option B (have `compactJson()` +simply return the existing `JSONValue`). Both options presuppose keeping +the in-memory `compact()`; the A/B choice is only about the return shape of +the JSON-form method. So Swift's planned end state has both halves of the +ably-js split; the Java / Python proposal has only the JSON half. + +### `LiveList` + +**Java / Python:** `LiveListPathObject` is included in the type system +already (`asLiveList`, `LiveListPathObject.get(index)`, `size()`), forward- +looking for when LiveList lands. + +**Swift:** no `LiveList` type is included yet. (Not in ably-js main either.) + +[1]: https://github.com/ably/ably-java/pull/1190 +[2]: https://github.com/ably/ably-java/pull/1190/commits/be13cdce9db6b70ea646e56fbba4821ee194f887 diff --git a/PATH-BASED-API-MAIN-DELTA.md b/PATH-BASED-API-MAIN-DELTA.md new file mode 100644 index 00000000..342ca6af --- /dev/null +++ b/PATH-BASED-API-MAIN-DELTA.md @@ -0,0 +1,221 @@ +# Bringing the proposed Swift path-based API into line with ably-js main + +This document records a comparison between the version of the ably-js path-based +API that the Swift port (`Sources/AblyLiveObjects/Public/PublicTypes.swift`) was +based on, and the version currently on ably-js `main`. It enumerates the +changes that affect the Swift public surface, and lists the smaller set of +follow-up changes needed to align it. + +## What was compared + +- **Basis** — `ably.d.ts` as it existed in ably-js at commit `0bdd674` (which is + no longer reachable from ably-js `main`, since the integration branch it + belonged to was rebased). The Swift port copied this file into the repo on + commit `3a002f4 Copy ably.d.ts from ably-js at 0bdd674` and the proposed + Swift API has been derived from it ever since. The file is checked in at the + repo root as `ably.d.ts`. +- **Target** — `liveobjects.d.ts` on ably-js `main` at commit `498d26df` + (the tip of `origin/main` at the time of writing; the file itself was last + touched on main by `4235266a Implement LiveObjects REST client`). The + LiveObjects type definitions have been moved out of `ably.d.ts` into a + dedicated module since the basis was copied. + +## Method + +The two files were read end to end and compared section by section, focused on +the symbols that the Swift port currently models (the path-based API, +`Instance`, `CompactedValue`, the `LiveMap` / `LiveCounter` factories and the +`RealtimeObject` entry point). + +The following are deliberately out of scope: + +- **The REST LiveObjects API** (`RestObject`, `RestObjectOperation*`, + `RestLiveMap`, `RestLiveCounter`, etc.) — there is no plan to port this to + Swift imminently. +- `BatchContext` / `BatchOperations` / `batch()` — present in both basis and + main but deliberately omitted from the Swift port; not a basis→main delta. +- Choices the Swift port made for Swift-idiomatic reasons (e.g. method-vs-property + for `id`, `path`, `value`, `instance`) — these are orthogonal to the diff. + +## Findings + +### Changes that affect the Swift public surface today + +#### 1. Remove `RealtimeObject.offAll()` + +- Basis: `RealtimeObject` had `get`, `on`, `off(event, callback)` and + `offAll()`. +- Main: `offAll()` was removed (ably-js commit `09ecb55f`). Listeners are + deregistered either via the `StatusSubscription` returned by `.on()` or via + `off(event, callback)`. +- Swift today: `PublicTypes.swift` still declares + `RealtimeObject.offAll()`. Delete it. Swift doesn't currently expose + `off(event:callback:)` either, but that's fine — `StatusSubscription.off()` + covers individual unsubscription. + +#### 2. Implement implicit channel attach in `RealtimeObject.get()` + +- Main: `get()` now *"Implicitly attaches to the channel if not already + attached"* (ably-js commit `96c39f9d`). This is a behavioural change, not + just a docstring change — the SDK has to perform the attach. +- Swift today: `RealtimeObject.get()` is a stub (`fatalError("Not + implemented")`) and the behaviour is not modelled. Tracked separately as + [AIT-455][3]. Beyond the implementation, the docstring on + `RealtimeObject.get()` should also be updated to call this out. + +#### 3. Split `compact()` into `compact()` + `compactJson()` + +This is the substantive change. It is recorded retroactively in [LODR-057][1] +and was implemented in [ably-js#2129][2]. The motivation is that the single +`compact()` from the basis was trying to be both an in-memory, traversable +representation *and* a JSON-serialisable one, and the two goals conflict: + +- For cycle handling, an in-memory representation can use shared object + pointers (cheap, easy to traverse, but not JSON-serialisable); a + JSON-serialisable representation has to use `{ objectId: string }` references + (loses type-checker support, but `JSON.stringify`-safe). +- For binary data, in-memory wants to preserve the original `Buffer`/`Data` + (so callers can tell strings from bytes); JSON-serialisable wants + base64-encoded strings (consistent with the REST API). + +The basis conflated the two: cycles were already shared in memory (so the +result wasn't `JSON.stringify`-safe) but binary was base64-encoded (so the +original type was lost). The split in main resolves this: + +- `CompactedValue` (in-memory) — LiveMap → object, LiveCounter → number, + binary stays as `Buffer | ArrayBuffer`, cycles via shared in-memory + references. Provides full TypeScript intellisense. +- `CompactedJsonValue` (JSON-serialisable, new) — same shape, but binary → + base64 string, cycles → a new `ObjectIdReference` (`{ objectId: string }`). + Consistent with the REST API's compact representation. + +Concrete Swift consequences: + +- **`CompactedValue` enum needs a `.data(Data)` case.** Today it has + `.string`, `.number`, `.bool`, `.null`, `.object`, `.array` — modelled on + the basis where binary was base64-stringified. In main's `CompactedValue`, + binary must survive as `Data`. Cycle handling, on the other hand, is + already correct: Swift's `ObjectReference` / `ArrayReference` are `final + class`es, so the existing type already supports in-memory cycles via shared + class references, which is exactly the new `CompactedValue` semantics. +- **For the JSON-serialisable form, pick one of two designs.** + - *Option A: introduce a new `CompactedJsonValue` Swift type.* Unlike + `CompactedValue`, this one must *not* permit in-memory cycles: object + and array payloads should be value types (e.g. `[String: + CompactedJsonValue]` / `[CompactedJsonValue]` directly, rather than + wrapped in a class), and cycles are represented explicitly via a new + `ObjectIdReference` value (e.g. `struct ObjectIdReference { let + objectId: String }`). Binary appears as `.string` (base64-encoded). + In JS the point of this representation is that it can be fed straight + to `JSON.stringify`. Swift callers don't get that for free, so we'd + probably also want a `toJSONValue()` method converting to the existing + `JSONValue` (a total conversion, since `CompactedJsonValue` has no + cycles) and/or a convenience that goes directly to `Data`. + - *Option B: just return `JSONValue` directly from `compactJson()`.* + Skip introducing a new Swift type entirely. This is arguably closer to + what JS actually does: `CompactedJsonValue` and `ObjectIdReference` are + only *type-level* distinctions in TypeScript — at runtime, + `ObjectIdReference` is just a plain `{ objectId: string }` object, + indistinguishable from any other JSON object. Callers detect cycles by + checking for the `objectId` key. Returning `JSONValue` from + `compactJson()` matches that reality, removes a public type from the + Swift surface, and subsumes the toJSON-conversion question raised in + [`PATH-BASED-API.md`](./PATH-BASED-API.md). The cost is that the + cycle/objectId case is no + longer pattern-matchable as a distinct enum case — callers inspect the + object shape, same as JS callers do. + + Option B looks like the right default unless we want the Swift API to + expose a typed distinction that JS doesn't. +- **Add `compactJson()` to every type that has `compact()`** — + `PathObject`, `LiveMapPathObject`, `LiveCounterPathObject`, and the + corresponding `Instance` variants. Return types depend on which option + above is chosen: + - Under option A: `CompactedJsonValue?` for the general/LiveMap cases, + `Double?` for the counter cases. Note that JS's return type for the + LiveMap case is `CompactedJsonValue> = { ... } | + ObjectIdReference`, so the top-level result can itself be an + `ObjectIdReference` — the Swift return type can't be the narrow + "object form only" analogue used for `compact()` on `LiveMapPathObject`. + - Under option B: `JSONValue?` for general/LiveMap cases (callers detect + cycles via the `objectId` key), `Double?` for counters. + +### Changes that don't affect Swift today + +These affect the `ObjectMessage` / `ObjectOperation` / `ObjectData` internals, +which Swift has deliberately stubbed out (see the `ObjectMessage` placeholder +in `PublicTypes.swift`). They'll need to be reflected when `ObjectMessage` is +fleshed out, but no action is needed now: + +- **New `MAP_CLEAR` operation action** and `MapClear` payload type. +- **New `UNKNOWN` cases** in both `ObjectOperationAction` and + `ObjectsMapSemantics` (forward-compat for unrecognised server values). +- **`ObjectOperation` gets typed per-action payloads** (`mapCreate`, `mapSet`, + `mapRemove`, `counterCreate`, `counterInc`, `objectDelete`, `mapClear`). + Old `mapOp` / `counterOp` / `map` / `counter` are kept but `@deprecated`. +- **`ObjectData` gets typed leaf fields** (`boolean`, `bytes`, `number`, + `string`, `json`); the old `value: Primitive` is `@deprecated`. + +### Things that look like changes but aren't + +- **`LiveMap.create` / `LiveCounter.create` static factories.** The Swift + port already implements these (commits `c58f455` and `b33178d`) and they + are aligned with main. The naming wasn't a Swift invention: at basis time + it was already the documented public API even though the `.d.ts` hadn't + yet caught up. Specifically: + - The basis `ably.d.ts` (at `0bdd674`) didn't expose any factories on + `LiveMap` / `LiveCounter` — they were empty branded interfaces. + - The ably-js *runtime* at the same commit already had the factory + methods, just on differently-named classes: + `LiveMapValueType.create()` and `LiveCounterValueType.create(initialCount + = 0)` in `src/plugins/objects/{livemapvaluetype,livecountervaluetype}.ts`. + - The migration guide added in `b083173e` (the direct child of the basis, + less than 10 hours later) already documents the public API as + `LiveMap.create()` / `LiveCounter.create()` from line 145 onwards. + - On main, the `.d.ts` now exposes those factories with the documented + names via `class LiveMap { static create(...) }` / + `class LiveCounter { static create(initialCount?) }`, + declaration-merged with the branded interface of the same name. + + One small follow-up worth checking: Swift's `LiveCounter.create(initialCount: + Double = 0)` should produce the same wire payload as JS's + `LiveCounter.create()` with no argument, i.e. the `COUNTER_CREATE` payload + should match what main sends when `initialCount` is `undefined`. +- **`id` as a property** (`readonly id: string | undefined` on `InstanceBase` + and `BatchContextBase` in main, vs `id(): string | undefined` method in the + basis). Swift already uses `var id: String? { get }` — accidentally correct. +- **`path` / `value` / `instance` as properties vs methods.** Swift turned + these into properties as a deliberate idiomatic choice; that's orthogonal to + the basis→main delta (both JS versions use methods). +- **`Subscription` / `StatusSubscription` / structured subscribe callback + (`{ object, message }`).** The basis already had the new structured callback + shape, so no change is needed. +- **`subscribeIterator`.** Identical in both; Swift has commented it out as a + known TODO — no action required relative to this diff. +- **`BatchContext` / `BatchOperations` / `batch()` method.** Present in both + basis and main; Swift deliberately doesn't expose it. No basis→main delta. +- **Stale `LiveObject` lifecycle types in Swift** + (`OnLiveObjectLifecycleEventResponse`, `LiveObjectLifecycleEventCallback`): + unused leftovers from a pre-`9d67c25` state. Can be deleted, but unrelated + to this diff. + +## TL;DR action list for the Swift port + +1. Remove `RealtimeObject.offAll()`. +2. Implement implicit channel attach in `RealtimeObject.get()` ([AIT-455][3]), + and update its docstring. +3. Add a `.data(Data)` case to `CompactedValue` so it can hold in-memory + binary. +4. Decide between option A (introduce a `CompactedJsonValue` type with + `.string` for base64 binary and an `ObjectIdReference` value for cycles) + and option B (just return the existing `JSONValue` from `compactJson()`, + matching JS's runtime behaviour). Option B is the suggested default. +5. Add `compactJson()` to every type that currently has `compact()`, with the + return type implied by the choice in (4). +6. (Deferred, when `ObjectMessage` is filled in) model `MAP_CLEAR`, `UNKNOWN`, + the new typed `ObjectOperation` payloads, and the new typed `ObjectData` + leaves. + +[1]: https://ably.atlassian.net/wiki/spaces/LOB/pages/4710694927/LODR-057+SDK+API+for+JSON+Serialized+and+memory-traversable+compact+object+representations +[2]: https://github.com/ably/ably-js/pull/2129 +[3]: https://ably.atlassian.net/browse/AIT-455 diff --git a/PATH-BASED-API.md b/PATH-BASED-API.md new file mode 100644 index 00000000..f910be93 --- /dev/null +++ b/PATH-BASED-API.md @@ -0,0 +1,44 @@ +# Notes on the path-based API in Swift + +- Have not updated the docstrings +- Unlike in my previous go on this, I am just going to use e.g. `PathObject` directly where the spec does, but make it not be generic, i.e. their `AnyFoo` is my `Foo`. Ditto `Instance` (their `AnyInstance` is my `Instance`). This is to avoid things like `any AnyInstance` which would look weird, and also because "AnyFoo" already has a meaning in Swift +- Where things in the JS API appear to be O(1) things that don't throw and have no side-effects, I am making them properties +- `compact()`: + - for `LiveMapPathObject`, `LiveMapInstance`: returns `CompactedValue.ObjectReference?` + - for `LiveCounterPathObject`, `LiveCounterInstance`: returns `Double?` + - for `PathObject` returns `CompactedValue?` +- Have assumed that where the docstring in JS doesn't mention the method throwing, it doesn't throw (e.g. `LiveMapPathObjectCollectionMethods.{entries, keys}`) +- `CompactedValue` is represented by a JSON-like type whose collection cases have class instances as their associated data, to allow cycles (see https://github.com/ably/ably-js/pull/2122/files) + - also do we need an API to allow people to try and convert this to a `JSONValue`? +- I've introduced the `Primitive` type which was omitted from Swift in the first API, because it's now used in multiple places (i.e. there are `value` getters that return one). And for consistency I've updated `Value` to use it, even though it adds a layer of indirection. +- I haven't ported `PrimitivePathObject` because it doesn't really have anything to offer us in Swift — its only methods `value()` and `compact()` are available via `PathObject` anyway. (Writing this point months later; I _think_ this was my logic). Can revisit this if we want to be consistent or anticipate adding further methods to `PrimitivePathObject` in the future. + +## Not done + +- The `AsyncIterableIterator` versions of the subscribe methods; I know how they'll look (we already had one similar) +- The public `ObjectMessage` type that JS has added; it's a lot of code but nothing too interesting + +## Questions + +- What is the right thing to do for `AnyPathObjectCollectionMethods`? Note that they all take generic parameters that let you specialise that specific call. We don't have an equivalent to that so currently it just collapses to the same as `LiveMapPathObject`, but the problem is that it's not going to work well if we also have a `LiveList` that also has `entries`. We need some other way of communicating "this is the type I want to treat it as". + - For now I'm going to not have this `AnyPathObjectCollectionMethods`, all of which have a specific behaviour _if_ you resolve to a `LiveMap`, and will instead just have a `asLiveMap` property which gives you a `LiveMapPathObject`, which behaves the same way + - And then, for consistency, I also just won't have `PathObject` conforming to `AnyOperations`; you have to figure out which type you want, call the e.g. `asLiveCounter` / `asLiveMap` and then call your methods; it's overall a smaller API surface and I think easier to reason about + - TODO: Find out from Andrii and Mike whether there are any times that you'd actually need to treat a `PathObject` homogeneously + - TODO: What is the purpose of the `PathObject.get`; do we need it, is there any time that you'd want to use paths without the resolved thing being a map? + - I'm going to do the same for `Instance` too; won't have `AnyInstanceCollectionMethods` and `AnyOperations` and will instead just have a `asLiveMap` / `asLiveCounter` + - (An alternative option for just handling the return type option would be to have an `Entries` enum that collects the different collections' `entries` return values) + +## The `Instance` API + +- Given that `Instance` (their `AnyInstance`) doesn't conform to it, I have made `LiveMapInstanceCollectionMethods` not behave as if the instance might not be a map. Concretely, this means that none of the "if not a map" documented behaviours apply, and `size` does not return an optional. (Ditto `LiveCounterInstance.value` returns non-optional) + - I think that once you have an `Instance` you should be sure about its type. I don't see why we're trying to provide a homogeneous type for instances + +## Other questions + +- It remains unclear whether things like `value` etc can throw given channel state conditions (so all of my `Instance` things are non-throwing at the moment, but maybe they should retain the same `throws` as they currently have?) +- Why do we have an `Instance` type for a primitive value? An "instance" suggests an "instance of an object", and in this context I'd read that as meaning "instance of a LiveObject". + +## To do at end + +- check all of the `throws` +- check all the `*Base` types are `Sendable`, ditto any new structs diff --git a/Sources/AblyLiveObjects/Internal/ARTClientOptions+Objects.swift b/Sources/AblyLiveObjects/Internal/ARTClientOptions+Objects.swift deleted file mode 100644 index 68554345..00000000 --- a/Sources/AblyLiveObjects/Internal/ARTClientOptions+Objects.swift +++ /dev/null @@ -1,46 +0,0 @@ -internal import _AblyPluginSupportPrivate -import Ably - -internal extension ARTClientOptions { - private class Box { - internal let boxed: T - - internal init(boxed: T) { - self.boxed = boxed - } - } - - private static let garbageCollectionOptionsKey = "Objects.garbageCollectionOptions" - - /// Can be overriden for testing purposes. - var garbageCollectionOptions: InternalDefaultRealtimeObjects.GarbageCollectionOptions? { - get { - let optionsValue = Plugin.defaultPluginAPI.pluginOptionsValue( - forKey: Self.garbageCollectionOptionsKey, - clientOptions: asPluginPublicClientOptions, - ) - - guard let optionsValue else { - return nil - } - - guard let box = optionsValue as? Box else { - preconditionFailure("Expected GarbageCollectionOptionsBox, got \(optionsValue)") - } - - return box.boxed - } - - set { - guard let newValue else { - preconditionFailure("Not implemented the ability to un-set GC options") - } - - Plugin.defaultPluginAPI.setPluginOptionsValue( - Box(boxed: newValue), - forKey: Self.garbageCollectionOptionsKey, - clientOptions: asPluginPublicClientOptions, - ) - } - } -} diff --git a/Sources/AblyLiveObjects/Internal/CoreSDK.swift b/Sources/AblyLiveObjects/Internal/CoreSDK.swift deleted file mode 100644 index 07493cf0..00000000 --- a/Sources/AblyLiveObjects/Internal/CoreSDK.swift +++ /dev/null @@ -1,137 +0,0 @@ -internal import _AblyPluginSupportPrivate -import Ably - -/// The API that the internal components of the SDK (that is, `DefaultLiveObjects` and down) use to interact with our core SDK (i.e. ably-cocoa). -/// -/// This provides us with a mockable interface to ably-cocoa, and it also allows internal components and their tests not to need to worry about some of the boring details of how we bridge Swift types to `_AblyPluginSupportPrivate`'s Objective-C API (i.e. boxing). -internal protocol CoreSDK: AnyObject, Sendable { - /// Implements the internal `#publish` method of RTO15. - func publish(objectMessages: [OutboundObjectMessage]) async throws(ARTErrorInfo) - - /// Implements the server time fetch of RTO16, including the storing and usage of the local clock offset. - func fetchServerTime() async throws(ARTErrorInfo) -> Date - - /// Replaces the implementation of ``publish(objectMessages:)``. - /// - /// Used by integration tests, for example to disable `ObjectMessage` publishing so that a test can verify that a behaviour is not a side effect of an `ObjectMessage` sent by the SDK. - func testsOnly_overridePublish(with newImplementation: @escaping ([OutboundObjectMessage]) async throws(ARTErrorInfo) -> Void) - - /// Returns the current state of the Realtime channel that this wraps. - var nosync_channelState: _AblyPluginSupportPrivate.RealtimeChannelState { get } -} - -internal final class DefaultCoreSDK: CoreSDK { - /// Used to synchronize access to internal mutable state. - private let mutex = NSLock() - - private let channel: _AblyPluginSupportPrivate.RealtimeChannel - private let client: _AblyPluginSupportPrivate.RealtimeClient - private let pluginAPI: PluginAPIProtocol - private let logger: Logger - - /// If set to true, ``publish(objectMessages:)`` will behave like a no-op. - /// - /// This enables the `testsOnly_overridePublish(with:)` test hook. - /// - /// - Note: This should be `throws(ARTErrorInfo)` but that causes a compilation error of "Runtime support for typed throws function types is only available in macOS 15.0.0 or newer". - private nonisolated(unsafe) var overriddenPublishImplementation: (([OutboundObjectMessage]) async throws -> Void)? - - internal init( - channel: _AblyPluginSupportPrivate.RealtimeChannel, - client: _AblyPluginSupportPrivate.RealtimeClient, - pluginAPI: PluginAPIProtocol, - logger: Logger - ) { - self.channel = channel - self.client = client - self.pluginAPI = pluginAPI - self.logger = logger - } - - // MARK: - CoreSDK conformance - - internal func publish(objectMessages: [OutboundObjectMessage]) async throws(ARTErrorInfo) { - logger.log("publish(objectMessages: \(LoggingUtilities.formatObjectMessagesForLogging(objectMessages)))", level: .debug) - - // Use the overridden implementation if supplied - let overriddenImplementation = mutex.withLock { - overriddenPublishImplementation - } - if let overriddenImplementation { - do { - try await overriddenImplementation(objectMessages) - } catch { - guard let artErrorInfo = error as? ARTErrorInfo else { - preconditionFailure("Expected ARTErrorInfo, got \(error)") - } - throw artErrorInfo - } - return - } - - // TODO: Implement message size checking (https://github.com/ably/ably-liveobjects-swift-plugin/issues/13) - try await DefaultInternalPlugin.sendObject( - objectMessages: objectMessages, - channel: channel, - client: client, - pluginAPI: pluginAPI, - ) - } - - internal func testsOnly_overridePublish(with newImplementation: @escaping ([OutboundObjectMessage]) async throws(ARTErrorInfo) -> Void) { - mutex.withLock { - overriddenPublishImplementation = newImplementation - } - } - - internal func fetchServerTime() async throws(ARTErrorInfo) -> Date { - try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in - let internalQueue = pluginAPI.internalQueue(for: client) - - internalQueue.async { [client, pluginAPI] in - pluginAPI.nosync_fetchServerTime(for: client) { serverTime, error in - // We don't currently rely on this documented behaviour of `noSync_fetchServerTime` but we may do later, so assert it to be sure it's happening. - dispatchPrecondition(condition: .onQueue(internalQueue)) - - if let error { - continuation.resume(returning: .failure(ARTErrorInfo.castPluginPublicErrorInfo(error))) - } else { - guard let serverTime else { - preconditionFailure("nosync_fetchServerTime gave nil serverTime and nil error") - } - continuation.resume(returning: .success(serverTime)) - } - } - } - }.get() - } - - internal var nosync_channelState: _AblyPluginSupportPrivate.RealtimeChannelState { - pluginAPI.nosync_state(for: channel) - } -} - -// MARK: - Channel State Validation - -/// Extension on CoreSDK to provide channel state validation utilities. -internal extension CoreSDK { - /// Validates that the channel is not in any of the specified invalid states. - /// - /// - Parameters: - /// - invalidStates: Array of channel states that are considered invalid for the operation - /// - operationDescription: A description of the operation being performed, used in error messages - /// - Throws: `ARTErrorInfo` with code 90001 and statusCode 400 if the channel is in any of the invalid states - func nosync_validateChannelState( - notIn invalidStates: [_AblyPluginSupportPrivate.RealtimeChannelState], - operationDescription: String, - ) throws(ARTErrorInfo) { - let currentChannelState = nosync_channelState - if invalidStates.contains(currentChannelState) { - throw LiveObjectsError.objectsOperationFailedInvalidChannelState( - operationDescription: operationDescription, - channelState: currentChannelState, - ) - .toARTErrorInfo() - } - } -} diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift deleted file mode 100644 index e9f801db..00000000 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ /dev/null @@ -1,183 +0,0 @@ -internal import _AblyPluginSupportPrivate -import Ably - -// We explicitly import the NSObject class, else it seems to get transitively imported from `internal import _AblyPluginSupportPrivate`, leading to the error "Class cannot be declared public because its superclass is internal". -import ObjectiveC.NSObject - -/// The default implementation of `_AblyPluginSupportPrivate`'s `LiveObjectsInternalPluginProtocol`. Implements the interface that ably-cocoa uses to access the functionality provided by the LiveObjects plugin. -@objc -internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate.LiveObjectsInternalPluginProtocol { - private let pluginAPI: _AblyPluginSupportPrivate.PluginAPIProtocol - - internal init(pluginAPI: _AblyPluginSupportPrivate.PluginAPIProtocol) { - self.pluginAPI = pluginAPI - } - - // MARK: - Channel `objects` property - - /// The `pluginDataValue(forKey:channel:)` key that we use to store the value of the `ARTRealtimeChannel.objects` property. - private static let pluginDataKey = "LiveObjects" - - /// Retrieves the `RealtimeObjects` for this channel. - /// - /// We expect this value to have been previously set by ``prepare(_:)``. - internal static func nosync_realtimeObjects(for channel: _AblyPluginSupportPrivate.RealtimeChannel, pluginAPI: _AblyPluginSupportPrivate.PluginAPIProtocol) -> InternalDefaultRealtimeObjects { - guard let pluginData = pluginAPI.nosync_pluginDataValue(forKey: pluginDataKey, channel: channel) else { - // InternalPlugin.prepare was not called - fatalError("To access LiveObjects functionality, you must pass the LiveObjects plugin in the client options when creating the ARTRealtime instance: `clientOptions.plugins = [.liveObjects: AblyLiveObjects.Plugin.self]`") - } - - // swiftlint:disable:next force_cast - return pluginData as! InternalDefaultRealtimeObjects - } - - // MARK: - LiveObjectsInternalPluginProtocol - - // Populates the channel's `objects` property. - internal func nosync_prepare(_ channel: _AblyPluginSupportPrivate.RealtimeChannel, client: _AblyPluginSupportPrivate.RealtimeClient) { - let pluginLogger = pluginAPI.logger(for: channel) - let internalQueue = pluginAPI.internalQueue(for: client) - let callbackQueue = pluginAPI.callbackQueue(for: client) - let options = ARTClientOptions.castPluginPublicClientOptions(pluginAPI.options(for: client)) - - let garbageCollectionOptions = options.garbageCollectionOptions ?? { - if let latestConnectionDetails = pluginAPI.nosync_latestConnectionDetails(for: client), let gracePeriod = latestConnectionDetails.objectsGCGracePeriod { - // If we already have connection details, then use its grace period per RTO10b2 - .init(gracePeriod: .dynamic(gracePeriod.doubleValue)) - } else { - // Use the default grace period - .init() - } - }() - - let logger = DefaultLogger(pluginLogger: pluginLogger, pluginAPI: pluginAPI) - logger.log("LiveObjects.DefaultInternalPlugin received prepare(_:)", level: .debug) - let liveObjects = InternalDefaultRealtimeObjects( - logger: logger, - internalQueue: internalQueue, - userCallbackQueue: callbackQueue, - clock: DefaultSimpleClock(), - garbageCollectionOptions: garbageCollectionOptions, - ) - pluginAPI.nosync_setPluginDataValue(liveObjects, forKey: Self.pluginDataKey, channel: channel) - } - - /// Retrieves the internally-typed `objects` property for the channel. - private func nosync_realtimeObjects(for channel: _AblyPluginSupportPrivate.RealtimeChannel) -> InternalDefaultRealtimeObjects { - Self.nosync_realtimeObjects(for: channel, pluginAPI: pluginAPI) - } - - /// A class that wraps an object message. - /// - /// We need this intermediate type because we want object messages to be structs — because they're nicer to work with internally — but a struct can't conform to the class-bound `_AblyPluginSupportPrivate.ObjectMessageProtocol`. - private final class ObjectMessageBox: _AblyPluginSupportPrivate.ObjectMessageProtocol where T: Sendable { - internal let objectMessage: T - - init(objectMessage: T) { - self.objectMessage = objectMessage - } - } - - internal func decodeObjectMessage( - _ serialized: [String: Any], - context: DecodingContextProtocol, - format: EncodingFormat, - error errorPtr: AutoreleasingUnsafeMutablePointer<_AblyPluginSupportPrivate.PublicErrorInfo?>?, - ) -> (any ObjectMessageProtocol)? { - let wireObject = WireValue.objectFromPluginSupportData(serialized) - - do { - let wireObjectMessage = try InboundWireObjectMessage( - wireObject: wireObject, - decodingContext: context, - ) - let objectMessage = try InboundObjectMessage( - wireObjectMessage: wireObjectMessage, - format: format, - ) - return ObjectMessageBox(objectMessage: objectMessage) - } catch { - errorPtr?.pointee = error.asPluginPublicErrorInfo - return nil - } - } - - internal func encodeObjectMessage( - _ publicObjectMessage: any _AblyPluginSupportPrivate.ObjectMessageProtocol, - format: EncodingFormat, - ) -> [String: Any] { - guard let outboundObjectMessageBox = publicObjectMessage as? ObjectMessageBox else { - preconditionFailure("Expected to receive the same OutboundObjectMessage type as we emit") - } - - let wireObjectMessage = outboundObjectMessageBox.objectMessage.toWire(format: format) - return wireObjectMessage.toWireObject.toPluginSupportDataDictionary - } - - internal func nosync_onChannelAttached(_ channel: _AblyPluginSupportPrivate.RealtimeChannel, hasObjects: Bool) { - nosync_realtimeObjects(for: channel).nosync_onChannelAttached(hasObjects: hasObjects) - } - - internal func nosync_handleObjectProtocolMessage(withObjectMessages publicObjectMessages: [any _AblyPluginSupportPrivate.ObjectMessageProtocol], channel: _AblyPluginSupportPrivate.RealtimeChannel) { - guard let inboundObjectMessageBoxes = publicObjectMessages as? [ObjectMessageBox] else { - preconditionFailure("Expected to receive the same InboundObjectMessage type as we emit") - } - - let objectMessages = inboundObjectMessageBoxes.map(\.objectMessage) - - nosync_realtimeObjects(for: channel).nosync_handleObjectProtocolMessage( - objectMessages: objectMessages, - ) - } - - internal func nosync_handleObjectSyncProtocolMessage(withObjectMessages publicObjectMessages: [any _AblyPluginSupportPrivate.ObjectMessageProtocol], protocolMessageChannelSerial: String?, channel: _AblyPluginSupportPrivate.RealtimeChannel) { - guard let inboundObjectMessageBoxes = publicObjectMessages as? [ObjectMessageBox] else { - preconditionFailure("Expected to receive the same InboundObjectMessage type as we emit") - } - - let objectMessages = inboundObjectMessageBoxes.map(\.objectMessage) - - nosync_realtimeObjects(for: channel).nosync_handleObjectSyncProtocolMessage( - objectMessages: objectMessages, - protocolMessageChannelSerial: protocolMessageChannelSerial, - ) - } - - internal func nosync_onConnected(withConnectionDetails connectionDetails: (any ConnectionDetailsProtocol)?, channel: any RealtimeChannel) { - let gracePeriod = connectionDetails?.objectsGCGracePeriod?.doubleValue ?? InternalDefaultRealtimeObjects.GarbageCollectionOptions.defaultGracePeriod - - // RTO10b - nosync_realtimeObjects(for: channel).nosync_setGarbageCollectionGracePeriod(gracePeriod) - } - - // MARK: - Sending `OBJECT` ProtocolMessage - - internal static func sendObject( - objectMessages: [OutboundObjectMessage], - channel: _AblyPluginSupportPrivate.RealtimeChannel, - client: _AblyPluginSupportPrivate.RealtimeClient, - pluginAPI: PluginAPIProtocol, - ) async throws(ARTErrorInfo) { - let objectMessageBoxes: [ObjectMessageBox] = objectMessages.map { .init(objectMessage: $0) } - - try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in - let internalQueue = pluginAPI.internalQueue(for: client) - - internalQueue.async { - pluginAPI.nosync_sendObject( - withObjectMessages: objectMessageBoxes, - channel: channel, - ) { error in - // We don't currently rely on this documented behaviour of `nosync_sendObject` but we may do later, so assert it to be sure it's happening. - dispatchPrecondition(condition: .onQueue(internalQueue)) - - if let error { - continuation.resume(returning: .failure(ARTErrorInfo.castPluginPublicErrorInfo(error))) - } else { - continuation.resume(returning: .success(())) - } - } - } - }.get() - } -} diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveCounterUpdate.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveCounterUpdate.swift deleted file mode 100644 index b45a130d..00000000 --- a/Sources/AblyLiveObjects/Internal/DefaultLiveCounterUpdate.swift +++ /dev/null @@ -1,3 +0,0 @@ -internal struct DefaultLiveCounterUpdate: LiveCounterUpdate, Equatable { - internal var amount: Double -} diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveMapUpdate.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveMapUpdate.swift deleted file mode 100644 index 3c145395..00000000 --- a/Sources/AblyLiveObjects/Internal/DefaultLiveMapUpdate.swift +++ /dev/null @@ -1,3 +0,0 @@ -internal struct DefaultLiveMapUpdate: LiveMapUpdate, Equatable { - internal var update: [String: LiveMapUpdateAction] -} diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift deleted file mode 100644 index 15f12a10..00000000 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ /dev/null @@ -1,470 +0,0 @@ -internal import _AblyPluginSupportPrivate -import Ably -import Foundation - -/// This provides the implementation behind ``PublicDefaultLiveCounter``, via internal versions of the ``LiveCounter`` API. -internal final class InternalDefaultLiveCounter: Sendable { - private let mutableStateMutex: DispatchQueueMutex - - internal var testsOnly_siteTimeserials: [String: String] { - mutableStateMutex.withSync { mutableState in - mutableState.liveObjectMutableState.siteTimeserials - } - } - - internal var testsOnly_createOperationIsMerged: Bool { - mutableStateMutex.withSync { mutableState in - mutableState.liveObjectMutableState.createOperationIsMerged - } - } - - private let logger: Logger - private let userCallbackQueue: DispatchQueue - private let clock: SimpleClock - - // MARK: - Initialization - - internal convenience init( - testsOnly_data data: Double, - objectID: String, - logger: Logger, - internalQueue: DispatchQueue, - userCallbackQueue: DispatchQueue, - clock: SimpleClock - ) { - self.init( - data: data, - objectID: objectID, - logger: logger, - internalQueue: internalQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ) - } - - private init( - data: Double, - objectID: String, - logger: Logger, - internalQueue: DispatchQueue, - userCallbackQueue: DispatchQueue, - clock: SimpleClock - ) { - mutableStateMutex = .init( - dispatchQueue: internalQueue, - initialValue: .init(liveObjectMutableState: .init(objectID: objectID), data: data), - ) - self.logger = logger - self.userCallbackQueue = userCallbackQueue - self.clock = clock - } - - /// Creates a "zero-value LiveCounter", per RTLC4. - /// - /// - Parameters: - /// - objectID: The value for the "private objectId field" of RTO5c1b1a. - internal static func createZeroValued( - objectID: String, - logger: Logger, - internalQueue: DispatchQueue, - userCallbackQueue: DispatchQueue, - clock: SimpleClock, - ) -> Self { - .init( - data: 0, - objectID: objectID, - logger: logger, - internalQueue: internalQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ) - } - - // MARK: - Data access - - internal var nosync_objectID: String { - mutableStateMutex.withoutSync { mutableState in - mutableState.liveObjectMutableState.objectID - } - } - - /// Test-only accessor for objectID that handles locking internally. - internal var testsOnly_objectID: String { - mutableStateMutex.withSync { mutableState in - mutableState.liveObjectMutableState.objectID - } - } - - // MARK: - Internal methods that back LiveCounter conformance - - internal func value(coreSDK: CoreSDK) throws(ARTErrorInfo) -> Double { - try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in - try mutableState.nosync_value(coreSDK: coreSDK) - } - } - - internal func increment(amount: Double, coreSDK: CoreSDK) async throws(ARTErrorInfo) { - let objectMessage = try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in - // RTLC12c - try coreSDK.nosync_validateChannelState( - notIn: [.detached, .failed, .suspended], - operationDescription: "LiveCounter.increment", - ) - - // RTLC12e1 - if !amount.isFinite { - throw LiveObjectsError.counterIncrementAmountInvalid(amount: amount).toARTErrorInfo() - } - - return OutboundObjectMessage( - operation: .init( - // RTLC12e2 - action: .known(.counterInc), - // RTLC12e3 - objectId: mutableState.liveObjectMutableState.objectID, - counterOp: .init( - // RTLC12e4 - amount: .init(value: amount), - ), - ), - ) - } - - // RTLC12f - try await coreSDK.publish(objectMessages: [objectMessage]) - } - - internal func decrement(amount: Double, coreSDK: CoreSDK) async throws(ARTErrorInfo) { - // RTLC13b - try await increment(amount: -amount, coreSDK: coreSDK) - } - - @discardableResult - internal func subscribe(listener: @escaping LiveObjectUpdateCallback, coreSDK: CoreSDK) throws(ARTErrorInfo) -> any SubscribeResponse { - try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in - // swiftlint:disable:next trailing_closure - try mutableState.liveObjectMutableState.nosync_subscribe(listener: listener, coreSDK: coreSDK, updateSelfLater: { [weak self] action in - guard let self else { - return - } - - mutableStateMutex.withSync { mutableState in - action(&mutableState.liveObjectMutableState) - } - }) - } - } - - internal func unsubscribeAll() { - mutableStateMutex.withSync { mutableState in - mutableState.liveObjectMutableState.unsubscribeAll() - } - } - - @discardableResult - internal func on(event: LiveObjectLifecycleEvent, callback: @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { - mutableStateMutex.withSync { mutableState in - // swiftlint:disable:next trailing_closure - mutableState.liveObjectMutableState.on(event: event, callback: callback, updateSelfLater: { [weak self] action in - guard let self else { - return - } - - mutableStateMutex.withSync { mutableState in - action(&mutableState.liveObjectMutableState) - } - }) - } - } - - internal func offAll() { - mutableStateMutex.withSync { mutableState in - mutableState.liveObjectMutableState.offAll() - } - } - - // MARK: - Emitting update from external sources - - /// Emit an event from this `LiveCounter`. - /// - /// This is used to instruct this counter to emit updates during an `OBJECT_SYNC`. - internal func nosync_emit(_ update: LiveObjectUpdate) { - mutableStateMutex.withoutSync { mutableState in - mutableState.liveObjectMutableState.emit(update, on: userCallbackQueue) - } - } - - // MARK: - Data manipulation - - /// Replaces the internal data of this counter with the provided ObjectState, per RTLC6. - /// - /// - Parameters: - /// - objectMessageSerialTimestamp: The `serialTimestamp` of the containing `ObjectMessage`. Used if we need to tombstone this counter. - internal func nosync_replaceData( - using state: ObjectState, - objectMessageSerialTimestamp: Date?, - ) -> LiveObjectUpdate { - mutableStateMutex.withoutSync { mutableState in - mutableState.replaceData( - using: state, - objectMessageSerialTimestamp: objectMessageSerialTimestamp, - logger: logger, - clock: clock, - userCallbackQueue: userCallbackQueue, - ) - } - } - - /// Merges the initial value from an ObjectOperation into this LiveCounter, per RTLC10. - internal func nosync_mergeInitialValue(from operation: ObjectOperation) -> LiveObjectUpdate { - mutableStateMutex.withoutSync { mutableState in - mutableState.mergeInitialValue(from: operation) - } - } - - /// Test-only method to apply a COUNTER_CREATE operation, per RTLC8. - internal func testsOnly_applyCounterCreateOperation(_ operation: ObjectOperation) -> LiveObjectUpdate { - mutableStateMutex.withSync { mutableState in - mutableState.applyCounterCreateOperation(operation, logger: logger) - } - } - - /// Test-only method to apply a COUNTER_INC operation, per RTLC9. - internal func testsOnly_applyCounterIncOperation(_ operation: WireObjectsCounterOp?) -> LiveObjectUpdate { - mutableStateMutex.withSync { mutableState in - mutableState.applyCounterIncOperation(operation) - } - } - - /// Attempts to apply an operation from an inbound `ObjectMessage`, per RTLC7. - internal func nosync_apply( - _ operation: ObjectOperation, - objectMessageSerial: String?, - objectMessageSiteCode: String?, - objectMessageSerialTimestamp: Date?, - objectsPool: inout ObjectsPool, - ) { - mutableStateMutex.withoutSync { mutableState in - mutableState.apply( - operation, - objectMessageSerial: objectMessageSerial, - objectMessageSiteCode: objectMessageSiteCode, - objectMessageSerialTimestamp: objectMessageSerialTimestamp, - objectsPool: &objectsPool, - logger: logger, - clock: clock, - userCallbackQueue: userCallbackQueue, - ) - } - } - - // MARK: - LiveObject - - /// Returns the object's RTLO3d `isTombstone` property. - internal var nosync_isTombstone: Bool { - mutableStateMutex.withoutSync { mutableState in - mutableState.liveObjectMutableState.isTombstone - } - } - - /// Test-only accessor for isTombstone that handles locking internally. - internal var testsOnly_isTombstone: Bool { - mutableStateMutex.withSync { mutableState in - mutableState.liveObjectMutableState.isTombstone - } - } - - /// Returns the object's RTLO3e `tombstonedAt` property. - internal var nosync_tombstonedAt: Date? { - mutableStateMutex.withoutSync { mutableState in - mutableState.liveObjectMutableState.tombstonedAt - } - } - - /// Test-only accessor for tombstonedAt that handles locking internally. - internal var testsOnly_tombstonedAt: Date? { - mutableStateMutex.withSync { mutableState in - mutableState.liveObjectMutableState.tombstonedAt - } - } - - // MARK: - Mutable state and the operations that affect it - - private struct MutableState: InternalLiveObject { - /// The mutable state common to all LiveObjects. - internal var liveObjectMutableState: LiveObjectMutableState - - /// The internal data that this map holds, per RTLC3. - internal var data: Double - - /// Replaces the internal data of this counter with the provided ObjectState, per RTLC6. - /// - /// - Parameters: - /// - objectMessageSerialTimestamp: The `serialTimestamp` of the containing `ObjectMessage`. Used if we need to tombstone this counter. - internal mutating func replaceData( - using state: ObjectState, - objectMessageSerialTimestamp: Date?, - logger: Logger, - clock: SimpleClock, - userCallbackQueue: DispatchQueue, - ) -> LiveObjectUpdate { - // RTLC6a: Replace the private siteTimeserials with the value from ObjectState.siteTimeserials - liveObjectMutableState.siteTimeserials = state.siteTimeserials - - // RTLC6e, RTLC6e1: No-op if we're already tombstone - if liveObjectMutableState.isTombstone { - return .noop - } - - // RTLC6f: Tombstone if state indicates tombstoned - if state.tombstone { - let dataBeforeTombstoning = data - - tombstone( - objectMessageSerialTimestamp: objectMessageSerialTimestamp, - logger: logger, - clock: clock, - userCallbackQueue: userCallbackQueue, - ) - - // RTLC6f1 - return .update(.init(amount: -dataBeforeTombstoning)) - } - - // RTLC6b: Set the private flag createOperationIsMerged to false - liveObjectMutableState.createOperationIsMerged = false - - // RTLC6c: Set data to the value of ObjectState.counter.count, or to 0 if it does not exist - data = state.counter?.count?.doubleValue ?? 0 - - // RTLC6d: If ObjectState.createOp is present, merge the initial value into the LiveCounter as described in RTLC10 - return if let createOp = state.createOp { - mergeInitialValue(from: createOp) - } else { - // TODO: I assume this is what to do, clarify in https://github.com/ably/specification/pull/346/files#r2201363446 - .noop - } - } - - /// Merges the initial value from an ObjectOperation into this LiveCounter, per RTLC10. - internal mutating func mergeInitialValue(from operation: ObjectOperation) -> LiveObjectUpdate { - let update: LiveObjectUpdate - - // RTLC10a: Add ObjectOperation.counter.count to data, if it exists - if let operationCount = operation.counter?.count?.doubleValue { - data += operationCount - // RTLC10c - update = .update(DefaultLiveCounterUpdate(amount: operationCount)) - } else { - // RTLC10d - update = .noop - } - - // RTLC10b: Set the private flag createOperationIsMerged to true - liveObjectMutableState.createOperationIsMerged = true - - return update - } - - /// Attempts to apply an operation from an inbound `ObjectMessage`, per RTLC7. - internal mutating func apply( - _ operation: ObjectOperation, - objectMessageSerial: String?, - objectMessageSiteCode: String?, - objectMessageSerialTimestamp: Date?, - objectsPool: inout ObjectsPool, - logger: Logger, - clock: SimpleClock, - userCallbackQueue: DispatchQueue, - ) { - guard let applicableOperation = liveObjectMutableState.canApplyOperation(objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, logger: logger) else { - // RTLC7b - logger.log("Operation \(operation) (serial: \(String(describing: objectMessageSerial)), siteCode: \(String(describing: objectMessageSiteCode))) should not be applied; discarding", level: .debug) - return - } - - // RTLC7c - liveObjectMutableState.siteTimeserials[applicableOperation.objectMessageSiteCode] = applicableOperation.objectMessageSerial - - // RTLC7e - // TODO: are we still meant to update siteTimeserials? https://github.com/ably/specification/pull/350/files#r2218718854 - if liveObjectMutableState.isTombstone { - return - } - - switch operation.action { - case .known(.counterCreate): - // RTLC7d1 - let update = applyCounterCreateOperation( - operation, - logger: logger, - ) - // RTLC7d1a - liveObjectMutableState.emit(update, on: userCallbackQueue) - case .known(.counterInc): - // RTLC7d2 - let update = applyCounterIncOperation(operation.counterOp) - // RTLC7d2a - liveObjectMutableState.emit(update, on: userCallbackQueue) - case .known(.objectDelete): - let dataBeforeApplyingOperation = data - - // RTLC7d4 - applyObjectDeleteOperation( - objectMessageSerialTimestamp: objectMessageSerialTimestamp, - logger: logger, - clock: clock, - userCallbackQueue: userCallbackQueue, - ) - - // RTLC7d4a - liveObjectMutableState.emit(.update(.init(amount: -dataBeforeApplyingOperation)), on: userCallbackQueue) - default: - // RTLC7d3 - logger.log("Operation \(operation) has unsupported action for LiveCounter; discarding", level: .warn) - } - } - - /// Applies a `COUNTER_CREATE` operation, per RTLC8. - internal mutating func applyCounterCreateOperation( - _ operation: ObjectOperation, - logger: Logger, - ) -> LiveObjectUpdate { - if liveObjectMutableState.createOperationIsMerged { - // RTLC8b - logger.log("Not applying COUNTER_CREATE because a COUNTER_CREATE has already been applied", level: .warn) - return .noop - } - - // RTLC8c, RTLC8e - return mergeInitialValue(from: operation) - } - - /// Applies a `COUNTER_INC` operation, per RTLC9. - internal mutating func applyCounterIncOperation(_ operation: WireObjectsCounterOp?) -> LiveObjectUpdate { - guard let operation else { - // RTL9e - return .noop - } - - // RTLC9b, RTLC9d - let amount = operation.amount.doubleValue - data += amount - return .update(DefaultLiveCounterUpdate(amount: amount)) - } - - /// Needed for ``InternalLiveObject`` conformance. - mutating func resetDataToZeroValued() { - // RTLC4 - data = 0 - } - - internal func nosync_value(coreSDK: CoreSDK) throws(ARTErrorInfo) -> Double { - // RTLC5b: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 - try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed], operationDescription: "LiveCounter.value") - - // RTLC5c - return data - } - } -} diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift deleted file mode 100644 index d1ec8922..00000000 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ /dev/null @@ -1,1003 +0,0 @@ -internal import _AblyPluginSupportPrivate -import Ably - -/// Protocol for accessing objects from the ObjectsPool. This is used by a LiveMap when it needs to return an object given an object ID. -internal protocol LiveMapObjectsPoolDelegate: AnyObject, Sendable { - /// A snapshot of the objects pool. - var nosync_objectsPool: ObjectsPool { get } -} - -/// This provides the implementation behind ``PublicDefaultLiveMap``, via internal versions of the ``LiveMap`` API. -internal final class InternalDefaultLiveMap: Sendable { - private let mutableStateMutex: DispatchQueueMutex - - internal var testsOnly_data: [String: InternalObjectsMapEntry] { - mutableStateMutex.withSync { mutableState in - mutableState.data - } - } - - internal var testsOnly_semantics: WireEnum? { - mutableStateMutex.withSync { mutableState in - mutableState.semantics - } - } - - internal var testsOnly_siteTimeserials: [String: String] { - mutableStateMutex.withSync { mutableState in - mutableState.liveObjectMutableState.siteTimeserials - } - } - - internal var testsOnly_createOperationIsMerged: Bool { - mutableStateMutex.withSync { mutableState in - mutableState.liveObjectMutableState.createOperationIsMerged - } - } - - private let logger: Logger - private let userCallbackQueue: DispatchQueue - private let clock: SimpleClock - - // MARK: - Initialization - - internal convenience init( - testsOnly_data data: [String: InternalObjectsMapEntry], - objectID: String, - testsOnly_semantics semantics: WireEnum? = nil, - logger: Logger, - internalQueue: DispatchQueue, - userCallbackQueue: DispatchQueue, - clock: SimpleClock, - ) { - self.init( - data: data, - objectID: objectID, - semantics: semantics, - logger: logger, - internalQueue: internalQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ) - } - - private init( - data: [String: InternalObjectsMapEntry], - objectID: String, - semantics: WireEnum?, - logger: Logger, - internalQueue: DispatchQueue, - userCallbackQueue: DispatchQueue, - clock: SimpleClock, - ) { - mutableStateMutex = .init( - dispatchQueue: internalQueue, - initialValue: .init(liveObjectMutableState: .init(objectID: objectID), data: data, semantics: semantics), - ) - self.logger = logger - self.userCallbackQueue = userCallbackQueue - self.clock = clock - } - - /// Creates a "zero-value LiveMap", per RTLM4. - /// - /// - Parameters: - /// - objectID: The value to use for the RTLO3a `objectID` property. - /// - semantics: The value to use for the "private `semantics` field" of RTO5c1b1b. - internal static func createZeroValued( - objectID: String, - semantics: WireEnum? = nil, - logger: Logger, - internalQueue: DispatchQueue, - userCallbackQueue: DispatchQueue, - clock: SimpleClock, - ) -> Self { - .init( - data: [:], - objectID: objectID, - semantics: semantics, - logger: logger, - internalQueue: internalQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ) - } - - // MARK: - Data access - - internal var nosync_objectID: String { - mutableStateMutex.withoutSync { mutableState in - mutableState.liveObjectMutableState.objectID - } - } - - /// Test-only accessor for objectID that handles locking internally. - internal var testsOnly_objectID: String { - mutableStateMutex.withSync { mutableState in - mutableState.liveObjectMutableState.objectID - } - } - - // MARK: - Internal methods that back LiveMap conformance - - /// Returns the value associated with a given key, following RTLM5d specification. - internal func get(key: String, coreSDK: CoreSDK, delegate: LiveMapObjectsPoolDelegate) throws(ARTErrorInfo) -> InternalLiveMapValue? { - try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in - try mutableState.nosync_get( - key: key, - coreSDK: coreSDK, - objectsPool: delegate.nosync_objectsPool, - ) - } - } - - internal func size(coreSDK: CoreSDK, delegate: LiveMapObjectsPoolDelegate) throws(ARTErrorInfo) -> Int { - try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in - try mutableState.nosync_size( - coreSDK: coreSDK, - objectsPool: delegate.nosync_objectsPool, - ) - } - } - - internal func entries(coreSDK: CoreSDK, delegate: LiveMapObjectsPoolDelegate) throws(ARTErrorInfo) -> [(key: String, value: InternalLiveMapValue)] { - try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in - try mutableState.nosync_entries( - coreSDK: coreSDK, - objectsPool: delegate.nosync_objectsPool, - ) - } - } - - internal func keys(coreSDK: CoreSDK, delegate: LiveMapObjectsPoolDelegate) throws(ARTErrorInfo) -> [String] { - // RTLM12b: Identical to LiveMap#entries, except that it returns only the keys from the internal data map - try entries(coreSDK: coreSDK, delegate: delegate).map(\.key) - } - - internal func values(coreSDK: CoreSDK, delegate: LiveMapObjectsPoolDelegate) throws(ARTErrorInfo) -> [InternalLiveMapValue] { - // RTLM13b: Identical to LiveMap#entries, except that it returns only the values from the internal data map - try entries(coreSDK: coreSDK, delegate: delegate).map(\.value) - } - - internal func set(key: String, value: InternalLiveMapValue, coreSDK: CoreSDK) async throws(ARTErrorInfo) { - let objectMessage = try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in - // RTLM20c - try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "LiveMap.set") - - return OutboundObjectMessage( - operation: .init( - // RTLM20e2 - action: .known(.mapSet), - // RTLM20e3 - objectId: mutableState.liveObjectMutableState.objectID, - mapOp: .init( - // RTLM20e4 - key: key, - // RTLM20e5 - data: value.nosync_toObjectData, - ), - ), - ) - } - - try await coreSDK.publish(objectMessages: [objectMessage]) - } - - internal func remove(key: String, coreSDK: CoreSDK) async throws(ARTErrorInfo) { - let objectMessage = try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in - // RTLM21c - try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "LiveMap.remove") - - return OutboundObjectMessage( - operation: .init( - // RTLM21e2 - action: .known(.mapRemove), - // RTLM21e3 - objectId: mutableState.liveObjectMutableState.objectID, - mapOp: .init( - // RTLM21e4 - key: key, - ), - ), - ) - } - - // RTLM21f - try await coreSDK.publish(objectMessages: [objectMessage]) - } - - @discardableResult - internal func subscribe(listener: @escaping LiveObjectUpdateCallback, coreSDK: CoreSDK) throws(ARTErrorInfo) -> any SubscribeResponse { - try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in - // swiftlint:disable:next trailing_closure - try mutableState.liveObjectMutableState.nosync_subscribe(listener: listener, coreSDK: coreSDK, updateSelfLater: { [weak self] action in - guard let self else { - return - } - - mutableStateMutex.withSync { mutableState in - action(&mutableState.liveObjectMutableState) - } - }) - } - } - - internal func unsubscribeAll() { - mutableStateMutex.withSync { mutableState in - mutableState.liveObjectMutableState.unsubscribeAll() - } - } - - @discardableResult - internal func on(event: LiveObjectLifecycleEvent, callback: @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { - mutableStateMutex.withSync { mutableState in - // swiftlint:disable:next trailing_closure - mutableState.liveObjectMutableState.on(event: event, callback: callback, updateSelfLater: { [weak self] action in - guard let self else { - return - } - - mutableStateMutex.withSync { mutableState in - action(&mutableState.liveObjectMutableState) - } - }) - } - } - - internal func offAll() { - mutableStateMutex.withSync { mutableState in - mutableState.liveObjectMutableState.offAll() - } - } - - // MARK: - Emitting update from external sources - - /// Emit an event from this `LiveMap`. - /// - /// This is used to instruct this map to emit updates during an `OBJECT_SYNC`. - internal func nosync_emit(_ update: LiveObjectUpdate) { - mutableStateMutex.withoutSync { mutableState in - mutableState.liveObjectMutableState.emit(update, on: userCallbackQueue) - } - } - - // MARK: - Data manipulation - - /// Replaces the internal data of this map with the provided ObjectState, per RTLM6. - /// - /// - Parameters: - /// - objectsPool: The pool into which should be inserted any objects created by a `MAP_SET` operation. - /// - objectMessageSerialTimestamp: The `serialTimestamp` of the containing `ObjectMessage`. Used if we need to tombstone this map. - internal func nosync_replaceData( - using state: ObjectState, - objectMessageSerialTimestamp: Date?, - objectsPool: inout ObjectsPool, - ) -> LiveObjectUpdate { - mutableStateMutex.withoutSync { mutableState in - mutableState.replaceData( - using: state, - objectMessageSerialTimestamp: objectMessageSerialTimestamp, - objectsPool: &objectsPool, - logger: logger, - clock: clock, - internalQueue: mutableStateMutex.dispatchQueue, - userCallbackQueue: userCallbackQueue, - ) - } - } - - /// Merges the initial value from an ObjectOperation into this LiveMap, per RTLM17. - internal func nosync_mergeInitialValue(from operation: ObjectOperation, objectsPool: inout ObjectsPool) -> LiveObjectUpdate { - mutableStateMutex.withoutSync { mutableState in - mutableState.mergeInitialValue( - from: operation, - objectsPool: &objectsPool, - logger: logger, - internalQueue: mutableStateMutex.dispatchQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ) - } - } - - /// Test-only method to apply a MAP_CREATE operation, per RTLM16. - internal func testsOnly_applyMapCreateOperation(_ operation: ObjectOperation, objectsPool: inout ObjectsPool) -> LiveObjectUpdate { - mutableStateMutex.withSync { mutableState in - mutableState.applyMapCreateOperation( - operation, - objectsPool: &objectsPool, - logger: logger, - internalQueue: mutableStateMutex.dispatchQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ) - } - } - - /// Attempts to apply an operation from an inbound `ObjectMessage`, per RTLM15. - internal func nosync_apply( - _ operation: ObjectOperation, - objectMessageSerial: String?, - objectMessageSiteCode: String?, - objectMessageSerialTimestamp: Date?, - objectsPool: inout ObjectsPool, - ) { - mutableStateMutex.withoutSync { mutableState in - mutableState.apply( - operation, - objectMessageSerial: objectMessageSerial, - objectMessageSiteCode: objectMessageSiteCode, - objectMessageSerialTimestamp: objectMessageSerialTimestamp, - objectsPool: &objectsPool, - logger: logger, - internalQueue: mutableStateMutex.dispatchQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ) - } - } - - /// Applies a `MAP_SET` operation to a key, per RTLM7. - /// - /// This is currently exposed just so that the tests can test RTLM7 without having to go through a convoluted replaceData(…) call, but I _think_ that it's going to be used in further contexts when we introduce the handling of incoming object operations in a future spec PR. - internal func testsOnly_applyMapSetOperation( - key: String, - operationTimeserial: String?, - operationData: ObjectData, - objectsPool: inout ObjectsPool, - ) -> LiveObjectUpdate { - mutableStateMutex.withSync { mutableState in - mutableState.applyMapSetOperation( - key: key, - operationTimeserial: operationTimeserial, - operationData: operationData, - objectsPool: &objectsPool, - logger: logger, - internalQueue: mutableStateMutex.dispatchQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ) - } - } - - /// Applies a `MAP_REMOVE` operation to a key, per RTLM8. - /// - /// This is currently exposed just so that the tests can test RTLM8 without having to go through a convoluted replaceData(…) call, but I _think_ that it's going to be used in further contexts when we introduce the handling of incoming object operations in a future spec PR. - internal func testsOnly_applyMapRemoveOperation(key: String, operationTimeserial: String?, operationSerialTimestamp: Date?) -> LiveObjectUpdate { - mutableStateMutex.withSync { mutableState in - mutableState.applyMapRemoveOperation( - key: key, - operationTimeserial: operationTimeserial, - operationSerialTimestamp: operationSerialTimestamp, - logger: logger, - clock: clock, - ) - } - } - - /// Resets the map's data, per RTO4b2. This is to be used when an `ATTACHED` ProtocolMessage indicates that the only object in a channel is an empty root map. - internal func nosync_resetData() { - mutableStateMutex.withoutSync { mutableState in - mutableState.resetData(userCallbackQueue: userCallbackQueue) - } - } - - /// Releases entries that were tombstoned more than `gracePeriod` ago, per RTLM19. - internal func nosync_releaseTombstonedEntries(gracePeriod: TimeInterval, clock: SimpleClock) { - mutableStateMutex.withoutSync { mutableState in - mutableState.releaseTombstonedEntries(gracePeriod: gracePeriod, logger: logger, clock: clock) - } - } - - // MARK: - LiveObject - - /// Returns the object's RTLO3d `isTombstone` property. - internal var nosync_isTombstone: Bool { - mutableStateMutex.withoutSync { mutableState in - mutableState.liveObjectMutableState.isTombstone - } - } - - /// Test-only accessor for isTombstone that handles locking internally. - internal var testsOnly_isTombstone: Bool { - mutableStateMutex.withSync { mutableState in - mutableState.liveObjectMutableState.isTombstone - } - } - - /// Returns the object's RTLO3e `tombstonedAt` property. - internal var nosync_tombstonedAt: Date? { - mutableStateMutex.withoutSync { mutableState in - mutableState.liveObjectMutableState.tombstonedAt - } - } - - /// Test-only accessor for tombstonedAt that handles locking internally. - internal var testsOnly_tombstonedAt: Date? { - mutableStateMutex.withSync { mutableState in - mutableState.liveObjectMutableState.tombstonedAt - } - } - - // MARK: - Mutable state and the operations that affect it - - private struct MutableState: InternalLiveObject { - /// The mutable state common to all LiveObjects. - internal var liveObjectMutableState: LiveObjectMutableState - - /// The internal data that this map holds, per RTLM3. - internal var data: [String: InternalObjectsMapEntry] - - /// The "private `semantics` field" of RTO5c1b1b. - internal var semantics: WireEnum? - - /// Replaces the internal data of this map with the provided ObjectState, per RTLM6. - /// - /// - Parameters: - /// - objectsPool: The pool into which should be inserted any objects created by a `MAP_SET` operation. - /// - objectMessageSerialTimestamp: The `serialTimestamp` of the containing `ObjectMessage`. Used if we need to tombstone this map. - internal mutating func replaceData( - using state: ObjectState, - objectMessageSerialTimestamp: Date?, - objectsPool: inout ObjectsPool, - logger: Logger, - clock: SimpleClock, - internalQueue: DispatchQueue, - userCallbackQueue: DispatchQueue, - ) -> LiveObjectUpdate { - // RTLM6a: Replace the private siteTimeserials with the value from ObjectState.siteTimeserials - liveObjectMutableState.siteTimeserials = state.siteTimeserials - - // RTLM6e, RTLM6e1: No-op if we're already tombstone - if liveObjectMutableState.isTombstone { - return .noop - } - - // RTLM6f: Tombstone if state indicates tombstoned - if state.tombstone { - let dataBeforeTombstoning = data - - tombstone( - objectMessageSerialTimestamp: objectMessageSerialTimestamp, - logger: logger, - clock: clock, - userCallbackQueue: userCallbackQueue, - ) - - // RTLM6f1 - return .update(.init(update: dataBeforeTombstoning.mapValues { _ in .removed })) - } - - // RTLM6b: Set the private flag createOperationIsMerged to false - liveObjectMutableState.createOperationIsMerged = false - - // RTLM6c: Set data to ObjectState.map.entries, or to an empty map if it does not exist - data = state.map?.entries?.mapValues { entry in - // Set tombstonedAt for tombstoned entries - let tombstonedAt: Date? - if entry.tombstone == true { - // RTLM6c1a - if let serialTimestamp = entry.serialTimestamp { - tombstonedAt = serialTimestamp - } else { - // RTLM6c1b - logger.log("serialTimestamp not found in ObjectsMapEntry, using local clock for tombstone timestamp", level: .debug) - // RTLM6cb1 - tombstonedAt = clock.now - } - } else { - tombstonedAt = nil - } - - return .init(objectsMapEntry: entry, tombstonedAt: tombstonedAt) - } ?? [:] - - // RTLM6d: If ObjectState.createOp is present, merge the initial value into the LiveMap as described in RTLM17 - return if let createOp = state.createOp { - mergeInitialValue( - from: createOp, - objectsPool: &objectsPool, - logger: logger, - internalQueue: internalQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ) - } else { - // TODO: I assume this is what to do, clarify in https://github.com/ably/specification/pull/346/files#r2201363446 - .noop - } - } - - /// Merges the initial value from an ObjectOperation into this LiveMap, per RTLM17. - internal mutating func mergeInitialValue( - from operation: ObjectOperation, - objectsPool: inout ObjectsPool, - logger: Logger, - internalQueue: DispatchQueue, - userCallbackQueue: DispatchQueue, - clock: SimpleClock, - ) -> LiveObjectUpdate { - // RTLM17a: For each key–ObjectsMapEntry pair in ObjectOperation.map.entries - let perKeyUpdates: [LiveObjectUpdate] = if let entries = operation.map?.entries { - entries.map { key, entry in - if entry.tombstone == true { - // RTLM17a2: If ObjectsMapEntry.tombstone is true, apply the MAP_REMOVE operation - // as described in RTLM8, passing in the current key as ObjectsMapOp, ObjectsMapEntry.timeserial as the operation's serial, and ObjectsMapEntry.serialTimestamp as the operation's serial timestamp - applyMapRemoveOperation( - key: key, - operationTimeserial: entry.timeserial, - operationSerialTimestamp: entry.serialTimestamp, - logger: logger, - clock: clock, - ) - } else { - // RTLM17a1: If ObjectsMapEntry.tombstone is false, apply the MAP_SET operation - // as described in RTLM7, passing in ObjectsMapEntry.data and the current key as ObjectsMapOp, and ObjectsMapEntry.timeserial as the operation's serial - applyMapSetOperation( - key: key, - operationTimeserial: entry.timeserial, - operationData: entry.data, - objectsPool: &objectsPool, - logger: logger, - internalQueue: internalQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ) - } - } - } else { - [] - } - - // RTLM17b: Set the private flag createOperationIsMerged to true - liveObjectMutableState.createOperationIsMerged = true - - // RTLM17c: Merge the updates, skipping no-ops - // I don't love having to use uniqueKeysWithValues, when I shouldn't have to. I should be able to reason _statically_ that there are no overlapping keys. The problem that we're trying to use LiveMapUpdate throughout instead of something more communicative. But I don't know what's to come in the spec so I don't want to mess with this internal interface. - let filteredPerKeyUpdates = perKeyUpdates.compactMap { update -> LiveMapUpdate? in - switch update { - case .noop: - nil - case let .update(update): - update - } - } - let filteredPerKeyUpdateKeyValuePairs = filteredPerKeyUpdates.reduce(into: []) { result, element in - result.append(contentsOf: Array(element.update)) - } - let update = Dictionary(uniqueKeysWithValues: filteredPerKeyUpdateKeyValuePairs) - return .update(DefaultLiveMapUpdate(update: update)) - } - - /// Attempts to apply an operation from an inbound `ObjectMessage`, per RTLM15. - internal mutating func apply( - _ operation: ObjectOperation, - objectMessageSerial: String?, - objectMessageSiteCode: String?, - objectMessageSerialTimestamp: Date?, - objectsPool: inout ObjectsPool, - logger: Logger, - internalQueue: DispatchQueue, - userCallbackQueue: DispatchQueue, - clock: SimpleClock, - ) { - guard let applicableOperation = liveObjectMutableState.canApplyOperation(objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, logger: logger) else { - // RTLM15b - logger.log("Operation \(operation) (serial: \(String(describing: objectMessageSerial)), siteCode: \(String(describing: objectMessageSiteCode))) should not be applied; discarding", level: .debug) - return - } - - // RTLM15c - liveObjectMutableState.siteTimeserials[applicableOperation.objectMessageSiteCode] = applicableOperation.objectMessageSerial - - // RTLM15e - // TODO: are we still meant to update siteTimeserials? https://github.com/ably/specification/pull/350/files#r2218718854 - if liveObjectMutableState.isTombstone { - return - } - - switch operation.action { - case .known(.mapCreate): - // RTLM15d1 - let update = applyMapCreateOperation( - operation, - objectsPool: &objectsPool, - logger: logger, - internalQueue: internalQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ) - // RTLM15d1a - liveObjectMutableState.emit(update, on: userCallbackQueue) - case .known(.mapSet): - guard let mapOp = operation.mapOp else { - logger.log("Could not apply MAP_SET since operation.mapOp is missing", level: .warn) - return - } - guard let data = mapOp.data else { - logger.log("Could not apply MAP_SET since operation.data is missing", level: .warn) - return - } - - // RTLM15d2 - let update = applyMapSetOperation( - key: mapOp.key, - operationTimeserial: applicableOperation.objectMessageSerial, - operationData: data, - objectsPool: &objectsPool, - logger: logger, - internalQueue: internalQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ) - // RTLM15d2a - liveObjectMutableState.emit(update, on: userCallbackQueue) - case .known(.mapRemove): - guard let mapOp = operation.mapOp else { - return - } - - // RTLM15d3 - let update = applyMapRemoveOperation( - key: mapOp.key, - operationTimeserial: applicableOperation.objectMessageSerial, - operationSerialTimestamp: objectMessageSerialTimestamp, - logger: logger, - clock: clock, - ) - // RTLM15d3a - liveObjectMutableState.emit(update, on: userCallbackQueue) - case .known(.objectDelete): - let dataBeforeApplyingOperation = data - - // RTLM15d5 - applyObjectDeleteOperation( - objectMessageSerialTimestamp: objectMessageSerialTimestamp, - logger: logger, - clock: clock, - userCallbackQueue: userCallbackQueue, - ) - - // RTLM15d5a - liveObjectMutableState.emit(.update(.init(update: dataBeforeApplyingOperation.mapValues { _ in .removed })), on: userCallbackQueue) - default: - // RTLM15d4 - logger.log("Operation \(operation) has unsupported action for LiveMap; discarding", level: .warn) - } - } - - /// Applies a `MAP_SET` operation to a key, per RTLM7. - internal mutating func applyMapSetOperation( - key: String, - operationTimeserial: String?, - operationData: ObjectData?, - objectsPool: inout ObjectsPool, - logger: Logger, - internalQueue: DispatchQueue, - userCallbackQueue: DispatchQueue, - clock: SimpleClock, - ) -> LiveObjectUpdate { - // RTLM7a: If an entry exists in the private data for the specified key - if let existingEntry = data[key] { - // RTLM7a1: If the operation cannot be applied as per RTLM9, discard the operation - if !Self.canApplyMapOperation(entryTimeserial: existingEntry.timeserial, operationTimeserial: operationTimeserial) { - return .noop - } - // RTLM7a2: Otherwise, apply the operation - // RTLM7a2a: Set ObjectsMapEntry.data to the ObjectData from the operation - // RTLM7a2b: Set ObjectsMapEntry.timeserial to the operation's serial - // RTLM7a2c: Set ObjectsMapEntry.tombstone to false (same as RTLM7a2d: Set ObjectsMapEntry.tombstonedAt to nil) - var updatedEntry = existingEntry - updatedEntry.data = operationData - updatedEntry.timeserial = operationTimeserial - updatedEntry.tombstonedAt = nil - data[key] = updatedEntry - } else { - // RTLM7b: If an entry does not exist in the private data for the specified key - // RTLM7b1: Create a new entry in data for the specified key with the provided ObjectData and the operation's serial - // RTLM7b2: Set ObjectsMapEntry.tombstone for the new entry to false (same as RTLM7b3: Set tombstonedAt to nil) - data[key] = InternalObjectsMapEntry(tombstonedAt: nil, timeserial: operationTimeserial, data: operationData) - } - - // RTLM7c: If the operation has a non-empty ObjectData.objectId attribute - if let objectId = operationData?.objectId, !objectId.isEmpty { - // RTLM7c1: Create a zero-value LiveObject in the internal ObjectsPool per RTO6 - _ = objectsPool.createZeroValueObject( - forObjectID: objectId, - logger: logger, - internalQueue: internalQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ) - } - - // RTLM7f - return .update(DefaultLiveMapUpdate(update: [key: .updated])) - } - - /// Applies a `MAP_REMOVE` operation to a key, per RTLM8. - internal mutating func applyMapRemoveOperation(key: String, operationTimeserial: String?, operationSerialTimestamp: Date?, logger: Logger, clock: SimpleClock) -> LiveObjectUpdate { - // (Note that, where the spec tells us to set ObjectsMapEntry.data to nil, we actually set it to an empty ObjectData, which is equivalent, since it contains no data) - - // Calculate the tombstonedAt for the new or updated entry per RTLM8f - let tombstonedAt: Date? - if let operationSerialTimestamp { - // RTLM8f1 - tombstonedAt = operationSerialTimestamp - } else { - // RTLM8f2 - logger.log("serialTimestamp not provided for MAP_REMOVE, using local clock for tombstone timestamp", level: .debug) - // RTLM8f2a - tombstonedAt = clock.now - } - - // RTLM8a: If an entry exists in the private data for the specified key - if let existingEntry = data[key] { - // RTLM8a1: If the operation cannot be applied as per RTLM9, discard the operation - if !Self.canApplyMapOperation(entryTimeserial: existingEntry.timeserial, operationTimeserial: operationTimeserial) { - return .noop - } - // RTLM8a2: Otherwise, apply the operation - // RTLM8a2a: Set ObjectsMapEntry.data to undefined/null - // RTLM8a2b: Set ObjectsMapEntry.timeserial to the operation's serial - // RTLM8a2c: Set ObjectsMapEntry.tombstone to true (equivalent to next point) - // RTLM8a2d: Set ObjectsMapEntry.tombstonedAt per RTLM8a2d - var updatedEntry = existingEntry - updatedEntry.data = nil - updatedEntry.timeserial = operationTimeserial - updatedEntry.tombstonedAt = tombstonedAt - data[key] = updatedEntry - } else { - // RTLM8b: If an entry does not exist in the private data for the specified key - // RTLM8b1: Create a new entry in data for the specified key, with ObjectsMapEntry.data set to undefined/null and the operation's serial - // RTLM8b2: Set ObjectsMapEntry.tombstone for the new entry to true - // RTLM8b3: Set ObjectsMapEntry.tombstonedAt per RTLM8f - data[key] = InternalObjectsMapEntry(tombstonedAt: tombstonedAt, timeserial: operationTimeserial, data: nil) - } - - return .update(DefaultLiveMapUpdate(update: [key: .removed])) - } - - /// Determines whether a map operation can be applied to a map entry, per RTLM9. - private static func canApplyMapOperation(entryTimeserial: String?, operationTimeserial: String?) -> Bool { - // I am going to treat "exists" and "is non-empty" as equivalent here, because the spec mentions "null or empty" in some places and is vague in others. - func normalize(timeserial: String?) -> String? { - // swiftlint:disable:next empty_string - timeserial == "" ? nil : timeserial - } - - let ( - normalizedEntryTimeserial, - normalizedOperationTimeserial - ) = ( - normalize(timeserial: entryTimeserial), - normalize(timeserial: operationTimeserial), - ) - - return switch (normalizedEntryTimeserial, normalizedOperationTimeserial) { - case let (.some(normalizedEntryTimeserial), .some(normalizedOperationTimeserial)): - // RTLM9a: For a LiveMap using LWW (Last-Write-Wins) CRDT semantics, the operation must - // only be applied if its serial is strictly greater ("after") than the entry's serial - // when compared lexicographically - // RTLM9e: If both serials exist, compare them lexicographically and allow operation - // to be applied only if the operation's serial is greater than the entry's serial - normalizedOperationTimeserial > normalizedEntryTimeserial - case (nil, .some): - // RTLM9d: If only the operation serial exists, it is considered greater than the missing - // entry serial, so the operation can be applied - true - case (.some, nil): - // RTLM9c: If only the entry serial exists, the missing operation serial is considered lower - // than the existing entry serial, so the operation must not be applied - false - case (nil, nil): - // RTLM9b: If both the entry serial and the operation serial are null or empty strings, - // they are treated as the "earliest possible" serials and considered "equal", - // so the operation must not be applied - false - } - } - - /// Applies a `MAP_CREATE` operation, per RTLM16. - internal mutating func applyMapCreateOperation( - _ operation: ObjectOperation, - objectsPool: inout ObjectsPool, - logger: Logger, - internalQueue: DispatchQueue, - userCallbackQueue: DispatchQueue, - clock: SimpleClock, - ) -> LiveObjectUpdate { - if liveObjectMutableState.createOperationIsMerged { - // RTLM16b - logger.log("Not applying MAP_CREATE because a MAP_CREATE has already been applied", level: .warn) - return .noop - } - - // TODO: RTLM16c `semantics` comparison; outstanding question in https://github.com/ably/specification/pull/343/files#r2192784482 - - // RTLM16d, RTLM16f - return mergeInitialValue( - from: operation, - objectsPool: &objectsPool, - logger: logger, - internalQueue: internalQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ) - } - - /// Resets the map's data and emits a `removed` event for the existing keys, per RTO4b2 and RTO4b2a. This is to be used when an `ATTACHED` ProtocolMessage indicates that the only object in a channel is an empty root map. - internal mutating func resetData(userCallbackQueue: DispatchQueue) { - // RTO4b2 - let previousData = data - data = [:] - - // RTO4b2a - let mapUpdate = DefaultLiveMapUpdate(update: previousData.mapValues { _ in .removed }) - liveObjectMutableState.emit(.update(mapUpdate), on: userCallbackQueue) - } - - /// Needed for ``InternalLiveObject`` conformance. - mutating func resetDataToZeroValued() { - // RTLM4 - data = [:] - } - - /// Releases entries that were tombstoned more than `gracePeriod` ago, per RTLM19. - internal mutating func releaseTombstonedEntries( - gracePeriod: TimeInterval, - logger: Logger, - clock: SimpleClock, - ) { - let now = clock.now - - // RTLM19a, RTLM19a1 - data = data.filter { key, entry in - let shouldRelease = { - guard let tombstonedAt = entry.tombstonedAt else { - return false - } - - return now.timeIntervalSince(tombstonedAt) >= gracePeriod - }() - - if shouldRelease { - logger.log("Releasing tombstoned entry \(entry) for key \(key)", level: .debug) - } - return !shouldRelease - } - } - - /// Returns the value associated with a given key, following RTLM5d specification. - internal func nosync_get(key: String, coreSDK: CoreSDK, objectsPool: ObjectsPool) throws(ARTErrorInfo) -> InternalLiveMapValue? { - // RTLM5c: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 - try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed], operationDescription: "LiveMap.get") - - // RTLM5e - Return nil if self is tombstone - if liveObjectMutableState.isTombstone { - return nil - } - - // RTLM5d1: If no ObjectsMapEntry exists at the key, return undefined/null - guard let entry = data[key] else { - return nil - } - - // RTLM5d2: If a ObjectsMapEntry exists at the key, convert it using the shared logic - return nosync_convertEntryToLiveMapValue(entry, objectsPool: objectsPool) - } - - internal func nosync_size(coreSDK: CoreSDK, objectsPool: ObjectsPool) throws(ARTErrorInfo) -> Int { - // RTLM10c: If the channel is in the DETACHED or FAILED state, the library should throw an ErrorInfo error with statusCode 400 and code 90001 - try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed], operationDescription: "LiveMap.size") - - // RTLM10d: Returns the number of non-tombstoned entries (per RTLM14) in the internal data map - return data.values.count { entry in - !Self.nosync_isEntryTombstoned(entry, objectsPool: objectsPool) - } - } - - internal func nosync_entries(coreSDK: CoreSDK, objectsPool: ObjectsPool) throws(ARTErrorInfo) -> [(key: String, value: InternalLiveMapValue)] { - // RTLM11c: If the channel is in the DETACHED or FAILED state, the library should throw an ErrorInfo error with statusCode 400 and code 90001 - try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed], operationDescription: "LiveMap.entries") - - // RTLM11d: Returns key-value pairs from the internal data map - // RTLM11d1: Pairs with tombstoned entries (per RTLM14) are not returned - var result: [(key: String, value: InternalLiveMapValue)] = [] - - for (key, entry) in data where !Self.nosync_isEntryTombstoned(entry, objectsPool: objectsPool) { - // Convert entry to LiveMapValue using the same logic as get(key:) - if let value = nosync_convertEntryToLiveMapValue(entry, objectsPool: objectsPool) { - result.append((key: key, value: value)) - } - } - - return result - } - - // MARK: - Helper Methods - - /// Returns whether a map entry should be considered tombstoned, per the check described in RTLM14. - private static func nosync_isEntryTombstoned(_ entry: InternalObjectsMapEntry, objectsPool: ObjectsPool) -> Bool { - // RTLM14a - if entry.tombstone { - return true - } - - // RTLM14c - if let objectId = entry.data?.objectId { - if let poolEntry = objectsPool.entries[objectId], poolEntry.nosync_isTombstone { - return true - } - } - - // RTLM14b - return false - } - - /// Converts an InternalObjectsMapEntry to LiveMapValue using the same logic as get(key:) - /// This is used by entries to ensure consistent value conversion - private func nosync_convertEntryToLiveMapValue(_ entry: InternalObjectsMapEntry, objectsPool: ObjectsPool) -> InternalLiveMapValue? { - // RTLM5d2a: If ObjectsMapEntry.tombstone is true, return undefined/null - if entry.tombstone == true { - return nil - } - - // Handle primitive values in the order specified by RTLM5d2b through RTLM5d2e - - // RTLM5d2b: If ObjectsMapEntry.data.boolean exists, return it - if let boolean = entry.data?.boolean { - return .bool(boolean) - } - - // RTLM5d2c: If ObjectsMapEntry.data.bytes exists, return it - if let bytes = entry.data?.bytes { - return .data(bytes) - } - - // RTLM5d2d: If ObjectsMapEntry.data.number exists, return it - if let number = entry.data?.number { - return .number(number.doubleValue) - } - - // RTLM5d2e: If ObjectsMapEntry.data.string exists, return it - if let string = entry.data?.string { - return .string(string) - } - - // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) - if let json = entry.data?.json { - switch json { - case let .array(array): - return .jsonArray(array) - case let .object(object): - return .jsonObject(object) - } - } - - // RTLM5d2f: If ObjectsMapEntry.data.objectId exists, get the object stored at that objectId from the internal ObjectsPool - if let objectId = entry.data?.objectId { - // RTLM5d2f1: If an object with id objectId does not exist, return undefined/null - guard let poolEntry = objectsPool.entries[objectId] else { - return nil - } - - // RTLM5d2f3: If referenced object is tombstoned, return nil - if poolEntry.nosync_isTombstone { - return nil - } - - // RTLM5d2f2: Return referenced object - switch poolEntry { - case let .map(map): - return .liveMap(map) - case let .counter(counter): - return .liveCounter(counter) - } - } - - // RTLM5d2g: Otherwise, return undefined/null - return nil - } - } -} diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift deleted file mode 100644 index 86f17577..00000000 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ /dev/null @@ -1,787 +0,0 @@ -internal import _AblyPluginSupportPrivate -import Ably - -/// This provides the implementation behind ``PublicDefaultRealtimeObjects``, via internal versions of the ``RealtimeObjects`` API. -internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoolDelegate { - private let mutableStateMutex: DispatchQueueMutex - - private let logger: Logger - private let userCallbackQueue: DispatchQueue - private let clock: SimpleClock - - // These drive the testsOnly_* properties that expose the received ProtocolMessages to the test suite. - private let receivedObjectProtocolMessages: AsyncStream<[InboundObjectMessage]> - private let receivedObjectProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation - private let receivedObjectSyncProtocolMessages: AsyncStream<[InboundObjectMessage]> - private let receivedObjectSyncProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation - - /// The RTO10a interval at which we will perform garbage collection. - private let garbageCollectionInterval: TimeInterval - // The task that runs the periodic garbage collection described in RTO10. - private nonisolated(unsafe) var garbageCollectionTask: Task! - - /// Parameters used to control the garbage collection of tombstoned objects and map entries, as described in RTO10. - internal struct GarbageCollectionOptions: Encodable, Hashable { - /// The RTO10a interval at which we will perform garbage collection. - /// - /// The default value comes from the suggestion in RTO10a. - internal var interval: TimeInterval = 5 * 60 - - /// The initial RTO10b grace period for which we will retain tombstoned objects and map entries. This value may later get overridden by the `objectsGCGracePeriod` of a `CONNECTED` `ProtocolMessage` from Realtime. - /// - /// This default value comes from RTO10b3; can be overridden for testing. - internal var gracePeriod: GracePeriod = .dynamic(Self.defaultGracePeriod) - - /// The default value from RTO10b3. - internal static let defaultGracePeriod: TimeInterval = 24 * 60 * 60 - - internal enum GracePeriod: Encodable, Hashable { - /// The client will always use this grace period, and will not update the grace period from the `objectsGCGracePeriod` of a `CONNECTED` `ProtocolMessage`. - /// - /// - Important: This should only be used in tests. - case fixed(TimeInterval) - - /// The client will use this grace period, which may be subsequently updated by the `objectsGCGracePeriod` of a `CONNECTED` `ProtocolMessage`. - case dynamic(TimeInterval) - - internal var toTimeInterval: TimeInterval { - switch self { - case let .fixed(timeInterval), let .dynamic(timeInterval): - timeInterval - } - } - } - } - - internal var testsOnly_objectsPool: ObjectsPool { - mutableStateMutex.withSync { mutableState in - mutableState.objectsPool - } - } - - /// If this returns false, it means that there is currently no stored sync sequence ID, SyncObjectsPool, or BufferedObjectOperations. - internal var testsOnly_hasSyncSequence: Bool { - mutableStateMutex.withSync { mutableState in - if case let .syncing(syncingData) = mutableState.state, syncingData.syncSequence != nil { - true - } else { - false - } - } - } - - // These drive the testsOnly_waitingForSyncEvents property that informs the test suite when `getRoot()` is waiting for the object sync sequence to complete per RTO1c. - private let waitingForSyncEvents: AsyncStream - private let waitingForSyncEventsContinuation: AsyncStream.Continuation - /// Emits an element whenever `getRoot()` starts waiting for the object sync sequence to complete per RTO1c. - internal var testsOnly_waitingForSyncEvents: AsyncStream { - waitingForSyncEvents - } - - /// Contains the data gathered during an `OBJECT_SYNC` sequence. - private struct SyncSequence { - /// The sync sequence ID, per RTO5a1. - internal var id: String - - /// The `ObjectMessage`s gathered during this sync sequence. - internal var syncObjectsPool: [SyncObjectsPoolEntry] - - /// `OBJECT` ProtocolMessages that were received during this sync sequence, to be applied once the sync sequence is complete, per RTO7a. - internal var bufferedObjectOperations: [InboundObjectMessage] - } - - internal init( - logger: Logger, - internalQueue: DispatchQueue, - userCallbackQueue: DispatchQueue, - clock: SimpleClock, - garbageCollectionOptions: GarbageCollectionOptions = .init() - ) { - self.logger = logger - self.userCallbackQueue = userCallbackQueue - self.clock = clock - (receivedObjectProtocolMessages, receivedObjectProtocolMessagesContinuation) = AsyncStream.makeStream() - (receivedObjectSyncProtocolMessages, receivedObjectSyncProtocolMessagesContinuation) = AsyncStream.makeStream() - (waitingForSyncEvents, waitingForSyncEventsContinuation) = AsyncStream.makeStream() - (completedGarbageCollectionEventsWithoutBuffering, completedGarbageCollectionEventsWithoutBufferingContinuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(0)) - mutableStateMutex = .init( - dispatchQueue: internalQueue, - initialValue: .init( - objectsPool: .init( - logger: logger, - internalQueue: internalQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ), - garbageCollectionGracePeriod: garbageCollectionOptions.gracePeriod, - ), - ) - garbageCollectionInterval = garbageCollectionOptions.interval - - garbageCollectionTask = Task { [weak self, garbageCollectionInterval] in - do { - while true { - logger.log("Will perform garbage collection in \(garbageCollectionInterval)s", level: .debug) - try await Task.sleep(nanoseconds: UInt64(garbageCollectionInterval * Double(NSEC_PER_SEC))) - - guard let self else { - return - } - - performGarbageCollection() - } - } catch { - precondition(error is CancellationError) - logger.log("Garbage collection task terminated due to cancellation", level: .debug) - } - } - } - - deinit { - garbageCollectionTask.cancel() - } - - // MARK: - LiveMapObjectsPoolDelegate - - internal var nosync_objectsPool: ObjectsPool { - mutableStateMutex.withoutSync { mutableState in - mutableState.objectsPool - } - } - - // MARK: - Internal methods that power RealtimeObjects conformance - - internal func getRoot(coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveMap { - let state = try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in - // RTO1b: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 - try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed], operationDescription: "getRoot") - - return mutableState.state - } - - if state.toObjectsSyncState != .synced { - // RTO1c - waitingForSyncEventsContinuation.yield() - logger.log("getRoot started waiting for sync sequence to complete", level: .debug) - await withCheckedContinuation { continuation in - onInternal(event: .synced) { subscription in - subscription.off() - continuation.resume() - } - } - logger.log("getRoot completed waiting for sync sequence to complete", level: .debug) - } - - return mutableStateMutex.withSync { mutableState in - // RTO1d - mutableState.objectsPool.root - } - } - - internal func createMap(entries: [String: InternalLiveMapValue], coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveMap { - try mutableStateMutex.withSync { _ throws(ARTErrorInfo) in - // RTO11d - try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "RealtimeObjects.createMap") - } - - // RTO11f7 - let timestamp = try await coreSDK.fetchServerTime() - - let creationOperation = mutableStateMutex.withSync { _ in - // RTO11f - ObjectCreationHelpers.nosync_creationOperationForLiveMap( - entries: entries, - timestamp: timestamp, - ) - } - - // RTO11g - try await coreSDK.publish(objectMessages: [creationOperation.objectMessage]) - - // RTO11h - return mutableStateMutex.withSync { mutableState in - mutableState.objectsPool.nosync_getOrCreateMap( - creationOperation: creationOperation, - logger: logger, - internalQueue: mutableStateMutex.dispatchQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ) - } - } - - internal func createMap(coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveMap { - // RTO11f4b - try await createMap(entries: [:], coreSDK: coreSDK) - } - - internal func createCounter(count: Double, coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveCounter { - // RTO12d - try mutableStateMutex.withSync { _ throws(ARTErrorInfo) in - try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "RealtimeObjects.createCounter") - } - - // RTO12f1 - if !count.isFinite { - throw LiveObjectsError.counterInitialValueInvalid(value: count).toARTErrorInfo() - } - - // RTO12f - - // RTO12f5 - let timestamp = try await coreSDK.fetchServerTime() - - let creationOperation = ObjectCreationHelpers.creationOperationForLiveCounter( - count: count, - timestamp: timestamp, - ) - - // RTO12g - try await coreSDK.publish(objectMessages: [creationOperation.objectMessage]) - - // RTO12h - return mutableStateMutex.withSync { mutableState in - mutableState.objectsPool.nosync_getOrCreateCounter( - creationOperation: creationOperation, - logger: logger, - internalQueue: mutableStateMutex.dispatchQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ) - } - } - - internal func createCounter(coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveCounter { - // RTO12f2a - try await createCounter(count: 0, coreSDK: coreSDK) - } - - // RTO18 - @discardableResult - internal func on(event: ObjectsEvent, callback: @escaping ObjectsEventCallback) -> any OnObjectsEventResponse { - mutableStateMutex.withSync { mutableState in - // swiftlint:disable:next trailing_closure - mutableState.on(event: event, callback: callback, updateSelfLater: { [weak self] action in - guard let self else { - return - } - - mutableStateMutex.withSync { mutableState in - action(&mutableState) - } - }) - } - } - - /// Adds a subscriber to the ``internalObjectsEventSubscriptionStorage`` (i.e. unaffected by `offAll()`). - @discardableResult - internal func onInternal(event: ObjectsEvent, callback: @escaping ObjectsEventCallback) -> any OnObjectsEventResponse { - // TODO: Looking at this again later the whole process for adding a subscriber is really verbose and boilerplate-y, and I think the unfortunate result of me trying to be clever at some point; revisit in https://github.com/ably/ably-liveobjects-swift-plugin/issues/102 - mutableStateMutex.withSync { mutableState in - // swiftlint:disable:next trailing_closure - mutableState.onInternal(event: event, callback: callback, updateSelfLater: { [weak self] action in - guard let self else { - return - } - - mutableStateMutex.withSync { mutableState in - action(&mutableState) - } - }) - } - } - - internal func offAll() { - mutableStateMutex.withSync { mutableState in - mutableState.offAll() - } - } - - // MARK: Handling channel events - - internal var testsOnly_onChannelAttachedHasObjects: Bool? { - mutableStateMutex.withSync { mutableState in - mutableState.onChannelAttachedHasObjects - } - } - - internal func nosync_onChannelAttached(hasObjects: Bool) { - mutableStateMutex.withoutSync { mutableState in - mutableState.nosync_onChannelAttached( - hasObjects: hasObjects, - logger: logger, - userCallbackQueue: userCallbackQueue, - ) - } - } - - internal var testsOnly_receivedObjectProtocolMessages: AsyncStream<[InboundObjectMessage]> { - receivedObjectProtocolMessages - } - - /// Implements the `OBJECT` handling of RTO8. - internal func nosync_handleObjectProtocolMessage(objectMessages: [InboundObjectMessage]) { - mutableStateMutex.withoutSync { mutableState in - mutableState.nosync_handleObjectProtocolMessage( - objectMessages: objectMessages, - logger: logger, - internalQueue: mutableStateMutex.dispatchQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - receivedObjectProtocolMessagesContinuation: receivedObjectProtocolMessagesContinuation, - ) - } - } - - internal var testsOnly_receivedObjectSyncProtocolMessages: AsyncStream<[InboundObjectMessage]> { - receivedObjectSyncProtocolMessages - } - - /// Implements the `OBJECT_SYNC` handling of RTO5. - internal func nosync_handleObjectSyncProtocolMessage(objectMessages: [InboundObjectMessage], protocolMessageChannelSerial: String?) { - mutableStateMutex.withoutSync { mutableState in - mutableState.nosync_handleObjectSyncProtocolMessage( - objectMessages: objectMessages, - protocolMessageChannelSerial: protocolMessageChannelSerial, - logger: logger, - internalQueue: mutableStateMutex.dispatchQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - receivedObjectSyncProtocolMessagesContinuation: receivedObjectSyncProtocolMessagesContinuation, - ) - } - } - - /// Creates a zero-value LiveObject in the object pool for this object ID. - /// - /// Intended as a way for tests to populate the object pool. - internal func testsOnly_createZeroValueLiveObject(forObjectID objectID: String) -> ObjectsPool.Entry? { - mutableStateMutex.withSync { mutableState in - mutableState.objectsPool.createZeroValueObject( - forObjectID: objectID, - logger: logger, - internalQueue: mutableStateMutex.dispatchQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ) - } - } - - // MARK: - Sending `OBJECT` ProtocolMessage - - // This is currently exposed so that we can try calling it from the tests in the early days of the SDK to check that we can send an OBJECT ProtocolMessage. We'll probably make it private later on. - internal func testsOnly_publish(objectMessages: [OutboundObjectMessage], coreSDK: CoreSDK) async throws(ARTErrorInfo) { - try await coreSDK.publish(objectMessages: objectMessages) - } - - // MARK: - Garbage collection of deleted objects and map entries - - /// Performs garbage collection of tombstoned objects and map entries, per RTO10c. - internal func performGarbageCollection() { - mutableStateMutex.withSync { mutableState in - mutableState.objectsPool.nosync_performGarbageCollection( - gracePeriod: mutableState.garbageCollectionGracePeriod.toTimeInterval, - clock: clock, - logger: logger, - eventsContinuation: completedGarbageCollectionEventsWithoutBufferingContinuation, - ) - } - } - - // These drive the testsOnly_completedGarbageCollectionEventsWithoutBuffering property that informs the test suite when a garbage collection cycle has completed. - private let completedGarbageCollectionEventsWithoutBuffering: AsyncStream - private let completedGarbageCollectionEventsWithoutBufferingContinuation: AsyncStream.Continuation - /// Emits an element whenever a garbage collection cycle has completed. - internal var testsOnly_completedGarbageCollectionEventsWithoutBuffering: AsyncStream { - completedGarbageCollectionEventsWithoutBuffering - } - - /// Sets the garbage collection grace period. - /// - /// Call this upon receiving a `CONNECTED` `ProtocolMessage`, per RTO10b2. - /// - /// - Note: If the `.fixed` grace period option was chosen on instantiation, this is a no-op. - internal func nosync_setGarbageCollectionGracePeriod(_ gracePeriod: TimeInterval) { - mutableStateMutex.withoutSync { mutableState in - switch mutableState.garbageCollectionGracePeriod { - case .fixed: - // no-op - break - case .dynamic: - mutableState.garbageCollectionGracePeriod = .dynamic(gracePeriod) - } - } - } - - internal var testsOnly_gcGracePeriod: TimeInterval { - mutableStateMutex.withSync { mutableState in - mutableState.garbageCollectionGracePeriod.toTimeInterval - } - } - - // MARK: - Testing - - /// Finishes the following streams, to allow a test to perform assertions about which elements the streams have emitted to this moment: - /// - /// - testsOnly_receivedObjectProtocolMessages - /// - testsOnly_receivedObjectStateProtocolMessages - /// - testsOnly_waitingForSyncEvents - /// - testsOnly_completedGarbageCollectionEventsWithoutBuffering - internal func testsOnly_finishAllTestHelperStreams() { - receivedObjectProtocolMessagesContinuation.finish() - receivedObjectSyncProtocolMessagesContinuation.finish() - waitingForSyncEventsContinuation.finish() - completedGarbageCollectionEventsWithoutBufferingContinuation.finish() - } - - // MARK: - Mutable state and the operations that affect it - - private struct MutableState { - internal var objectsPool: ObjectsPool - internal var onChannelAttachedHasObjects: Bool? - internal var objectsEventSubscriptionStorage = SubscriptionStorage() - - /// Used when the object wishes to subscribe to its own events (i.e. unaffected by `offAll()`); used e.g. to wait for a sync before returning from `getRoot()`, per RTO1c. - internal var internalObjectsEventSubscriptionStorage = SubscriptionStorage() - - /// The RTO10b grace period for which we will retain tombstoned objects and map entries. - internal var garbageCollectionGracePeriod: GarbageCollectionOptions.GracePeriod - - /// The RTO17 sync state. Also stores the sync sequence data. - internal var state = State.initialized - - /// Has the same cases as `ObjectsSyncState` but with associated data to store the sync sequence data and represent the constraint that you only have a sync sequence if you're SYNCING. - internal enum State { - case initialized - case syncing(AssociatedData.Syncing) - case synced - - /// Note: We follow the same pattern as used in the WIP ably-swift: a state's associated data is a class instance and the convention is that to update the associated data for the current state you mutate the existing instance instead of creating a new one. - enum AssociatedData { - class Syncing { - /// Note that we only ever populate this during a multi-`ProtocolMessage` sync sequence. It is not used in the RTO4b or RTO5a5 cases where the sync data is entirely contained within a single ProtocolMessage, because an individual ProtocolMessage is processed atomically and so no other operations that might wish to query this property can occur concurrently with the handling of these cases. - /// - /// It is optional because there are times that we transition to SYNCING even when the sync data is contained in a single ProtocolMessage. - var syncSequence: SyncSequence? - - init(syncSequence: SyncSequence?) { - self.syncSequence = syncSequence - } - } - } - - var toObjectsSyncState: ObjectsSyncState { - switch self { - case .initialized: - .initialized - case .syncing: - .syncing - case .synced: - .synced - } - } - } - - mutating func transition( - to newState: State, - userCallbackQueue: DispatchQueue, - ) { - guard newState.toObjectsSyncState != state.toObjectsSyncState else { - preconditionFailure("Cannot transition to the current state") - } - state = newState - guard let event = newState.toObjectsSyncState.toEvent else { - return - } - // RTO17b - emitObjectsEvent(event, on: userCallbackQueue) - } - - internal mutating func nosync_onChannelAttached( - hasObjects: Bool, - logger: Logger, - userCallbackQueue: DispatchQueue, - ) { - logger.log("onChannelAttached(hasObjects: \(hasObjects)", level: .debug) - - onChannelAttachedHasObjects = hasObjects - - // We will subsequently transition to .synced either by the completion of the RTO4a OBJECT_SYNC, or by the RTO4b no-HAS_OBJECTS case below - if state.toObjectsSyncState != .syncing { - // RTO4c - transition(to: .syncing(.init(syncSequence: nil)), userCallbackQueue: userCallbackQueue) - } - - // We only care about the case where HAS_OBJECTS is not set (RTO4b); if it is set then we're going to shortly receive an OBJECT_SYNC instead (RTO4a) - guard !hasObjects else { - return - } - - // RTO4b1, RTO4b2: Reset the ObjectsPool to have a single empty root object - objectsPool.nosync_reset() - - // I have, for now, not directly implemented the "perform the actions for object sync completion" of RTO4b4 since my implementation doesn't quite match the model given there; here you only have a SyncObjectsPool if you have an OBJECT_SYNC in progress, which you might not have upon receiving an ATTACHED. Instead I've just implemented what seem like the relevant side effects. Can revisit this if "the actions for object sync completion" get more complex. - - // RTO4b3, RTO4b4, RTO4b5, RTO5c3, RTO5c4, RTO5c5, RTO5c8 - transition(to: .synced, userCallbackQueue: userCallbackQueue) - } - - /// Implements the `OBJECT_SYNC` handling of RTO5. - internal mutating func nosync_handleObjectSyncProtocolMessage( - objectMessages: [InboundObjectMessage], - protocolMessageChannelSerial: String?, - logger: Logger, - internalQueue: DispatchQueue, - userCallbackQueue: DispatchQueue, - clock: SimpleClock, - receivedObjectSyncProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation, - ) { - logger.log("handleObjectSyncProtocolMessage(objectMessages: \(LoggingUtilities.formatObjectMessagesForLogging(objectMessages)), protocolMessageChannelSerial: \(String(describing: protocolMessageChannelSerial)))", level: .debug) - - receivedObjectSyncProtocolMessagesContinuation.yield(objectMessages) - - // If populated, this contains a full set of sync data for the channel, and should be applied to the ObjectsPool. - let completedSyncObjectsPool: [SyncObjectsPoolEntry]? - // If populated, this contains a set of buffered inbound OBJECT messages that should be applied. - let completedSyncBufferedObjectOperations: [InboundObjectMessage]? - // The SyncSequence, if any, to store in the SYNCING state that results from this OBJECT_SYNC. - let syncSequenceForSyncingState: SyncSequence? - - if let protocolMessageChannelSerial { - let syncCursor: SyncCursor - do { - // RTO5a - syncCursor = try SyncCursor(channelSerial: protocolMessageChannelSerial) - } catch { - logger.log("Failed to parse sync cursor: \(error)", level: .error) - return - } - - // Figure out whether to continue any existing sync sequence or start a new one - var updatedSyncSequence: SyncSequence = if case let .syncing(syncingData) = state, let syncSequence = syncingData.syncSequence { - if syncCursor.sequenceID == syncSequence.id { - // RTO5a3: Continue existing sync sequence - syncSequence - } else { - // RTO5a2a, RTO5a2b: new sequence started, discard previous - .init(id: syncCursor.sequenceID, syncObjectsPool: [], bufferedObjectOperations: []) - } - } else { - // There's no current sync sequence; start one - .init(id: syncCursor.sequenceID, syncObjectsPool: [], bufferedObjectOperations: []) - } - - // RTO5b - updatedSyncSequence.syncObjectsPool.append(contentsOf: objectMessages.compactMap { objectMessage in - if let object = objectMessage.object { - .init(state: object, objectMessageSerialTimestamp: objectMessage.serialTimestamp) - } else { - nil - } - }) - - (completedSyncObjectsPool, completedSyncBufferedObjectOperations) = if syncCursor.isEndOfSequence { - (updatedSyncSequence.syncObjectsPool, updatedSyncSequence.bufferedObjectOperations) - } else { - (nil, nil) - } - - syncSequenceForSyncingState = updatedSyncSequence - } else { - // RTO5a5: The sync data is contained entirely within this single OBJECT_SYNC - completedSyncObjectsPool = objectMessages.compactMap { objectMessage in - if let object = objectMessage.object { - .init(state: object, objectMessageSerialTimestamp: objectMessage.serialTimestamp) - } else { - nil - } - } - completedSyncBufferedObjectOperations = nil - syncSequenceForSyncingState = nil - } - - if case let .syncing(syncingData) = state { - syncingData.syncSequence = syncSequenceForSyncingState - } else { - // RTO5e - transition(to: .syncing(.init(syncSequence: syncSequenceForSyncingState)), userCallbackQueue: userCallbackQueue) - } - - if let completedSyncObjectsPool { - // RTO5c - objectsPool.nosync_applySyncObjectsPool( - completedSyncObjectsPool, - logger: logger, - internalQueue: internalQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ) - - // RTO5c6 - if let completedSyncBufferedObjectOperations, !completedSyncBufferedObjectOperations.isEmpty { - logger.log("Applying \(completedSyncBufferedObjectOperations.count) buffered OBJECT ObjectMessages", level: .debug) - for objectMessage in completedSyncBufferedObjectOperations { - nosync_applyObjectProtocolMessageObjectMessage( - objectMessage, - logger: logger, - internalQueue: internalQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ) - } - } - - // RTO5c3, RTO5c4, RTO5c5, RTO5c8 - transition(to: .synced, userCallbackQueue: userCallbackQueue) - } - } - - /// Implements the `OBJECT` handling of RTO8. - internal mutating func nosync_handleObjectProtocolMessage( - objectMessages: [InboundObjectMessage], - logger: Logger, - internalQueue: DispatchQueue, - userCallbackQueue: DispatchQueue, - clock: SimpleClock, - receivedObjectProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation, - ) { - receivedObjectProtocolMessagesContinuation.yield(objectMessages) - - logger.log("handleObjectProtocolMessage(objectMessages: \(LoggingUtilities.formatObjectMessagesForLogging(objectMessages)))", level: .debug) - - if case let .syncing(syncingData) = state, let existingSyncSequence = syncingData.syncSequence { - // RTO8a: Buffer the OBJECT message, to be handled once the sync completes - logger.log("Buffering OBJECT message due to in-progress sync", level: .debug) - var newSyncSequence = existingSyncSequence - newSyncSequence.bufferedObjectOperations.append(contentsOf: objectMessages) - syncingData.syncSequence = newSyncSequence - } else { - // RTO8b: Handle the OBJECT message immediately - for objectMessage in objectMessages { - nosync_applyObjectProtocolMessageObjectMessage( - objectMessage, - logger: logger, - internalQueue: internalQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ) - } - } - } - - /// Implements the `OBJECT` application of RTO9. - private mutating func nosync_applyObjectProtocolMessageObjectMessage( - _ objectMessage: InboundObjectMessage, - logger: Logger, - internalQueue: DispatchQueue, - userCallbackQueue: DispatchQueue, - clock: SimpleClock, - ) { - guard let operation = objectMessage.operation else { - // RTO9a1 - logger.log("Unsupported OBJECT message received (no operation); \(objectMessage)", level: .warn) - return - } - - // RTO9a2a1, RTO9a2a2 - let entry: ObjectsPool.Entry - if let existingEntry = objectsPool.entries[operation.objectId] { - entry = existingEntry - } else { - guard let newEntry = objectsPool.createZeroValueObject( - forObjectID: operation.objectId, - logger: logger, - internalQueue: internalQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ) else { - logger.log("Unable to create zero-value object for \(operation.objectId) when processing OBJECT message; dropping", level: .warn) - return - } - - entry = newEntry - } - - switch operation.action { - case let .known(action): - switch action { - case .mapCreate, .mapSet, .mapRemove, .counterCreate, .counterInc, .objectDelete: - // RTO9a2a3 - entry.nosync_apply( - operation, - objectMessageSerial: objectMessage.serial, - objectMessageSiteCode: objectMessage.siteCode, - objectMessageSerialTimestamp: objectMessage.serialTimestamp, - objectsPool: &objectsPool, - ) - } - case let .unknown(rawValue): - // RTO9a2b - logger.log("Unsupported OBJECT operation action \(rawValue) received", level: .warn) - return - } - } - - internal typealias UpdateMutableState = @Sendable (_ action: (inout Self) -> Void) -> Void - - @discardableResult - internal mutating func on(event: ObjectsEvent, callback: @escaping ObjectsEventCallback, updateSelfLater: @escaping UpdateMutableState) -> any OnObjectsEventResponse { - let updateSubscriptionStorage: SubscriptionStorage.UpdateSubscriptionStorage = { action in - updateSelfLater { mutableState in - action(&mutableState.objectsEventSubscriptionStorage) - } - } - - let subscription = objectsEventSubscriptionStorage.subscribe( - listener: { _, subscriptionInCallback in - let response = ObjectsEventResponse(subscription: subscriptionInCallback) - callback(response) - }, - eventName: event, - updateSelfLater: updateSubscriptionStorage, - ) - - return ObjectsEventResponse(subscription: subscription) - } - - /// Adds a subscriber to the ``internalObjectsEventSubscriptionStorage`` (i.e. unaffected by `offAll()`). - @discardableResult - internal mutating func onInternal(event: ObjectsEvent, callback: @escaping ObjectsEventCallback, updateSelfLater: @escaping UpdateMutableState) -> any OnObjectsEventResponse { - // TODO: Looking at this again later the whole process for adding a subscriber is really verbose and boilerplate-y, and I think the unfortunate result of me trying to be clever at some point; revisit in https://github.com/ably/ably-liveobjects-swift-plugin/issues/102 - let updateSubscriptionStorage: SubscriptionStorage.UpdateSubscriptionStorage = { action in - updateSelfLater { mutableState in - action(&mutableState.internalObjectsEventSubscriptionStorage) - } - } - - let subscription = internalObjectsEventSubscriptionStorage.subscribe( - listener: { _, subscriptionInCallback in - let response = ObjectsEventResponse(subscription: subscriptionInCallback) - callback(response) - }, - eventName: event, - updateSelfLater: updateSubscriptionStorage, - ) - - return ObjectsEventResponse(subscription: subscription) - } - - // RTO18f - private struct ObjectsEventResponse: OnObjectsEventResponse { - let subscription: any SubscribeResponse - - func off() { - subscription.unsubscribe() - } - } - - internal mutating func offAll() { - objectsEventSubscriptionStorage.unsubscribeAll() - } - - internal func emitObjectsEvent(_ event: ObjectsEvent, on queue: DispatchQueue) { - objectsEventSubscriptionStorage.emit(eventName: event, on: queue) - internalObjectsEventSubscriptionStorage.emit(eventName: event, on: queue) - } - } -} diff --git a/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift b/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift deleted file mode 100644 index 7bf39f14..00000000 --- a/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift +++ /dev/null @@ -1,165 +0,0 @@ -import Foundation - -/// Same as the public ``LiveMapValue`` type but with associated values of internal type. -internal enum InternalLiveMapValue: Sendable, Equatable { - case string(String) - case number(Double) - case bool(Bool) - case data(Data) - case jsonArray([JSONValue]) - case jsonObject([String: JSONValue]) - case liveMap(InternalDefaultLiveMap) - case liveCounter(InternalDefaultLiveCounter) - - // MARK: - Creating from a public LiveMapValue - - /// Converts a public ``LiveMapValue`` into an ``InternalLiveMapValue``. - /// - /// Needed in order to access the internals of user-provided LiveObject-valued LiveMap entries to extract their object ID. - internal init(liveMapValue: LiveMapValue) { - switch liveMapValue { - case let .string(value): - self = .string(value) - case let .number(value): - self = .number(value) - case let .bool(value): - self = .bool(value) - case let .data(value): - self = .data(value) - case let .jsonArray(value): - self = .jsonArray(value) - case let .jsonObject(value): - self = .jsonObject(value) - case let .liveMap(publicLiveMap): - guard let publicDefaultLiveMap = publicLiveMap as? PublicDefaultLiveMap else { - // TODO: Try and remove this runtime check and know this type statically, see https://github.com/ably/ably-liveobjects-swift-plugin/issues/37 - preconditionFailure("Expected PublicDefaultLiveMap, got \(publicLiveMap)") - } - self = .liveMap(publicDefaultLiveMap.proxied) - case let .liveCounter(publicLiveCounter): - guard let publicDefaultLiveCounter = publicLiveCounter as? PublicDefaultLiveCounter else { - // TODO: Try and remove this runtime check and know this type statically, see https://github.com/ably/ably-liveobjects-swift-plugin/issues/37 - preconditionFailure("Expected PublicDefaultLiveCounter, got \(publicLiveCounter)") - } - self = .liveCounter(publicDefaultLiveCounter.proxied) - } - } - - // MARK: - Representation in the Realtime protocol - - /// 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. - internal var nosync_toObjectData: ObjectData { - // RTO11f4c1: Create an ObjectsMapEntry for the current value - switch self { - case let .bool(value): - .init(boolean: value) - case let .data(value): - .init(bytes: value) - case let .number(value): - .init(number: NSNumber(value: value)) - case let .string(value): - .init(string: value) - case let .jsonArray(value): - .init(json: .array(value)) - case let .jsonObject(value): - .init(json: .object(value)) - case let .liveMap(liveMap): - // RTO11f4c1a: If the value is of type LiveMap, set ObjectsMapEntry.data.objectId to the objectId of that object - .init(objectId: liveMap.nosync_objectID) - case let .liveCounter(liveCounter): - // RTO11f4c1a: If the value is of type LiveCounter, set ObjectsMapEntry.data.objectId to the objectId of that object - .init(objectId: liveCounter.nosync_objectID) - } - } - - // MARK: - Convenience getters for associated values - - /// If this `InternalLiveMapValue` has case `liveMap`, this returns the associated value. Else, it returns `nil`. - internal var liveMapValue: InternalDefaultLiveMap? { - if case let .liveMap(value) = self { - return value - } - return nil - } - - /// If this `InternalLiveMapValue` has case `liveCounter`, this returns the associated value. Else, it returns `nil`. - internal var liveCounterValue: InternalDefaultLiveCounter? { - if case let .liveCounter(value) = self { - return value - } - return nil - } - - /// If this `InternalLiveMapValue` has case `string`, this returns that value. Else, it returns `nil`. - internal var stringValue: String? { - if case let .string(value) = self { - return value - } - return nil - } - - /// If this `InternalLiveMapValue` has case `number`, this returns that value. Else, it returns `nil`. - internal var numberValue: Double? { - if case let .number(value) = self { - return value - } - return nil - } - - /// If this `InternalLiveMapValue` has case `bool`, this returns that value. Else, it returns `nil`. - internal var boolValue: Bool? { - if case let .bool(value) = self { - return value - } - return nil - } - - /// If this `InternalLiveMapValue` has case `data`, this returns that value. Else, it returns `nil`. - internal var dataValue: Data? { - if case let .data(value) = self { - return value - } - return nil - } - - /// If this `InternalLiveMapValue` has case `jsonArray`, this returns that value. Else, it returns `nil`. - internal var jsonArrayValue: [JSONValue]? { - if case let .jsonArray(value) = self { - return value - } - return nil - } - - /// If this `InternalLiveMapValue` has case `jsonObject`, this returns that value. Else, it returns `nil`. - internal var jsonObjectValue: [String: JSONValue]? { - if case let .jsonObject(value) = self { - return value - } - return nil - } - - // MARK: - Equatable Implementation - - internal static func == (lhs: InternalLiveMapValue, rhs: InternalLiveMapValue) -> Bool { - switch (lhs, rhs) { - case let (.string(lhsValue), .string(rhsValue)): - lhsValue == rhsValue - case let (.number(lhsValue), .number(rhsValue)): - lhsValue == rhsValue - case let (.bool(lhsValue), .bool(rhsValue)): - lhsValue == rhsValue - case let (.data(lhsValue), .data(rhsValue)): - lhsValue == rhsValue - case let (.jsonArray(lhsValue), .jsonArray(rhsValue)): - lhsValue == rhsValue - case let (.jsonObject(lhsValue), .jsonObject(rhsValue)): - lhsValue == rhsValue - case let (.liveMap(lhsMap), .liveMap(rhsMap)): - lhsMap === rhsMap - case let (.liveCounter(lhsCounter), .liveCounter(rhsCounter)): - lhsCounter === rhsCounter - default: - false - } - } -} diff --git a/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift b/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift deleted file mode 100644 index 11679d15..00000000 --- a/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift +++ /dev/null @@ -1,58 +0,0 @@ -internal import _AblyPluginSupportPrivate - -/// Provides RTLO spec point functionality common to all LiveObjects. -/// -/// This exists in addition to ``LiveObjectMutableState`` to enable polymorphism. -internal protocol InternalLiveObject { - associatedtype Update: Sendable - - var liveObjectMutableState: LiveObjectMutableState { get set } - - /// Resets the LiveObject's internal data to that of a zero-value, per RTLO4e4. - mutating func resetDataToZeroValued() -} - -internal extension InternalLiveObject { - /// Convenience method for tombstoning a `LiveObject`, as specified in RTLO4e. - mutating func tombstone( - objectMessageSerialTimestamp: Date?, - logger: Logger, - clock: SimpleClock, - userCallbackQueue: DispatchQueue, - ) { - // RTLO4e2, RTLO4e3 - if let objectMessageSerialTimestamp { - // RTLO4e3a - liveObjectMutableState.tombstonedAt = objectMessageSerialTimestamp - } else { - // RTLO4e3b1 - logger.log("serialTimestamp not found in ObjectMessage, using local clock for tombstone timestamp", level: .debug) - // RTLO4e3b - liveObjectMutableState.tombstonedAt = clock.now - } - - // RTLO4e4 - resetDataToZeroValued() - - // Emit the deleted lifecycle event - // Taken from https://github.com/ably/ably-js/blob/e280bff11a4a7627362c5185e764b7ebd0490570/src/plugins/objects/liveobject.ts#L168 - // TODO: Bring in line with spec once it exists (https://github.com/ably/ably-liveobjects-swift-plugin/issues/77) - liveObjectMutableState.emitLifecycleEvent(.deleted, on: userCallbackQueue) - } - - /// Applies an `OBJECT_DELETE` operation, per RTLO5. - mutating func applyObjectDeleteOperation( - objectMessageSerialTimestamp: Date?, - logger: Logger, - clock: SimpleClock, - userCallbackQueue: DispatchQueue, - ) { - // RTLO5b - tombstone( - objectMessageSerialTimestamp: objectMessageSerialTimestamp, - logger: logger, - clock: clock, - userCallbackQueue: userCallbackQueue, - ) - } -} diff --git a/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift b/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift deleted file mode 100644 index 4922ddfc..00000000 --- a/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Foundation - -/// The entries stored in a `LiveMap`'s data. Same as an `ObjectsMapEntry` but with an additional `tombstonedAt` property, per RTLM3a. -internal struct InternalObjectsMapEntry: Equatable { - internal var tombstonedAt: Date? // RTLM3a - internal var tombstone: Bool { - // TODO: Confirm that we don't need to store this (https://github.com/ably/specification/pull/350/files#r2213895661) - tombstonedAt != nil - } - - internal var timeserial: String? // OME2b - internal var data: ObjectData? // OME2c -} - -internal extension InternalObjectsMapEntry { - init(objectsMapEntry: ObjectsMapEntry, tombstonedAt: Date?) { - self.tombstonedAt = tombstonedAt - timeserial = objectsMapEntry.timeserial - data = objectsMapEntry.data - } -} diff --git a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift deleted file mode 100644 index 8978b9ea..00000000 --- a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift +++ /dev/null @@ -1,152 +0,0 @@ -internal import _AblyPluginSupportPrivate -import Ably - -/// This is the equivalent of the `LiveObject` abstract class described in RTLO. -/// -/// ``InternalDefaultLiveCounter`` and ``InternalDefaultLiveMap`` include it by composition. -internal struct LiveObjectMutableState { - // RTLO3a - internal var objectID: String - // RTLO3b - internal var siteTimeserials: [String: String] = [:] - // RTLO3c - internal var createOperationIsMerged = false - // RTLO3d - internal var isTombstone: Bool { - // TODO: Confirm that we don't need to store this (https://github.com/ably/specification/pull/350/files#r2213895661) - tombstonedAt != nil - } - - // RTLO3e - internal var tombstonedAt: Date? - - private enum EventName { - case update - } - - /// Internal subscription storage. - private var subscriptionStorage = SubscriptionStorage() - - /// Internal lifecycle event subscription storage. - private var lifecycleEventSubscriptionStorage = SubscriptionStorage() - - internal init( - objectID: String, - testsOnly_siteTimeserials siteTimeserials: [String: String]? = nil, - testsOnly_tombstonedAt tombstonedAt: Date? = nil, - ) { - self.objectID = objectID - self.siteTimeserials = siteTimeserials ?? [:] - self.tombstonedAt = tombstonedAt - } - - /// Represents parameters of an operation that `canApplyOperation` has decided can be applied to a `LiveObject`. - /// - /// The key thing is that it offers a non-nil `serial` and `siteCode`, which will be needed when subsequently performing the operation. - internal struct ApplicableOperation: Equatable { - internal let objectMessageSerial: String - internal let objectMessageSiteCode: String - } - - /// Indicates whether an operation described by an `ObjectMessage` should be applied or discarded, per RTLO4a. - /// - /// Instead of returning a `Bool`, in the case where the operation can be applied it returns a non-nil `ApplicableOperation` (whose non-nil `serial` and `siteCode` will be needed as part of subsequently performing this operation). - internal func canApplyOperation(objectMessageSerial: String?, objectMessageSiteCode: String?, logger: Logger) -> ApplicableOperation? { - // RTLO4a3: Both ObjectMessage.serial and ObjectMessage.siteCode must be non-empty strings - guard let serial = objectMessageSerial, !serial.isEmpty, - let siteCode = objectMessageSiteCode, !siteCode.isEmpty - else { - // RTLO4a3: Otherwise, log a warning that the object operation message has invalid serial values - logger.log("Object operation message has invalid serial values: serial=\(objectMessageSerial ?? "nil"), siteCode=\(objectMessageSiteCode ?? "nil")", level: .warn) - return nil - } - - // RTLO4a4: Get the siteSerial value stored for this LiveObject in the siteTimeserials map using the key ObjectMessage.siteCode - let siteSerial = siteTimeserials[siteCode] - - // RTLO4a5: If the siteSerial for this LiveObject is null or an empty string, return true - guard let siteSerial, !siteSerial.isEmpty else { - return ApplicableOperation(objectMessageSerial: serial, objectMessageSiteCode: siteCode) - } - - // RTLO4a6: If the siteSerial for this LiveObject is not an empty string, return true if ObjectMessage.serial is greater than siteSerial when compared lexicographically - if serial > siteSerial { - return ApplicableOperation(objectMessageSerial: serial, objectMessageSiteCode: siteCode) - } - - return nil - } - - // MARK: - Subscriptions - - internal typealias UpdateLiveObject = @Sendable (_ action: (inout Self) -> Void) -> Void - - @discardableResult - internal mutating func nosync_subscribe(listener: @escaping LiveObjectUpdateCallback, coreSDK: CoreSDK, updateSelfLater: @escaping UpdateLiveObject) throws(ARTErrorInfo) -> any AblyLiveObjects.SubscribeResponse { - // RTLO4b2 - try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed], operationDescription: "subscribe") - - let updateSubscriptionStorage: SubscriptionStorage.UpdateSubscriptionStorage = { action in - updateSelfLater { liveObject in - action(&liveObject.subscriptionStorage) - } - } - - return subscriptionStorage.subscribe( - listener: listener, - eventName: .update, - updateSelfLater: updateSubscriptionStorage, - ) - } - - @discardableResult - internal mutating func on(event: LiveObjectLifecycleEvent, callback: @escaping LiveObjectLifecycleEventCallback, updateSelfLater: @escaping UpdateLiveObject) -> any OnLiveObjectLifecycleEventResponse { - let updateSubscriptionStorage: SubscriptionStorage.UpdateSubscriptionStorage = { action in - updateSelfLater { liveObject in - action(&liveObject.lifecycleEventSubscriptionStorage) - } - } - - let subscription = lifecycleEventSubscriptionStorage.subscribe( - listener: { _, subscriptionInCallback in - let response = LifecycleEventResponse(subscription: subscriptionInCallback) - callback(response) - }, - eventName: event, - updateSelfLater: updateSubscriptionStorage, - ) - - return LifecycleEventResponse(subscription: subscription) - } - - private struct LifecycleEventResponse: OnLiveObjectLifecycleEventResponse { - let subscription: any SubscribeResponse - - func off() { - subscription.unsubscribe() - } - } - - internal mutating func unsubscribeAll() { - subscriptionStorage.unsubscribeAll() - } - - internal mutating func offAll() { - lifecycleEventSubscriptionStorage.unsubscribeAll() - } - - internal func emit(_ update: LiveObjectUpdate, on queue: DispatchQueue) { - switch update { - case .noop: - // RTLO4b4c1 - return - case let .update(update): - // RTLO4b4c2 - subscriptionStorage.emit(update, eventName: .update, on: queue) - } - } - - internal func emitLifecycleEvent(_ event: LiveObjectLifecycleEvent, on queue: DispatchQueue) { - lifecycleEventSubscriptionStorage.emit(eventName: event, on: queue) - } -} diff --git a/Sources/AblyLiveObjects/Internal/LiveObjectUpdate.swift b/Sources/AblyLiveObjects/Internal/LiveObjectUpdate.swift deleted file mode 100644 index e31a0639..00000000 --- a/Sources/AblyLiveObjects/Internal/LiveObjectUpdate.swift +++ /dev/null @@ -1,26 +0,0 @@ -internal enum LiveObjectUpdate: Sendable { - case noop // RTLO4b4 - case update(Update) // RTLO4b4a - - // MARK: - Convenience getters - - /// Returns `true` if and only if this `LiveObjectUpdate` has case `noop`. - internal var isNoop: Bool { - if case .noop = self { - true - } else { - false - } - } - - /// If this `LiveObjectUpdate` has case `update`, returns the associated value. Else, returns `nil`. - internal var update: Update? { - if case let .update(update) = self { - update - } else { - nil - } - } -} - -extension LiveObjectUpdate: Equatable where Update: Equatable {} diff --git a/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift b/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift deleted file mode 100644 index e823ab03..00000000 --- a/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift +++ /dev/null @@ -1,223 +0,0 @@ -internal import _AblyPluginSupportPrivate -import CryptoKit -import Foundation - -/// Helpers for creating a new LiveObject. -/// -/// These generate an object ID and the `ObjectMessage` needed to create the LiveObject. -internal enum ObjectCreationHelpers { - /// The metadata that `createCounter` needs in order to request that Realtime create a LiveCounter and to populate the local objects pool. - internal struct CounterCreationOperation { - /// The generated object ID. Needed for populating the local objects pool. - /// - /// We include this property separately as a non-nil value, instead of expecting the caller to fish the nullable value out of ``objectMessage``. - internal var objectID: String - - /// The operation that should be merged into any created LiveCounter. - /// - /// We include this property separately as a non-nil value, instead of expecting the caller to fish the nullable value out of ``objectMessage``. - internal var operation: ObjectOperation - - /// The ObjectMessage that must be sent in order for Realtime to create the object. - internal var objectMessage: OutboundObjectMessage - } - - /// The metadata that `createMap` needs in order to request that Realtime create a LiveMap and to populate the local objects pool. - internal struct MapCreationOperation { - /// The generated object ID. Needed for populating the local objects pool. - /// - /// We include this property separately as a non-nil value, instead of expecting the caller to fish the nullable value out of ``objectMessage``. - internal var objectID: String - - /// The operation that should be merged into any created LiveMap. - /// - /// We include this property separately as a non-nil value, instead of expecting the caller to fish the nullable value out of ``objectMessage``. - internal var operation: ObjectOperation - - /// The ObjectMessage that must be sent in order for Realtime to create the object. - internal var objectMessage: OutboundObjectMessage - - /// The semantics that should be used for the created LiveMap. - /// - /// We include this property separately as a non-nil value, instead of expecting the caller to fish the nullable value out of ``objectMessage``. - internal var semantics: ObjectsMapSemantics - } - - /// Creates a `COUNTER_CREATE` `ObjectMessage` for the `RealtimeObjects.createCounter` method per RTO12f. - /// - /// - Parameters: - /// - count: The initial count for the new LiveCounter object - /// - timestamp: The timestamp to use for the generated object ID. - internal static func creationOperationForLiveCounter( - count: Double, - timestamp: Date, - ) -> CounterCreationOperation { - // RTO12f2: Create initial value for the new LiveCounter - let initialValue = PartialObjectOperation( - counter: WireObjectsCounter(count: NSNumber(value: count)), - ) - - // RTO12f3: Create an initial value JSON string as described in RTO13 - let initialValueJSONString = createInitialValueJSONString(from: initialValue) - - // RTO12f4: Create a unique nonce as a random string - let nonce = generateNonce() - - // RTO12f5: Get the current server time (using the provided timestamp) - let serverTime = timestamp - - // RTO12f6: Create an objectId for the new LiveCounter object as described in RTO14 - let objectId = createObjectID( - type: "counter", - initialValue: initialValueJSONString, - nonce: nonce, - timestamp: serverTime, - ) - - // RTO12f7-12: Set ObjectMessage.operation fields - let operation = ObjectOperation( - action: .known(.counterCreate), - objectId: objectId, - counter: WireObjectsCounter(count: NSNumber(value: count)), - nonce: nonce, - initialValue: initialValueJSONString, - ) - - // Create the OutboundObjectMessage - let objectMessage = OutboundObjectMessage( - operation: operation, - ) - - return CounterCreationOperation( - objectID: objectId, - operation: operation, - objectMessage: objectMessage, - ) - } - - /// Creates a `MAP_CREATE` `ObjectMessage` for the `RealtimeObjects.createMap` method per RTO11f. - /// - /// - Parameters: - /// - entries: The initial entries for the new LiveMap object - /// - timestamp: The timestamp to use for the generated object ID. - internal static func nosync_creationOperationForLiveMap( - entries: [String: InternalLiveMapValue], - timestamp: Date, - ) -> MapCreationOperation { - // RTO11f4: Create initial value for the new LiveMap - let mapEntries = entries.mapValues { liveMapValue -> ObjectsMapEntry in - ObjectsMapEntry(data: liveMapValue.nosync_toObjectData) - } - - let initialValue = PartialObjectOperation( - map: ObjectsMap( - semantics: .known(.lww), - entries: mapEntries, - ), - ) - - // RTO11f5: Create an initial value JSON string as described in RTO13 - let initialValueJSONString = createInitialValueJSONString(from: initialValue) - - // RTO11f6: Create a unique nonce as a random string - let nonce = generateNonce() - - // RTO11f7: Get the current server time (using the provided timestamp) - let serverTime = timestamp - - // RTO11f8: Create an objectId for the new LiveMap object as described in RTO14 - let objectId = createObjectID( - type: "map", - initialValue: initialValueJSONString, - nonce: nonce, - timestamp: serverTime, - ) - - // RTO11f9-13: Set ObjectMessage.operation fields - let semantics = ObjectsMapSemantics.lww - let operation = ObjectOperation( - action: .known(.mapCreate), - objectId: objectId, - map: ObjectsMap( - semantics: .known(semantics), - entries: mapEntries, - ), - nonce: nonce, - initialValue: initialValueJSONString, - ) - - // Create the OutboundObjectMessage - let objectMessage = OutboundObjectMessage( - operation: operation, - ) - - return MapCreationOperation( - objectID: objectId, - operation: operation, - objectMessage: objectMessage, - semantics: semantics, - ) - } - - // MARK: - Private Helper Methods - - /// Creates an initial value JSON string from a PartialObjectOperation, per RTO13. - private static func createInitialValueJSONString(from initialValue: PartialObjectOperation) -> String { - // RTO13b: Encode the initial value using OM4 encoding - let partialWireObjectOperation = initialValue.toWire(format: .json) - let jsonObject = partialWireObjectOperation.toWireObject.mapValues { wireValue in - do { - return try wireValue.toJSONValue - } catch { - // 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.) - preconditionFailure("Failed to convert WireValue \(wireValue) to JSONValue when encoding initialValue") - } - } - - // RTO13c - return JSONObjectOrArray.object(jsonObject).toJSONString - } - - /// Creates an Object ID for a new LiveObject instance, per RTO14. - internal static func testsOnly_createObjectID( - type: String, - initialValue: String, - nonce: String, - timestamp: Date, - ) -> String { - createObjectID( - type: type, - initialValue: initialValue, - nonce: nonce, - timestamp: timestamp, - ) - } - - /// Creates an Object ID for a new LiveObject instance, per RTO14. - private static func createObjectID( - type: String, - initialValue: String, - nonce: String, - timestamp: Date, - ) -> String { - // RTO14b1: Generate a SHA-256 digest - let hash = SHA256.hash(data: Data("\(initialValue):\(nonce)".utf8)) - - // RTO14b2: Base64URL-encode the generated digest - let base64URLHash = Data(hash).base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - - // RTO14c: Return an Object ID in the format [type]:[hash]@[timestamp] - let timestampMillis = Int(timestamp.timeIntervalSince1970 * 1000) - return "\(type):\(base64URLHash)@\(timestampMillis)" - } - - /// Generates a unique nonce as a random string, per RTO11f6 and RTO12f4. - private static func generateNonce() -> String { - // TODO: confirm if there's any specific rules here: https://github.com/ably/specification/pull/353/files#r2228252389 - let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - return String((0 ..< 16).map { _ in letters.randomElement()! }) - } -} diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift deleted file mode 100644 index addfe833..00000000 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ /dev/null @@ -1,519 +0,0 @@ -internal import _AblyPluginSupportPrivate - -/// Maintains the list of objects present on a channel, as described by RTO3. -/// -/// Note that this is a value type. -internal struct ObjectsPool { - /// The possible `ObjectsPool` entries, as described by RTO3a. - internal enum Entry { - case map(InternalDefaultLiveMap) - case counter(InternalDefaultLiveCounter) - - /// Convenience getter for accessing the map value if this entry is a map - internal var mapValue: InternalDefaultLiveMap? { - switch self { - case let .map(map): - map - case .counter: - nil - } - } - - /// Convenience getter for accessing the counter value if this entry is a counter - internal var counterValue: InternalDefaultLiveCounter? { - switch self { - case .map: - nil - case let .counter(counter): - counter - } - } - - /// Applies an operation to a LiveObject, per RTO9a2a3. - internal func nosync_apply( - _ operation: ObjectOperation, - objectMessageSerial: String?, - objectMessageSiteCode: String?, - objectMessageSerialTimestamp: Date?, - objectsPool: inout ObjectsPool, - ) { - switch self { - case let .map(map): - map.nosync_apply( - operation, - objectMessageSerial: objectMessageSerial, - objectMessageSiteCode: objectMessageSiteCode, - objectMessageSerialTimestamp: objectMessageSerialTimestamp, - objectsPool: &objectsPool, - ) - case let .counter(counter): - counter.nosync_apply( - operation, - objectMessageSerial: objectMessageSerial, - objectMessageSiteCode: objectMessageSiteCode, - objectMessageSerialTimestamp: objectMessageSerialTimestamp, - objectsPool: &objectsPool, - ) - } - } - - /// A LiveObject plus an update that can be emitted on this LiveObject. Can be used to store pending events while applying the `SyncObjectsPool`. - fileprivate enum DeferredUpdate { - case map(InternalDefaultLiveMap, LiveObjectUpdate) - case counter(InternalDefaultLiveCounter, LiveObjectUpdate) - - /// Causes the referenced `LiveObject` to emit the stored event to its subscribers. - internal func nosync_emit() { - switch self { - case let .map(map, update): - map.nosync_emit(update) - case let .counter(counter, update): - counter.nosync_emit(update) - } - } - } - - /// Overrides the internal data for the object as per RTLC6, RTLM6. - /// - /// Returns a ``DeferredUpdate`` which contains the object plus an update that should be emitted on this object once the `SyncObjectsPool` has been applied. - /// - /// - Parameters: - /// - objectMessageSerialTimestamp: The `serialTimestamp` of the containing `ObjectMessage`. Used if we need to tombstone the object. - fileprivate func nosync_replaceData( - using state: ObjectState, - objectMessageSerialTimestamp: Date?, - objectsPool: inout ObjectsPool, - userCallbackQueue: DispatchQueue, - ) -> DeferredUpdate { - switch self { - case let .map(map): - .map( - map, - map.nosync_replaceData( - using: state, - objectMessageSerialTimestamp: objectMessageSerialTimestamp, - objectsPool: &objectsPool, - ), - ) - case let .counter(counter): - .counter( - counter, - counter.nosync_replaceData( - using: state, - objectMessageSerialTimestamp: objectMessageSerialTimestamp, - ), - ) - } - } - - /// Returns the object's RTLO3d `isTombstone` property. - internal var nosync_isTombstone: Bool { - switch self { - case let .counter(counter): - counter.nosync_isTombstone - case let .map(map): - map.nosync_isTombstone - } - } - - internal var nosync_tombstonedAt: Date? { - switch self { - case let .counter(counter): - counter.nosync_tombstonedAt - case let .map(map): - map.nosync_tombstonedAt - } - } - - /// Test-only accessor for isTombstone that handles locking internally. - internal var testsOnly_isTombstone: Bool { - switch self { - case let .counter(counter): - counter.testsOnly_isTombstone - case let .map(map): - map.testsOnly_isTombstone - } - } - - /// Test-only accessor for tombstonedAt that handles locking internally. - internal var testsOnly_tombstonedAt: Date? { - switch self { - case let .counter(counter): - counter.testsOnly_tombstonedAt - case let .map(map): - map.testsOnly_tombstonedAt - } - } - } - - /// Keyed by `objectId`. - /// - /// Per RTO3b, always contains an entry for `ObjectsPool.rootKey`, and this entry is always of type `map`. - internal private(set) var entries: [String: Entry] - - /// The key under which the root object is stored. - internal static let rootKey = "root" - - // MARK: - Initialization - - /// Creates an `ObjectsPool` whose root is a zero-value `LiveMap`. - internal init( - logger: Logger, - internalQueue: DispatchQueue, - userCallbackQueue: DispatchQueue, - clock: SimpleClock, - testsOnly_otherEntries otherEntries: [String: Entry]? = nil, - ) { - self.init( - logger: logger, - internalQueue: internalQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - otherEntries: otherEntries, - ) - } - - private init( - logger: Logger, - internalQueue: DispatchQueue, - userCallbackQueue: DispatchQueue, - clock: SimpleClock, - otherEntries: [String: Entry]? - ) { - entries = otherEntries ?? [:] - // TODO: What initial root entry to use? https://github.com/ably/specification/pull/333/files#r2152312933 - entries[Self.rootKey] = .map( - .createZeroValued( - objectID: Self.rootKey, - logger: logger, - internalQueue: internalQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ), - ) - } - - // MARK: - Typed root - - /// Fetches the root object. - internal var root: InternalDefaultLiveMap { - guard let rootEntry = entries[Self.rootKey] else { - preconditionFailure("ObjectsPool should always contain a root object") - } - - switch rootEntry { - case let .map(map): - return map - case .counter: - preconditionFailure("The ObjectsPool root object must always be a map") - } - } - - // MARK: - Data manipulation - - /// Creates a zero-value object if it does not exist in the pool, per RTO6. This is used when applying a `MAP_SET` operation that contains a reference to another object. - /// - /// - Parameters: - /// - objectID: The ID of the object to create - /// - logger: The logger to use for any created LiveObject - /// - userCallbackQueue: The callback queue to use for any created LiveObject - /// - clock: The clock to use for any created LiveObject - /// - Returns: The existing or newly created object - internal mutating func createZeroValueObject( - forObjectID objectID: String, - logger: Logger, - internalQueue: DispatchQueue, - userCallbackQueue: DispatchQueue, - clock: SimpleClock, - ) -> Entry? { - // RTO6a: If an object with objectId exists in ObjectsPool, do not create a new object - if let existingEntry = entries[objectID] { - return existingEntry - } - - // RTO6b: The expected type of the object can be inferred from the provided objectId - // RTO6b1: Split the objectId (formatted as type:hash@timestamp) on the separator : and parse the first part as the type string - let components = objectID.split(separator: ":") - guard let typeString = components.first else { - return nil - } - - // RTO6b2: If the parsed type is map, create a zero-value LiveMap per RTLM4 in the ObjectsPool - // RTO6b3: If the parsed type is counter, create a zero-value LiveCounter per RTLC4 in the ObjectsPool - let entry: Entry - switch typeString { - case "map": - entry = .map( - .createZeroValued( - objectID: objectID, - logger: logger, - internalQueue: internalQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ), - ) - case "counter": - entry = .counter( - .createZeroValued( - objectID: objectID, - logger: logger, - internalQueue: internalQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ), - ) - default: - return nil - } - - // Note that already know that the key is not "root" per the above check so there's no risk of breaking the RTO3b invariant that the root object is always a map - entries[objectID] = entry - return entry - } - - /// Applies the objects gathered during an `OBJECT_SYNC` to this `ObjectsPool`, per RTO5c1 and RTO5c2. - internal mutating func nosync_applySyncObjectsPool( - _ syncObjectsPool: [SyncObjectsPoolEntry], - logger: Logger, - internalQueue: DispatchQueue, - userCallbackQueue: DispatchQueue, - clock: SimpleClock, - ) { - logger.log("applySyncObjectsPool called with \(syncObjectsPool.count) objects", level: .debug) - - // Keep track of object IDs that were received during sync for RTO5c2 - var receivedObjectIds = Set() - - // Keep track of updates to existing objects during sync for RTO5c1a2 - var updatesToExistingObjects: [ObjectsPool.Entry.DeferredUpdate] = [] - - // RTO5c1: For each ObjectState member in the SyncObjectsPool list - for syncObjectsPoolEntry in syncObjectsPool { - receivedObjectIds.insert(syncObjectsPoolEntry.state.objectId) - - // RTO5c1a: If an object with ObjectState.objectId exists in the internal ObjectsPool - if let existingEntry = entries[syncObjectsPoolEntry.state.objectId] { - logger.log("Updating existing object with ID: \(syncObjectsPoolEntry.state.objectId)", level: .debug) - - // RTO5c1a1: Override the internal data for the object as per RTLC6, RTLM6 - let deferredUpdate = existingEntry.nosync_replaceData( - using: syncObjectsPoolEntry.state, - objectMessageSerialTimestamp: syncObjectsPoolEntry.objectMessageSerialTimestamp, - objectsPool: &self, - userCallbackQueue: userCallbackQueue, - ) - // RTO5c1a2: Store this update to emit at end - updatesToExistingObjects.append(deferredUpdate) - } else { - // RTO5c1b: If an object with ObjectState.objectId does not exist in the internal ObjectsPool - logger.log("Creating new object with ID: \(syncObjectsPoolEntry.state.objectId)", level: .debug) - - // RTO5c1b1: Create a new LiveObject using the data from ObjectState and add it to the internal ObjectsPool: - let newEntry: Entry? - - if syncObjectsPoolEntry.state.counter != nil { - // RTO5c1b1a: If ObjectState.counter is present, create a zero-value LiveCounter, - // set its private objectId equal to ObjectState.objectId and override its internal data per RTLC6 - let counter = InternalDefaultLiveCounter.createZeroValued( - objectID: syncObjectsPoolEntry.state.objectId, - logger: logger, - internalQueue: internalQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ) - _ = counter.nosync_replaceData( - using: syncObjectsPoolEntry.state, - objectMessageSerialTimestamp: syncObjectsPoolEntry.objectMessageSerialTimestamp, - ) - newEntry = .counter(counter) - } else if let objectsMap = syncObjectsPoolEntry.state.map { - // RTO5c1b1b: If ObjectState.map is present, create a zero-value LiveMap, - // set its private objectId equal to ObjectState.objectId, set its private semantics - // equal to ObjectState.map.semantics and override its internal data per RTLM6 - let map = InternalDefaultLiveMap.createZeroValued( - objectID: syncObjectsPoolEntry.state.objectId, - semantics: objectsMap.semantics, - logger: logger, - internalQueue: internalQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ) - _ = map.nosync_replaceData( - using: syncObjectsPoolEntry.state, - objectMessageSerialTimestamp: syncObjectsPoolEntry.objectMessageSerialTimestamp, - objectsPool: &self, - ) - newEntry = .map(map) - } else { - // RTO5c1b1c: Otherwise, log a warning that an unsupported object state message has been received, and discard the current ObjectState without taking any action - logger.log("Unsupported object state message received for objectId: \(syncObjectsPoolEntry.state.objectId)", level: .warn) - newEntry = nil - } - - if let newEntry { - // Note that we will never replace the root object here, and thus never break the RTO3b invariant that the root object is always a map. This is because the pool always contains a root object and thus we always go through the RTO5c1a branch of the `if` above. - entries[syncObjectsPoolEntry.state.objectId] = newEntry - } - } - } - - // RTO5c2: Remove any objects from the internal ObjectsPool for which objectIds were not received during the sync sequence - // RTO5c2a: The object with ID "root" must not be removed from ObjectsPool, as per RTO3b - let objectIdsToRemove = Set(entries.keys).subtracting(receivedObjectIds + [Self.rootKey]) - if !objectIdsToRemove.isEmpty { - logger.log("Removing objects with IDs: \(objectIdsToRemove) as they were not in sync", level: .debug) - for objectId in objectIdsToRemove { - entries.removeValue(forKey: objectId) - } - } - - // RTO5c7: Emit the updates to existing objects - for deferredUpdate in updatesToExistingObjects { - deferredUpdate.nosync_emit() - } - - logger.log("applySyncObjectsPool completed. Pool now contains \(entries.count) objects", level: .debug) - } - - /// Gets or creates a counter object in the pool, implementing the "find or create zero-value" behavior of RTO12h1. - /// - /// - Parameters: - /// - creationOperation: The CounterCreationOperation containing the object ID and operation to merge - /// - logger: The logger to use for any created LiveObject - /// - internalQueue: The internal queue to use for any created LiveObject - /// - userCallbackQueue: The callback queue to use for any created LiveObject - /// - clock: The clock to use for any created LiveObject - /// - Returns: The existing or newly created counter object - internal mutating func nosync_getOrCreateCounter( - creationOperation: ObjectCreationHelpers.CounterCreationOperation, - logger: Logger, - internalQueue: DispatchQueue, - userCallbackQueue: DispatchQueue, - clock: SimpleClock, - ) -> InternalDefaultLiveCounter { - // RTO12h2: If an object with the ObjectMessage.operation.objectId exists in the internal ObjectsPool, return it - if let existingEntry = entries[creationOperation.objectID] { - switch existingEntry { - case let .counter(counter): - return counter - case .map: - // TODO: Add the ability to statically reason about the type of pool entries in https://github.com/ably/ably-liveobjects-swift-plugin/issues/36 - preconditionFailure("Expected counter object with ID \(creationOperation.objectID) but found map object") - } - } - - // RTO12h3: Otherwise, if the object does not exist in the internal ObjectsPool: - // RTO12h3a: Create a zero-value LiveCounter, set its objectId to ObjectMessage.operation.objectId, and merge the initial value - let counter = InternalDefaultLiveCounter.createZeroValued( - objectID: creationOperation.objectID, - logger: logger, - internalQueue: internalQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ) - - // Merge the initial value from the creation operation - _ = counter.nosync_mergeInitialValue(from: creationOperation.operation) - - // RTO12h3b: Add the created LiveCounter instance to the internal ObjectsPool - entries[creationOperation.objectID] = .counter(counter) - - // RTO12h3c: Return the created LiveCounter instance - return counter - } - - /// Gets or creates a map object in the pool, implementing the "find or create zero-value" behavior of RTO11h1. - /// - /// - Parameters: - /// - creationOperation: The MapCreationOperation containing the object ID and operation to merge - /// - logger: The logger to use for any created LiveObject - /// - internalQueue: The internal queue to use for any created LiveObject - /// - userCallbackQueue: The callback queue to use for any created LiveObject - /// - clock: The clock to use for any created LiveObject - /// - Returns: The existing or newly created map object - internal mutating func nosync_getOrCreateMap( - creationOperation: ObjectCreationHelpers.MapCreationOperation, - logger: Logger, - internalQueue: DispatchQueue, - userCallbackQueue: DispatchQueue, - clock: SimpleClock, - ) -> InternalDefaultLiveMap { - // RTO11h2: If an object with the ObjectMessage.operation.objectId exists in the internal ObjectsPool, return it - if let existingEntry = entries[creationOperation.objectID] { - switch existingEntry { - case let .map(map): - return map - case .counter: - // TODO: Add the ability to statically reason about the type of pool entries in https://github.com/ably/ably-liveobjects-swift-plugin/issues/36 - preconditionFailure("Expected map object with ID \(creationOperation.objectID) but found counter object") - } - } - - // RTO11h3: Otherwise, if the object does not exist in the internal ObjectsPool: - // RTO11h3a: Create a zero-value LiveMap, set its objectId to ObjectMessage.operation.objectId, set its semantics to ObjectMessage.operation.map.semantics, and merge the initial value - let map = InternalDefaultLiveMap.createZeroValued( - objectID: creationOperation.objectID, - semantics: .known(creationOperation.semantics), - logger: logger, - internalQueue: internalQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ) - - // Merge the initial value from the creation operation - _ = map.nosync_mergeInitialValue(from: creationOperation.operation, objectsPool: &self) - - // RTO11h3b: Add the created LiveMap instance to the internal ObjectsPool - entries[creationOperation.objectID] = .map(map) - - // RTO11h3c: Return the created LiveMap instance - return map - } - - /// Removes all entries except the root, and clears the root's data. This is to be used when an `ATTACHED` ProtocolMessage indicates that the only object in a channel is an empty root map, per RTO4b. - internal mutating func nosync_reset() { - let root = root - - // RTO4b1 - entries = [Self.rootKey: .map(root)] - - // RTO4b2 - // TODO: this one is unclear (are we meant to replace the root or just clear its data?) https://github.com/ably/specification/pull/333/files#r2183493458. I believe that the answer is that we should just clear its data but the spec point needs to be clearer, see https://github.com/ably/specification/pull/346/files#r2201434895. - root.nosync_resetData() - } - - /// Performs garbage collection of tombstoned objects and map entries, per RTO10c. - internal mutating func nosync_performGarbageCollection( - gracePeriod: TimeInterval, - clock: SimpleClock, - logger: Logger, - eventsContinuation: AsyncStream.Continuation, - ) { - logger.log("Performing garbage collection, grace period \(gracePeriod)s", level: .debug) - - let now = clock.now - - entries = entries.filter { key, entry in - if case let .map(map) = entry { - // RTO10c1a - map.nosync_releaseTombstonedEntries(gracePeriod: gracePeriod, clock: clock) - } - - // RTO10c1b - let shouldRelease = { - guard let tombstonedAt = entry.nosync_tombstonedAt else { - return false - } - - return now.timeIntervalSince(tombstonedAt) >= gracePeriod - }() - - if shouldRelease { - logger.log("Releasing tombstoned entry \(entry) for key \(key)", level: .debug) - } - return !shouldRelease - } - - eventsContinuation.yield() - } -} diff --git a/Sources/AblyLiveObjects/Internal/ObjectsSyncState.swift b/Sources/AblyLiveObjects/Internal/ObjectsSyncState.swift deleted file mode 100644 index 0a95d952..00000000 --- a/Sources/AblyLiveObjects/Internal/ObjectsSyncState.swift +++ /dev/null @@ -1,18 +0,0 @@ -/// The type that the spec uses to represent the client's state of syncing its local Objects data with the server, per RTO17a. -internal enum ObjectsSyncState { - case initialized - case syncing - case synced - - /// The event to emit when transitioning to this state, per RTO17b. - internal var toEvent: ObjectsEvent? { - switch self { - case .initialized: - nil - case .syncing: - .syncing - case .synced: - .synced - } - } -} diff --git a/Sources/AblyLiveObjects/Internal/SimpleClock.swift b/Sources/AblyLiveObjects/Internal/SimpleClock.swift deleted file mode 100644 index bc6c0faa..00000000 --- a/Sources/AblyLiveObjects/Internal/SimpleClock.swift +++ /dev/null @@ -1,19 +0,0 @@ -import Foundation - -/// A simple clock interface for getting the current time. -/// -/// This protocol allows for dependency injection of time-related functionality, -/// making it easier to test time-dependent code. -internal protocol SimpleClock: Sendable { - /// Returns the current time as a Date. - var now: Date { get } -} - -/// The default implementation of SimpleClock that uses the system clock. -internal final class DefaultSimpleClock: SimpleClock { - internal init() {} - - internal var now: Date { - Date() - } -} diff --git a/Sources/AblyLiveObjects/Internal/SubscriptionStorage.swift b/Sources/AblyLiveObjects/Internal/SubscriptionStorage.swift deleted file mode 100644 index e03ac2a2..00000000 --- a/Sources/AblyLiveObjects/Internal/SubscriptionStorage.swift +++ /dev/null @@ -1,91 +0,0 @@ -import Foundation - -/// Handles subscription bookkeeping, providing methods for subscribing and emitting events. -internal struct SubscriptionStorage { - /// Internal bookkeeping for subscriptions, organized by event name. - /// Each event name maps to a dictionary of subscriptions keyed by their ID for O(1) operations. - private var subscriptionsByEventName: [EventName: [Subscription.ID: Subscription]] = [:] - - // MARK: - Subscriptions - - private struct Subscription: Identifiable { - var id = UUID() - var listener: LiveObjectUpdateCallback - var updateSubscriptionStorage: UpdateSubscriptionStorage - } - - /// A function that allows a `SubscriptionStorage` to later perform mutations to an externally-held copy of itself. This is used to allow a `SubscribeResponse` to unsubscribe. - /// - /// Accepts an action, which, if called, should be called with an `inout` reference to the externally-held copy. The function is not required to call this action (for example, if the function holds a weak reference which is now `nil`). - /// - /// Note that the `SubscriptionStorage` will store a copy of this function and thus this function should be careful not to introduce a strong reference cycle. - internal typealias UpdateSubscriptionStorage = @Sendable (_ action: (inout Self) -> Void) -> Void - - private struct SubscribeResponse: AblyLiveObjects.SubscribeResponse { - var subscriptionID: Subscription.ID - var eventName: EventName - var updateSubscriptionStorage: UpdateSubscriptionStorage - - func unsubscribe() { - updateSubscriptionStorage { subscriptionStorage in - subscriptionStorage.unsubscribe(subscriptionID: subscriptionID, eventName: eventName) - } - } - } - - @discardableResult - internal mutating func subscribe( - listener: @escaping LiveObjectUpdateCallback, - eventName: EventName, - updateSelfLater: @escaping UpdateSubscriptionStorage, - ) -> any AblyLiveObjects.SubscribeResponse { - let subscription = Subscription(listener: listener, updateSubscriptionStorage: updateSelfLater) - - // Initialize the dictionary for this event name if it doesn't exist - if subscriptionsByEventName[eventName] == nil { - subscriptionsByEventName[eventName] = [:] - } - - // Add the subscription to the appropriate event name dictionary - subscriptionsByEventName[eventName]?[subscription.id] = subscription - - return SubscribeResponse(subscriptionID: subscription.id, eventName: eventName, updateSubscriptionStorage: updateSelfLater) - } - - internal mutating func unsubscribeAll() { - subscriptionsByEventName.removeAll() - } - - private mutating func unsubscribe(subscriptionID: Subscription.ID, eventName: EventName) { - // O(1) removal using dictionary key - subscriptionsByEventName[eventName]?.removeValue(forKey: subscriptionID) - - // Clean up empty event name dictionaries - if subscriptionsByEventName[eventName]?.isEmpty == true { - subscriptionsByEventName.removeValue(forKey: eventName) - } - } - - internal func emit(_ update: Update, eventName: EventName, on queue: DispatchQueue) { - // Only emit to subscribers who subscribed to this specific event name - guard let subscriptions = subscriptionsByEventName[eventName] else { - return - } - - for subscription in subscriptions.values { - queue.async { - let response = SubscribeResponse(subscriptionID: subscription.id, eventName: eventName, updateSubscriptionStorage: subscription.updateSubscriptionStorage) - subscription.listener(update, response) - } - } - } -} - -// MARK: - Convenience extension for Void updates - -internal extension SubscriptionStorage where Update == Void { - /// Convenience method for emitting events when there's no update data to pass. - func emit(eventName: EventName, on queue: DispatchQueue) { - emit((), eventName: eventName, on: queue) - } -} diff --git a/Sources/AblyLiveObjects/Internal/SyncObjectsPoolEntry.swift b/Sources/AblyLiveObjects/Internal/SyncObjectsPoolEntry.swift deleted file mode 100644 index 3a702137..00000000 --- a/Sources/AblyLiveObjects/Internal/SyncObjectsPoolEntry.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Foundation - -/// The contents of the spec's `SyncObjectsPool` that is built during an `OBJECT_SYNC` sync sequence. -internal struct SyncObjectsPoolEntry { - internal var state: ObjectState - /// The `serialTimestamp` of the `ObjectMessage` that generated this entry. - internal var objectMessageSerialTimestamp: Date? - - // We replace the default memberwise initializer because we don't want a default argument for objectMessageSerialTimestamp (want to make sure we don't forget to set it whenever we create an entry). - // swiftlint:disable:next unneeded_synthesized_initializer - internal init(state: ObjectState, objectMessageSerialTimestamp: Date?) { - self.state = state - self.objectMessageSerialTimestamp = objectMessageSerialTimestamp - } -} diff --git a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift deleted file mode 100644 index f1411894..00000000 --- a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift +++ /dev/null @@ -1,604 +0,0 @@ -internal import _AblyPluginSupportPrivate -import Ably -import Foundation - -// This file contains the ObjectMessage types that we use within the codebase. We convert them to and from the corresponding wire types (e.g. `InboundWireObjectMessage`) for sending and receiving over the wire. - -/// An `ObjectMessage` received in the `state` property of an `OBJECT` or `OBJECT_SYNC` `ProtocolMessage`. -internal struct InboundObjectMessage { - internal var id: String? // OM2a - internal var clientId: String? // OM2b - internal var connectionId: String? // OM2c - internal var extras: [String: JSONValue]? // OM2d - internal var timestamp: Date? // OM2e - internal var operation: ObjectOperation? // OM2f - internal var object: ObjectState? // OM2g - internal var serial: String? // OM2h - internal var siteCode: String? // OM2i - internal var serialTimestamp: Date? // OM2j -} - -/// An `ObjectMessage` to be sent in the `state` property of an `OBJECT` `ProtocolMessage`. -internal struct OutboundObjectMessage: Equatable { - internal var id: String? // OM2a - internal var clientId: String? // OM2b - internal var connectionId: String? - internal var extras: [String: JSONValue]? // OM2d - internal var timestamp: Date? // OM2e - internal var operation: ObjectOperation? // OM2f - internal var object: ObjectState? // OM2g - internal var serial: String? // OM2h - internal var siteCode: String? // OM2i - internal var serialTimestamp: Date? // OM2j -} - -/// A partial version of `ObjectOperation` that excludes the `action` and `objectId` property. Used for encoding initial values which don't include the `action` and where the `objectId` is not yet known. -/// -/// `ObjectOperation` delegates its encoding and decoding to `PartialObjectOperation`. -internal struct PartialObjectOperation { - internal var mapOp: ObjectsMapOp? // OOP3c - internal var counterOp: WireObjectsCounterOp? // OOP3d - internal var map: ObjectsMap? // OOP3e - internal var counter: WireObjectsCounter? // OOP3f - internal var nonce: String? // OOP3g - internal var initialValue: String? // OOP3h -} - -internal struct ObjectOperation: Equatable { - internal var action: WireEnum // OOP3a - internal var objectId: String // OOP3b - internal var mapOp: ObjectsMapOp? // OOP3c - internal var counterOp: WireObjectsCounterOp? // OOP3d - internal var map: ObjectsMap? // OOP3e - internal var counter: WireObjectsCounter? // OOP3f - internal var nonce: String? // OOP3g - internal var initialValue: String? // OOP3h -} - -internal struct ObjectData: Equatable { - internal var objectId: String? // OD2a - internal var boolean: Bool? // OD2c - internal var bytes: Data? // OD2d - internal var number: NSNumber? // OD2e - internal var string: String? // OD2f - internal var json: JSONObjectOrArray? // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) -} - -internal struct ObjectsMapOp: Equatable { - internal var key: String // OMO2a - internal var data: ObjectData? // OMO2b -} - -internal struct ObjectsMapEntry: Equatable { - internal var tombstone: Bool? // OME2a - internal var timeserial: String? // OME2b - internal var data: ObjectData? // OME2c - internal var serialTimestamp: Date? // OME2d -} - -internal struct ObjectsMap: Equatable { - internal var semantics: WireEnum // OMP3a - internal var entries: [String: ObjectsMapEntry]? // OMP3b -} - -internal struct ObjectState: Equatable { - internal var objectId: String // OST2a - internal var siteTimeserials: [String: String] // OST2b - internal var tombstone: Bool // OST2c - internal var createOp: ObjectOperation? // OST2d - internal var map: ObjectsMap? // OST2e - internal var counter: WireObjectsCounter? // OST2f -} - -internal extension InboundObjectMessage { - /// Initializes an `InboundObjectMessage` from an `InboundWireObjectMessage`, applying the data decoding rules of OD5. - /// - /// - Parameters: - /// - format: The format to use when applying the decoding rules of OD5. - /// - Throws: `ARTErrorInfo` if JSON or Base64 decoding fails. - init( - wireObjectMessage: InboundWireObjectMessage, - format: _AblyPluginSupportPrivate.EncodingFormat - ) throws(ARTErrorInfo) { - id = wireObjectMessage.id - clientId = wireObjectMessage.clientId - connectionId = wireObjectMessage.connectionId - extras = wireObjectMessage.extras - timestamp = wireObjectMessage.timestamp - operation = try wireObjectMessage.operation.map { wireObjectOperation throws(ARTErrorInfo) in - try .init(wireObjectOperation: wireObjectOperation, format: format) - } - object = try wireObjectMessage.object.map { wireObjectState throws(ARTErrorInfo) in - try .init(wireObjectState: wireObjectState, format: format) - } - serial = wireObjectMessage.serial - siteCode = wireObjectMessage.siteCode - serialTimestamp = wireObjectMessage.serialTimestamp - } -} - -internal extension OutboundObjectMessage { - /// Converts this `OutboundObjectMessage` to an `OutboundWireObjectMessage`, applying the data encoding rules of OD4. - /// - /// - Parameters: - /// - format: The format to use when applying the encoding rules of OD4. - func toWire(format: _AblyPluginSupportPrivate.EncodingFormat) -> OutboundWireObjectMessage { - .init( - id: id, - clientId: clientId, - connectionId: connectionId, - extras: extras, - timestamp: timestamp, - operation: operation?.toWire(format: format), - object: object?.toWire(format: format), - serial: serial, - siteCode: siteCode, - serialTimestamp: serialTimestamp, - ) - } -} - -internal extension ObjectOperation { - /// Initializes an `ObjectOperation` from a `WireObjectOperation`, applying the data decoding rules of OD5. - /// - /// - Parameters: - /// - format: The format to use when applying the decoding rules of OD5. - /// - Throws: `ARTErrorInfo` if JSON or Base64 decoding fails. - init( - wireObjectOperation: WireObjectOperation, - format: _AblyPluginSupportPrivate.EncodingFormat - ) throws(ARTErrorInfo) { - // Decode the action and objectId first they're not part of PartialObjectOperation - action = wireObjectOperation.action - objectId = wireObjectOperation.objectId - - // Delegate to PartialObjectOperation for decoding - let partialOperation = try PartialObjectOperation( - partialWireObjectOperation: PartialWireObjectOperation( - mapOp: wireObjectOperation.mapOp, - counterOp: wireObjectOperation.counterOp, - map: wireObjectOperation.map, - counter: wireObjectOperation.counter, - nonce: wireObjectOperation.nonce, - initialValue: wireObjectOperation.initialValue, - ), - format: format, - ) - - // Copy the decoded values - mapOp = partialOperation.mapOp - counterOp = partialOperation.counterOp - map = partialOperation.map - counter = partialOperation.counter - nonce = partialOperation.nonce - initialValue = partialOperation.initialValue - } - - /// Converts this `ObjectOperation` to a `WireObjectOperation`, applying the data encoding rules of OD4. - /// - /// - Parameters: - /// - format: The format to use when applying the encoding rules of OD4. - func toWire(format: _AblyPluginSupportPrivate.EncodingFormat) -> WireObjectOperation { - let partialWireOperation = PartialObjectOperation( - mapOp: mapOp, - counterOp: counterOp, - map: map, - counter: counter, - nonce: nonce, - initialValue: initialValue, - ).toWire(format: format) - - // Create WireObjectOperation from PartialWireObjectOperation and add action and objectId - return WireObjectOperation( - action: action, - objectId: objectId, - mapOp: partialWireOperation.mapOp, - counterOp: partialWireOperation.counterOp, - map: partialWireOperation.map, - counter: partialWireOperation.counter, - nonce: partialWireOperation.nonce, - initialValue: partialWireOperation.initialValue, - ) - } -} - -internal extension PartialObjectOperation { - /// Initializes a `PartialObjectOperation` from a `PartialWireObjectOperation`, applying the data decoding rules of OD5. - /// - /// - Parameters: - /// - format: The format to use when applying the decoding rules of OD5. - /// - Throws: `ARTErrorInfo` if JSON or Base64 decoding fails. - init( - partialWireObjectOperation: PartialWireObjectOperation, - format: _AblyPluginSupportPrivate.EncodingFormat - ) throws(ARTErrorInfo) { - mapOp = try partialWireObjectOperation.mapOp.map { wireObjectsMapOp throws(ARTErrorInfo) in - try .init(wireObjectsMapOp: wireObjectsMapOp, format: format) - } - counterOp = partialWireObjectOperation.counterOp - map = try partialWireObjectOperation.map.map { wireMap throws(ARTErrorInfo) in - try .init(wireObjectsMap: wireMap, format: format) - } - counter = partialWireObjectOperation.counter - - // Do not access on inbound data, per OOP3g - nonce = nil - // Do not access on inbound data, per OOP3h - initialValue = nil - } - - /// Converts this `PartialObjectOperation` to a `PartialWireObjectOperation`, applying the data encoding rules of OD4. - /// - /// - Parameters: - /// - format: The format to use when applying the encoding rules of OD4. - func toWire(format: _AblyPluginSupportPrivate.EncodingFormat) -> PartialWireObjectOperation { - .init( - mapOp: mapOp?.toWire(format: format), - counterOp: counterOp, - map: map?.toWire(format: format), - counter: counter, - nonce: nonce, - initialValue: initialValue, - ) - } -} - -internal extension ObjectData { - /// Initializes an `ObjectData` from a `WireObjectData`, applying the data decoding rules of OD5. - /// - /// - Parameters: - /// - format: The format to use when applying the decoding rules of OD5. - /// - Throws: `ARTErrorInfo` if JSON or Base64 decoding fails. - init( - wireObjectData: WireObjectData, - format: _AblyPluginSupportPrivate.EncodingFormat - ) throws(ARTErrorInfo) { - objectId = wireObjectData.objectId - boolean = wireObjectData.boolean - number = wireObjectData.number - string = wireObjectData.string - - // OD5: Decode data based on format - switch format { - case .messagePack: - // OD5a: When the MessagePack protocol is used - // OD5a1: The payloads in (…) ObjectData.bytes (…) are decoded as their corresponding MessagePack types - if let wireBytes = wireObjectData.bytes { - switch wireBytes { - case let .data(data): - bytes = data - case .string: - // Not very clear what we're meant to do if `bytes` contains a string; let's ignore it. I think it's a bit moot - shouldn't happen. The only reason I'm considering it here is because of our slightly weird WireObjectData.bytes type which is typed as a string or data; might be good to at some point figure out how to rule out the string case earlier when using MessagePack, but it's not a big issue - bytes = nil - } - } else { - bytes = nil - } - case .json: - // OD5b: When the JSON protocol is used - // OD5b2: The ObjectData.bytes payload is Base64-decoded into a binary value - if let wireBytes = wireObjectData.bytes { - switch wireBytes { - case let .string(base64String): - bytes = try Data.fromBase64Throwing(base64String) - case .data: - // This is an error in our logic, not a malformed wire value - preconditionFailure("Should not receive Data for JSON encoding format") - } - } else { - bytes = nil - } - } - - // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) - if let wireJson = wireObjectData.json { - let jsonValue = try JSONObjectOrArray(jsonString: wireJson) - json = jsonValue - } else { - json = nil - } - } - - /// Converts this `ObjectData` to a `WireObjectData`, applying the data encoding rules of OD4. - /// - /// - Parameters: - /// - format: The format to use when applying the encoding rules of OD4. - func toWire(format: _AblyPluginSupportPrivate.EncodingFormat) -> WireObjectData { - // OD4: Encode data based on format - let wireBytes: StringOrData? = if let bytes { - switch format { - case .messagePack: - // OD4c: When the MessagePack protocol is used - // OD4c2: A binary payload is encoded as a MessagePack binary type, and the result is set on the ObjectData.bytes attribute - .data(bytes) - case .json: - // OD4d: When the JSON protocol is used - // OD4d2: A binary payload is Base64-encoded and represented as a JSON string; the result is set on the ObjectData.bytes attribute - .string(bytes.base64EncodedString()) - } - } else { - nil - } - - let wireNumber: NSNumber? = if let number { - switch format { - case .json: - number - case .messagePack: - // OD4c: When the MessagePack protocol is used - // OD4c3 A number payload is encoded as a MessagePack float64 type, and the result is set on the ObjectData.number attribute - .init(value: number.doubleValue) - } - } else { - nil - } - - return .init( - objectId: objectId, - boolean: boolean, - bytes: wireBytes, - number: wireNumber, - // OD4c4: A string payload is encoded as a MessagePack string type, and the result is set on the ObjectData.string attribute - // OD4d4: A string payload is represented as a JSON string and set on the ObjectData.string attribute - string: string, - // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) - json: json?.toJSONString, - ) - } -} - -internal extension ObjectsMapOp { - /// Initializes a `ObjectsMapOp` from a `WireObjectsMapOp`, applying the data decoding rules of OD5. - /// - /// - Parameters: - /// - format: The format to use when applying the decoding rules of OD5. - /// - Throws: `ARTErrorInfo` if JSON or Base64 decoding fails. - init( - wireObjectsMapOp: WireObjectsMapOp, - format: _AblyPluginSupportPrivate.EncodingFormat - ) throws(ARTErrorInfo) { - key = wireObjectsMapOp.key - data = try wireObjectsMapOp.data.map { wireObjectData throws(ARTErrorInfo) in - try .init(wireObjectData: wireObjectData, format: format) - } - } - - /// Converts this `ObjectsMapOp` to a `WireObjectsMapOp`, applying the data encoding rules of OD4. - /// - /// - Parameters: - /// - format: The format to use when applying the encoding rules of OD4. - func toWire(format: _AblyPluginSupportPrivate.EncodingFormat) -> WireObjectsMapOp { - .init( - key: key, - data: data?.toWire(format: format), - ) - } -} - -internal extension ObjectsMapEntry { - /// Initializes an `ObjectsMapEntry` from a `WireObjectsMapEntry`, applying the data decoding rules of OD5. - /// - /// - Parameters: - /// - format: The format to use when applying the decoding rules of OD5. - /// - Throws: `ARTErrorInfo` if JSON or Base64 decoding fails. - init( - wireObjectsMapEntry: WireObjectsMapEntry, - format: _AblyPluginSupportPrivate.EncodingFormat - ) throws(ARTErrorInfo) { - tombstone = wireObjectsMapEntry.tombstone - timeserial = wireObjectsMapEntry.timeserial - data = if let wireObjectData = wireObjectsMapEntry.data { - try .init(wireObjectData: wireObjectData, format: format) - } else { - nil - } - serialTimestamp = wireObjectsMapEntry.serialTimestamp - } - - /// Converts this `ObjectsMapEntry` to a `WireObjectsMapEntry`, applying the data encoding rules of OD4. - /// - /// - Parameters: - /// - format: The format to use when applying the encoding rules of OD4. - func toWire(format: _AblyPluginSupportPrivate.EncodingFormat) -> WireObjectsMapEntry { - .init( - tombstone: tombstone, - timeserial: timeserial, - data: data?.toWire(format: format), - ) - } -} - -internal extension ObjectsMap { - /// Initializes an `ObjectsMap` from a `WireObjectsMap`, applying the data decoding rules of OD5. - /// - /// - Parameters: - /// - format: The format to use when applying the decoding rules of OD5. - /// - Throws: `ARTErrorInfo` if JSON or Base64 decoding fails. - init( - wireObjectsMap: WireObjectsMap, - format: _AblyPluginSupportPrivate.EncodingFormat - ) throws(ARTErrorInfo) { - semantics = wireObjectsMap.semantics - entries = try wireObjectsMap.entries?.ablyLiveObjects_mapValuesWithTypedThrow { wireMapEntry throws(ARTErrorInfo) in - try .init(wireObjectsMapEntry: wireMapEntry, format: format) - } - } - - /// Converts this `ObjectsMap` to a `WireObjectsMap`, applying the data encoding rules of OD4. - /// - /// - Parameters: - /// - format: The format to use when applying the encoding rules of OD4. - func toWire(format: _AblyPluginSupportPrivate.EncodingFormat) -> WireObjectsMap { - .init( - semantics: semantics, - entries: entries?.mapValues { $0.toWire(format: format) }, - ) - } -} - -internal extension ObjectState { - /// Initializes an `ObjectState` from a `WireObjectState`, applying the data decoding rules of OD5. - /// - /// - Parameters: - /// - format: The format to use when applying the decoding rules of OD5. - /// - Throws: `ARTErrorInfo` if JSON or Base64 decoding fails. - init( - wireObjectState: WireObjectState, - format: _AblyPluginSupportPrivate.EncodingFormat - ) throws(ARTErrorInfo) { - objectId = wireObjectState.objectId - siteTimeserials = wireObjectState.siteTimeserials - tombstone = wireObjectState.tombstone - createOp = try wireObjectState.createOp.map { wireObjectOperation throws(ARTErrorInfo) in - try .init(wireObjectOperation: wireObjectOperation, format: format) - } - map = try wireObjectState.map.map { wireObjectsMap throws(ARTErrorInfo) in - try .init(wireObjectsMap: wireObjectsMap, format: format) - } - counter = wireObjectState.counter - } - - /// Converts this `ObjectState` to a `WireObjectState`, applying the data encoding rules of OD4. - /// - /// - Parameters: - /// - format: The format to use when applying the encoding rules of OD4. - func toWire(format: _AblyPluginSupportPrivate.EncodingFormat) -> WireObjectState { - .init( - objectId: objectId, - siteTimeserials: siteTimeserials, - tombstone: tombstone, - createOp: createOp?.toWire(format: format), - map: map?.toWire(format: format), - counter: counter, - ) - } -} - -// MARK: - CustomDebugStringConvertible - -extension InboundObjectMessage: CustomDebugStringConvertible { - internal var debugDescription: String { - var parts: [String] = [] - - if let id { parts.append("id: \(id)") } - if let clientId { parts.append("clientId: \(clientId)") } - if let connectionId { parts.append("connectionId: \(connectionId)") } - if let extras { parts.append("extras: \(extras)") } - if let timestamp { parts.append("timestamp: \(timestamp)") } - if let operation { parts.append("operation: \(operation)") } - if let object { parts.append("object: \(object)") } - if let serial { parts.append("serial: \(serial)") } - if let siteCode { parts.append("siteCode: \(siteCode)") } - if let serialTimestamp { parts.append("serialTimestamp: \(serialTimestamp)") } - - return "{ " + parts.joined(separator: ", ") + " }" - } -} - -extension OutboundObjectMessage: CustomDebugStringConvertible { - internal var debugDescription: String { - var parts: [String] = [] - - if let id { parts.append("id: \(id)") } - if let clientId { parts.append("clientId: \(clientId)") } - if let connectionId { parts.append("connectionId: \(connectionId)") } - if let extras { parts.append("extras: \(extras)") } - if let timestamp { parts.append("timestamp: \(timestamp)") } - if let operation { parts.append("operation: \(operation)") } - if let object { parts.append("object: \(object)") } - if let serial { parts.append("serial: \(serial)") } - if let siteCode { parts.append("siteCode: \(siteCode)") } - if let serialTimestamp { parts.append("serialTimestamp: \(serialTimestamp)") } - - return "{ " + parts.joined(separator: ", ") + " }" - } -} - -extension ObjectOperation: CustomDebugStringConvertible { - internal var debugDescription: String { - var parts: [String] = [] - - parts.append("action: \(action)") - parts.append("objectId: \(objectId)") - if let mapOp { parts.append("mapOp: \(mapOp)") } - if let counterOp { parts.append("counterOp: \(counterOp)") } - if let map { parts.append("map: \(map)") } - if let counter { parts.append("counter: \(counter)") } - if let nonce { parts.append("nonce: \(nonce)") } - if let initialValue { parts.append("initialValue: \(initialValue)") } - - return "{ " + parts.joined(separator: ", ") + " }" - } -} - -extension ObjectState: CustomDebugStringConvertible { - internal var debugDescription: String { - var parts: [String] = [] - - parts.append("objectId: \(objectId)") - parts.append("siteTimeserials: \(siteTimeserials)") - parts.append("tombstone: \(tombstone)") - if let createOp { parts.append("createOp: \(createOp)") } - if let map { parts.append("map: \(map)") } - if let counter { parts.append("counter: \(counter)") } - - return "{ " + parts.joined(separator: ", ") + " }" - } -} - -extension ObjectsMapOp: CustomDebugStringConvertible { - internal var debugDescription: String { - var parts: [String] = [] - - parts.append("key: \(key)") - if let data { parts.append("data: \(data)") } - - return "{ " + parts.joined(separator: ", ") + " }" - } -} - -extension ObjectsMap: CustomDebugStringConvertible { - internal var debugDescription: String { - var parts: [String] = [] - - parts.append("semantics: \(semantics)") - if let entries { - let formattedEntries = entries - .map { key, entry in - "\(key): \(entry)" - } - .joined(separator: ", ") - parts.append("entries: { \(formattedEntries) }") - } - - return "{ " + parts.joined(separator: ", ") + " }" - } -} - -extension ObjectsMapEntry: CustomDebugStringConvertible { - internal var debugDescription: String { - var parts: [String] = [] - - if let tombstone { parts.append("tombstone: \(tombstone)") } - if let timeserial { parts.append("timeserial: \(timeserial)") } - if let data { parts.append("data: \(data)") } - if let serialTimestamp { parts.append("serialTimestamp: \(serialTimestamp)") } - - return "{ " + parts.joined(separator: ", ") + " }" - } -} - -extension ObjectData: CustomDebugStringConvertible { - internal var debugDescription: String { - var parts: [String] = [] - - if let objectId { parts.append("objectId: \(objectId)") } - if let boolean { parts.append("boolean: \(boolean)") } - if let bytes { parts.append("bytes: \(bytes.count) bytes") } - if let number { parts.append("number: \(number)") } - if let string { parts.append("string: \(string)") } - if let json { parts.append("json: \(json)") } - - return "{ " + parts.joined(separator: ", ") + " }" - } -} diff --git a/Sources/AblyLiveObjects/Protocol/SyncCursor.swift b/Sources/AblyLiveObjects/Protocol/SyncCursor.swift deleted file mode 100644 index 311e8f98..00000000 --- a/Sources/AblyLiveObjects/Protocol/SyncCursor.swift +++ /dev/null @@ -1,39 +0,0 @@ -import Ably -import Foundation - -/// The `OBJECT_SYNC` sync cursor, as extracted from a `channelSerial` per RTO5a1 and RTO5a4. -internal struct SyncCursor { - internal var sequenceID: String - /// `nil` in the case where the objects sync sequence is complete (RTO5a4). - internal var cursorValue: String? - - internal enum Error: Swift.Error { - case channelSerialDoesNotMatchExpectedFormat(String) - } - - /// Creates a `SyncCursor` from the `channelSerial` of an `OBJECT_SYNC` `ProtocolMessage`. - internal init(channelSerial: String) throws(ARTErrorInfo) { - let scanner = Scanner(string: channelSerial) - scanner.charactersToBeSkipped = nil - - // Get everything up to the colon as the sequence ID - let sequenceID = scanner.scanUpToString(":") ?? "" - - // Check if we have a colon - guard scanner.scanString(":") != nil else { - throw Error.channelSerialDoesNotMatchExpectedFormat(channelSerial).toARTErrorInfo() - } - - // Everything after the colon (if anything) is the cursor value - let remainingString = channelSerial[scanner.currentIndex...] - let cursorValue = remainingString.isEmpty ? nil : String(remainingString) - - self.sequenceID = sequenceID - self.cursorValue = cursorValue - } - - /// Whether this cursor represents the end of the sync sequence, per RTO5a4. - internal var isEndOfSequence: Bool { - cursorValue == nil - } -} diff --git a/Sources/AblyLiveObjects/Protocol/WireEnum.swift b/Sources/AblyLiveObjects/Protocol/WireEnum.swift deleted file mode 100644 index 9df4f47e..00000000 --- a/Sources/AblyLiveObjects/Protocol/WireEnum.swift +++ /dev/null @@ -1,25 +0,0 @@ -/// An enum extracted from a wire representation that either belongs to one of a set of known values or is a new, unknown value. -internal enum WireEnum where Known: RawRepresentable { - case known(Known) - case unknown(Known.RawValue) - - internal init(rawValue: Known.RawValue) { - if let known = Known(rawValue: rawValue) { - self = .known(known) - } else { - self = .unknown(rawValue) - } - } - - internal var rawValue: Known.RawValue { - switch self { - case let .known(known): - known.rawValue - case let .unknown(rawValue): - rawValue - } - } -} - -extension WireEnum: Sendable where Known: Sendable, Known.RawValue: Sendable {} -extension WireEnum: Equatable where Known: Equatable, Known.RawValue: Equatable {} diff --git a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift deleted file mode 100644 index 20342724..00000000 --- a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift +++ /dev/null @@ -1,605 +0,0 @@ -internal import _AblyPluginSupportPrivate -import Ably -import Foundation - -// This file contains the ObjectMessage types that we send and receive over the wire. We convert them to and from the corresponding non-wire types (e.g. `InboundObjectMessage`) for use within the codebase. - -/// An `ObjectMessage` received in the `state` property of an `OBJECT` or `OBJECT_SYNC` `ProtocolMessage`. -internal struct InboundWireObjectMessage { - // TODO: Spec has `id`, `connectionId`, `timestamp`, `clientId`, `serial`, `sideCode` as non-nullable but I don't think this is right; raised https://github.com/ably/specification/issues/334 - internal var id: String? // OM2a - internal var clientId: String? // OM2b - internal var connectionId: String? // OM2c - internal var extras: [String: JSONValue]? // OM2d - internal var timestamp: Date? // OM2e - internal var operation: WireObjectOperation? // OM2f - internal var object: WireObjectState? // OM2g - internal var serial: String? // OM2h - internal var siteCode: String? // OM2i - internal var serialTimestamp: Date? // OM2j -} - -/// An `ObjectMessage` to be sent in the `state` property of an `OBJECT` `ProtocolMessage`. -internal struct OutboundWireObjectMessage { - internal var id: String? // OM2a - internal var clientId: String? // OM2b - internal var connectionId: String? - internal var extras: [String: JSONValue]? // OM2d - internal var timestamp: Date? // OM2e - internal var operation: WireObjectOperation? // OM2f - internal var object: WireObjectState? // OM2g - internal var serial: String? // OM2h - internal var siteCode: String? // OM2i - internal var serialTimestamp: Date? // OM2j -} - -/// The keys for decoding an `InboundWireObjectMessage` or encoding an `OutboundWireObjectMessage`. -internal enum WireObjectMessageWireKey: String { - case id - case clientId - case connectionId - case extras - case timestamp - case operation - case object - case serial - case siteCode - case serialTimestamp -} - -internal extension InboundWireObjectMessage { - /// An error that can occur when decoding an ``InboundWireObjectMessage``. - enum DecodingError: Error { - // TODO: after https://github.com/ably/specification/issues/334 resolved, throw or remove these as needed - /// The containing `ProtocolMessage` does not have an `id`. - case parentMissingID - /// The containing `ProtocolMessage` does not have a `connectionId`. - case parentMissingConnectionID - /// The containing `ProtocolMessage` does not have a `timestamp`. - case parentMissingTimestamp - } - - /// Decodes the `ObjectMessage` and then uses the containing `ProtocolMessage` to populate some absent fields per the rules of the specification. - init( - wireObject: [String: WireValue], - decodingContext: _AblyPluginSupportPrivate.DecodingContextProtocol - ) throws(ARTErrorInfo) { - // OM2a - if let id = try wireObject.optionalStringValueForKey(WireObjectMessageWireKey.id.rawValue) { - self.id = id - } else if let parentID = decodingContext.parentID { - id = "\(parentID):\(decodingContext.indexInParent)" - } - - clientId = try wireObject.optionalStringValueForKey(WireObjectMessageWireKey.clientId.rawValue) - - // OM2c - if let connectionId = try wireObject.optionalStringValueForKey(WireObjectMessageWireKey.connectionId.rawValue) { - self.connectionId = connectionId - } else if let parentConnectionID = decodingContext.parentConnectionID { - connectionId = parentConnectionID - } - - // Convert WireValue extras to JSONValue extras - if let wireExtras = try wireObject.optionalObjectValueForKey(WireObjectMessageWireKey.extras.rawValue) { - extras = try wireExtras.ablyLiveObjects_mapValuesWithTypedThrow { wireValue throws(ARTErrorInfo) in - try wireValue.toJSONValue - } - } else { - extras = nil - } - - // OM2e - if let timestamp = try wireObject.optionalAblyProtocolDateValueForKey(WireObjectMessageWireKey.timestamp.rawValue) { - self.timestamp = timestamp - } else if let parentTimestamp = decodingContext.parentTimestamp { - timestamp = parentTimestamp - } - - operation = try wireObject.optionalDecodableValueForKey(WireObjectMessageWireKey.operation.rawValue) - object = try wireObject.optionalDecodableValueForKey(WireObjectMessageWireKey.object.rawValue) - serial = try wireObject.optionalStringValueForKey(WireObjectMessageWireKey.serial.rawValue) - siteCode = try wireObject.optionalStringValueForKey(WireObjectMessageWireKey.siteCode.rawValue) - serialTimestamp = try wireObject.optionalAblyProtocolDateValueForKey(WireObjectMessageWireKey.serialTimestamp.rawValue) - } -} - -extension OutboundWireObjectMessage: WireObjectEncodable { - internal var toWireObject: [String: WireValue] { - var result: [String: WireValue] = [:] - - if let id { - result[WireObjectMessageWireKey.id.rawValue] = .string(id) - } - if let connectionId { - result[WireObjectMessageWireKey.connectionId.rawValue] = .string(connectionId) - } - if let timestamp { - result[WireObjectMessageWireKey.timestamp.rawValue] = .number(NSNumber(value: (timestamp.timeIntervalSince1970) * 1000)) - } - if let siteCode { - result[WireObjectMessageWireKey.siteCode.rawValue] = .string(siteCode) - } - if let serial { - result[WireObjectMessageWireKey.serial.rawValue] = .string(serial) - } - if let clientId { - result[WireObjectMessageWireKey.clientId.rawValue] = .string(clientId) - } - if let extras { - // Convert JSONValue extras to WireValue extras - result[WireObjectMessageWireKey.extras.rawValue] = .object(extras.mapValues { .init(jsonValue: $0) }) - } - if let operation { - result[WireObjectMessageWireKey.operation.rawValue] = .object(operation.toWireObject) - } - if let object { - result[WireObjectMessageWireKey.object.rawValue] = .object(object.toWireObject) - } - if let serialTimestamp { - result[WireObjectMessageWireKey.serialTimestamp.rawValue] = .number(NSNumber(value: serialTimestamp.timeIntervalSince1970 * 1000)) - } - return result - } -} - -// OOP2 -internal enum ObjectOperationAction: Int { - case mapCreate = 0 - case mapSet = 1 - case mapRemove = 2 - case counterCreate = 3 - case counterInc = 4 - case objectDelete = 5 -} - -// OMP2 -internal enum ObjectsMapSemantics: Int { - case lww = 0 -} - -/// A partial version of `WireObjectOperation` that excludes the `action` and `objectId` property. Used for encoding initial values which don't include the `action` and where the `objectId` is not yet known. -/// -/// `WireObjectOperation` delegates its encoding and decoding to `PartialWireObjectOperation`. -internal struct PartialWireObjectOperation { - internal var mapOp: WireObjectsMapOp? // OOP3c - internal var counterOp: WireObjectsCounterOp? // OOP3d - internal var map: WireObjectsMap? // OOP3e - internal var counter: WireObjectsCounter? // OOP3f - internal var nonce: String? // OOP3g - internal var initialValue: String? // OOP3h -} - -extension PartialWireObjectOperation: WireObjectCodable { - internal enum WireKey: String { - case mapOp - case counterOp - case map - case counter - case nonce - case initialValue - } - - internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { - mapOp = try wireObject.optionalDecodableValueForKey(WireKey.mapOp.rawValue) - counterOp = try wireObject.optionalDecodableValueForKey(WireKey.counterOp.rawValue) - map = try wireObject.optionalDecodableValueForKey(WireKey.map.rawValue) - counter = try wireObject.optionalDecodableValueForKey(WireKey.counter.rawValue) - - // Do not access on inbound data, per OOP3g - nonce = nil - // Do not access on inbound data, per OOP3h - initialValue = nil - } - - internal var toWireObject: [String: WireValue] { - var result: [String: WireValue] = [:] - - if let mapOp { - result[WireKey.mapOp.rawValue] = .object(mapOp.toWireObject) - } - if let counterOp { - result[WireKey.counterOp.rawValue] = .object(counterOp.toWireObject) - } - if let map { - result[WireKey.map.rawValue] = .object(map.toWireObject) - } - if let counter { - result[WireKey.counter.rawValue] = .object(counter.toWireObject) - } - if let nonce { - result[WireKey.nonce.rawValue] = .string(nonce) - } - if let initialValue { - result[WireKey.initialValue.rawValue] = .string(initialValue) - } - - return result - } -} - -internal struct WireObjectOperation { - internal var action: WireEnum // OOP3a - internal var objectId: String // OOP3b - internal var mapOp: WireObjectsMapOp? // OOP3c - internal var counterOp: WireObjectsCounterOp? // OOP3d - internal var map: WireObjectsMap? // OOP3e - internal var counter: WireObjectsCounter? // OOP3f - internal var nonce: String? // OOP3g - internal var initialValue: String? // OOP3h -} - -extension WireObjectOperation: WireObjectCodable { - internal enum WireKey: String { - case action - case objectId - } - - internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { - // Decode the action and objectId first since they're not part of PartialWireObjectOperation - action = try wireObject.wireEnumValueForKey(WireKey.action.rawValue) - objectId = try wireObject.stringValueForKey(WireKey.objectId.rawValue) - - // Delegate to PartialWireObjectOperation for decoding - let partialOperation = try PartialWireObjectOperation(wireObject: wireObject) - - // Copy the decoded values - mapOp = partialOperation.mapOp - counterOp = partialOperation.counterOp - map = partialOperation.map - counter = partialOperation.counter - nonce = partialOperation.nonce - initialValue = partialOperation.initialValue - } - - internal var toWireObject: [String: WireValue] { - var result = PartialWireObjectOperation( - mapOp: mapOp, - counterOp: counterOp, - map: map, - counter: counter, - nonce: nonce, - initialValue: initialValue, - ).toWireObject - - // Add the objectId field - result[WireKey.action.rawValue] = .number(action.rawValue as NSNumber) - result[WireKey.objectId.rawValue] = .string(objectId) - - return result - } -} - -internal struct WireObjectState { - internal var objectId: String // OST2a - internal var siteTimeserials: [String: String] // OST2b - internal var tombstone: Bool // OST2c - internal var createOp: WireObjectOperation? // OST2d - internal var map: WireObjectsMap? // OST2e - internal var counter: WireObjectsCounter? // OST2f -} - -extension WireObjectState: WireObjectCodable { - internal enum WireKey: String { - case objectId - case siteTimeserials - case tombstone - case createOp - case map - case counter - } - - internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { - objectId = try wireObject.stringValueForKey(WireKey.objectId.rawValue) - siteTimeserials = try wireObject.objectValueForKey(WireKey.siteTimeserials.rawValue).ablyLiveObjects_mapValuesWithTypedThrow { value throws(ARTErrorInfo) in - guard case let .string(string) = value else { - throw WireValueDecodingError.wrongTypeForKey(WireKey.siteTimeserials.rawValue, actualValue: value).toARTErrorInfo() - } - return string - } - tombstone = try wireObject.boolValueForKey(WireKey.tombstone.rawValue) - createOp = try wireObject.optionalDecodableValueForKey(WireKey.createOp.rawValue) - map = try wireObject.optionalDecodableValueForKey(WireKey.map.rawValue) - counter = try wireObject.optionalDecodableValueForKey(WireKey.counter.rawValue) - } - - internal var toWireObject: [String: WireValue] { - var result: [String: WireValue] = [ - WireKey.objectId.rawValue: .string(objectId), - WireKey.siteTimeserials.rawValue: .object(siteTimeserials.mapValues { .string($0) }), - WireKey.tombstone.rawValue: .bool(tombstone), - ] - - if let createOp { - result[WireKey.createOp.rawValue] = .object(createOp.toWireObject) - } - if let map { - result[WireKey.map.rawValue] = .object(map.toWireObject) - } - if let counter { - result[WireKey.counter.rawValue] = .object(counter.toWireObject) - } - - return result - } -} - -internal struct WireObjectsMapOp { - internal var key: String // OMO2a - internal var data: WireObjectData? // OMO2b -} - -extension WireObjectsMapOp: WireObjectCodable { - internal enum WireKey: String { - case key - case data - } - - internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { - key = try wireObject.stringValueForKey(WireKey.key.rawValue) - data = try wireObject.optionalDecodableValueForKey(WireKey.data.rawValue) - } - - internal var toWireObject: [String: WireValue] { - var result: [String: WireValue] = [ - WireKey.key.rawValue: .string(key), - ] - - if let data { - result[WireKey.data.rawValue] = .object(data.toWireObject) - } - - return result - } -} - -internal struct WireObjectsCounterOp: Equatable { - internal var amount: NSNumber // OCO2a -} - -extension WireObjectsCounterOp: WireObjectCodable { - internal enum WireKey: String { - case amount - } - - internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { - amount = try wireObject.numberValueForKey(WireKey.amount.rawValue) - } - - internal var toWireObject: [String: WireValue] { - [ - WireKey.amount.rawValue: .number(amount), - ] - } -} - -internal struct WireObjectsMap { - internal var semantics: WireEnum // OMP3a - internal var entries: [String: WireObjectsMapEntry]? // OMP3b -} - -extension WireObjectsMap: WireObjectCodable { - internal enum WireKey: String { - case semantics - case entries - } - - internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { - semantics = try wireObject.wireEnumValueForKey(WireKey.semantics.rawValue) - entries = try wireObject.optionalObjectValueForKey(WireKey.entries.rawValue)?.ablyLiveObjects_mapValuesWithTypedThrow { value throws(ARTErrorInfo) in - guard case let .object(object) = value else { - throw WireValueDecodingError.wrongTypeForKey(WireKey.entries.rawValue, actualValue: value).toARTErrorInfo() - } - return try WireObjectsMapEntry(wireObject: object) - } - } - - internal var toWireObject: [String: WireValue] { - var result: [String: WireValue] = [ - WireKey.semantics.rawValue: .number(semantics.rawValue as NSNumber), - ] - - if let entries { - result[WireKey.entries.rawValue] = .object(entries.mapValues { .object($0.toWireObject) }) - } - - return result - } -} - -internal struct WireObjectsCounter: Equatable { - internal var count: NSNumber? // OCN2a -} - -extension WireObjectsCounter: WireObjectCodable { - internal enum WireKey: String { - case count - } - - internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { - count = try wireObject.optionalNumberValueForKey(WireKey.count.rawValue) - } - - internal var toWireObject: [String: WireValue] { - var result: [String: WireValue] = [:] - if let count { - result[WireKey.count.rawValue] = .number(count) - } - return result - } -} - -internal struct WireObjectsMapEntry { - internal var tombstone: Bool? // OME2a - internal var timeserial: String? // OME2b - internal var data: WireObjectData? // OME2c - internal var serialTimestamp: Date? // OME2d -} - -extension WireObjectsMapEntry: WireObjectCodable { - internal enum WireKey: String { - case tombstone - case timeserial - case data - case serialTimestamp - } - - internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { - tombstone = try wireObject.optionalBoolValueForKey(WireKey.tombstone.rawValue) - timeserial = try wireObject.optionalStringValueForKey(WireKey.timeserial.rawValue) - data = try wireObject.optionalDecodableValueForKey(WireKey.data.rawValue) - serialTimestamp = try wireObject.optionalAblyProtocolDateValueForKey(WireKey.serialTimestamp.rawValue) - } - - internal var toWireObject: [String: WireValue] { - var result: [String: WireValue] = [:] - - if let data { - result[WireKey.data.rawValue] = .object(data.toWireObject) - } - if let tombstone { - result[WireKey.tombstone.rawValue] = .bool(tombstone) - } - if let timeserial { - result[WireKey.timeserial.rawValue] = .string(timeserial) - } - if let serialTimestamp { - result[WireKey.serialTimestamp.rawValue] = .number(NSNumber(value: serialTimestamp.timeIntervalSince1970 * 1000)) - } - - return result - } -} - -internal struct WireObjectData { - internal var objectId: String? // OD2a - internal var boolean: Bool? // OD2c - internal var bytes: StringOrData? // OD2d - internal var number: NSNumber? // OD2e - internal var string: String? // OD2f - internal var json: String? // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) -} - -extension WireObjectData: WireObjectCodable { - internal enum WireKey: String { - case objectId - case boolean - case bytes - case number - case string - case json - } - - internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { - objectId = try wireObject.optionalStringValueForKey(WireKey.objectId.rawValue) - boolean = try wireObject.optionalBoolValueForKey(WireKey.boolean.rawValue) - bytes = try wireObject.optionalDecodableValueForKey(WireKey.bytes.rawValue) - number = try wireObject.optionalNumberValueForKey(WireKey.number.rawValue) - string = try wireObject.optionalStringValueForKey(WireKey.string.rawValue) - json = try wireObject.optionalStringValueForKey(WireKey.json.rawValue) - } - - internal var toWireObject: [String: WireValue] { - var result: [String: WireValue] = [:] - - if let objectId { - result[WireKey.objectId.rawValue] = .string(objectId) - } - if let boolean { - result[WireKey.boolean.rawValue] = .bool(boolean) - } - if let bytes { - result[WireKey.bytes.rawValue] = bytes.toWireValue - } - if let number { - result[WireKey.number.rawValue] = .number(number) - } - if let string { - result[WireKey.string.rawValue] = .string(string) - } - if let json { - result[WireKey.json.rawValue] = .string(json) - } - - return result - } -} - -/// A type that can be either a string or binary data. -/// -/// Used to represent the values that `WireObjectData.bytes` might hold, after being encoded per OD4 or before being decoded per OD5. -internal enum StringOrData: WireCodable { - case string(String) - case data(Data) - - /// An error that can occur when decoding a ``StringOrData``. - internal enum DecodingError: Error { - case unsupportedValue(WireValue) - } - - internal init(wireValue: WireValue) throws(ARTErrorInfo) { - self = switch wireValue { - case let .string(string): - .string(string) - case let .data(data): - .data(data) - default: - throw DecodingError.unsupportedValue(wireValue).toARTErrorInfo() - } - } - - internal var toWireValue: WireValue { - switch self { - case let .string(string): - .string(string) - case let .data(data): - .data(data) - } - } -} - -// MARK: - CustomDebugStringConvertible - -extension WireObjectsCounter: CustomDebugStringConvertible { - internal var debugDescription: String { - if let count { - "{ count: \(count) }" - } else { - "{ count: nil }" - } - } -} - -extension WireObjectsCounterOp: CustomDebugStringConvertible { - internal var debugDescription: String { - "{ amount: \(amount) }" - } -} - -extension WireObjectsMapEntry: CustomDebugStringConvertible { - internal var debugDescription: String { - var parts: [String] = [] - - if let tombstone { parts.append("tombstone: \(tombstone)") } - if let timeserial { parts.append("timeserial: \(timeserial)") } - if let data { parts.append("data: \(data)") } - if let serialTimestamp { parts.append("serialTimestamp: \(serialTimestamp)") } - - return "{ " + parts.joined(separator: ", ") + " }" - } -} - -extension WireObjectData: CustomDebugStringConvertible { - internal var debugDescription: String { - var parts: [String] = [] - - if let objectId { parts.append("objectId: \(objectId)") } - if let boolean { parts.append("boolean: \(boolean)") } - if let bytes { parts.append("bytes: \(bytes)") } - if let number { parts.append("number: \(number)") } - if let string { parts.append("string: \(string)") } - if let json { parts.append("json: \(json)") } - - return "{ " + parts.joined(separator: ", ") + " }" - } -} diff --git a/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift b/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift index 0beb04b0..1d5e2389 100644 --- a/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift +++ b/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift @@ -2,40 +2,8 @@ internal import _AblyPluginSupportPrivate import Ably public extension ARTRealtimeChannel { - /// A ``RealtimeObjects`` object. - var objects: RealtimeObjects { - nonTypeErasedObjects - } - - private var nonTypeErasedObjects: PublicDefaultRealtimeObjects { - let pluginAPI = Plugin.defaultPluginAPI - let underlyingObjects = pluginAPI.underlyingObjects(for: asPluginPublicRealtimeChannel) - let internalQueue = pluginAPI.internalQueue(for: underlyingObjects.client) - let internalObjects = internalQueue.ably_syncNoDeadlock { - DefaultInternalPlugin.nosync_realtimeObjects(for: underlyingObjects.channel, pluginAPI: pluginAPI) - } - - let pluginLogger = pluginAPI.logger(for: underlyingObjects.channel) - let logger = DefaultLogger(pluginLogger: pluginLogger, pluginAPI: pluginAPI) - - let coreSDK = DefaultCoreSDK( - channel: underlyingObjects.channel, - client: underlyingObjects.client, - pluginAPI: Plugin.defaultPluginAPI, - logger: logger, - ) - - return PublicObjectsStore.shared.getOrCreateRealtimeObjects( - proxying: internalObjects, - creationArgs: .init( - coreSDK: coreSDK, - logger: logger, - ), - ) - } - - /// For tests to access the non-public API of `PublicDefaultRealtimeObjects`. - internal var testsOnly_nonTypeErasedObjects: PublicDefaultRealtimeObjects { - nonTypeErasedObjects + /// A ``RealtimeObject`` object. + var object: RealtimeObject { + fatalError("Not implemented") } } diff --git a/Sources/AblyLiveObjects/Public/Plugin.swift b/Sources/AblyLiveObjects/Public/Plugin.swift deleted file mode 100644 index a39b7a74..00000000 --- a/Sources/AblyLiveObjects/Public/Plugin.swift +++ /dev/null @@ -1,46 +0,0 @@ -internal import _AblyPluginSupportPrivate - -// We explicitly import the NSObject class, else it seems to get transitively imported from `internal import _AblyPluginSupportPrivate`, leading to the error "Class cannot be declared public because its superclass is internal". -import ObjectiveC.NSObject - -/// This plugin enables LiveObjects functionality in ably-cocoa. Set the `.liveObjects` key in the ably-cocoa `plugins` client option to this class in order to enable LiveObjects. -/// -/// For example: -/// ```swift -/// import Ably -/// import AblyLiveObjects -/// -/// let clientOptions = ARTClientOptions(key: /* */) -/// clientOptions.plugins = [.liveObjects: AblyLiveObjects.Plugin.self] -/// -/// let realtime = ARTRealtime(options: clientOptions) -/// -/// // Fetch a channel, specifying the `.objectPublish` and `.objectSubscribe` modes -/// let channelOptions = ARTRealtimeChannelOptions() -/// channelOptions.modes = [.objectPublish, .objectSubscribe] -/// let channel = realtime.channels.get("myChannel", options: channelOptions) -/// -/// // Attach the channel -/// try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in -/// channel.attach { error in -/// if let error { -/// continuation.resume(throwing: error) -/// } else { -/// continuation.resume() -/// } -/// } -/// } -/// -/// // You can now access LiveObjects functionality via the channel's `objects` property: -/// let rootObject = try await channel.objects.getRoot() -/// // …and so on -/// ``` -@objc -public class Plugin: NSObject { - /// The `_AblyPluginSupportPrivate.PluginAPIProtocol` that the LiveObjects plugin should use by default (i.e. when one hasn't been injected for test purposes). - internal static let defaultPluginAPI = _AblyPluginSupportPrivate.DependencyStore.sharedInstance().fetchPluginAPI() - - // MARK: - Informal conformance to _AblyPluginSupportPrivate.LiveObjectsPluginProtocol - - @objc private static let internalPlugin = DefaultInternalPlugin(pluginAPI: defaultPluginAPI) -} diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift deleted file mode 100644 index d669c0cd..00000000 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift +++ /dev/null @@ -1,51 +0,0 @@ -internal import _AblyPluginSupportPrivate - -internal extension InternalLiveMapValue { - // MARK: - Mapping to public types - - struct PublicValueCreationArgs { - internal var coreSDK: CoreSDK - internal var mapDelegate: LiveMapObjectsPoolDelegate - internal var logger: Logger - - internal var toCounterCreationArgs: PublicObjectsStore.CounterCreationArgs { - .init(coreSDK: coreSDK, logger: logger) - } - - internal var toMapCreationArgs: PublicObjectsStore.MapCreationArgs { - .init(coreSDK: coreSDK, delegate: mapDelegate, logger: logger) - } - } - - /// Fetches the cached public object that wraps this `InternalLiveMapValue`'s associated value, creating a new public object if there isn't already one. - func toPublic(creationArgs: PublicValueCreationArgs) -> LiveMapValue { - switch self { - case let .string(value): - .string(value) - case let .number(value): - .number(value) - case let .bool(value): - .bool(value) - case let .data(value): - .data(value) - case let .jsonArray(value): - .jsonArray(value) - case let .jsonObject(value): - .jsonObject(value) - case let .liveMap(internalLiveMap): - .liveMap( - PublicObjectsStore.shared.getOrCreateMap( - proxying: internalLiveMap, - creationArgs: creationArgs.toMapCreationArgs, - ), - ) - case let .liveCounter(internalLiveCounter): - .liveCounter( - PublicObjectsStore.shared.getOrCreateCounter( - proxying: internalLiveCounter, - creationArgs: creationArgs.toCounterCreationArgs, - ), - ) - } - } -} diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift deleted file mode 100644 index 90a408da..00000000 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift +++ /dev/null @@ -1,52 +0,0 @@ -internal import _AblyPluginSupportPrivate -import Ably - -/// Our default implementation of ``LiveCounter``. -/// -/// This is largely a wrapper around ``InternalDefaultLiveCounter``. -internal final class PublicDefaultLiveCounter: LiveCounter { - internal let proxied: InternalDefaultLiveCounter - - // MARK: - Dependencies that hold a strong reference to `proxied` - - private let coreSDK: CoreSDK - private let logger: Logger - - internal init(proxied: InternalDefaultLiveCounter, coreSDK: CoreSDK, logger: Logger) { - self.proxied = proxied - self.coreSDK = coreSDK - self.logger = logger - } - - // MARK: - `LiveCounter` protocol - - internal var value: Double { - get throws(ARTErrorInfo) { - try proxied.value(coreSDK: coreSDK) - } - } - - internal func increment(amount: Double) async throws(ARTErrorInfo) { - try await proxied.increment(amount: amount, coreSDK: coreSDK) - } - - internal func decrement(amount: Double) async throws(ARTErrorInfo) { - try await proxied.decrement(amount: amount, coreSDK: coreSDK) - } - - internal func subscribe(listener: @escaping LiveObjectUpdateCallback) throws(ARTErrorInfo) -> any SubscribeResponse { - try proxied.subscribe(listener: listener, coreSDK: coreSDK) - } - - internal func unsubscribeAll() { - proxied.unsubscribeAll() - } - - internal func on(event: LiveObjectLifecycleEvent, callback: @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { - proxied.on(event: event, callback: callback) - } - - internal func offAll() { - proxied.offAll() - } -} diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift deleted file mode 100644 index 58f2036d..00000000 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift +++ /dev/null @@ -1,103 +0,0 @@ -internal import _AblyPluginSupportPrivate -import Ably - -/// Our default implementation of ``LiveMap``. -/// -/// This is largely a wrapper around ``InternalDefaultLiveMap``. -internal final class PublicDefaultLiveMap: LiveMap { - internal let proxied: InternalDefaultLiveMap - - // MARK: - Dependencies that hold a strong reference to `proxied` - - private let coreSDK: CoreSDK - private let delegate: LiveMapObjectsPoolDelegate - private let logger: Logger - - internal init(proxied: InternalDefaultLiveMap, coreSDK: CoreSDK, delegate: LiveMapObjectsPoolDelegate, logger: Logger) { - self.proxied = proxied - self.coreSDK = coreSDK - self.delegate = delegate - self.logger = logger - } - - // MARK: - `LiveMap` protocol - - internal func get(key: String) throws(ARTErrorInfo) -> LiveMapValue? { - try proxied.get(key: key, coreSDK: coreSDK, delegate: delegate)?.toPublic( - creationArgs: .init( - coreSDK: coreSDK, - mapDelegate: delegate, - logger: logger, - ), - ) - } - - internal var size: Int { - get throws(ARTErrorInfo) { - try proxied.size(coreSDK: coreSDK, delegate: delegate) - } - } - - internal var entries: [(key: String, value: LiveMapValue)] { - get throws(ARTErrorInfo) { - try proxied.entries(coreSDK: coreSDK, delegate: delegate).map { entry in - ( - entry.key, - entry.value.toPublic( - creationArgs: .init( - coreSDK: coreSDK, - mapDelegate: delegate, - logger: logger, - ), - ) - ) - } - } - } - - internal var keys: [String] { - get throws(ARTErrorInfo) { - try proxied.keys(coreSDK: coreSDK, delegate: delegate) - } - } - - internal var values: [LiveMapValue] { - get throws(ARTErrorInfo) { - try proxied.values(coreSDK: coreSDK, delegate: delegate).map { value in - value.toPublic( - creationArgs: .init( - coreSDK: coreSDK, - mapDelegate: delegate, - logger: logger, - ), - ) - } - } - } - - internal func set(key: String, value: LiveMapValue) async throws(ARTErrorInfo) { - let internalValue = InternalLiveMapValue(liveMapValue: value) - - try await proxied.set(key: key, value: internalValue, coreSDK: coreSDK) - } - - internal func remove(key: String) async throws(ARTErrorInfo) { - try await proxied.remove(key: key, coreSDK: coreSDK) - } - - internal func subscribe(listener: @escaping LiveObjectUpdateCallback) throws(ARTErrorInfo) -> any SubscribeResponse { - try proxied.subscribe(listener: listener, coreSDK: coreSDK) - } - - internal func unsubscribeAll() { - proxied.unsubscribeAll() - } - - internal func on(event: LiveObjectLifecycleEvent, callback: @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { - proxied.on(event: event, callback: callback) - } - - internal func offAll() { - proxied.offAll() - } -} diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift deleted file mode 100644 index 66cd808c..00000000 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift +++ /dev/null @@ -1,129 +0,0 @@ -internal import _AblyPluginSupportPrivate -import Ably - -/// The class that provides the public API for interacting with LiveObjects, via the ``ARTRealtimeChannel/objects`` property. -/// -/// This is largely a wrapper around ``InternalDefaultRealtimeObjects``. -internal final class PublicDefaultRealtimeObjects: RealtimeObjects { - private let proxied: InternalDefaultRealtimeObjects - internal var testsOnly_proxied: InternalDefaultRealtimeObjects { - proxied - } - - // MARK: - Dependencies that hold a strong reference to `proxied` - - private let coreSDK: CoreSDK - private let logger: Logger - - internal init(proxied: InternalDefaultRealtimeObjects, coreSDK: CoreSDK, logger: Logger) { - self.proxied = proxied - self.coreSDK = coreSDK - self.logger = logger - } - - // MARK: - `RealtimeObjects` protocol - - internal func getRoot() async throws(ARTErrorInfo) -> any LiveMap { - let internalMap = try await proxied.getRoot(coreSDK: coreSDK) - return PublicObjectsStore.shared.getOrCreateMap( - proxying: internalMap, - creationArgs: .init( - coreSDK: coreSDK, - delegate: proxied, - logger: logger, - ), - ) - } - - internal func createMap(entries: [String: LiveMapValue]) async throws(ARTErrorInfo) -> any LiveMap { - let internalEntries: [String: InternalLiveMapValue] = entries.mapValues { .init(liveMapValue: $0) } - let internalMap = try await proxied.createMap(entries: internalEntries, coreSDK: coreSDK) - - return PublicObjectsStore.shared.getOrCreateMap( - proxying: internalMap, - creationArgs: .init( - coreSDK: coreSDK, - delegate: proxied, - logger: logger, - ), - ) - } - - internal func createMap() async throws(ARTErrorInfo) -> any LiveMap { - let internalMap = try await proxied.createMap(coreSDK: coreSDK) - - return PublicObjectsStore.shared.getOrCreateMap( - proxying: internalMap, - creationArgs: .init( - coreSDK: coreSDK, - delegate: proxied, - logger: logger, - ), - ) - } - - internal func createCounter(count: Double) async throws(ARTErrorInfo) -> any LiveCounter { - let internalCounter = try await proxied.createCounter(count: count, coreSDK: coreSDK) - - return PublicObjectsStore.shared.getOrCreateCounter( - proxying: internalCounter, - creationArgs: .init( - coreSDK: coreSDK, - logger: logger, - ), - ) - } - - internal func createCounter() async throws(ARTErrorInfo) -> any LiveCounter { - let internalCounter = try await proxied.createCounter(coreSDK: coreSDK) - - return PublicObjectsStore.shared.getOrCreateCounter( - proxying: internalCounter, - creationArgs: .init( - coreSDK: coreSDK, - logger: logger, - ), - ) - } - - internal func on(event: ObjectsEvent, callback: @escaping ObjectsEventCallback) -> any OnObjectsEventResponse { - proxied.on(event: event, callback: callback) - } - - internal func offAll() { - proxied.offAll() - } - - // MARK: - Test-only APIs - - // These are only used by our plumbingSmokeTest (the rest of our unit tests test the internal classes, not the public ones). - - internal var testsOnly_onChannelAttachedHasObjects: Bool? { - proxied.testsOnly_onChannelAttachedHasObjects - } - - internal var testsOnly_receivedObjectProtocolMessages: AsyncStream<[InboundObjectMessage]> { - proxied.testsOnly_receivedObjectProtocolMessages - } - - internal func testsOnly_publish(objectMessages: [OutboundObjectMessage]) async throws(ARTErrorInfo) { - try await proxied.testsOnly_publish(objectMessages: objectMessages, coreSDK: coreSDK) - } - - internal var testsOnly_receivedObjectSyncProtocolMessages: AsyncStream<[InboundObjectMessage]> { - proxied.testsOnly_receivedObjectSyncProtocolMessages - } - - // These are used by the integration tests. - - /// Replaces the method that this `RealtimeObjects` uses to send any outbound `ObjectMessage`s. - /// - /// Used by integration tests, for example to disable `ObjectMessage` publishing so that a test can verify that a behaviour is not a side effect of an `ObjectMessage` sent by the SDK. - internal func testsOnly_overridePublish(with newImplementation: @escaping ([OutboundObjectMessage]) async throws(ARTErrorInfo) -> Void) { - coreSDK.testsOnly_overridePublish(with: newImplementation) - } - - internal var testsOnly_gcGracePeriod: TimeInterval { - proxied.testsOnly_gcGracePeriod - } -} diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift deleted file mode 100644 index d8d915b0..00000000 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift +++ /dev/null @@ -1,158 +0,0 @@ -internal import _AblyPluginSupportPrivate -import Foundation - -/// Stores the public objects that wrap the SDK's internal components. -/// -/// This allows us to provide stable object identity for our public `RealtimeObjects`, `LiveMap`, and `LiveCounter` objects. Concretely, this means that it allows us to, for example, consistently return: -/// -/// - the same `PublicDefaultRealtimeObjects` instance across multiple calls to `ARTRealtimeChannel.objects` -/// - the same `PublicDefaultLiveMap` instance across multiple calls to `PublicDefaultRealtimeObjects.getRoot()` -/// - the same `PublicDefaultLiveMap` and `PublicDefaultLiveCounter` instance across multiple calls to `PublicDefaultLiveMap.get(…)` with the same key (similarly for other `LiveMap` getters) -/// -/// This differs from the approach that we take in ably-cocoa, in which we create a new public object each time we need to return one. Given that the LiveObjects SDK revolves around the concept of various live-updating objects, it seemed like it might be quite a confusing user experience if the pointer identity of, say, a `LiveMap` changed each time it was fetched. -/// -/// - Note: We can only make a best-effort attempt to maintain the pointer identity of the public objects. Since the SDK cannot maintain a strong reference to the public objects (given that the whole reason that these objects exist is for us to know whether the user holds a strong reference to them), if the user releases all of their strong references to a public object then the next time they fetch the public object they will receive a new object. -internal final class PublicObjectsStore: Sendable { - // Used to synchronize access to mutable state - private let mutex = NSLock() - private nonisolated(unsafe) var mutableState = MutableState() - - internal static let shared = PublicObjectsStore() - - internal struct RealtimeObjectsCreationArgs { - internal var coreSDK: CoreSDK - internal var logger: Logger - } - - /// Fetches the cached `PublicDefaultRealtimeObjects` that wraps a given `InternalDefaultRealtimeObjects`, creating a new public object if there isn't already one. - internal func getOrCreateRealtimeObjects(proxying proxied: InternalDefaultRealtimeObjects, creationArgs: RealtimeObjectsCreationArgs) -> PublicDefaultRealtimeObjects { - mutex.withLock { - mutableState.getOrCreateRealtimeObjects(proxying: proxied, creationArgs: creationArgs) - } - } - - internal struct CounterCreationArgs { - internal var coreSDK: CoreSDK - internal var logger: Logger - } - - /// Fetches the cached `PublicDefaultLiveCounter` that wraps a given `InternalDefaultLiveCounter`, creating a new public object if there isn't already one. - internal func getOrCreateCounter(proxying proxied: InternalDefaultLiveCounter, creationArgs: CounterCreationArgs) -> PublicDefaultLiveCounter { - mutex.withLock { - mutableState.getOrCreateCounter(proxying: proxied, creationArgs: creationArgs) - } - } - - internal struct MapCreationArgs { - internal var coreSDK: CoreSDK - internal var delegate: LiveMapObjectsPoolDelegate - internal var logger: Logger - } - - /// Fetches the cached `PublicDefaultLiveMap` that wraps a given `InternalDefaultLiveMap`, creating a new public object if there isn't already one. - internal func getOrCreateMap(proxying proxied: InternalDefaultLiveMap, creationArgs: MapCreationArgs) -> PublicDefaultLiveMap { - mutex.withLock { - mutableState.getOrCreateMap(proxying: proxied, creationArgs: creationArgs) - } - } - - private struct MutableState { - private var realtimeObjectsProxies = Proxies() - private var counterProxies = Proxies() - private var mapProxies = Proxies() - - /// Stores weak references to proxy objects. - private struct Proxies { - private var proxiesByProxiedObjectIdentifier: [ObjectIdentifier: WeakRef] = [:] - - /// Fetches the proxy that wraps `proxied`, creating a new proxy if there isn't already one. Stores a weak reference to the proxy. - mutating func getOrCreate( - proxying proxied: some AnyObject, - logger: Logger, - logObjectType: String, - createProxy: () -> Proxy, - ) -> Proxy { - // Remove any entries that are no longer useful - removeDeallocatedEntries(logger: logger, logObjectType: logObjectType) - - // Do the get-or-create - let proxiedObjectIdentifier = ObjectIdentifier(proxied) - - if let existing = proxiesByProxiedObjectIdentifier[proxiedObjectIdentifier]?.referenced { - logger.log("Reusing existing \(logObjectType) proxy (proxy: \(ObjectIdentifier(existing)), proxied: \(proxiedObjectIdentifier))", level: .debug) - return existing - } - - let created = createProxy() - proxiesByProxiedObjectIdentifier[proxiedObjectIdentifier] = .init(referenced: created) - logger.log("Creating new \(logObjectType) proxy (proxy: \(ObjectIdentifier(created)), proxied: \(proxiedObjectIdentifier))", level: .debug) - - return created - } - - private mutating func removeDeallocatedEntries(logger: Logger, logObjectType: String) { - var keysToRemove: Set = [] - for (proxiedObjectIdentifier, weakProxyRef) in proxiesByProxiedObjectIdentifier where weakProxyRef.referenced == nil { - logger.log("Clearing unused \(logObjectType) proxy from cache (proxied: \(proxiedObjectIdentifier))", level: .debug) - keysToRemove.insert(proxiedObjectIdentifier) - } - - for key in keysToRemove { - proxiesByProxiedObjectIdentifier.removeValue(forKey: key) - } - } - } - - internal mutating func getOrCreateRealtimeObjects( - proxying proxied: InternalDefaultRealtimeObjects, - creationArgs: RealtimeObjectsCreationArgs, - ) -> PublicDefaultRealtimeObjects { - realtimeObjectsProxies.getOrCreate( - proxying: proxied, - logger: creationArgs.logger, - logObjectType: "RealtimeObjects", - ) { - .init( - proxied: proxied, - coreSDK: creationArgs.coreSDK, - logger: creationArgs.logger, - ) - } - } - - internal mutating func getOrCreateCounter( - proxying proxied: InternalDefaultLiveCounter, - creationArgs: CounterCreationArgs, - ) -> PublicDefaultLiveCounter { - counterProxies.getOrCreate( - proxying: proxied, - logger: creationArgs.logger, - logObjectType: "LiveCounter", - ) { - .init( - proxied: proxied, - coreSDK: creationArgs.coreSDK, - logger: creationArgs.logger, - ) - } - } - - internal mutating func getOrCreateMap( - proxying proxied: InternalDefaultLiveMap, - creationArgs: MapCreationArgs, - ) -> PublicDefaultLiveMap { - mapProxies.getOrCreate( - proxying: proxied, - logger: creationArgs.logger, - logObjectType: "LiveMap", - ) { - .init( - proxied: proxied, - coreSDK: creationArgs.coreSDK, - delegate: creationArgs.delegate, - logger: creationArgs.logger, - ) - } - } - } -} diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift index 086ee986..7d288a9e 100644 --- a/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ b/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -1,16 +1,11 @@ import Ably -/// A callback used in ``LiveObject`` to listen for updates to the object. -/// -/// - Parameters: -/// - update: The update object describing the changes made to the object. -/// - subscription: A ``SubscribeResponse`` object that allows the provided listener to deregister itself from future updates. -public typealias LiveObjectUpdateCallback = @Sendable (_ update: sending T, _ subscription: SubscribeResponse) -> Void +public typealias EventCallback = @Sendable (_ event: sending T, _ subscription: Subscription) -> Void /// The callback used for the events emitted by ``RealtimeObjects``. /// /// - Parameter subscription: An ``OnObjectsEventResponse`` object that allows the provided listener to deregister itself from future updates. -public typealias ObjectsEventCallback = @Sendable (_ subscription: OnObjectsEventResponse) -> Void +public typealias ObjectsEventCallback = @Sendable (_ subscription: StatusSubscription) -> Void /// The callback used for the lifecycle events emitted by ``LiveObject``. /// - Parameter subscription: A ``OnLiveObjectLifecycleEventResponse`` object that allows the provided listener to deregister itself from future updates. @@ -24,32 +19,9 @@ public enum ObjectsEvent: Sendable { case synced } -/// Describes the events emitted by a ``LiveObject`` object. -public enum LiveObjectLifecycleEvent: Sendable { - /// Indicates that the object has been deleted from the Objects pool and should no longer be interacted with. - case deleted -} - /// Enables the Objects to be read, modified and subscribed to for a channel. -public protocol RealtimeObjects: Sendable { - /// Retrieves the root ``LiveMap`` object for Objects on a channel. - func getRoot() async throws(ARTErrorInfo) -> any LiveMap - - /// Creates a new ``LiveMap`` object instance with the provided entries. - /// - /// - Parameter entries: The initial entries for the new ``LiveMap`` object. - func createMap(entries: [String: LiveMapValue]) async throws(ARTErrorInfo) -> any LiveMap - - /// Creates a new empty ``LiveMap`` object instance. - func createMap() async throws(ARTErrorInfo) -> any LiveMap - - /// Creates a new ``LiveCounter`` object instance with the provided `count` value. - /// - /// - Parameter count: The initial value for the new ``LiveCounter`` object. - func createCounter(count: Double) async throws(ARTErrorInfo) -> any LiveCounter - - /// Creates a new ``LiveCounter`` object instance with a value of zero. - func createCounter() async throws(ARTErrorInfo) -> any LiveCounter +public protocol RealtimeObject: Sendable { + func get() async throws(ARTErrorInfo) -> LiveMapPathObject /// Registers the provided listener for the specified event. If `on()` is called more than once with the same listener and event, the listener is added multiple times to its listener registry. Therefore, as an example, assuming the same listener is registered twice using `on()`, and an event is emitted once, the listener would be invoked twice. /// @@ -58,7 +30,7 @@ public protocol RealtimeObjects: Sendable { /// - callback: The event listener. /// - Returns: An ``OnObjectsEventResponse`` object that allows the provided listener to be deregistered from future updates. @discardableResult - func on(event: ObjectsEvent, callback: @escaping ObjectsEventCallback) -> OnObjectsEventResponse + func on(event: ObjectsEvent, callback: @escaping ObjectsEventCallback) -> StatusSubscription /// Deregisters all registrations, for all events and listeners. func offAll() @@ -67,7 +39,7 @@ public protocol RealtimeObjects: Sendable { /// Represents the type of data stored for a given key in a ``LiveMap``. /// It may be a primitive value (string, number, boolean, binary data, JSON array, or JSON object), or another ``LiveObject``. /// -/// `LiveMapValue` implements Swift's `ExpressibleBy*Literal` protocols. This, in combination with `JSONValue`'s conformance to these protocols, allows you to write type-safe map values using familiar syntax. For example: +/// `Value` implements Swift's `ExpressibleBy*Literal` protocols. This, in combination with `JSONValue`'s conformance to these protocols, allows you to write type-safe map values using familiar syntax. For example: /// /// ```swift /// let map = try await channel.objects.createMap(entries: [ @@ -87,35 +59,201 @@ public protocol RealtimeObjects: Sendable { /// ], /// ]) /// ``` -public enum LiveMapValue: Sendable, Equatable { +public enum Value: Sendable { + case primitive(Primitive) + case liveMap(LiveMap) + case liveCounter(LiveCounter) +} + +// MARK: - Value ExpressibleBy*Literal conformances + +extension Value: ExpressibleByDictionaryLiteral { + public init(dictionaryLiteral elements: (String, JSONValue)...) { + self = .primitive(.jsonObject(.init(uniqueKeysWithValues: elements))) + } +} + +extension Value: ExpressibleByArrayLiteral { + public init(arrayLiteral elements: JSONValue...) { + self = .primitive(.jsonArray(elements)) + } +} + +extension Value: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self = .primitive(.string(value)) + } +} + +extension Value: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { + self = .primitive(.number(Double(value))) + } +} + +extension Value: ExpressibleByFloatLiteral { + public init(floatLiteral value: Double) { + self = .primitive(.number(value)) + } +} + +extension Value: ExpressibleByBooleanLiteral { + public init(booleanLiteral value: Bool) { + self = .primitive(.bool(value)) + } +} + +/// Object returned from an `on` call, allowing the listener provided in that call to be deregistered. +public protocol StatusSubscription: Sendable { + /// Deregisters the listener passed to the `on` call. + func off() +} + +public protocol PathObjectBase: AnyObject, Sendable { + var path: String { get } + + @discardableResult + func subscribe(listener: @escaping EventCallback, options: PathObjectSubscriptionOptions?) throws(ARTErrorInfo) -> Subscription +} + +public protocol PathObject: PathObjectBase, PathObjectCollectionMethods { + func get(key: String) -> PathObject + + // Note that PathObject does not offer any of the LiveMap or LiveCounter methods. To access those, you must first convert this PathObject to a type-specific PathObject using asLiveMap or asLiveCounter. + + // TODO: For both of these we have to understand how subscriptions work; does it just add a subscription to the underlying PathObject? (I think I need a better understanding of where subscriptions are stored in the path-based API in general; are they actually done at a string path level?) + + /// A proxy for this PathObject, which exposes the LiveMap API. As in JS, LiveMap methods subsequently called on this proxy will fail or return some empty value if the resolved value at this path is not a LiveMap. + /// + /// - Note: Accessing this property does not perform any resolution of the value contained at the path. + var asLiveMap: LiveMapPathObject { get } + + /// A proxy for this PathObject, which exposes the LiveCounter API. As in JS, LiveMap methods subsequently called on this proxy will fail or return some empty value if the resolved value at this path is not a LiveCounter. + /// + /// - Note: This does not perform any resolution of the value contained at the path. + var asLiveCounter: LiveCounterPathObject { get } + + var value: Primitive? { get } + var instance: Instance? { get } + + func compact() -> CompactedValue? +} + +public protocol PathObjectCollectionMethods { + func at(path: String) -> PathObject +} + +public protocol LiveMapPathObject: PathObjectBase, PathObjectCollectionMethods, LiveMapPathObjectCollectionMethods, LiveMapOperations { + func get(key: String) -> PathObject + + var instance: LiveMapInstance? { get } + + func compact() -> CompactedValue.ObjectReference? +} + +public protocol LiveMapPathObjectCollectionMethods { + var entries: [(key: String, value: PathObject)] { get } + + var keys: [String] { get } + var values: [PathObject] { get } + + var size: Int? { get } +} + +public protocol LiveMapOperations { + func set(key: String, value: Value) async throws(ARTErrorInfo) + + func remove(key: String) async throws(ARTErrorInfo) +} + +public protocol LiveCounterPathObject: PathObjectBase, LiveCounterOperations { + var value: Double? { get } + + var instance: LiveCounterInstance? { get } + + func compact() -> Double? +} + +public protocol LiveCounterOperations { + func increment(amount: Double) async throws(ARTErrorInfo) + func decrement(amount: Double) async throws(ARTErrorInfo) +} + +/// Object returned from a `subscribe` call, allowing the listener provided in that call to be deregistered. +public protocol Subscription: Sendable { + /// Deregisters the listener passed to the `subscribe` call. + func unsubscribe() +} + +/// Object returned from an `on` call, allowing the listener provided in that call to be deregistered. +public protocol OnLiveObjectLifecycleEventResponse: Sendable { + /// Deregisters the listener passed to the `on` call. + func off() +} + +public protocol InstanceBase: AnyObject, Sendable { + var id: String? { get } + + @discardableResult + func subscribe(listener: @escaping EventCallback) throws(ARTErrorInfo) -> Subscription +} + +public protocol Instance { + func get(key: String) -> Instance? + + /// A proxy for this Instance, which exposes the LiveMap API. Returns `nil` if the underlying instance is not a LiveMap. + var asLiveMap: LiveMapInstance? { get } + + /// A proxy for this Instance, which exposes the LiveCounter API. Returns `nil` if the underlying instance is not a LiveMap. + var asLiveCounter: LiveCounterInstance? { get } + + var value: Primitive? { get } + + func compact() -> CompactedValue? +} + +public protocol LiveMapInstance: InstanceBase, LiveMapInstanceCollectionMethods, LiveMapOperations { + func get(key: String) -> Instance? + + func compact() -> CompactedValue.ObjectReference? +} + +public protocol LiveMapInstanceCollectionMethods { + var entries: [(key: String, value: Instance)] { get } + + var keys: [String] { get } + var values: [Instance] { get } + + var size: Int { get } +} + +public protocol LiveCounterInstance: InstanceBase, LiveCounterOperations { + var value: Double { get } + + func compact() -> Double? +} + +public struct LiveMap: Sendable { + public static func create(initialEntries _: [String: Value]? = nil) -> Self { + fatalError("Not implemented") + } +} + +public struct LiveCounter: Sendable { + public static func create(initialCount _: Double = 0) -> Self { + fatalError("Not implemented") + } +} + +public enum Primitive: Sendable { case string(String) case number(Double) case bool(Bool) case data(Data) case jsonArray([JSONValue]) case jsonObject([String: JSONValue]) - case liveMap(any LiveMap) - case liveCounter(any LiveCounter) - - // MARK: - Convenience getters for associated values - /// If this `LiveMapValue` has case `liveMap`, this returns the associated value. Else, it returns `nil`. - public var liveMapValue: (any LiveMap)? { - if case let .liveMap(value) = self { - return value - } - return nil - } - - /// If this `LiveMapValue` has case `liveCounter`, this returns the associated value. Else, it returns `nil`. - public var liveCounterValue: (any LiveCounter)? { - if case let .liveCounter(value) = self { - return value - } - return nil - } - - /// If this `LiveMapValue` has case `string`, this returns the associated value. Else, it returns `nil`. + /// If this `Primitive` has case `string`, this returns the associated value. Else, it returns `nil`. public var stringValue: String? { if case let .string(value) = self { return value @@ -123,7 +261,7 @@ public enum LiveMapValue: Sendable, Equatable { return nil } - /// If this `LiveMapValue` has case `number`, this returns the associated value. Else, it returns `nil`. + /// If this `Primitive` has case `number`, this returns the associated value. Else, it returns `nil`. public var numberValue: Double? { if case let .number(value) = self { return value @@ -131,7 +269,7 @@ public enum LiveMapValue: Sendable, Equatable { return nil } - /// If this `LiveMapValue` has case `bool`, this returns the associated value. Else, it returns `nil`. + /// If this `Primitive` has case `bool`, this returns the associated value. Else, it returns `nil`. public var boolValue: Bool? { if case let .bool(value) = self { return value @@ -139,7 +277,7 @@ public enum LiveMapValue: Sendable, Equatable { return nil } - /// If this `LiveMapValue` has case `data`, this returns the associated value. Else, it returns `nil`. + /// If this `Primitive` has case `data`, this returns the associated value. Else, it returns `nil`. public var dataValue: Data? { if case let .data(value) = self { return value @@ -147,7 +285,7 @@ public enum LiveMapValue: Sendable, Equatable { return nil } - /// If this `LiveMapValue` has case `jsonArray`, this returns the associated value. Else, it returns `nil`. + /// If this `Primitive` has case `jsonArray`, this returns the associated value. Else, it returns `nil`. public var jsonArrayValue: [JSONValue]? { if case let .jsonArray(value) = self { return value @@ -155,235 +293,178 @@ public enum LiveMapValue: Sendable, Equatable { return nil } - /// If this `LiveMapValue` has case `jsonObject`, this returns the associated value. Else, it returns `nil`. + /// If this `Primitive` has case `jsonObject`, this returns the associated value. Else, it returns `nil`. public var jsonObjectValue: [String: JSONValue]? { if case let .jsonObject(value) = self { return value } return nil } - - // MARK: - Equatable Implementation - - public static func == (lhs: LiveMapValue, rhs: LiveMapValue) -> Bool { - switch (lhs, rhs) { - case let (.string(lhsValue), .string(rhsValue)): - lhsValue == rhsValue - case let (.number(lhsValue), .number(rhsValue)): - lhsValue == rhsValue - case let (.bool(lhsValue), .bool(rhsValue)): - lhsValue == rhsValue - case let (.data(lhsValue), .data(rhsValue)): - lhsValue == rhsValue - case let (.jsonArray(lhsValue), .jsonArray(rhsValue)): - lhsValue == rhsValue - case let (.jsonObject(lhsValue), .jsonObject(rhsValue)): - lhsValue == rhsValue - case let (.liveMap(lhsMap), .liveMap(rhsMap)): - lhsMap === rhsMap - case let (.liveCounter(lhsCounter), .liveCounter(rhsCounter)): - lhsCounter === rhsCounter - default: - false - } - } } -// MARK: - ExpressibleBy*Literal conformances +// MARK: - Primitive ExpressibleBy*Literal conformances -extension LiveMapValue: ExpressibleByDictionaryLiteral { +extension Primitive: ExpressibleByDictionaryLiteral { public init(dictionaryLiteral elements: (String, JSONValue)...) { self = .jsonObject(.init(uniqueKeysWithValues: elements)) } } -extension LiveMapValue: ExpressibleByArrayLiteral { +extension Primitive: ExpressibleByArrayLiteral { public init(arrayLiteral elements: JSONValue...) { self = .jsonArray(elements) } } -extension LiveMapValue: ExpressibleByStringLiteral { +extension Primitive: ExpressibleByStringLiteral { public init(stringLiteral value: String) { self = .string(value) } } -extension LiveMapValue: ExpressibleByIntegerLiteral { +extension Primitive: ExpressibleByIntegerLiteral { public init(integerLiteral value: Int) { self = .number(Double(value)) } } -extension LiveMapValue: ExpressibleByFloatLiteral { +extension Primitive: ExpressibleByFloatLiteral { public init(floatLiteral value: Double) { self = .number(value) } } -extension LiveMapValue: ExpressibleByBooleanLiteral { +extension Primitive: ExpressibleByBooleanLiteral { public init(booleanLiteral value: Bool) { self = .bool(value) } } -/// Object returned from an `on` call, allowing the listener provided in that call to be deregistered. -public protocol OnObjectsEventResponse: Sendable { - /// Deregisters the listener passed to the `on` call. - func off() +public struct PathObjectSubscriptionEvent { + var object: PathObject + var message: ObjectMessage? } -/// The `LiveMap` class represents a key-value map data structure, similar to a Swift `Dictionary`, where all changes are synchronized across clients in realtime. -/// Conflicts in a LiveMap are automatically resolved with 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 ``LiveObject``, or a primitive type, such as a string, number, boolean, JSON-serializable object or array, or binary data. -public protocol LiveMap: LiveObject where Update == LiveMapUpdate { - /// Returns the value associated with a given key. Returns `nil` if the key doesn't exist in a map or if the associated ``LiveObject`` has been deleted. - /// - /// Always returns `nil` if this map object is deleted. - /// - /// - Parameter key: The key to retrieve the value for. - /// - Returns: A ``LiveObject``, a primitive type (string, number, boolean, JSON-serializable object or array, or binary data) or `nil` if the key doesn't exist in a map or the associated ``LiveObject`` has been deleted. Always `nil` if this map object is deleted. - func get(key: String) throws(ARTErrorInfo) -> LiveMapValue? - - /// Returns the number of key-value pairs in the map. - var size: Int { get throws(ARTErrorInfo) } - - /// Returns an array of key-value pairs for every entry in the map. - var entries: [(key: String, value: LiveMapValue)] { get throws(ARTErrorInfo) } - - /// Returns an array of keys in the map. - var keys: [String] { get throws(ARTErrorInfo) } - - /// Returns an iterable of values in the map. - var values: [LiveMapValue] { get throws(ARTErrorInfo) } - - /// Sends an operation to the Ably system to set a key on this `LiveMap` object to a specified value. - /// - /// This does not modify the underlying data of this object. Instead, the change is applied when - /// the published operation is echoed back to the client and applied to the object. - /// To get notified when object gets updated, use the ``LiveObject/subscribe(listener:)`` method. - /// - /// - Parameters: - /// - key: The key to set the value for. - /// - value: The value to assign to the key. - func set(key: String, value: LiveMapValue) async throws(ARTErrorInfo) - - /// Sends an operation to the Ably system to remove a key from this `LiveMap` object. - /// - /// This does not modify the underlying data of this object. Instead, the change is applied when - /// the published operation is echoed back to the client and applied to the object. - /// To get notified when object gets updated, use the ``LiveObject/subscribe(listener:)`` method. - /// - /// - Parameter key: The key to remove. - func remove(key: String) async throws(ARTErrorInfo) +public struct PathObjectSubscriptionOptions { + var depth: Int? } -/// Describes whether an entry in ``LiveMapUpdate/update`` represents an update or a removal. -public enum LiveMapUpdateAction: Sendable { - /// The value of a key in the map was updated. - case updated - /// The value of a key in the map was removed. - case removed +public struct InstanceSubscriptionEvent { + var object: Instance + var message: ObjectMessage? } -/// Represents an update to a ``LiveMap`` object, describing the keys that were updated or removed. -public protocol LiveMapUpdate: Sendable { - /// An object containing keys from a `LiveMap` that have changed, along with their change status: - /// - ``LiveMapUpdateAction/updated`` - the value of a key in the map was updated. - /// - ``LiveMapUpdateAction/removed`` - the key was removed from the map. - var update: [String: LiveMapUpdateAction] { get } +public struct ObjectMessage { + // TODO: fill this in; there's nothing too interesting here (just need to avoid a clash with the internal types with the same name) } -/// The `LiveCounter` class represents a counter that can be incremented or decremented and is synchronized across clients in realtime. -public protocol LiveCounter: LiveObject where Update == LiveCounterUpdate { - /// Returns the current value of the counter. - var value: Double { get throws(ARTErrorInfo) } - - /// Sends an operation to the Ably system to increment the value of this `LiveCounter` object. - /// - /// This does not modify the underlying data of this object. Instead, the change is applied when - /// the published operation is echoed back to the client and applied to the object. - /// To get notified when object gets updated, use the ``LiveObject/subscribe(listener:)`` method. - /// - /// - Parameter amount: The amount by which to increase the counter value. - func increment(amount: Double) async throws(ARTErrorInfo) - - /// An alias for calling [`increment(-amount)`](doc:LiveCounter/increment(amount:)). - /// - /// - Parameter amount: The amount by which to decrease the counter value. - func decrement(amount: Double) async throws(ARTErrorInfo) -} - -/// Represents an update to a ``LiveCounter`` object. -public protocol LiveCounterUpdate: Sendable { - /// Holds the numerical change to the counter value. - var amount: Double { get } -} +// A ``JSONValue``-like value whose `object` and `array` cases may contain cyclical references. +public enum CompactedValue: Sendable { + case object(ObjectReference) + case array(ArrayReference) + case string(String) + case number(Double) + case bool(Bool) + case null -/// Describes the common interface for all conflict-free data structures supported by the Objects. -public protocol LiveObject: AnyObject, Sendable { - /// The type of update event that this object emits. - associatedtype Update + public final class ObjectReference: Sendable { + public let value: [String: CompactedValue] - /// Registers a listener that is called each time this LiveObject is updated. - /// - /// - Parameter listener: An event listener function that is called with an update object whenever this LiveObject is updated. - /// - Returns: A ``SubscribeResponse`` object that allows the provided listener to be deregistered from future updates. - @discardableResult - func subscribe(listener: @escaping LiveObjectUpdateCallback) throws(ARTErrorInfo) -> SubscribeResponse + init(value: [String: CompactedValue]) { + self.value = value + } + } - /// Deregisters all listeners from updates for this LiveObject. - func unsubscribeAll() + public final class ArrayReference: Sendable { + public let value: [CompactedValue] - /// Registers the provided listener for the specified event. If `on()` is called more than once with the same listener and event, the listener is added multiple times to its listener registry. Therefore, as an example, assuming the same listener is registered twice using `on()`, and an event is emitted once, the listener would be invoked twice. - /// - /// - Parameters: - /// - event: The named event to listen for. - /// - callback: The event listener. - /// - Returns: A ``OnLiveObjectLifecycleEventResponse`` object that allows the provided listener to be deregistered from future updates. - @discardableResult - func on(event: LiveObjectLifecycleEvent, callback: @escaping LiveObjectLifecycleEventCallback) -> OnLiveObjectLifecycleEventResponse - - /// Deregisters all registrations, for all events and listeners. - func offAll() -} + init(value: [CompactedValue]) { + self.value = value + } + } -/// Object returned from a `subscribe` call, allowing the listener provided in that call to be deregistered. -public protocol SubscribeResponse: Sendable { - /// Deregisters the listener passed to the `subscribe` call. - func unsubscribe() -} + // MARK: - Convenience getters for associated values -/// Object returned from an `on` call, allowing the listener provided in that call to be deregistered. -public protocol OnLiveObjectLifecycleEventResponse: Sendable { - /// Deregisters the listener passed to the `on` call. - func off() -} + /// If this `CompactedValue` has case `object`, this returns the associated value. Else, it returns `nil`. + public var objectValue: ObjectReference? { + if case let .object(objectValue) = self { + objectValue + } else { + nil + } + } -// MARK: - AsyncSequence Extensions + /// If this `CompactedValue` has case `array`, this returns the associated value. Else, it returns `nil`. + public var arrayValue: ArrayReference? { + if case let .array(arrayValue) = self { + arrayValue + } else { + nil + } + } -/// Extension to provide AsyncSequence-based subscription for `LiveObject` updates. -public extension LiveObject { - /// Returns an `AsyncSequence` that emits updates to this `LiveObject`. - /// - /// This provides an alternative to the callback-based ``subscribe(listener:)`` method, - /// allowing you to use Swift's structured concurrency features like `for await` loops. - /// - /// - Returns: An AsyncSequence that emits ``Update`` values when the object is updated. - /// - Throws: An ``ARTErrorInfo`` if the subscription fails. - func updates() throws(ARTErrorInfo) -> AsyncStream { - let (stream, continuation) = AsyncStream.makeStream(of: Update.self) + /// If this `CompactedValue` has case `string`, this returns the associated value. Else, it returns `nil`. + public var stringValue: String? { + if case let .string(stringValue) = self { + stringValue + } else { + nil + } + } - let subscription = try subscribe { update, _ in - continuation.yield(update) + /// If this `CompactedValue` has case `number`, this returns the associated value. Else, it returns `nil`. + public var numberValue: Double? { + if case let .number(numberValue) = self { + numberValue + } else { + nil } + } - continuation.onTermination = { _ in - subscription.unsubscribe() + /// If this `CompactedValue` has case `bool`, this returns the associated value. Else, it returns `nil`. + public var boolValue: Bool? { + if case let .bool(boolValue) = self { + boolValue + } else { + nil } + } - return stream + /// Returns true if and only if this `CompactedValue` has case `null`. + public var isNull: Bool { + if case .null = self { + true + } else { + false + } } } + +// TODO: Update for new API (also note that JS now has a similar one with AsyncIterableIterator +/* + // MARK: - AsyncSequence Extensions + + /// Extension to provide AsyncSequence-based subscription for `LiveObject` updates. + public extension LiveObject { + /// Returns an `AsyncSequence` that emits updates to this `LiveObject`. + /// + /// This provides an alternative to the callback-based ``subscribe(listener:)`` method, + /// allowing you to use Swift's structured concurrency features like `for await` loops. + /// + /// - Returns: An AsyncSequence that emits ``Update`` values when the object is updated. + /// - Throws: An ``ARTErrorInfo`` if the subscription fails. + func updates() throws(ARTErrorInfo) -> AsyncStream { + let (stream, continuation) = AsyncStream.makeStream(of: Update.self) + + let subscription = try subscribe { update, _ in + continuation.yield(update) + } + + continuation.onTermination = { _ in + subscription.unsubscribe() + } + + return stream + } + } + */ diff --git a/Sources/AblyLiveObjects/Utility/Assertions.swift b/Sources/AblyLiveObjects/Utility/Assertions.swift deleted file mode 100644 index bc1045e2..00000000 --- a/Sources/AblyLiveObjects/Utility/Assertions.swift +++ /dev/null @@ -1,7 +0,0 @@ -/// Stops execution because we tried to use a feature that is not yet implemented. -internal func notYetImplemented(_ message: @autoclosure () -> String = String(), file _: StaticString = #file, line _: UInt = #line) -> Never { - fatalError({ - let returnedMessage = message() - return "Not yet implemented\(returnedMessage.isEmpty ? "" : ": \(returnedMessage)")" - }()) -} diff --git a/Sources/AblyLiveObjects/Utility/Data+Extensions.swift b/Sources/AblyLiveObjects/Utility/Data+Extensions.swift deleted file mode 100644 index 286e0bb6..00000000 --- a/Sources/AblyLiveObjects/Utility/Data+Extensions.swift +++ /dev/null @@ -1,19 +0,0 @@ -import Ably -import Foundation - -/// Errors that can occur during decoding operations. -internal enum DecodingError: Error, Equatable { - case invalidBase64String(String) -} - -internal extension Data { - /// Initialize Data from a Base64-encoded string, throwing an error if decoding fails. - /// - Parameter base64String: The Base64-encoded string to decode - /// - Throws: `ARTErrorInfo` if the string cannot be decoded as Base64 - static func fromBase64Throwing(_ base64String: String) throws(ARTErrorInfo) -> Data { - guard let data = Data(base64Encoded: base64String) else { - throw DecodingError.invalidBase64String(base64String).toARTErrorInfo() - } - return data - } -} diff --git a/Sources/AblyLiveObjects/Utility/Dictionary+Extensions.swift b/Sources/AblyLiveObjects/Utility/Dictionary+Extensions.swift deleted file mode 100644 index 9635b9eb..00000000 --- a/Sources/AblyLiveObjects/Utility/Dictionary+Extensions.swift +++ /dev/null @@ -1,8 +0,0 @@ -internal extension Dictionary { - /// Behaves like `Dictionary.mapValues`, but the thrown error has the same type as that thrown by the transform. (`mapValues` uses `rethrows`, which is always an untyped throw.) - func ablyLiveObjects_mapValuesWithTypedThrow(_ transform: (Value) throws(E) -> T) throws(E) -> [Key: T] where E: Error { - try .init(uniqueKeysWithValues: map { key, value throws(E) in - try (key, transform(value)) - }) - } -} diff --git a/Sources/AblyLiveObjects/Utility/DispatchQueue+Extensions.swift b/Sources/AblyLiveObjects/Utility/DispatchQueue+Extensions.swift deleted file mode 100644 index fc188589..00000000 --- a/Sources/AblyLiveObjects/Utility/DispatchQueue+Extensions.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Foundation - -internal extension DispatchQueue { - /// Same as `sync(execute:)` but with a runtime precondition that we are not already on this queue. - func ably_syncNoDeadlock(execute block: () -> Void) { - dispatchPrecondition(condition: .notOnQueue(self)) - sync(execute: block) - } - - /// Same as `sync(execute:)` but with a runtime precondition that we are not already on this queue. - func ably_syncNoDeadlock(execute work: () throws -> T) rethrows -> T { - dispatchPrecondition(condition: .notOnQueue(self)) - return try sync(execute: work) - } -} diff --git a/Sources/AblyLiveObjects/Utility/DispatchQueueMutex.swift b/Sources/AblyLiveObjects/Utility/DispatchQueueMutex.swift deleted file mode 100644 index 5be97eb4..00000000 --- a/Sources/AblyLiveObjects/Utility/DispatchQueueMutex.swift +++ /dev/null @@ -1,48 +0,0 @@ -import Foundation - -/// A class that provides mutually exclusive access to a value using a serial dispatch queue. -/// -/// In order to access or mutate the mutex's value, it is expected that you know whether or not you are already executing on the mutex's queue. If you are, then use ``withoutSync(_:)``, which simply performs a runtime check that the current queue is correct. If not, then use ``withSync(_:)``, which synchronously dispatches to the queue. ``withSync(_:)`` must not be called from the queue, as doing so would cause a deadlock (there is a runtime check which terminates execution in this case). -/// -/// This class is styled on Swift's built-in `Mutex` type. -internal final class DispatchQueueMutex: Sendable { - /// The queue that this mutex uses to synchronise access to the wrapped value. - internal let dispatchQueue: DispatchQueue - - private nonisolated(unsafe) var value: T - - internal init(dispatchQueue: DispatchQueue, initialValue: T) { - self.dispatchQueue = dispatchQueue - value = initialValue - } - - /// Provides access to the wrapped value by dispatching synchronously to the dispatch queue. - /// - /// - Parameters: - /// - body: The action to perform. It can read and/or mutate the wrapped value. - /// - /// - Warning: This must only be called when not already on the dispatch queue. Violating this precondition will result in a runtime error. - internal func withSync(_ body: (inout T) throws(E) -> R) throws(E) -> R { - let result: Result = dispatchQueue.ably_syncNoDeadlock { - do throws(E) { - return try .success(body(&value)) - } catch { - return .failure(error) - } - } - - return try result.get() - } - - /// Provides access to the wrapped value without dispatching to the dispatch queue. - /// - /// - Parameters: - /// - body: The action to perform. It can read and/or mutate the wrapped value. - /// - /// - Warning: This must only be called when already on the dispatch queue. Violating this precondition will result in a runtime error. - internal func withoutSync(_ body: (inout T) throws(E) -> R) throws(E) -> R { - dispatchPrecondition(condition: .onQueue(dispatchQueue)) - - return try body(&value) - } -} diff --git a/Sources/AblyLiveObjects/Utility/Errors.swift b/Sources/AblyLiveObjects/Utility/Errors.swift deleted file mode 100644 index 564f382f..00000000 --- a/Sources/AblyLiveObjects/Utility/Errors.swift +++ /dev/null @@ -1,144 +0,0 @@ -internal import _AblyPluginSupportPrivate -import Ably - -/** - Describes the errors that can be thrown by the LiveObjects SDK. Use ``toARTErrorInfo()`` to convert to an `ARTErrorInfo` that you can throw. - */ -internal enum LiveObjectsError { - // operationDescription should be a description of a method like "LiveCounter.value"; it will be interpolated into an error message - case objectsOperationFailedInvalidChannelState(operationDescription: String, channelState: _AblyPluginSupportPrivate.RealtimeChannelState) - case counterInitialValueInvalid(value: Double) - case counterIncrementAmountInvalid(amount: Double) - case other(Error) - - /// The ``ARTErrorInfo/code`` that should be returned for this error. - internal var code: ARTErrorCode { - switch self { - case .objectsOperationFailedInvalidChannelState: - .channelOperationFailedInvalidState - case .counterInitialValueInvalid, .counterIncrementAmountInvalid: - // RTO12f1, RTLC12e1 - .invalidParameterValue - case .other: - .badRequest - } - } - - /// The ``ARTErrorInfo/statusCode`` that should be returned for this error. - internal var statusCode: Int { - switch self { - case .objectsOperationFailedInvalidChannelState, - .counterInitialValueInvalid, - .counterIncrementAmountInvalid, - .other: - 400 - } - } - - /// The ``ARTErrorInfo/localizedDescription`` that should be returned for this error. - internal var localizedDescription: String { - switch self { - case let .objectsOperationFailedInvalidChannelState(operationDescription: operationDescription, channelState: channelState): - "\(operationDescription) operation failed (invalid channel state: \(channelState))" - case let .counterInitialValueInvalid(value: value): - "Invalid counter initial value (must be a finite number): \(value)" - case let .counterIncrementAmountInvalid(amount: amount): - "Invalid counter increment amount (must be a finite number): \(amount)" - case let .other(error): - "\(error)" - } - } - - internal func toARTErrorInfo() -> ARTErrorInfo { - ARTErrorInfo.create( - withCode: Int(code.rawValue), - status: statusCode, - message: localizedDescription, - additionalUserInfo: [liveObjectsErrorUserInfoKey: self], - ) - } -} - -// MARK: - ConvertibleToLiveObjectsError Protocol - -/// Protocol for types that can be converted to a `LiveObjectsError`. -/// -/// We deliberately do not conform `ARTErrorInfo` (or its parent types `NSError` or `Error`) to this protocol, so that we do not accidentally end up flattening an `ARTErrorInfo` into the `.other` `LiveObjectsError` case; if we have an `ARTErrorInfo` then it should just be thrown directly. -/// -/// If you need to convert a non-specific `NSError` or `Error` to a `LiveObjects` error, then do so explicitly using `LiveObjectsError.other`. -internal protocol ConvertibleToLiveObjectsError { - func toLiveObjectsError() -> LiveObjectsError -} - -internal extension ConvertibleToLiveObjectsError { - /// Convenience method to convert directly to an `ARTErrorInfo`. - func toARTErrorInfo() -> ARTErrorInfo { - toLiveObjectsError().toARTErrorInfo() - } -} - -// MARK: - Conversion Extensions - -extension DecodingError: ConvertibleToLiveObjectsError { - internal func toLiveObjectsError() -> LiveObjectsError { - .other(self) - } -} - -extension WireValueDecodingError: ConvertibleToLiveObjectsError { - internal func toLiveObjectsError() -> LiveObjectsError { - .other(self) - } -} - -extension WireValue.ConversionError: ConvertibleToLiveObjectsError { - internal func toLiveObjectsError() -> LiveObjectsError { - .other(self) - } -} - -extension SyncCursor.Error: ConvertibleToLiveObjectsError { - internal func toLiveObjectsError() -> LiveObjectsError { - .other(self) - } -} - -extension InboundWireObjectMessage.DecodingError: ConvertibleToLiveObjectsError { - internal func toLiveObjectsError() -> LiveObjectsError { - .other(self) - } -} - -extension StringOrData.DecodingError: ConvertibleToLiveObjectsError { - internal func toLiveObjectsError() -> LiveObjectsError { - .other(self) - } -} - -extension JSONObjectOrArray.ConversionError: ConvertibleToLiveObjectsError { - internal func toLiveObjectsError() -> LiveObjectsError { - .other(self) - } -} - -// MARK: - ARTErrorInfo Extension - -/// The `ARTErrorInfo.userInfo` key under which we store the underlying `LiveObjectsError`. Used by `testsOnly_underlyingLiveObjectsError`. -private let liveObjectsErrorUserInfoKey = "LiveObjectsError" - -internal extension ARTErrorInfo { - /// Retrieves the underlying `LiveObjectsError` from this `ARTErrorInfo` if it was generated from a `LiveObjectsError`. - /// - /// - Returns: The underlying `LiveObjectsError` if this error was generated from one, `nil` otherwise. - var testsOnly_underlyingLiveObjectsError: LiveObjectsError? { - guard let userInfoEntry = userInfo[liveObjectsErrorUserInfoKey] else { - return nil - } - - guard let liveObjectsError = userInfoEntry as? LiveObjectsError else { - preconditionFailure("Expected a LiveObjectsError, got \(userInfoEntry)") - } - - return liveObjectsError - } -} diff --git a/Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift b/Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift deleted file mode 100644 index 1f1d133d..00000000 --- a/Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift +++ /dev/null @@ -1,93 +0,0 @@ -import Foundation - -/// Like ``JSONValue``, but provides a flexible `number` case and an additional case named `extra`, which allows you to support additional types of data. It's used as a common base for the implementations of ``JSONValue`` and ``WireValue``, and for converting between them. -internal indirect enum ExtendedJSONValue { - case object([String: Self]) - case array([Self]) - case string(String) - case number(Number) - case bool(Bool) - case null - case extra(Extra) -} - -// MARK: - Bridging with Foundation - -internal extension ExtendedJSONValue { - /// Creates an `ExtendedJSONValue` from an object. - /// - /// The rules for what `deserialized` will accept are the same as those of `JSONValue.init(jsonSerializationOutput)`, with one addition: any nonsupported values are passed to the `createExtraValue` function, and the result of this function will be used to create an `ExtendedJSONValue` of case `.extra`. - init(deserialized: Any, createNumberValue: (NSNumber) -> Number, createExtraValue: (Any) -> Extra) { - switch deserialized { - case let dictionary as [String: Any]: - self = .object(dictionary.mapValues { .init(deserialized: $0, createNumberValue: createNumberValue, createExtraValue: createExtraValue) }) - case let array as [Any]: - self = .array(array.map { .init(deserialized: $0, createNumberValue: createNumberValue, createExtraValue: createExtraValue) }) - case let string as String: - self = .string(string) - case let number as NSNumber: - // We need to be careful to distinguish booleans from numbers of value 0 or 1; technique taken from https://forums.swift.org/t/jsonserialization-turns-bool-value-to-nsnumber/31909/3 - if number === kCFBooleanTrue { - self = .bool(true) - } else if number === kCFBooleanFalse { - self = .bool(false) - } else { - self = .number(createNumberValue(number)) - } - case is NSNull: - self = .null - default: - self = .extra(createExtraValue(deserialized)) - } - } - - /// Converts an `ExtendedJSONValue` to an object. - /// - /// The contract for what this will return are the same as those of `JSONValue.toJSONSerializationInputElement`, with one addition: any values in the input of case `.extra` will be passed to the `serializeExtraValue` function, and the result of this function call will be inserted into the output object. - func serialized(serializeNumberValue: (Number) -> Any, serializeExtraValue: (Extra) -> Any) -> Any { - switch self { - case let .object(underlying): - underlying.mapValues { $0.serialized(serializeNumberValue: serializeNumberValue, serializeExtraValue: serializeExtraValue) } - case let .array(underlying): - underlying.map { $0.serialized(serializeNumberValue: serializeNumberValue, serializeExtraValue: serializeExtraValue) } - case let .string(underlying): - underlying - case let .number(underlying): - serializeNumberValue(underlying) - case let .bool(underlying): - underlying - case .null: - NSNull() - case let .extra(extra): - serializeExtraValue(extra) - } - } -} - -// MARK: - Transforming the extra data - -internal extension ExtendedJSONValue { - /// Converts this `ExtendedJSONValue` to an `ExtendedJSONValue` using given transformations. - func map(number transformNumber: @escaping (Number) throws(Failure) -> NewNumber, extra transformExtra: @escaping (Extra) throws(Failure) -> NewExtra) throws(Failure) -> ExtendedJSONValue { - switch self { - case let .object(underlying): - try .object(underlying.ablyLiveObjects_mapValuesWithTypedThrow { value throws(Failure) in - try value.map(number: transformNumber, extra: transformExtra) - }) - case let .array(underlying): - try .array(underlying.map { element throws(Failure) in - try element.map(number: transformNumber, extra: transformExtra) - }) - case let .string(underlying): - .string(underlying) - case let .number(underlying): - try .number(transformNumber(underlying)) - case let .bool(underlying): - .bool(underlying) - case .null: - .null - case let .extra(extra): - try .extra(transformExtra(extra)) - } - } -} diff --git a/Sources/AblyLiveObjects/Utility/JSONValue.swift b/Sources/AblyLiveObjects/Utility/JSONValue.swift index 02a71c90..9ebf461d 100644 --- a/Sources/AblyLiveObjects/Utility/JSONValue.swift +++ b/Sources/AblyLiveObjects/Utility/JSONValue.swift @@ -125,185 +125,3 @@ extension JSONValue: ExpressibleByBooleanLiteral { self = .bool(value) } } - -// MARK: - Bridging with JSONSerialization - -internal extension JSONValue { - /// Creates a `JSONValue` from the output of Foundation's `JSONSerialization`. - /// - /// This means that it accepts either: - /// - /// - The result of serializing an array or dictionary using `JSONSerialization` - /// - Some nested element of the result of serializing such an array or dictionary - init(jsonSerializationOutput: Any) { - let extended = ExtendedJSONValue(deserialized: jsonSerializationOutput, createNumberValue: { $0.doubleValue }, createExtraValue: { deserializedExtraValue in - // JSONSerialization is not conforming to our assumptions; our assumptions are probably wrong. Either way, bring this loudly to our attention instead of trying to carry on - preconditionFailure("JSONValue(jsonSerializationOutput:) was given unsupported value \(deserializedExtraValue)") - }) - - self.init(extendedJSONValue: extended) - } - - /// Converts a `JSONValue` to an input for Foundation's `JSONSerialization`. - /// - /// This means that it returns: - /// - /// - All cases: An object which we can put inside an array or dictionary that we ask `JSONSerialization` to serialize - /// - Additionally, if case `object` or `array`: An object which we can ask `JSONSerialization` to serialize - var toJSONSerializationInputElement: Any { - toExtendedJSONValue.serialized(serializeNumberValue: { $0 as NSNumber }, serializeExtraValue: { _ in }) - } -} - -// MARK: - JSON objects and arrays - -/// A subset of ``JSONValue`` that has only `object` or `array` cases. -internal enum JSONObjectOrArray: Equatable { - case object([String: JSONValue]) - case array([JSONValue]) - - internal enum ConversionError: Swift.Error { - case incompatibleJSONValue(JSONValue) - } - - internal init(jsonValue: JSONValue) throws(ARTErrorInfo) { - self = switch jsonValue { - case let .array(array): - .array(array) - case let .object(object): - .object(object) - case .bool, .number, .string, .null: - throw ConversionError.incompatibleJSONValue(jsonValue).toARTErrorInfo() - } - } - - // MARK: - Convenience getters for associated values - - /// If this `JSONObjectOrArray` has case `object`, this returns the associated value. Else, it returns `nil`. - internal var objectValue: [String: JSONValue]? { - if case let .object(value) = self { - return value - } - return nil - } - - /// If this `JSONObjectOrArray` has case `array`, this returns the associated value. Else, it returns `nil`. - internal var arrayValue: [JSONValue]? { - if case let .array(value) = self { - return value - } - return nil - } -} - -extension JSONObjectOrArray: ExpressibleByDictionaryLiteral { - internal init(dictionaryLiteral elements: (String, JSONValue)...) { - self = .object(.init(uniqueKeysWithValues: elements)) - } -} - -extension JSONObjectOrArray: ExpressibleByArrayLiteral { - internal init(arrayLiteral elements: JSONValue...) { - self = .array(elements) - } -} - -internal extension [String: JSONValue] { - /// Converts a dictionary that has string keys and `JSONValue` values into an input for Foundation's `JSONSerialization`. - var toJSONSerializationInput: [String: Any] { - mapValues(\.toJSONSerializationInputElement) - } -} - -internal extension [JSONValue] { - /// Converts an array that has `JSONValue` values into an input for Foundation's `JSONSerialization`. - var toJSONSerializationInput: [Any] { - map(\.toJSONSerializationInputElement) - } -} - -// MARK: - Conversion to/from ExtendedJSONValue - -internal extension JSONValue { - init(extendedJSONValue: ExtendedJSONValue) { - switch extendedJSONValue { - case let .object(underlying): - self = .object(underlying.mapValues { .init(extendedJSONValue: $0) }) - case let .array(underlying): - self = .array(underlying.map { .init(extendedJSONValue: $0) }) - case let .string(underlying): - self = .string(underlying) - case let .number(underlying): - self = .number(underlying) - case let .bool(underlying): - self = .bool(underlying) - case .null: - self = .null - } - } - - var toExtendedJSONValue: ExtendedJSONValue { - switch self { - case let .object(underlying): - .object(underlying.mapValues(\.toExtendedJSONValue)) - case let .array(underlying): - .array(underlying.map(\.toExtendedJSONValue)) - case let .string(underlying): - .string(underlying) - case let .number(underlying): - .number(underlying) - case let .bool(underlying): - .bool(underlying) - case .null: - .null - } - } -} - -// MARK: Serializing to and deserializing from a JSON string - -internal extension JSONObjectOrArray { - enum DecodingError: Swift.Error { - case incompatibleJSONValue(JSONValue) - } - - /// Deserializes a JSON string into a `JSONObjectOrArray`. Throws an error if not given a valid JSON string. - init(jsonString: String) throws(ARTErrorInfo) { - let data = Data(jsonString.utf8) - let jsonSerializationOutput: Any - do { - jsonSerializationOutput = try JSONSerialization.jsonObject(with: data) - } catch { - throw LiveObjectsError.other(error).toARTErrorInfo() - } - - let jsonValue = JSONValue(jsonSerializationOutput: jsonSerializationOutput) - try self.init(jsonValue: jsonValue) - } - - /// Converts a `JSONObjectOrArray` into an input for Foundation's `JSONSerialization`. - private var toJSONSerializationInput: Any { - switch self { - case let .array(array): - array.toJSONSerializationInput - case let .object(object): - object.toJSONSerializationInput - } - } - - /// Serializes a `JSONObjectOrArray` to a JSON string. - var toJSONString: String { - let data: Data - do { - data = try JSONSerialization.data(withJSONObject: toJSONSerializationInput) - } catch { - preconditionFailure("Unexpected error encoding to JSON: \(error)") - } - - guard let string = String(data: data, encoding: .utf8) else { - preconditionFailure("Unexpected failure to decode output of JSONSerialization as UTF-8") - } - - return string - } -} diff --git a/Sources/AblyLiveObjects/Utility/Logger.swift b/Sources/AblyLiveObjects/Utility/Logger.swift deleted file mode 100644 index 90cb7d49..00000000 --- a/Sources/AblyLiveObjects/Utility/Logger.swift +++ /dev/null @@ -1,41 +0,0 @@ -internal import _AblyPluginSupportPrivate - -/// A reference to a line within a source code file. -internal struct CodeLocation: Equatable { - /// A file identifier in the format used by Swift's `#fileID` macro. For example, `"AblyChat/Room.swift"`. - internal var fileID: String - /// The line number in the source code file referred to by ``fileID``. - internal var line: Int -} - -internal protocol Logger: Sendable { - func log(_ message: String, level: _AblyPluginSupportPrivate.LogLevel, codeLocation: CodeLocation) -} - -internal extension AblyLiveObjects.Logger { - /// A convenience method that provides default values for `file` and `line`. - func log(_ message: String, level: _AblyPluginSupportPrivate.LogLevel, fileID: String = #fileID, line: Int = #line) { - let codeLocation = CodeLocation(fileID: fileID, line: line) - log(message, level: level, codeLocation: codeLocation) - } -} - -internal final class DefaultLogger: Logger { - private let pluginLogger: _AblyPluginSupportPrivate.Logger - private let pluginAPI: _AblyPluginSupportPrivate.PluginAPIProtocol - - internal init(pluginLogger: _AblyPluginSupportPrivate.Logger, pluginAPI: _AblyPluginSupportPrivate.PluginAPIProtocol) { - self.pluginLogger = pluginLogger - self.pluginAPI = pluginAPI - } - - internal func log(_ message: String, level: LogLevel, codeLocation: CodeLocation) { - pluginAPI.log( - message, - with: level, - file: codeLocation.fileID, - line: codeLocation.line, - logger: pluginLogger, - ) - } -} diff --git a/Sources/AblyLiveObjects/Utility/LoggingUtilities.swift b/Sources/AblyLiveObjects/Utility/LoggingUtilities.swift deleted file mode 100644 index c1939abd..00000000 --- a/Sources/AblyLiveObjects/Utility/LoggingUtilities.swift +++ /dev/null @@ -1,14 +0,0 @@ -import Foundation - -internal enum LoggingUtilities { - /// Formats an array of object messages for logging with one message per line. - /// - Parameter objectMessages: The array of object messages to format - /// - Returns: A formatted string with one message per line - internal static func formatObjectMessagesForLogging(_ objectMessages: [some CustomDebugStringConvertible]) -> String { - guard !objectMessages.isEmpty else { - return "[]" - } - - return "[\n" + objectMessages.map { " \($0)" }.joined(separator: ",\n") + "\n]" - } -} diff --git a/Sources/AblyLiveObjects/Utility/MarkerProtocolHelpers.swift b/Sources/AblyLiveObjects/Utility/MarkerProtocolHelpers.swift deleted file mode 100644 index 28bf619c..00000000 --- a/Sources/AblyLiveObjects/Utility/MarkerProtocolHelpers.swift +++ /dev/null @@ -1,54 +0,0 @@ -internal import _AblyPluginSupportPrivate -import Ably - -/// Upcasts an instance of an `_AblyPluginSupportPrivate` marker protocol to the concrete type that this marker protocol represents. -internal func castPluginPublicMarkerProtocolValue(_ pluginMarkerProtocolValue: Any, to _: T.Type) -> T { - guard let actualPublicValue = pluginMarkerProtocolValue as? T else { - preconditionFailure("Expected \(T.self), got \(type(of: pluginMarkerProtocolValue))") - } - - return actualPublicValue -} - -internal extension ARTRealtimeChannel { - /// Downcasts this `ARTRealtimeChannel` to its `_AblyPluginSupportPrivate` equivalent type `PublicRealtimeChannel`. - /// - /// - Note: Swift compiler restrictions prevent us from declaring `ARTRealtimeChannel` as conforming to `PublicRealtimeChannel` (this is due to our use of `internal import`). - var asPluginPublicRealtimeChannel: _AblyPluginSupportPrivate.PublicRealtimeChannel { - // In order for this cast to succeed, we rely on the fact that ably-cocoa internally declares ARTRealtimeChannel as conforming to PublicRealtimeChannel. - // swiftlint:disable:next force_cast - self as! _AblyPluginSupportPrivate.PublicRealtimeChannel - } -} - -internal extension ARTClientOptions { - /// Downcasts this `ARTClientOptions` to its `_AblyPluginSupportPrivate` marker protocol type `PublicClientOptions`. - /// - /// - Note: Swift compiler restrictions prevent us from declaring `ARTClientOptions` as conforming to `PublicClientOptions` (this is due to our use of `internal import`). - var asPluginPublicClientOptions: _AblyPluginSupportPrivate.PublicClientOptions { - // In order for this cast to succeed, we rely on the fact that ably-cocoa internally declares ARTClientOptions as conforming to PublicClientOptions. - // swiftlint:disable:next force_cast - self as! _AblyPluginSupportPrivate.PublicClientOptions - } - - /// Upcasts an instance of `_AblyPluginSupportPrivate`'s `PublicClientOptions`, which is the marker protocol that it uses to represent an `ARTClientOptions`, to an `ARTClientOptions`. - static func castPluginPublicClientOptions(_ pluginPublicClientOptions: PublicClientOptions) -> Self { - castPluginPublicMarkerProtocolValue(pluginPublicClientOptions, to: Self.self) - } -} - -internal extension ARTErrorInfo { - /// Downcasts this `ARTErrorInfo` to its `_AblyPluginSupportPrivate` marker protocol type `PublicErrorInfo`. - /// - /// - Note: Swift compiler restrictions prevent us from declaring `ARTErrorInfo` as conforming to `PublicErrorInfo` (this is due to our use of `internal import`). - var asPluginPublicErrorInfo: _AblyPluginSupportPrivate.PublicErrorInfo { - // In order for this cast to succeed, we rely on the fact that ably-cocoa internally declares ARTErrorInfo as conforming to PublicErrorInfo. - // swiftlint:disable:next force_cast - self as! _AblyPluginSupportPrivate.PublicErrorInfo - } - - /// Upcasts an instance of `_AblyPluginSupportPrivate`'s `PublicErrorInfo`, which is the marker protocol that it uses to represent an `ARTErrorInfo`, to an `ARTErrorInfo`. - static func castPluginPublicErrorInfo(_ pluginPublicErrorInfo: PublicErrorInfo) -> Self { - castPluginPublicMarkerProtocolValue(pluginPublicErrorInfo, to: Self.self) - } -} diff --git a/Sources/AblyLiveObjects/Utility/NSLock+Extensions.swift b/Sources/AblyLiveObjects/Utility/NSLock+Extensions.swift deleted file mode 100644 index 0a9540aa..00000000 --- a/Sources/AblyLiveObjects/Utility/NSLock+Extensions.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Foundation - -internal extension NSLock { - /// Behaves like `NSLock.withLock`, but the thrown error has the same type as that thrown by the body. (`withLock` uses `rethrows`, which is always an untyped throw.) - func ablyLiveObjects_withLockWithTypedThrow(_ body: () throws(E) -> R) throws(E) -> R { - lock() - defer { unlock() } - return try body() - } -} diff --git a/Sources/AblyLiveObjects/Utility/WeakRef.swift b/Sources/AblyLiveObjects/Utility/WeakRef.swift deleted file mode 100644 index c7175a7e..00000000 --- a/Sources/AblyLiveObjects/Utility/WeakRef.swift +++ /dev/null @@ -1,8 +0,0 @@ -/// A struct that holds a weak reference to an object. -/// -/// This allows us to store a weak reference inside a Sendable object. The pattern comes from the [`weak let` proposal](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0481-weak-let.md). (We can get rid of this type and use `weak let` once Swift 6.2 is out.) -internal struct WeakRef { - internal weak var referenced: Referenced? -} - -extension WeakRef: Sendable where Referenced: Sendable {} diff --git a/Sources/AblyLiveObjects/Utility/WireCodable.swift b/Sources/AblyLiveObjects/Utility/WireCodable.swift deleted file mode 100644 index b68485d1..00000000 --- a/Sources/AblyLiveObjects/Utility/WireCodable.swift +++ /dev/null @@ -1,390 +0,0 @@ -import Ably -import Foundation - -internal protocol WireEncodable { - var toWireValue: WireValue { get } -} - -internal protocol WireDecodable { - init(wireValue: WireValue) throws(ARTErrorInfo) -} - -internal typealias WireCodable = WireDecodable & WireEncodable - -internal protocol WireObjectEncodable: WireEncodable { - var toWireObject: [String: WireValue] { get } -} - -// Default implementation of `WireEncodable` conformance for `WireObjectEncodable` -internal extension WireObjectEncodable { - var toWireValue: WireValue { - .object(toWireObject) - } -} - -internal protocol WireObjectDecodable: WireDecodable { - init(wireObject: [String: WireValue]) throws(ARTErrorInfo) -} - -internal enum WireValueDecodingError: Error { - case valueIsNotObject - case noValueForKey(String) - case wrongTypeForKey(String, actualValue: WireValue) - case failedToDecodeFromRawValue(String) -} - -// Default implementation of `WireDecodable` conformance for `WireObjectDecodable` -internal extension WireObjectDecodable { - init(wireValue: WireValue) throws(ARTErrorInfo) { - guard case let .object(wireObject) = wireValue else { - throw WireValueDecodingError.valueIsNotObject.toARTErrorInfo() - } - - self = try .init(wireObject: wireObject) - } -} - -internal typealias WireObjectCodable = WireObjectDecodable & WireObjectEncodable - -// MARK: - Extracting primitive values from a dictionary - -/// This extension adds some helper methods for extracting values from a dictionary of `WireValue` values; you may find them helpful when implementing `WireCodable`. -internal extension [String: WireValue] { - /// If this dictionary contains a value for `key`, and this value has case `object`, this returns the associated value. - /// - /// - Throws: - /// - `WireValueDecodingError.noValueForKey` if the key is absent - /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `object` - func objectValueForKey(_ key: String) throws(ARTErrorInfo) -> [String: WireValue] { - guard let value = self[key] else { - throw WireValueDecodingError.noValueForKey(key).toARTErrorInfo() - } - - guard case let .object(objectValue) = value else { - throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() - } - - return objectValue - } - - /// If this dictionary contains a value for `key`, and this value has case `object`, this returns the associated value. If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. - /// - /// - Throws: `WireValueDecodingError.wrongTypeForKey` if the value does not have case `object` or `null` - func optionalObjectValueForKey(_ key: String) throws(ARTErrorInfo) -> [String: WireValue]? { - guard let value = self[key] else { - return nil - } - - if case .null = value { - return nil - } - - guard case let .object(objectValue) = value else { - throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() - } - - return objectValue - } - - /// If this dictionary contains a value for `key`, and this value has case `array`, this returns the associated value. - /// - /// - Throws: - /// - `WireValueDecodingError.noValueForKey` if the key is absent - /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `array` - func arrayValueForKey(_ key: String) throws(ARTErrorInfo) -> [WireValue] { - guard let value = self[key] else { - throw WireValueDecodingError.noValueForKey(key).toARTErrorInfo() - } - - guard case let .array(arrayValue) = value else { - throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() - } - - return arrayValue - } - - /// If this dictionary contains a value for `key`, and this value has case `array`, this returns the associated value. If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. - /// - /// - Throws: `WireValueDecodingError.wrongTypeForKey` if the value does not have case `array` or `null` - func optionalArrayValueForKey(_ key: String) throws(ARTErrorInfo) -> [WireValue]? { - guard let value = self[key] else { - return nil - } - - if case .null = value { - return nil - } - - guard case let .array(arrayValue) = value else { - throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() - } - - return arrayValue - } - - /// If this dictionary contains a value for `key`, and this value has case `string`, this returns the associated value. - /// - /// - Throws: - /// - `WireValueDecodingError.noValueForKey` if the key is absent - /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `string` - func stringValueForKey(_ key: String) throws(ARTErrorInfo) -> String { - guard let value = self[key] else { - throw WireValueDecodingError.noValueForKey(key).toARTErrorInfo() - } - - guard case let .string(stringValue) = value else { - throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() - } - - return stringValue - } - - /// If this dictionary contains a value for `key`, and this value has case `string`, this returns the associated value. If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. - /// - /// - Throws: `WireValueDecodingError.wrongTypeForKey` if the value does not have case `string` or `null` - func optionalStringValueForKey(_ key: String) throws(ARTErrorInfo) -> String? { - guard let value = self[key] else { - return nil - } - - if case .null = value { - return nil - } - - guard case let .string(stringValue) = value else { - throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() - } - - return stringValue - } - - /// If this dictionary contains a value for `key`, and this value has case `number`, this returns the associated value. - /// - /// - Throws: - /// - `WireValueDecodingError.noValueForKey` if the key is absent - /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `number` - func numberValueForKey(_ key: String) throws(ARTErrorInfo) -> NSNumber { - guard let value = self[key] else { - throw WireValueDecodingError.noValueForKey(key).toARTErrorInfo() - } - - guard case let .number(numberValue) = value else { - throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() - } - - return numberValue - } - - /// If this dictionary contains a value for `key`, and this value has case `number`, this returns the associated value. If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. - /// - /// - Throws: `WireValueDecodingError.wrongTypeForKey` if the value does not have case `number` or `null` - func optionalNumberValueForKey(_ key: String) throws(ARTErrorInfo) -> NSNumber? { - guard let value = self[key] else { - return nil - } - - if case .null = value { - return nil - } - - guard case let .number(numberValue) = value else { - throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() - } - - return numberValue - } - - /// If this dictionary contains a value for `key`, and this value has case `bool`, this returns the associated value. - /// - /// - Throws: - /// - `WireValueDecodingError.noValueForKey` if the key is absent - /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `bool` - func boolValueForKey(_ key: String) throws(ARTErrorInfo) -> Bool { - guard let value = self[key] else { - throw WireValueDecodingError.noValueForKey(key).toARTErrorInfo() - } - - guard case let .bool(boolValue) = value else { - throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() - } - - return boolValue - } - - /// If this dictionary contains a value for `key`, and this value has case `bool`, this returns the associated value. If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. - /// - /// - Throws: `WireValueDecodingError.wrongTypeForKey` if the value does not have case `bool` or `null` - func optionalBoolValueForKey(_ key: String) throws(ARTErrorInfo) -> Bool? { - guard let value = self[key] else { - return nil - } - - if case .null = value { - return nil - } - - guard case let .bool(boolValue) = value else { - throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() - } - - return boolValue - } - - /// If this dictionary contains a value for `key`, and this value has case `data`, this returns the associated value. - /// - /// - Throws: - /// - `WireValueDecodingError.noValueForKey` if the key is absent - /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `data` - func dataValueForKey(_ key: String) throws(ARTErrorInfo) -> Data { - guard let value = self[key] else { - throw WireValueDecodingError.noValueForKey(key).toARTErrorInfo() - } - - guard case let .data(dataValue) = value else { - throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() - } - - return dataValue - } - - /// If this dictionary contains a value for `key`, and this value has case `data`, this returns the associated value. If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. - /// - /// - Throws: `WireValueDecodingError.wrongTypeForKey` if the value does not have case `data` or `null` - func optionalDataValueForKey(_ key: String) throws(ARTErrorInfo) -> Data? { - guard let value = self[key] else { - return nil - } - - if case .null = value { - return nil - } - - guard case let .data(dataValue) = value else { - throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() - } - - return dataValue - } -} - -// MARK: - Extracting dates from a dictionary - -internal extension [String: WireValue] { - /// If this dictionary contains a value for `key`, and this value has case `number`, this returns a date created by interpreting this value as the number of milliseconds since the Unix epoch (which is the format used by Ably). - /// - /// - Throws: - /// - `WireValueDecodingError.noValueForKey` if the key is absent - /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `number` - func ablyProtocolDateValueForKey(_ key: String) throws(ARTErrorInfo) -> Date { - let millisecondsSinceEpoch = try numberValueForKey(key).uint64Value - - return dateFromMillisecondsSinceEpoch(millisecondsSinceEpoch) - } - - /// If this dictionary contains a value for `key`, and this value has case `number`, this returns a date created by interpreting this value as the number of milliseconds since the Unix epoch (which is the format used by Ably). If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. - /// - /// - Throws: `WireValueDecodingError.wrongTypeForKey` if the value does not have case `number` or `null` - func optionalAblyProtocolDateValueForKey(_ key: String) throws(ARTErrorInfo) -> Date? { - guard let millisecondsSinceEpoch = try optionalNumberValueForKey(key)?.uint64Value else { - return nil - } - return dateFromMillisecondsSinceEpoch(millisecondsSinceEpoch) - } - - private func dateFromMillisecondsSinceEpoch(_ millisecondsSinceEpoch: UInt64) -> Date { - .init(timeIntervalSince1970: Double(millisecondsSinceEpoch) / 1000) - } -} - -// MARK: - Extracting RawRepresentable values from a dictionary - -internal extension [String: WireValue] { - /// If this dictionary contains a value for `key`, and this value has case `string`, this creates an instance of `T` using its `init(rawValue:)` initializer. - /// - /// - Throws: - /// - `WireValueDecodingError.noValueForKey` if the key is absent - /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `string` - /// - `WireValueDecodingError.failedToDecodeFromRawValue` if `init(rawValue:)` returns `nil` - func rawRepresentableValueForKey(_ key: String, type _: T.Type = T.self) throws(ARTErrorInfo) -> T where T.RawValue == String { - let rawValue = try stringValueForKey(key) - - return try rawRepresentableValueFromRawValue(rawValue, type: T.self) - } - - /// If this dictionary contains a value for `key`, and this value has case `string`, this creates an instance of `T` using its `init(rawValue:)` initializer. If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. - /// - /// - Throws: - /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `string` or `null` - /// - `WireValueDecodingError.failedToDecodeFromRawValue` if `init(rawValue:)` returns `nil` - func optionalRawRepresentableValueForKey(_ key: String, type _: T.Type = T.self) throws(ARTErrorInfo) -> T? where T.RawValue == String { - guard let rawValue = try optionalStringValueForKey(key) else { - return nil - } - - return try rawRepresentableValueFromRawValue(rawValue, type: T.self) - } - - private func rawRepresentableValueFromRawValue(_ rawValue: String, type _: T.Type = T.self) throws(ARTErrorInfo) -> T where T.RawValue == String { - guard let value = T(rawValue: rawValue) else { - throw WireValueDecodingError.failedToDecodeFromRawValue(rawValue).toARTErrorInfo() - } - - return value - } -} - -// MARK: - Extracting WireEnum values from a dictionary - -internal extension [String: WireValue] { - /// If this dictionary contains a value for `key`, and this value has case `number`, this creates a `WireEnum` instance using its `init(rawValue:)` initializer. - /// - /// - Throws: - /// - `WireValueDecodingError.noValueForKey` if the key is absent - /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `number` - func wireEnumValueForKey(_ key: String, type _: Known.Type = Known.self) throws(ARTErrorInfo) -> WireEnum where Known.RawValue == Int { - let rawValue = try numberValueForKey(key).intValue - return WireEnum(rawValue: rawValue) - } - - /// If this dictionary contains a value for `key`, and this value has case `number`, this creates a `WireEnum` instance using its `init(rawValue:)` initializer. If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. - /// - /// - Throws: `WireValueDecodingError.wrongTypeForKey` if the value does not have case `number` or `null` - func optionalWireEnumValueForKey(_ key: String, type _: Known.Type = Known.self) throws(ARTErrorInfo) -> WireEnum? where Known.RawValue == Int { - guard let rawValue = try optionalNumberValueForKey(key)?.intValue else { - return nil - } - return WireEnum(rawValue: rawValue) - } -} - -// MARK: - Extracting WireDecodable values from a dictionary - -internal extension [String: WireValue] { - /// If this dictionary contains a value for `key`, this attempts to decode it into an instance of `T` using its `init(wireValue:)` initializer. - /// - /// - Throws: - /// - `WireValueDecodingError.noValueForKey` if the key is absent - /// - Any error thrown by `T.init(wireValue:)` - func decodableValueForKey(_ key: String, type _: T.Type = T.self) throws(ARTErrorInfo) -> T { - guard let value = self[key] else { - throw WireValueDecodingError.noValueForKey(key).toARTErrorInfo() - } - - return try T(wireValue: value) - } - - /// If this dictionary contains a value for `key`, this attempts to decode it into an instance of `T` using its `init(wireValue:)` initializer. If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. - /// - /// - Throws: Any error thrown by `T.init(wireValue:)` - func optionalDecodableValueForKey(_ key: String, type _: T.Type = T.self) throws(ARTErrorInfo) -> T? { - guard let value = self[key] else { - return nil - } - - if case .null = value { - return nil - } - - return try T(wireValue: value) - } -} diff --git a/Sources/AblyLiveObjects/Utility/WireValue.swift b/Sources/AblyLiveObjects/Utility/WireValue.swift deleted file mode 100644 index c8b75f28..00000000 --- a/Sources/AblyLiveObjects/Utility/WireValue.swift +++ /dev/null @@ -1,250 +0,0 @@ -import Ably -import Foundation - -/// A wire value that can be represents the kinds of data that we expect to find inside a deserialized wire object received from `_AblyPluginSupportPrivate`, or which we may put inside a serialized wire object that we send to `_AblyPluginSupportPrivate`. -/// -/// Its cases are a superset of those of ``JSONValue``, adding a further `data` case for binary data (we expect to be able to send and receive binary data in the case where ably-cocoa is using the MessagePack format). Also, its `number` case is `NSNumber` instead of `Double`, to allow us to communicate to ably-cocoa's MessagePack encoder that it should encode certain values (e.g. enums) as integers, not doubles. -internal indirect enum WireValue: Sendable, Equatable { - case object([String: WireValue]) - case array([WireValue]) - case string(String) - case number(NSNumber) - case bool(Bool) - case null - case data(Data) - - // MARK: - Convenience getters for associated values - - /// If this `WireValue` has case `object`, this returns the associated value. Else, it returns `nil`. - internal var objectValue: [String: WireValue]? { - if case let .object(objectValue) = self { - objectValue - } else { - nil - } - } - - /// If this `WireValue` has case `array`, this returns the associated value. Else, it returns `nil`. - internal var arrayValue: [WireValue]? { - if case let .array(arrayValue) = self { - arrayValue - } else { - nil - } - } - - /// If this `WireValue` has case `string`, this returns the associated value. Else, it returns `nil`. - internal var stringValue: String? { - if case let .string(stringValue) = self { - stringValue - } else { - nil - } - } - - /// If this `WireValue` has case `number`, this returns the associated value. Else, it returns `nil`. - internal var numberValue: NSNumber? { - if case let .number(numberValue) = self { - numberValue - } else { - nil - } - } - - /// If this `WireValue` has case `bool`, this returns the associated value. Else, it returns `nil`. - internal var boolValue: Bool? { - if case let .bool(boolValue) = self { - boolValue - } else { - nil - } - } - - /// If this `WireValue` has case `data`, this returns the associated value. Else, it returns `nil`. - internal var dataValue: Data? { - if case let .data(dataValue) = self { - dataValue - } else { - nil - } - } - - /// Returns true if and only if this `WireValue` has case `null`. - internal var isNull: Bool { - if case .null = self { - true - } else { - false - } - } -} - -extension WireValue: ExpressibleByDictionaryLiteral { - internal init(dictionaryLiteral elements: (String, WireValue)...) { - self = .object(.init(uniqueKeysWithValues: elements)) - } -} - -extension WireValue: ExpressibleByArrayLiteral { - internal init(arrayLiteral elements: WireValue...) { - self = .array(elements) - } -} - -extension WireValue: ExpressibleByStringLiteral { - internal init(stringLiteral value: String) { - self = .string(value) - } -} - -extension WireValue: ExpressibleByIntegerLiteral { - internal init(integerLiteral value: Int) { - self = .number(value as NSNumber) - } -} - -extension WireValue: ExpressibleByFloatLiteral { - internal init(floatLiteral value: Double) { - self = .number(value as NSNumber) - } -} - -extension WireValue: ExpressibleByBooleanLiteral { - internal init(booleanLiteral value: Bool) { - self = .bool(value) - } -} - -// MARK: - Bridging with ably-cocoa - -internal extension WireValue { - /// Creates a `WireValue` from an `_AblyPluginSupportPrivate` deserialized wire object. - /// - /// Specifically, `pluginSupportData` can be a value that was passed to `LiveObjectsPlugin.decodeObjectMessage:…`. - init(pluginSupportData: Any) { - let extendedJSONValue = ExtendedJSONValue(deserialized: pluginSupportData, createNumberValue: { $0 }, createExtraValue: { deserializedExtraValue in - // We support binary data (used for MessagePack format) in addition to JSON values - if let data = deserializedExtraValue as? Data { - return .data(data) - } - - // ably-cocoa is not conforming to our assumptions; our assumptions are probably wrong. Either way, bring this loudly to our attention instead of trying to carry on - preconditionFailure("WireValue(pluginSupportData:) was given unsupported value \(deserializedExtraValue)") - }) - - self.init(extendedJSONValue: extendedJSONValue) - } - - /// Creates a `WireValue` from an `_AblyPluginSupportPrivate` deserialized wire object. Specifically, `pluginSupportData` can be a value that was passed to `LiveObjectsPlugin.decodeObjectMessage:…`. - static func objectFromPluginSupportData(_ pluginSupportData: [String: Any]) -> [String: WireValue] { - let wireValue = WireValue(pluginSupportData: pluginSupportData) - guard case let .object(wireObject) = wireValue else { - preconditionFailure() - } - - return wireObject - } - - /// Creates an `_AblyPluginSupportPrivate` deserialized wire object from a `WireValue`. - /// - /// Used by `[String: WireValue].toPluginSupportDataDictionary`. - var toPluginSupportData: Any { - toExtendedJSONValue.serialized(serializeNumberValue: { $0 }, serializeExtraValue: { extendedValue in - switch extendedValue { - case let .data(data): - data - } - }) - } -} - -internal extension [String: WireValue] { - /// Creates an `_AblyPluginSupportPrivate` deserialized wire object from a dictionary that has string keys and `WireValue` values. - /// - /// Specifically, the value of this property can be returned from `APLiveObjectsPlugin.encodeObjectMessage:`. - var toPluginSupportDataDictionary: [String: Any] { - mapValues(\.toPluginSupportData) - } -} - -// MARK: - Conversion to/from ExtendedJSONValue - -internal extension WireValue { - enum ExtraValue { - case data(Data) - } - - init(extendedJSONValue: ExtendedJSONValue) { - switch extendedJSONValue { - case let .object(underlying): - self = .object(underlying.mapValues { .init(extendedJSONValue: $0) }) - case let .array(underlying): - self = .array(underlying.map { .init(extendedJSONValue: $0) }) - case let .string(underlying): - self = .string(underlying) - case let .number(underlying): - self = .number(underlying) - case let .bool(underlying): - self = .bool(underlying) - case .null: - self = .null - case let .extra(extra): - switch extra { - case let .data(data): - self = .data(data) - } - } - } - - var toExtendedJSONValue: ExtendedJSONValue { - switch self { - case let .object(underlying): - .object(underlying.mapValues(\.toExtendedJSONValue)) - case let .array(underlying): - .array(underlying.map(\.toExtendedJSONValue)) - case let .string(underlying): - .string(underlying) - case let .number(underlying): - .number(underlying) - case let .bool(underlying): - .bool(underlying) - case .null: - .null - case let .data(data): - .extra(.data(data)) - } - } -} - -// MARK: - Conversion to/from JSONValue - -internal extension WireValue { - /// Converts a `JSONValue` to its corresponding `WireValue`. - init(jsonValue: JSONValue) { - self.init(extendedJSONValue: jsonValue.toExtendedJSONValue.map(number: { (number: Double) -> NSNumber in - number as NSNumber - }, extra: { (extra: Never) in extra })) - } - - enum ConversionError: Error { - case dataCannotBeConvertedToJSONValue - } - - /// Tries to convert this `WireValue` to its corresponding `JSONValue`. - /// - /// - Throws: `ConversionError.dataCannotBeConvertedToJSONValue` if `WireValue` represents binary data. - var toJSONValue: JSONValue { - get throws(ARTErrorInfo) { - let neverExtended = try toExtendedJSONValue.map(number: { (number: NSNumber) throws(ARTErrorInfo) -> Double in - number.doubleValue - }, extra: { (extra: ExtraValue) throws(ARTErrorInfo) -> Never in - switch extra { - case .data: - throw ConversionError.dataCannotBeConvertedToJSONValue.toARTErrorInfo() - } - }) - - return .init(extendedJSONValue: neverExtended) - } - } -} diff --git a/Sources/AblyLiveObjects/example.swift b/Sources/AblyLiveObjects/example.swift new file mode 100644 index 00000000..5e87b082 --- /dev/null +++ b/Sources/AblyLiveObjects/example.swift @@ -0,0 +1,37 @@ +import Ably + +// An example to show the usage of the proposed asLiveMap and asLiveCounter properties + +func exampleWithNoUserTypes(channel: ARTRealtimeChannel) async throws { + // `object` has type `any LiveMapPathObject` + let object = try await channel.object.get() + + // - `topLevelCounter` has type `any LiveCounterPathObject` + // - the compiler will not let you call any LiveMap methods on it (e.g. `entries()`, `set()`) + let topLevelCounter = object.get(key: "topLevelCounter").asLiveCounter + + // - `topLevelMap` has type `any LiveMapPathObject` + // - the compiler will not let you call any LiveCounter methods on it (e.g. `increment()`) + let topLevelMap = object.get(key: "topLevelMap").asLiveMap + + // And imagining if there were a LiveList type, we'd have an asLiveList property too. Its `entries()` would have a different return value type to that of `asLiveMap.entries()`. +} + +// An example to show the consequent changes to the Instance API + +func instanceExampleWithNoUserTypes(channel: ARTRealtimeChannel) async throws { + let object = try await channel.object.get() + + let topLevelCounter = object.get(key: "topLevelCounter") + + // topLevelCounterInstance has type `(any LiveCounterInstance)?`. If it is non-nil, then the underlying value is a LiveCounter + let topLevelCounterInstance = topLevelCounter.instance?.asLiveCounter + + guard let topLevelCounterInstance else { + // the underlying value is not a LiveCounter + return + } + + // counterInstanceValue has type Double (i.e. there's no equivalent to the "undefined" possibility in JS) + let counterInstanceValue = topLevelCounterInstance.value +} diff --git a/ably.d.ts b/ably.d.ts new file mode 100644 index 00000000..eda8fe47 --- /dev/null +++ b/ably.d.ts @@ -0,0 +1,5160 @@ +// Type definitions for Ably Realtime and Rest client library 1.2 +// Project: https://www.ably.com/ +// Definitions by: Ably +// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped + +/** + * You are currently viewing the default variant of the Ably JavaScript Client Library SDK. View the modular variant {@link modular | here}. + * + * To get started with the Ably JavaScript Client Library SDK, follow the [Quickstart Guide](https://ably.com/docs/quick-start-guide) or view the introductions to the [realtime](https://ably.com/docs/realtime/usage) and [REST](https://ably.com/docs/rest/usage) interfaces. + * + * @module + */ + +/** + * The `ChannelStates` namespace describes the possible values of the {@link ChannelState} type. + */ +declare namespace ChannelStates { + /** + * The channel has been initialized but no attach has yet been attempted. + */ + type INITIALIZED = 'initialized'; + /** + * An attach has been initiated by sending a request to Ably. This is a transient state, followed either by a transition to `ATTACHED`, `SUSPENDED`, or `FAILED`. + */ + type ATTACHING = 'attaching'; + /** + * The attach has succeeded. In the `ATTACHED` state a client may publish and subscribe to messages, or be present on the channel. + */ + type ATTACHED = 'attached'; + /** + * A detach has been initiated on an `ATTACHED` channel by sending a request to Ably. This is a transient state, followed either by a transition to `DETACHED` or `FAILED`. + */ + type DETACHING = 'detaching'; + /** + * The channel, having previously been `ATTACHED`, has been detached by the user. + */ + type DETACHED = 'detached'; + /** + * The channel, having previously been `ATTACHED`, has lost continuity, usually due to the client being disconnected from Ably for longer than two minutes. It will automatically attempt to reattach as soon as connectivity is restored. + */ + type SUSPENDED = 'suspended'; + /** + * An indefinite failure condition. This state is entered if a channel error has been received from the Ably service, such as an attempt to attach without the necessary access rights. + */ + type FAILED = 'failed'; +} +/** + * Describes the possible states of a {@link Channel} or {@link RealtimeChannel} object. + */ +export type ChannelState = + | ChannelStates.FAILED + | ChannelStates.INITIALIZED + | ChannelStates.SUSPENDED + | ChannelStates.ATTACHED + | ChannelStates.ATTACHING + | ChannelStates.DETACHED + | ChannelStates.DETACHING; + +/** + * The `ChannelEvents` namespace describes the possible values of the {@link ChannelEvent} type. + */ +declare namespace ChannelEvents { + /** + * The channel has been initialized but no attach has yet been attempted. + */ + type INITIALIZED = 'initialized'; + /** + * An attach has been initiated by sending a request to Ably. This is a transient state, followed either by a transition to `ATTACHED`, `SUSPENDED`, or `FAILED`. + */ + type ATTACHING = 'attaching'; + /** + * The attach has succeeded. In the `ATTACHED` state a client may publish and subscribe to messages, or be present on the channel. + */ + type ATTACHED = 'attached'; + /** + * A detach has been initiated on an `ATTACHED` channel by sending a request to Ably. This is a transient state, followed either by a transition to `DETACHED` or `FAILED`. + */ + type DETACHING = 'detaching'; + /** + * The channel, having previously been `ATTACHED`, has been detached by the user. + */ + type DETACHED = 'detached'; + /** + * The channel, having previously been `ATTACHED`, has lost continuity, usually due to the client being disconnected from Ably for longer than two minutes. It will automatically attempt to reattach as soon as connectivity is restored. + */ + type SUSPENDED = 'suspended'; + /** + * An indefinite failure condition. This state is entered if a channel error has been received from the Ably service, such as an attempt to attach without the necessary access rights. + */ + type FAILED = 'failed'; + /** + * An event for changes to channel conditions that do not result in a change in {@link ChannelState}. + */ + type UPDATE = 'update'; +} +/** + * Describes the events emitted by a {@link Channel} or {@link RealtimeChannel} object. An event is either an `UPDATE` or a {@link ChannelState}. + */ +export type ChannelEvent = + | ChannelEvents.FAILED + | ChannelEvents.INITIALIZED + | ChannelEvents.SUSPENDED + | ChannelEvents.ATTACHED + | ChannelEvents.ATTACHING + | ChannelEvents.DETACHED + | ChannelEvents.DETACHING + | ChannelEvents.UPDATE; + +/** + * The `ConnectionStates` namespace describes the possible values of the {@link ConnectionState} type. + */ +declare namespace ConnectionStates { + /** + * A connection with this state has been initialized but no connection has yet been attempted. + */ + type INITIALIZED = 'initialized'; + /** + * A connection attempt has been initiated. The connecting state is entered as soon as the library has completed initialization, and is reentered each time connection is re-attempted following disconnection. + */ + type CONNECTING = 'connecting'; + /** + * A connection exists and is active. + */ + type CONNECTED = 'connected'; + /** + * A temporary failure condition. No current connection exists because there is no network connectivity or no host is available. The disconnected state is entered if an established connection is dropped, or if a connection attempt was unsuccessful. In the disconnected state the library will periodically attempt to open a new connection (approximately every 15 seconds), anticipating that the connection will be re-established soon and thus connection and channel continuity will be possible. In this state, developers can continue to publish messages as they are automatically placed in a local queue, to be sent as soon as a connection is reestablished. Messages published by other clients while this client is disconnected will be delivered to it upon reconnection, so long as the connection was resumed within 2 minutes. After 2 minutes have elapsed, recovery is no longer possible and the connection will move to the `SUSPENDED` state. + */ + type DISCONNECTED = 'disconnected'; + /** + * A long term failure condition. No current connection exists because there is no network connectivity or no host is available. The suspended state is entered after a failed connection attempt if there has then been no connection for a period of two minutes. In the suspended state, the library will periodically attempt to open a new connection every 30 seconds. Developers are unable to publish messages in this state. A new connection attempt can also be triggered by an explicit call to {@link Connection.connect | `connect()`}. Once the connection has been re-established, channels will be automatically re-attached. The client has been disconnected for too long for them to resume from where they left off, so if it wants to catch up on messages published by other clients while it was disconnected, it needs to use the [History API](https://ably.com/docs/realtime/history). + */ + type SUSPENDED = 'suspended'; + /** + * An explicit request by the developer to close the connection has been sent to the Ably service. If a reply is not received from Ably within a short period of time, the connection is forcibly terminated and the connection state becomes `CLOSED`. + */ + type CLOSING = 'closing'; + /** + * The connection has been explicitly closed by the client. In the closed state, no reconnection attempts are made automatically by the library, and clients may not publish messages. No connection state is preserved by the service or by the library. A new connection attempt can be triggered by an explicit call to {@link Connection.connect | `connect()`}, which results in a new connection. + */ + type CLOSED = 'closed'; + /** + * This state is entered if the client library encounters a failure condition that it cannot recover from. This may be a fatal connection error received from the Ably service, for example an attempt to connect with an incorrect API key, or a local terminal error, for example the token in use has expired and the library does not have any way to renew it. In the failed state, no reconnection attempts are made automatically by the library, and clients may not publish messages. A new connection attempt can be triggered by an explicit call to {@link Connection.connect | `connect()`}. + */ + type FAILED = 'failed'; +} +/** + * Describes the realtime {@link Connection} object states. + */ +export type ConnectionState = + | ConnectionStates.INITIALIZED + | ConnectionStates.CONNECTED + | ConnectionStates.CONNECTING + | ConnectionStates.DISCONNECTED + | ConnectionStates.SUSPENDED + | ConnectionStates.CLOSED + | ConnectionStates.CLOSING + | ConnectionStates.FAILED; + +/** + * The `ConnectionEvents` namespace describes the possible values of the {@link ConnectionEvent} type. + */ +declare namespace ConnectionEvents { + /** + * A connection with this state has been initialized but no connection has yet been attempted. + */ + type INITIALIZED = 'initialized'; + /** + * A connection attempt has been initiated. The connecting state is entered as soon as the library has completed initialization, and is reentered each time connection is re-attempted following disconnection. + */ + type CONNECTING = 'connecting'; + /** + * A connection exists and is active. + */ + type CONNECTED = 'connected'; + /** + * A temporary failure condition. No current connection exists because there is no network connectivity or no host is available. The disconnected state is entered if an established connection is dropped, or if a connection attempt was unsuccessful. In the disconnected state the library will periodically attempt to open a new connection (approximately every 15 seconds), anticipating that the connection will be re-established soon and thus connection and channel continuity will be possible. In this state, developers can continue to publish messages as they are automatically placed in a local queue, to be sent as soon as a connection is reestablished. Messages published by other clients while this client is disconnected will be delivered to it upon reconnection, so long as the connection was resumed within 2 minutes. After 2 minutes have elapsed, recovery is no longer possible and the connection will move to the `SUSPENDED` state. + */ + type DISCONNECTED = 'disconnected'; + /** + * A long term failure condition. No current connection exists because there is no network connectivity or no host is available. The suspended state is entered after a failed connection attempt if there has then been no connection for a period of two minutes. In the suspended state, the library will periodically attempt to open a new connection every 30 seconds. Developers are unable to publish messages in this state. A new connection attempt can also be triggered by an explicit call to {@link Connection.connect | `connect()`}. Once the connection has been re-established, channels will be automatically re-attached. The client has been disconnected for too long for them to resume from where they left off, so if it wants to catch up on messages published by other clients while it was disconnected, it needs to use the [History API](https://ably.com/docs/realtime/history). + */ + type SUSPENDED = 'suspended'; + /** + * An explicit request by the developer to close the connection has been sent to the Ably service. If a reply is not received from Ably within a short period of time, the connection is forcibly terminated and the connection state becomes `CLOSED`. + */ + type CLOSING = 'closing'; + /** + * The connection has been explicitly closed by the client. In the closed state, no reconnection attempts are made automatically by the library, and clients may not publish messages. No connection state is preserved by the service or by the library. A new connection attempt can be triggered by an explicit call to {@link Connection.connect | `connect()`}, which results in a new connection. + */ + type CLOSED = 'closed'; + /** + * This state is entered if the client library encounters a failure condition that it cannot recover from. This may be a fatal connection error received from the Ably service, for example an attempt to connect with an incorrect API key, or a local terminal error, for example the token in use has expired and the library does not have any way to renew it. In the failed state, no reconnection attempts are made automatically by the library, and clients may not publish messages. A new connection attempt can be triggered by an explicit call to {@link Connection.connect | `connect()`}. + */ + type FAILED = 'failed'; + /** + * An event for changes to connection conditions for which the {@link ConnectionState} does not change. + */ + type UPDATE = 'update'; +} +/** + * Describes the events emitted by a {@link Connection} object. An event is either an `UPDATE` or a {@link ConnectionState}. + */ +export type ConnectionEvent = + | ConnectionEvents.INITIALIZED + | ConnectionEvents.CONNECTED + | ConnectionEvents.CONNECTING + | ConnectionEvents.DISCONNECTED + | ConnectionEvents.SUSPENDED + | ConnectionEvents.CLOSED + | ConnectionEvents.CLOSING + | ConnectionEvents.FAILED + | ConnectionEvents.UPDATE; + +/** + * The `PresenceActions` namespace describes the possible values of the {@link PresenceAction} type. + */ +declare namespace PresenceActions { + /** + * A member is not present in the channel. + */ + type ABSENT = 'absent'; + /** + * When subscribing to presence events on a channel that already has members present, this event is emitted for every member already present on the channel before the subscribe listener was registered. + */ + type PRESENT = 'present'; + /** + * A new member has entered the channel. + */ + type ENTER = 'enter'; + /** + * A member who was present has now left the channel. This may be a result of an explicit request to leave or implicitly when detaching from the channel. Alternatively, if a member's connection is abruptly disconnected and they do not resume their connection within a minute, Ably treats this as a leave event as the client is no longer present. + */ + type LEAVE = 'leave'; + /** + * An already present member has updated their member data. Being notified of member data updates can be very useful, for example, it can be used to update the status of a user when they are typing a message. + */ + type UPDATE = 'update'; +} +/** + * Describes the possible actions members in the presence set can emit. + */ +export type PresenceAction = + | PresenceActions.ABSENT + | PresenceActions.PRESENT + | PresenceActions.ENTER + | PresenceActions.LEAVE + | PresenceActions.UPDATE; + +/** + * The `StatsIntervalGranularities` namespace describes the possible values of the {@link StatsIntervalGranularity} type. + */ +declare namespace StatsIntervalGranularities { + /** + * Interval unit over which statistics are gathered as minutes. + */ + type MINUTE = 'minute'; + /** + * Interval unit over which statistics are gathered as hours. + */ + type HOUR = 'hour'; + /** + * Interval unit over which statistics are gathered as days. + */ + type DAY = 'day'; + /** + * Interval unit over which statistics are gathered as months. + */ + type MONTH = 'month'; +} +/** + * Describes the interval unit over which statistics are gathered. + */ +export type StatsIntervalGranularity = + | StatsIntervalGranularities.MINUTE + | StatsIntervalGranularities.HOUR + | StatsIntervalGranularities.DAY + | StatsIntervalGranularities.MONTH; + +/** + * HTTP Methods, used internally. + */ +declare namespace HTTPMethods { + /** + * Represents a HTTP POST request. + */ + type POST = 'POST'; + /** + * Represents a HTTP GET request. + */ + type GET = 'GET'; +} +/** + * HTTP Methods, used internally. + */ +export type HTTPMethod = HTTPMethods.GET | HTTPMethods.POST; + +/** + * A type which specifies the valid transport names. [See here](https://faqs.ably.com/which-transports-are-supported) for more information. + */ +export type Transport = 'web_socket' | 'xhr_polling' | 'comet'; + +/** + * Contains the details of a {@link Channel} or {@link RealtimeChannel} object such as its ID and {@link ChannelStatus}. + */ +export interface ChannelDetails { + /** + * The identifier of the channel. + */ + channelId: string; + /** + * A {@link ChannelStatus} object. + */ + status: ChannelStatus; +} + +/** + * Contains the status of a {@link Channel} or {@link RealtimeChannel} object such as whether it is active and its {@link ChannelOccupancy}. + */ +export interface ChannelStatus { + /** + * If `true`, the channel is active, otherwise `false`. + */ + isActive: boolean; + /** + * A {@link ChannelOccupancy} object. + */ + occupancy: ChannelOccupancy; +} + +/** + * Contains the metrics of a {@link Channel} or {@link RealtimeChannel} object. + */ +export interface ChannelOccupancy { + /** + * A {@link ChannelMetrics} object. + */ + metrics: ChannelMetrics; +} + +/** + * Contains the metrics associated with a {@link Channel} or {@link RealtimeChannel}, such as the number of publishers, subscribers and connections it has. + */ +export interface ChannelMetrics { + /** + * The number of realtime connections attached to the channel. + */ + connections: number; + /** + * The number of realtime connections attached to the channel with permission to enter the presence set, regardless of whether or not they have entered it. This requires the `presence` capability and for a client to not have specified a {@link ChannelMode} flag that excludes {@link ChannelModes.PRESENCE}. + */ + presenceConnections: number; + /** + * The number of members in the presence set of the channel. + */ + presenceMembers: number; + /** + * The number of realtime attachments receiving presence messages on the channel. This requires the `subscribe` capability and for a client to not have specified a {@link ChannelMode} flag that excludes {@link ChannelModes.PRESENCE_SUBSCRIBE}. + */ + presenceSubscribers: number; + /** + * The number of realtime attachments permitted to publish messages to the channel. This requires the `publish` capability and for a client to not have specified a {@link ChannelMode} flag that excludes {@link ChannelModes.PUBLISH}. + */ + publishers: number; + /** + * The number of realtime attachments receiving messages on the channel. This requires the `subscribe` capability and for a client to not have specified a {@link ChannelMode} flag that excludes {@link ChannelModes.SUBSCRIBE}. + */ + subscribers: number; +} + +/** + * Passes additional client-specific properties to the REST constructor or the Realtime constructor. + */ +export interface ClientOptions extends AuthOptions { + /** + * When `true`, the client connects to Ably as soon as it is instantiated. You can set this to `false` and explicitly connect to Ably using the {@link Connection.connect | `connect()`} method. The default is `true`. + * + * @defaultValue `true` + */ + autoConnect?: boolean; + + /** + * When a {@link TokenParams} object is provided, it overrides the client library defaults when issuing new Ably Tokens or Ably {@link TokenRequest | `TokenRequest`s}. + */ + defaultTokenParams?: TokenParams; + + /** + * If `false`, prevents messages originating from this connection being echoed back on the same connection. The default is `true`. + * + * @defaultValue `true` + */ + echoMessages?: boolean; + + /** + * Set a routing policy or FQDN to connect to Ably. See [platform customization](https://ably.com/docs/platform-customization). + */ + endpoint?: string; + + /** + * @deprecated This property is deprecated and will be removed in a future version. Use the {@link ClientOptions.endpoint} client option instead. + */ + environment?: string; + + /** + * Controls the verbosity of the logs output from the library. Valid values are: 0 (no logs), 1 (errors only), 2 (errors plus connection and channel state changes), 3 (high-level debug output), and 4 (full debug output). + */ + logLevel?: number; + + /** + * Controls the log output of the library. This is a function to handle each line of log output. If you do not set this value, then `console.log` will be used. + * + * @param msg - The log message emitted by the library. + * @param level - The level of the log. Values are one of: 0 (no logs), 1 (errors only), 2 (errors plus connection and channel state changes), 3 (high-level debug output), and 4 (full debug output). + */ + logHandler?: (msg: string, level: number) => void; + + /** + * Enables a non-default Ably port to be specified. For development environments only. The default value is 80. + * + * @defaultValue 80 + */ + port?: number; + + /** + * If `false`, this disables the default behavior whereby the library queues messages on a connection in the disconnected or connecting states. The default behavior enables applications to submit messages immediately upon instantiating the library without having to wait for the connection to be established. Applications may use this option to disable queueing if they wish to have application-level control over the queueing. The default is `true`. + * + * @defaultValue `true` + */ + queueMessages?: boolean; + + /** + * @deprecated This property is deprecated and will be removed in a future version. Use the {@link ClientOptions.endpoint} client option instead. + */ + restHost?: string; + + /** + * @deprecated This property is deprecated and will be removed in a future version. Use the {@link ClientOptions.endpoint} client option instead. + */ + realtimeHost?: string; + + /** + * An array of fallback hosts to be used in the case of an error necessitating the use of an alternative host. If you have been provided a set of custom fallback hosts by Ably, please specify them here. + * + * @defaultValue `['a.ably-realtime.com', 'b.ably-realtime.com', 'c.ably-realtime.com', 'd.ably-realtime.com', 'e.ably-realtime.com']`` + */ + fallbackHosts?: string[]; + + /** + * Set of configurable options to set on the HTTP(S) agent used for REST requests. + * + * See the [NodeJS docs](https://nodejs.org/api/http.html#new-agentoptions) for descriptions of these options. + */ + restAgentOptions?: { + /** + * See [NodeJS docs](https://nodejs.org/api/http.html#new-agentoptions) + */ + maxSockets?: number; + /** + * See [NodeJS docs](https://nodejs.org/api/http.html#new-agentoptions) + */ + keepAlive?: boolean; + }; + + /** + * Enables a connection to inherit the state of a previous connection that may have existed under a different instance of the Realtime library. This might typically be used by clients of the browser library to ensure connection state can be preserved when the user refreshes the page. A recovery key string can be explicitly provided, or alternatively if a callback function is provided, the client library will automatically persist the recovery key between page reloads and call the callback when the connection is recoverable. The callback is then responsible for confirming whether the connection should be recovered or not. See [connection state recovery](https://ably.com/docs/realtime/connection/#connection-state-recovery) for further information. + */ + recover?: string | recoverConnectionCallback; + + /** + * If specified, the SDK's internal persistence mechanism for storing the recovery key + * over page loads (see the `recover` client option) will store the recovery key under + * this identifier (in sessionstorage), so only another library instance which specifies + * the same recoveryKeyStorageName will attempt to recover from it. This is useful if you have + * multiple ably-js instances sharing a given origin (the origin being the scope of + * sessionstorage), as otherwise the multiple instances will overwrite each other's + * recovery keys, and after a reload they will all try and recover the same connection, + * which is not permitted and will cause broken behaviour. + */ + recoveryKeyStorageName?: string; + + /** + * When `false`, the client will use an insecure connection. The default is `true`, meaning a TLS connection will be used to connect to Ably. + * + * @defaultValue `true` + */ + tls?: boolean; + + /** + * Enables a non-default Ably TLS port to be specified. For development environments only. The default value is 443. + * + * @defaultValue 443 + */ + tlsPort?: number; + + /** + * When `true`, the more efficient MsgPack binary encoding is used. When `false`, JSON text encoding is used. The default is `true` for Node.js, and `false` for all other platforms. + * + * @defaultValue `true` for Node.js, `false` for all other platforms + */ + useBinaryProtocol?: boolean; + + /** + * Override the URL used by the realtime client to check if the internet is available. + * + * In the event of a failure to connect to the primary endpoint, the client will send a + * GET request to this URL to check if the internet is available. If this request returns + * a success response the client will attempt to connect to a fallback host. + */ + connectivityCheckUrl?: string; + + /** + * Override the URL used by the realtime client to check if WebSocket connections are available. + * + * If the client suspects that WebSocket connections are unavailable on the current network, + * it will attempt to open a WebSocket connection to this URL to check WebSocket connectivity. + * If this fails, the client will attempt to connect to Ably systems using fallback transports, if available. + */ + wsConnectivityCheckUrl?: string; + + /** + * Disable the check used by the realtime client to check if the internet + * is available before connecting to a fallback host. + */ + disableConnectivityCheck?: boolean; + + /** + * If the connection is still in the {@link ConnectionStates.DISCONNECTED} state after this delay in milliseconds, the client library will attempt to reconnect automatically. The default is 15 seconds. + * + * @defaultValue 15s + */ + disconnectedRetryTimeout?: number; + + /** + * When the connection enters the {@link ConnectionStates.SUSPENDED} state, after this delay in milliseconds, if the state is still {@link ConnectionStates.SUSPENDED | `SUSPENDED`}, the client library attempts to reconnect automatically. The default is 30 seconds. + * + * @defaultValue 30s + */ + suspendedRetryTimeout?: number; + + /** + * When `true`, the client library will automatically send a close request to Ably whenever the `window` [`beforeunload` event](https://developer.mozilla.org/en-US/docs/Web/API/BeforeUnloadEvent) fires. By enabling this option, the close request sent to Ably ensures the connection state will not be retained and all channels associated with the channel will be detached. This is commonly used by developers who want presence leave events to fire immediately (that is, if a user navigates to another page or closes their browser, then enabling this option will result in the presence member leaving immediately). Without this option or an explicit call to the `close` method of the `Connection` object, Ably expects that the abruptly disconnected connection could later be recovered and therefore does not immediately remove the user from presence. Instead, to avoid “twitchy” presence behaviour an abruptly disconnected client is removed from channels in which they are present after 15 seconds, and the connection state is retained for two minutes. Defaults to `true`. + */ + closeOnUnload?: boolean; + + /** + * When `true`, enables idempotent publishing by assigning a unique message ID client-side, allowing the Ably servers to discard automatic publish retries following a failure such as a network fault. The default is `true`. + * + * @defaultValue `true` + */ + idempotentRestPublishing?: boolean; + + /** + * A set of key-value pairs that can be used to pass in arbitrary connection parameters, such as [`heartbeatInterval`](https://ably.com/docs/realtime/connection#heartbeats) or [`remainPresentFor`](https://ably.com/docs/realtime/presence#unstable-connections). + */ + transportParams?: { [k: string]: string | number | boolean }; + + /** + * An array of transports to use, in descending order of preference. In the browser environment the available transports are: `web_socket` and `xhr`. + */ + transports?: Transport[]; + + /** + * The maximum number of fallback hosts to use as a fallback when an HTTP request to the primary host is unreachable or indicates that it is unserviceable. The default value is 3. + * + * @defaultValue 3 + */ + httpMaxRetryCount?: number; + + /** + * The maximum elapsed time in milliseconds in which fallback host retries for HTTP requests will be attempted. The default is 15 seconds. + * + * @defaultValue 15s + */ + httpMaxRetryDuration?: number; + + /** + * Timeout in milliseconds for opening a connection to Ably to initiate an HTTP request. The default is 4 seconds. + * + * @defaultValue 4s + */ + httpOpenTimeout?: number; + + /** + * Timeout in milliseconds for a client performing a complete HTTP request to Ably, including the connection phase. The default is 10 seconds. + * + * @defaultValue 10s + */ + httpRequestTimeout?: number; + + /** + * Timeout for the wait of acknowledgement for operations performed via a realtime connection, before the client library considers a request failed and triggers a failure condition. Operations include establishing a connection with Ably, or sending a `HEARTBEAT`, `CONNECT`, `ATTACH`, `DETACH` or `CLOSE` request. It is the equivalent of `httpRequestTimeout` but for realtime operations, rather than REST. The default is 10 seconds. + * + * @defaultValue 10s + */ + realtimeRequestTimeout?: number; + + /** + * A map between a plugin type and a plugin object. + */ + plugins?: Plugins; + + /** + * The maximum message size is an attribute of an Ably account which represents the largest permitted payload size of a single message or set of messages published in a single operation. Publish requests whose payload exceeds this limit are rejected by the server. `maxMessageSize` enables the client to enforce, or further restrict, the maximum size of a single message or set of messages published via REST. The default value is `65536` (64 KiB). In the case of a realtime connection, the server may indicate the associated maximum message size on connection establishment; this value takes precedence over the client's default `maxMessageSize`. + * + * @defaultValue 65536 + */ + maxMessageSize?: number; + + /** + * A URL pointing to a service worker script which is used as the target for web push notifications. + */ + pushServiceWorkerUrl?: string; +} + +/** + * Describes the {@link ClientOptions.plugins | plugins} accepted by all variants of the SDK. + */ +export interface CorePlugins { + /** + * A plugin capable of decoding `vcdiff`-encoded messages. For more information on how to configure a channel to use delta encoding, see the [documentation for the `@ably-forks/vcdiff-decoder` package](https://github.com/ably-forks/vcdiff-decoder#usage). + */ + vcdiff?: any; + + /** + * A plugin which allows the client to be the target of push notifications. + */ + Push?: unknown; + + /** + * A plugin which allows the client to use LiveObjects functionality at {@link RealtimeChannel.object}. + */ + Objects?: unknown; +} + +/** + * Passes authentication-specific properties in authentication requests to Ably. Properties set using `AuthOptions` are used instead of the default values set when the client library is instantiated, as opposed to being merged with them. + */ +export interface AuthOptions { + /** + * Called when a new token is required. The role of the callback is to obtain a fresh token, one of: an Ably Token string (in plain text format); a signed {@link TokenRequest}; a {@link TokenDetails} (in JSON format); an [Ably JWT](https://ably.com/docs/core-features/authentication.ably-jwt). See [the authentication documentation](https://ably.com/docs/realtime/authentication) for details of the Ably {@link TokenRequest} format and associated API calls. + * + * @param data - The parameters that should be used to generate the token. + * @param callback - A function which, upon success, the `authCallback` should call with one of: an Ably Token string (in plain text format); a signed `TokenRequest`; a `TokenDetails` (in JSON format); an [Ably JWT](https://ably.com/docs/core-features/authentication#ably-jwt). Upon failure, the `authCallback` should call this function with information about the error. + */ + authCallback?( + data: TokenParams, + /** + * A function which, upon success, the `authCallback` should call with one of: an Ably Token string (in plain text format); a signed `TokenRequest`; a `TokenDetails` (in JSON format); an [Ably JWT](https://ably.com/docs/core-features/authentication#ably-jwt). Upon failure, the `authCallback` should call this function with information about the error. + * + * @param error - Should be `null` if the auth request completed successfully, or containing details of the error if not. + * @param tokenRequestOrDetails - A valid `TokenRequest`, `TokenDetails` or Ably JWT to be used for authentication. + */ + callback: ( + error: ErrorInfo | string | null, + tokenRequestOrDetails: TokenDetails | TokenRequest | string | null, + ) => void, + ): void; + + /** + * A set of key-value pair headers to be added to any request made to the `authUrl`. Useful when an application requires these to be added to validate the request or implement the response. If the `authHeaders` object contains an `authorization` key, then `withCredentials` is set on the XHR request. + */ + authHeaders?: { [index: string]: string }; + + /** + * The HTTP verb to use for any request made to the `authUrl`, either `GET` or `POST`. The default value is `GET`. + * + * @defaultValue `HTTPMethod.GET` + */ + authMethod?: HTTPMethod; + + /** + * A set of key-value pair params to be added to any request made to the `authUrl`. When the `authMethod` is `GET`, query params are added to the URL, whereas when `authMethod` is `POST`, the params are sent as URL encoded form data. Useful when an application requires these to be added to validate the request or implement the response. + */ + authParams?: { [index: string]: string }; + + /** + * A URL that the library may use to obtain a token string (in plain text format), or a signed {@link TokenRequest} or {@link TokenDetails} (in JSON format) from. + */ + authUrl?: string; + + /** + * The full API key string, as obtained from the [Ably dashboard](https://ably.com/dashboard). Use this option if you wish to use Basic authentication, or wish to be able to issue Ably Tokens without needing to defer to a separate entity to sign Ably {@link TokenRequest | `TokenRequest`s}. Read more about [Basic authentication](https://ably.com/docs/core-features/authentication#basic-authentication). + */ + key?: string; + + /** + * If `true`, the library queries the Ably servers for the current time when issuing {@link TokenRequest | `TokenRequest`s} instead of relying on a locally-available time of day. Knowing the time accurately is needed to create valid signed Ably {@link TokenRequest | `TokenRequest`s}, so this option is useful for library instances on auth servers where for some reason the server clock cannot be kept synchronized through normal means, such as an [NTP daemon](https://en.wikipedia.org/wiki/Ntpd). The server is queried for the current time once per client library instance (which stores the offset from the local clock), so if using this option you should avoid instancing a new version of the library for each request. The default is `false`. + * + * @defaultValue `false` + */ + queryTime?: boolean; + + /** + * An authenticated token. This can either be a {@link TokenDetails} object or token string (obtained from the `token` property of a {@link TokenDetails} component of an Ably {@link TokenRequest} response, or a JSON Web Token satisfying [the Ably requirements for JWTs](https://ably.com/docs/core-features/authentication#ably-jwt)). This option is mostly useful for testing: since tokens are short-lived, in production you almost always want to use an authentication method that enables the client library to renew the token automatically when the previous one expires, such as `authUrl` or `authCallback`. Read more about [Token authentication](https://ably.com/docs/core-features/authentication#token-authentication). + */ + token?: TokenDetails | string; + + /** + * An authenticated {@link TokenDetails} object (most commonly obtained from an Ably Token Request response). This option is mostly useful for testing: since tokens are short-lived, in production you almost always want to use an authentication method that enables the client library to renew the token automatically when the previous one expires, such as `authUrl` or `authCallback`. Use this option if you wish to use Token authentication. Read more about [Token authentication](https://ably.com/docs/core-features/authentication#token-authentication). + */ + tokenDetails?: TokenDetails; + + /** + * When `true`, forces token authentication to be used by the library. If a `clientId` is not specified in the {@link ClientOptions} or {@link TokenParams}, then the Ably Token issued is [anonymous](https://ably.com/docs/core-features/authentication#identified-clients). + */ + useTokenAuth?: boolean; + + /** + * A client ID, used for identifying this client when publishing messages or for presence purposes. The `clientId` can be any non-empty string, except it cannot contain a `*`. This option is primarily intended to be used in situations where the library is instantiated with a key. Note that a `clientId` may also be implicit in a token used to instantiate the library. An error will be raised if a `clientId` specified here conflicts with the `clientId` implicit in the token. Find out more about [client identities](https://ably.com/documentation/how-ably-works#client-identity). + */ + clientId?: string; +} + +/** + * Capabilities which are available for use within {@link TokenParams}. + */ +export type capabilityOp = + | 'publish' + | 'subscribe' + | 'presence' + | 'message-update-any' + | 'message-update-own' + | 'message-delete-any' + | 'message-delete-own' + | 'history' + | 'stats' + | 'channel-metadata' + | 'push-subscribe' + | 'push-admin' + | 'privileged-headers'; + +/** + * Capabilities which are available for use within {@link TokenParams}. + */ +export type CapabilityOp = capabilityOp; + +/** + * Defines the properties of an Ably Token. + */ +export interface TokenParams { + /** + * The capabilities associated with this Ably Token. The capabilities value is a JSON-encoded representation of the resource paths and associated operations. Read more about capabilities in the [capabilities docs](https://ably.com/docs/core-features/authentication/#capabilities-explained). + * + * @defaultValue `'{"*":["*"]}'` + */ + capability?: { [key: string]: capabilityOp[] | ['*'] } | string; + /** + * A client ID, used for identifying this client when publishing messages or for presence purposes. The `clientId` can be any non-empty string, except it cannot contain a `*`. This option is primarily intended to be used in situations where the library is instantiated with a key. Note that a `clientId` may also be implicit in a token used to instantiate the library. An error is raised if a `clientId` specified here conflicts with the `clientId` implicit in the token. Find out more about [identified clients](https://ably.com/docs/core-features/authentication#identified-clients). + */ + clientId?: string; + /** + * A cryptographically secure random string of at least 16 characters, used to ensure the {@link TokenRequest} cannot be reused. + */ + nonce?: string; + /** + * The timestamp of this request as milliseconds since the Unix epoch. Timestamps, in conjunction with the `nonce`, are used to prevent requests from being replayed. `timestamp` is a "one-time" value, and is valid in a request, but is not validly a member of any default token params such as `ClientOptions.defaultTokenParams`. + */ + timestamp?: number; + /** + * Requested time to live for the token in milliseconds. The default is 60 minutes. + * + * @defaultValue 60min + */ + ttl?: number; +} + +/** + * Sets the properties to configure encryption for a {@link Channel} or {@link RealtimeChannel} object. + */ +export interface CipherParams { + /** + * The algorithm to use for encryption. Only `AES` is supported and is the default value. + * + * @defaultValue `"AES"` + */ + algorithm: string; + /** + * The private key used to encrypt and decrypt payloads. You should not set this value directly; rather, you should pass a `key` of type {@link CipherKeyParam} to {@link Crypto.getDefaultParams}. + */ + key: unknown; + /** + * The length of the key in bits; either 128 or 256. + */ + keyLength: number; + /** + * The cipher mode. Only `CBC` is supported and is the default value. + * + * @defaultValue `"CBC"` + */ + mode: string; +} + +/** + * Contains an Ably Token and its associated metadata. + */ +export interface TokenDetails { + /** + * The capabilities associated with this Ably Token. The capabilities value is a JSON-encoded representation of the resource paths and associated operations. Read more about capabilities in the [capabilities docs](https://ably.com/docs/core-features/authentication/#capabilities-explained). + */ + capability: string; + /** + * The client ID, if any, bound to this Ably Token. If a client ID is included, then the Ably Token authenticates its bearer as that client ID, and the Ably Token may only be used to perform operations on behalf of that client ID. The client is then considered to be an [identified client](https://ably.com/docs/core-features/authentication#identified-clients). + */ + clientId?: string; + /** + * The timestamp at which this token expires as milliseconds since the Unix epoch. + */ + expires: number; + /** + * The timestamp at which this token was issued as milliseconds since the Unix epoch. + */ + issued: number; + /** + * The [Ably Token](https://ably.com/docs/core-features/authentication#ably-tokens) itself. A typical Ably Token string appears with the form `xVLyHw.A-pwh7wicf3afTfgiw4k2Ku33kcnSA7z6y8FjuYpe3QaNRTEo4`. + */ + token: string; +} + +/** + * Contains the properties of a request for a token to Ably. Tokens are generated using {@link Auth.requestToken}. + */ +export interface TokenRequest { + /** + * Capability of the requested Ably Token. If the Ably `TokenRequest` is successful, the capability of the returned Ably Token will be the intersection of this capability with the capability of the issuing key. The capabilities value is a JSON-encoded representation of the resource paths and associated operations. Read more about capabilities in the [capabilities docs](https://ably.com/docs/realtime/authentication). + */ + capability: string; + /** + * The client ID to associate with the requested Ably Token. When provided, the Ably Token may only be used to perform operations on behalf of that client ID. + */ + clientId?: string; + /** + * The name of the key against which this request is made. The key name is public, whereas the key secret is private. + */ + keyName: string; + /** + * The Message Authentication Code for this request. + */ + mac: string; + /** + * A cryptographically secure random string of at least 16 characters, used to ensure the `TokenRequest` cannot be reused. + */ + nonce: string; + /** + * The timestamp of this request as milliseconds since the Unix epoch. + */ + timestamp: number; + /** + * Requested time to live for the Ably Token in milliseconds. If the Ably `TokenRequest` is successful, the TTL of the returned Ably Token is less than or equal to this value, depending on application settings and the attributes of the issuing key. The default is 60 minutes. + * + * @defaultValue 60min + */ + ttl?: number; +} + +/** + * [Channel Parameters](https://ably.com/docs/realtime/channels/channel-parameters/overview) used within {@link ChannelOptions}. + */ +export type ChannelParams = { [key: string]: string }; + +/** + * The `ChannelModes` namespace describes the possible values of the {@link ChannelMode} type. + */ +declare namespace ChannelModes { + /** + * The client can publish messages. + */ + type PUBLISH = 'PUBLISH' | 'publish'; + /** + * The client will receive messages. + */ + type SUBSCRIBE = 'SUBSCRIBE' | 'subscribe'; + /** + * The client can enter the presence set. + */ + type PRESENCE = 'PRESENCE' | 'presence'; + /** + * The client will receive presence messages. + */ + type PRESENCE_SUBSCRIBE = 'PRESENCE_SUBSCRIBE' | 'presence_subscribe'; + /** + * The client can publish object messages. + */ + type OBJECT_PUBLISH = 'OBJECT_PUBLISH' | 'object_publish'; + /** + * The client will receive object messages. + */ + type OBJECT_SUBSCRIBE = 'OBJECT_SUBSCRIBE' | 'object_subscribe'; + /** + * The client can publish annotations. + */ + type ANNOTATION_PUBLISH = 'ANNOTATION_PUBLISH' | 'annotation_publish'; + /** + * The client will receive annotations. + */ + type ANNOTATION_SUBSCRIBE = 'ANNOTATION_SUBSCRIBE' | 'annotation_subscribe'; +} + +/** + * Describes the possible flags used to configure client capabilities, using {@link ChannelOptions}. + * + * **Note:** This type admits uppercase or lowercase values for reasons of backwards compatibility. In the next major release of this SDK, it will be merged with {@link ResolvedChannelMode} and only admit lowercase values; see [this GitHub issue](https://github.com/ably/ably-js/issues/1954). + */ +export type ChannelMode = + | ChannelModes.PUBLISH + | ChannelModes.SUBSCRIBE + | ChannelModes.PRESENCE + | ChannelModes.PRESENCE_SUBSCRIBE + | ChannelModes.OBJECT_PUBLISH + | ChannelModes.OBJECT_SUBSCRIBE + | ChannelModes.ANNOTATION_PUBLISH + | ChannelModes.ANNOTATION_SUBSCRIBE; + +/** + * The `ResolvedChannelModes` namespace describes the possible values of the {@link ResolvedChannelMode} type. + */ +declare namespace ResolvedChannelModes { + /** + * The client can publish messages. + */ + type PUBLISH = 'publish'; + /** + * The client will receive messages. + */ + type SUBSCRIBE = 'subscribe'; + /** + * The client can enter the presence set. + */ + type PRESENCE = 'presence'; + /** + * The client will receive presence messages. + */ + type PRESENCE_SUBSCRIBE = 'presence_subscribe'; + /** + * The client can publish object messages. + */ + type OBJECT_PUBLISH = 'object_publish'; + /** + * The client will receive object messages. + */ + type OBJECT_SUBSCRIBE = 'object_subscribe'; + /** + * The client can publish annotations. + */ + type ANNOTATION_PUBLISH = 'annotation_publish'; + /** + * The client will receive annotations. + */ + type ANNOTATION_SUBSCRIBE = 'annotation_subscribe'; +} + +/** + * Describes the configuration that a {@link RealtimeChannel} is using, as returned by {@link RealtimeChannel.modes}. + * + * This type is the same as the {@link ChannelMode} type but with all of the values lowercased. + * + * **Note:** This type exists for reasons of backwards compatibility. In the next major release of this SDK, it will be merged with {@link ChannelMode}; see [this GitHub issue](https://github.com/ably/ably-js/issues/1954). + */ +export type ResolvedChannelMode = + | ResolvedChannelModes.PUBLISH + | ResolvedChannelModes.SUBSCRIBE + | ResolvedChannelModes.PRESENCE + | ResolvedChannelModes.PRESENCE_SUBSCRIBE + | ResolvedChannelModes.OBJECT_PUBLISH + | ResolvedChannelModes.OBJECT_SUBSCRIBE + | ResolvedChannelModes.ANNOTATION_PUBLISH + | ResolvedChannelModes.ANNOTATION_SUBSCRIBE; + +/** + * Passes additional properties to a {@link Channel} or {@link RealtimeChannel} object, such as encryption, {@link ChannelMode} and channel parameters. + */ +export interface ChannelOptions { + /** + * Requests encryption for this channel when not null, and specifies encryption-related parameters (such as algorithm, chaining mode, key length and key). See [an example](https://ably.com/docs/realtime/encryption#getting-started). When running in a browser, encryption is only available when the current environment is a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts). + */ + cipher?: CipherParamOptions | CipherParams; + /** + * [Channel Parameters](https://ably.com/docs/realtime/channels/channel-parameters/overview) that configure the behavior of the channel. + */ + params?: ChannelParams; + /** + * An array of {@link ChannelMode} objects. + */ + modes?: ChannelMode[]; + /** + * A boolean which determines whether calling subscribe + * on a channel or presence object should trigger an implicit attach. Defaults to `true` + * + * Note: this option is for realtime client libraries only + */ + attachOnSubscribe?: boolean; +} + +/** + * Passes additional properties to a {@link RealtimeChannel} name to produce a new derived channel + */ +export interface DeriveOptions { + /** + * The JMESPath Query filter string to be used to derive new channel. + */ + filter?: string; +} + +/** + * The `RestHistoryParams` interface describes the parameters accepted by the following methods: + * + * - {@link Presence.history} + * - {@link Channel.history} + */ +export interface RestHistoryParams { + /** + * The time from which messages are retrieved, specified as milliseconds since the Unix epoch. + */ + start?: number; + /** + * The time until messages are retrieved, specified as milliseconds since the Unix epoch. + * + * @defaultValue The current time. + */ + end?: number; + /** + * The order for which messages are returned in. Valid values are `'backwards'` which orders messages from most recent to oldest, or `'forwards'` which orders messages from oldest to most recent. The default is `'backwards'`. + * + * @defaultValue `'backwards'` + */ + direction?: 'forwards' | 'backwards'; + /** + * An upper limit on the number of messages returned. The default is 100, and the maximum is 1000. + * + * @defaultValue 100 + */ + limit?: number; +} + +/** + * Describes the parameters accepted by {@link RestAnnotations.get}. + */ +export interface GetAnnotationsParams { + /** + * An upper limit on the number of annotations returned. The default is 100, and the maximum is 1000. + * + * @defaultValue 100 + */ + limit?: number; +} + +/** + * The `RestPresenceParams` interface describes the parameters accepted by {@link Presence.get}. + */ +export interface RestPresenceParams { + /** + * An upper limit on the number of messages returned. The default is 100, and the maximum is 1000. + * + * @defaultValue 100 + */ + limit?: number; + /** + * Filters the list of returned presence members by a specific client using its ID. + */ + clientId?: string; + /** + * Filters the list of returned presence members by a specific connection using its ID. + */ + connectionId?: string; +} + +/** + * The `RealtimePresenceParams` interface describes the parameters accepted by {@link RealtimePresence.get}. + */ +export interface RealtimePresenceParams { + /** + * Sets whether to wait for a full presence set synchronization between Ably and the clients on the channel to complete before returning the results. Synchronization begins as soon as the channel is {@link ChannelStates.ATTACHED}. When set to `true` the results will be returned as soon as the sync is complete. When set to `false` the current list of members will be returned without the sync completing. The default is `true`. + * + * @defaultValue `true` + */ + waitForSync?: boolean; + /** + * Filters the array of returned presence members by a specific client using its ID. + */ + clientId?: string; + /** + * Filters the array of returned presence members by a specific connection using its ID. + */ + connectionId?: string; +} + +/** + * The `RealtimeHistoryParams` interface describes the parameters accepted by the following methods: + * + * - {@link RealtimePresence.history} + * - {@link RealtimeChannel.history} + */ +export interface RealtimeHistoryParams { + /** + * The time from which messages are retrieved, specified as milliseconds since the Unix epoch. + */ + start?: number; + /** + * The time until messages are retrieved, specified as milliseconds since the Unix epoch. + * + * @defaultValue The current time. + */ + end?: number; + /** + * The order for which messages are returned in. Valid values are `'backwards'` which orders messages from most recent to oldest, or `'forwards'` which orders messages from oldest to most recent. The default is `'backwards'`. + * + * @defaultValue `'backwards'` + */ + direction?: 'forwards' | 'backwards'; + /** + * An upper limit on the number of messages returned. The default is 100, and the maximum is 1000. + * + * @defaultValue 100 + */ + limit?: number; + /** + * When `true`, ensures message history is up until the point of the channel being attached. See [continuous history](https://ably.com/docs/realtime/history#continuous-history) for more info. Requires the `direction` to be `backwards`. If the channel is not attached, or if `direction` is set to `forwards`, this option results in an error. + */ + untilAttach?: boolean; +} + +/** + * Contains state change information emitted by {@link Channel} and {@link RealtimeChannel} objects. + */ +export interface ChannelStateChange { + /** + * The new current {@link ChannelState}. + */ + current: ChannelState; + /** + * The previous state. For the {@link ChannelEvents.UPDATE} event, this is equal to the `current` {@link ChannelState}. + */ + previous: ChannelState; + /** + * An {@link ErrorInfo} object containing any information relating to the transition. + */ + reason?: ErrorInfo; + /** + * Indicates whether message continuity on this channel is preserved, see [Nonfatal channel errors](https://ably.com/docs/realtime/channels#nonfatal-errors) for more info. + */ + resumed: boolean; + /** + * Indicates whether the client can expect a backlog of messages from a rewind or resume. + */ + hasBacklog?: boolean; +} + +/** + * Contains {@link ConnectionState} change information emitted by the {@link Connection} object. + */ +export interface ConnectionStateChange { + /** + * The new {@link ConnectionState}. + */ + current: ConnectionState; + /** + * The previous {@link ConnectionState}. For the {@link ConnectionEvents.UPDATE} event, this is equal to the current {@link ConnectionState}. + */ + previous: ConnectionState; + /** + * An {@link ErrorInfo} object containing any information relating to the transition. + */ + reason?: ErrorInfo; + /** + * Duration in milliseconds, after which the client retries a connection where applicable. + */ + retryIn?: number; +} + +/** + * The `DevicePlatforms` namespace describes the possible values of the {@link DevicePlatform} type. + */ +declare namespace DevicePlatforms { + /** + * The device platform is Android. + */ + type ANDROID = 'android'; + /** + * The device platform is iOS. + */ + type IOS = 'ios'; + /** + * The device platform is a web browser. + */ + type BROWSER = 'browser'; +} + +/** + * Describes the device receiving push notifications. + */ +export type DevicePlatform = DevicePlatforms.ANDROID | DevicePlatforms.IOS | DevicePlatforms.BROWSER; + +/** + * The `DeviceFormFactors` namespace describes the possible values of the {@link DeviceFormFactor} type. + */ +declare namespace DeviceFormFactors { + /** + * The device is a phone. + */ + type PHONE = 'phone'; + /** + * The device is tablet. + */ + type TABLET = 'tablet'; + /** + * The device is a desktop. + */ + type DESKTOP = 'desktop'; + /** + * The device is a TV. + */ + type TV = 'tv'; + /** + * The device is a watch. + */ + type WATCH = 'watch'; + /** + * The device is a car. + */ + type CAR = 'car'; + /** + * The device is embedded. + */ + type EMBEDDED = 'embedded'; + /** + * The device is other. + */ + type OTHER = 'other'; +} + +/** + * Describes the type of device receiving a push notification. + */ +export type DeviceFormFactor = + | DeviceFormFactors.PHONE + | DeviceFormFactors.TABLET + | DeviceFormFactors.DESKTOP + | DeviceFormFactors.TV + | DeviceFormFactors.WATCH + | DeviceFormFactors.CAR + | DeviceFormFactors.EMBEDDED + | DeviceFormFactors.OTHER; + +/** + * Contains the properties of a device registered for push notifications. + */ +export interface DeviceDetails { + /** + * A unique ID generated by the device. + */ + id: string; + /** + * The client ID the device is connected to Ably with. + */ + clientId?: string; + /** + * The {@link DevicePlatform} associated with the device. Describes the platform the device uses, such as `android` or `ios`. + */ + platform: DevicePlatform; + /** + * The {@link DeviceFormFactor} object associated with the device. Describes the type of the device, such as `phone` or `tablet`. + */ + formFactor: DeviceFormFactor; + /** + * A JSON object of key-value pairs that contains metadata for the device. + */ + metadata?: any; + /** + * A unique device secret generated by the Ably SDK. + */ + deviceSecret?: string; + /** + * The {@link DevicePushDetails} object associated with the device. Describes the details of the push registration of the device. + */ + push: DevicePushDetails; +} + +/** + * Contains the subscriptions of a device, or a group of devices sharing the same `clientId`, has to a channel in order to receive push notifications. + */ +export interface PushChannelSubscription { + /** + * The channel the push notification subscription is for. + */ + channel: string; + /** + * The unique ID of the device. + */ + deviceId?: string; + /** + * The ID of the client the device, or devices are associated to. + */ + clientId?: string; +} + +/** + * Valid states which a Push device may be in. + */ +export type DevicePushState = 'ACTIVE' | 'FAILING' | 'FAILED'; + +/** + * Contains the details of the push registration of a device. + */ +export interface DevicePushDetails { + /** + * A JSON object of key-value pairs that contains of the push transport and address. + */ + recipient: any; + /** + * The current state of the push registration. + */ + state?: DevicePushState; + /** + * An {@link ErrorInfo} object describing the most recent error when the `state` is `Failing` or `Failed`. + */ + error?: ErrorInfo; +} + +/** + * The `DeviceRegistrationParams` interface describes the parameters accepted by the following methods: + * + * - {@link PushDeviceRegistrations.list} + * - {@link PushDeviceRegistrations.removeWhere} + */ +export interface DeviceRegistrationParams { + /** + * Filter to restrict to devices associated with a client ID. + */ + clientId?: string; + /** + * Filter to restrict by the unique ID of the device. + */ + deviceId?: string; + /** + * A limit on the number of devices returned, up to 1,000. + */ + limit?: number; + /** + * Filter by the state of the device. + */ + state?: DevicePushState; +} + +/** + * The `PushChannelSubscriptionParams` interface describes the parameters accepted by the following methods: + * + * - {@link PushChannelSubscriptions.list} + * - {@link PushChannelSubscriptions.removeWhere} + */ +export interface PushChannelSubscriptionParams { + /** + * Filter to restrict to subscriptions associated with the given channel. + */ + channel?: string; + /** + * Filter to restrict to devices associated with the given client identifier. Cannot be used with a deviceId param. + */ + clientId?: string; + /** + * Filter to restrict to devices associated with that device identifier. Cannot be used with a clientId param. + */ + deviceId?: string; + /** + * A limit on the number of devices returned, up to 1,000. + */ + limit?: number; +} + +/** + * The `PushChannelsParams` interface describes the parameters accepted by {@link PushChannelSubscriptions.listChannels}. + */ +export interface PushChannelsParams { + /** + * A limit on the number of channels returned, up to 1,000. + */ + limit?: number; +} + +/** + * The `StatsParams` interface describes the parameters accepted by the following methods: + * + * - {@link RestClient.stats} + * - {@link RealtimeClient.stats} + */ +export interface StatsParams { + /** + * The time from which stats are retrieved, specified as milliseconds since the Unix epoch. + * + * @defaultValue The Unix epoch. + */ + start?: number; + /** + * The time until stats are retrieved, specified as milliseconds since the Unix epoch. + * + * @defaultValue The current time. + */ + end?: number; + /** + * The order for which stats are returned in. Valid values are `'backwards'` which orders stats from most recent to oldest, or `'forwards'` which orders stats from oldest to most recent. The default is `'backwards'`. + * + * @defaultValue `'backwards'` + */ + direction?: 'backwards' | 'forwards'; + /** + * An upper limit on the number of stats returned. The default is 100, and the maximum is 1000. + * + * @defaultValue 100 + */ + limit?: number; + /** + * Based on the unit selected, the given `start` or `end` times are rounded down to the start of the relevant interval depending on the unit granularity of the query. + * + * @defaultValue `StatsIntervalGranularity.MINUTE` + */ + unit?: StatsIntervalGranularity; +} + +/** + * Contains information about the results of a batch operation. + */ +export interface BatchResult { + /** + * The number of successful operations in the request. + */ + successCount: number; + /** + * The number of unsuccessful operations in the request. + */ + failureCount: number; + /** + * An array of results for the batch operation. + */ + results: T[]; +} + +/** + * Describes the messages that should be published by a batch publish operation, and the channels to which they should be published. + */ +export interface BatchPublishSpec { + /** + * The names of the channels to publish the `messages` to. + */ + channels: string[]; + /** + * An array of {@link Message} objects. + */ + messages: Message[]; +} + +/** + * Contains information about the result of successful publishes to a channel requested by a single {@link BatchPublishSpec}. + */ +export interface BatchPublishSuccessResult { + /** + * The name of the channel the message(s) was published to. + */ + channel: string; + /** + * A unique ID prefixed to the {@link Message.id} of each published message. + */ + messageId: string; +} + +/** + * Contains information about the result of unsuccessful publishes to a channel requested by a single {@link BatchPublishSpec}. + */ +export interface BatchPublishFailureResult { + /** + * The name of the channel the message(s) failed to be published to. + */ + channel: string; + /** + * Describes the reason for which the message(s) failed to publish to the channel as an {@link ErrorInfo} object. + */ + error: ErrorInfo; +} + +/** + * Contains information about the result of a successful batch presence request for a single channel. + */ +export interface BatchPresenceSuccessResult { + /** + * The channel name the presence state was retrieved for. + */ + channel: string; + /** + * An array of {@link PresenceMessage}s describing members present on the channel. + */ + presence: PresenceMessage[]; +} + +/** + * Contains information about the result of an unsuccessful batch presence request for a single channel. + */ +export interface BatchPresenceFailureResult { + /** + * The channel name the presence state failed to be retrieved for. + */ + channel: string; + /** + * Describes the reason for which presence state could not be retrieved for the channel as an {@link ErrorInfo} object. + */ + error: ErrorInfo; +} + +/** + * The `TokenRevocationOptions` interface describes the additional options accepted by {@link Auth.revokeTokens}. + */ +export interface TokenRevocationOptions { + /** + * A Unix timestamp in milliseconds where only tokens issued before this time are revoked. The default is the current time. Requests with an `issuedBefore` in the future, or more than an hour in the past, will be rejected. + */ + issuedBefore?: number; + /** + * If true, permits a token renewal cycle to take place without needing established connections to be dropped, by postponing enforcement to 30 seconds in the future, and sending any existing connections a hint to obtain (and upgrade the connection to use) a new token. The default is `false`, meaning that the effect is near-immediate. + */ + allowReauthMargin?: boolean; +} + +/** + * Describes which tokens should be affected by a token revocation request. + */ +export interface TokenRevocationTargetSpecifier { + /** + * The type of token revocation target specifier. Valid values include `clientId`, `revocationKey` and `channel`. + */ + type: string; + /** + * The value of the token revocation target specifier. + */ + value: string; +} + +/** + * Contains information about the result of a successful token revocation request for a single target specifier. + */ +export interface TokenRevocationSuccessResult { + /** + * The target specifier. + */ + target: string; + /** + * The time at which the token revocation will take effect, as a Unix timestamp in milliseconds. + */ + appliesAt: number; + /** + * A Unix timestamp in milliseconds. Only tokens issued earlier than this time will be revoked. + */ + issuedBefore: number; +} + +/** + * Contains information about the result of an unsuccessful token revocation request for a single target specifier. + */ +export interface TokenRevocationFailureResult { + /** + * The target specifier. + */ + target: string; + /** + * Describes the reason for which token revocation failed for the given `target` as an {@link ErrorInfo} object. + */ + error: ErrorInfo; +} + +// Common Listeners +/** + * A callback which returns only a single argument, used for {@link RealtimeChannel} subscriptions. + * + * @param message - The message which triggered the callback. + */ +export type messageCallback = (message: T) => void; +/** + * The callback used for the events emitted by {@link RealtimeChannel}. + * + * @param changeStateChange - The state change that occurred. + */ +export type channelEventCallback = (changeStateChange: ChannelStateChange) => void; +/** + * The callback used for the events emitted by {@link Connection}. + * + * @param connectionStateChange - The state change that occurred. + */ +export type connectionEventCallback = (connectionStateChange: ConnectionStateChange) => void; +/** + * The callback used by {@link recoverConnectionCallback}. + * + * @param shouldRecover - Whether the connection should be recovered. + */ +export type recoverConnectionCompletionCallback = (shouldRecover: boolean) => void; +/** + * Used in {@link ClientOptions} to configure connection recovery behaviour. + * + * @param lastConnectionDetails - Details of the connection used by the connection recovery process. + * @param callback - A callback which is called when a connection recovery attempt is complete. + */ +export type recoverConnectionCallback = ( + lastConnectionDetails: { + /** + * The recovery key can be used by another client to recover this connection’s state in the `recover` client options property. See [connection state recover options](https://ably.com/documentation/realtime/connection/#connection-state-recover-options) for more information. + */ + recoveryKey: string; + /** + * The time at which the previous client was abruptly disconnected before the page was unloaded. This is represented as milliseconds since Unix epoch. + */ + disconnectedAt: number; + /** + * A clone of the `location` object of the previous page’s document object before the page was unloaded. A common use case for this attribute is to ensure that the previous page URL is the same as the current URL before allowing the connection to be recovered. For example, you may want the connection to be recovered only for page reloads, but not when a user navigates to a different page. + */ + location: string; + /** + * The `clientId` of the client’s `Auth` object before the page was unloaded. A common use case for this attribute is to ensure that the current logged in user’s `clientId` matches the previous connection’s `clientId` before allowing the connection to be recovered. Ably prohibits changing a `clientId` for an existing connection, so any mismatch in `clientId` during a recover will result in the connection moving to the failed state. + */ + clientId: string | null; + }, + callback: recoverConnectionCompletionCallback, +) => void; + +/** + * A standard callback format which is invoked upon completion of a task. + * + * @param err - An error object if the task failed. + * @param result - The result of the task, if any. + */ +type StandardCallback = (err: ErrorInfo | null, result?: T) => void; + +/** + * A function passed to {@link Push.activate} in order to override the default implementation to register a device for push activation. + * + * @param device - A DeviceDetails object representing the local device + * @param callback - A callback to be invoked when the registration is complete + */ +export type RegisterCallback = (device: DeviceDetails, callback: StandardCallback) => void; + +/** + * A function passed to {@link Push.activate} in order to override the default implementation to deregister a device for push activation. + * + * @param device - A DeviceDetails object representing the local device + * @param callback - A callback to be invoked when the deregistration is complete + */ +export type DeregisterCallback = (device: DeviceDetails, callback: StandardCallback) => void; + +/** + * A callback which returns only an error, or null, when complete. + * + * @param error - The error if the task failed, or null not. + */ +export type ErrorCallback = (error: ErrorInfo | null) => void; + +/** + * A callback which returns only a single argument - an event object. + * + * @param event - The event which triggered the callback. + */ +export type EventCallback = (event: T) => void; + +/** + * The callback used for the events emitted by {@link RealtimeObject}. + */ +export type ObjectsEventCallback = () => void; + +/** + * A function passed to the {@link BatchOperations.batch | batch} method to group multiple Objects operations into a single channel message. + * + * The function must be synchronous. + * + * @param ctx - The {@link BatchContext} used to group operations together. + */ +export type BatchFunction = (ctx: BatchContext) => void; + +/** + * Represents a subscription that can be unsubscribed from. + * This interface provides a way to clean up and remove subscriptions when they are no longer needed. + * + * @example + * ```typescript + * const s = someService.subscribe(); + * // Later when done with the subscription + * s.unsubscribe(); + * ``` + */ +export interface Subscription { + /** + * Deregisters the listener previously passed to the `subscribe` method. + * + * This method should be called when the subscription is no longer needed, + * it will make sure no further events will be sent to the subscriber and + * that references to the subscriber are cleaned up. + */ + readonly unsubscribe: () => void; +} + +/** + * Represents a subscription to status change events that can be unsubscribed from. + * This interface provides a way to clean up and remove subscriptions when they are no longer needed. + * + * @example + * ```typescript + * const s = someService.on(); + * // Later when done with the subscription + * s.off(); + * ``` + */ +export interface StatusSubscription { + /** + * Deregisters the listener previously passed to the `on` method. + * + * Unsubscribes from the status change events. It will ensure that no + * further status change events will be sent to the subscriber and + * that references to the subscriber are cleaned up. + */ + readonly off: () => void; +} + +// Internal Interfaces + +// To allow a uniform (callback) interface between on and once even in the +// promisified version of the lib, but still allow once to be used in a way +// that returns a Promise if desired, EventEmitter uses method overloading to +// present both methods +/** + * A generic interface for event registration and delivery used in a number of the types in the Realtime client library. For example, the {@link Connection} object emits events for connection state using the `EventEmitter` pattern. + */ +export declare interface EventEmitter { + /** + * Registers the provided listener for the specified event. If `on()` is called more than once with the same listener and event, the listener is added multiple times to its listener registry. Therefore, as an example, assuming the same listener is registered twice using `on()`, and an event is emitted once, the listener would be invoked twice. + * + * @param event - The named event to listen for. + * @param callback - The event listener. + */ + on(event: EventType, callback: CallbackType): void; + /** + * Registers the provided listener for the specified events. If `on()` is called more than once with the same listener and event, the listener is added multiple times to its listener registry. Therefore, as an example, assuming the same listener is registered twice using `on()`, and an event is emitted once, the listener would be invoked twice. + * + * @param events - The named events to listen for. + * @param callback - The event listener. + */ + on(events: EventType[], callback: CallbackType): void; + /** + * Registers the provided listener all events. If `on()` is called more than once with the same listener and event, the listener is added multiple times to its listener registry. Therefore, as an example, assuming the same listener is registered twice using `on()`, and an event is emitted once, the listener would be invoked twice. + * + * @param callback - The event listener. + */ + on(callback: CallbackType): void; + /** + * Registers the provided listener for the first occurrence of a single named event specified as the `Event` argument. If `once` is called more than once with the same listener, the listener is added multiple times to its listener registry. Therefore, as an example, assuming the same listener is registered twice using `once`, and an event is emitted once, the listener would be invoked twice. However, all subsequent events emitted would not invoke the listener as `once` ensures that each registration is only invoked once. + * + * @param event - The named event to listen for. + * @param callback - The event listener. + */ + once(event: EventType, callback: CallbackType): void; + /** + * Registers the provided listener for the first event that is emitted. If `once()` is called more than once with the same listener, the listener is added multiple times to its listener registry. Therefore, as an example, assuming the same listener is registered twice using `once()`, and an event is emitted once, the listener would be invoked twice. However, all subsequent events emitted would not invoke the listener as `once()` ensures that each registration is only invoked once. + * + * @param callback - The event listener. + */ + once(callback: CallbackType): void; + /** + * Returns a promise which resolves upon the first occurrence of a single named event specified as the `Event` argument. + * + * @param event - The named event to listen for. + * @returns A promise which resolves upon the first occurrence of the named event. + */ + once(event: EventType): Promise; + /** + * Returns a promise which resolves upon the first occurrence of an event. + * + * @returns A promise which resolves upon the first occurrence of an event. + */ + once(): Promise; + /** + * Removes all registrations that match both the specified listener and the specified event. + * + * @param event - The named event. + * @param callback - The event listener. + */ + off(event: EventType, callback: CallbackType): void; + /** + * Deregisters the specified listener. Removes all registrations matching the given listener, regardless of whether they are associated with an event or not. + * + * @param callback - The event listener. + */ + off(callback: CallbackType): void; + /** + * Deregisters all registrations, for all events and listeners. + */ + off(): void; + /** + * Returns the listeners for a specified `EventType`. + * + * @param eventName - The event name to retrieve the listeners for. + */ + listeners(eventName?: EventType): CallbackType[] | null; +} + +// Interfaces +/** + * A client that offers a simple stateless API to interact directly with Ably's REST API. + */ +export declare interface RestClient { + /** + * An {@link Auth} object. + */ + auth: Auth; + /** + * A {@link Channels} object. + */ + channels: Channels; + /** + * Makes a REST request to a provided path. This is provided as a convenience for developers who wish to use REST API functionality that is either not documented or is not yet included in the public API, without having to directly handle features such as authentication, paging, fallback hosts, MsgPack and JSON support. + * + * @param method - The request method to use, such as `GET`, `POST`. + * @param path - The request path. + * @param version - The version of the Ably REST API to use. See the [REST API reference](https://ably.com/docs/api/rest-api#versioning) for information on versioning. + * @param params - The parameters to include in the URL query of the request. The parameters depend on the endpoint being queried. See the [REST API reference](https://ably.com/docs/api/rest-api) for the available parameters of each endpoint. + * @param body - The JSON body of the request. + * @param headers - Additional HTTP headers to include in the request. + * @returns A promise which, upon success, will be fulfilled with an {@link HttpPaginatedResponse} response object returned by the HTTP request. This response object will contain an empty or JSON-encodable object. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + */ + request( + method: string, + path: string, + version: number, + params?: any, + body?: any[] | any, + headers?: any, + ): Promise>; + /** + * Queries the REST `/stats` API and retrieves your application's usage statistics. Returns a {@link PaginatedResult} object, containing an array of {@link Stats} objects. See the [Stats docs](https://ably.com/docs/general/statistics). + * + * @param params - A set of parameters which are used to specify which statistics should be retrieved. If you do not provide this argument, then this method will use the default parameters described in the {@link StatsParams} interface. + * @returns A promise which, upon success, will be fulfilled with a {@link PaginatedResult} object containing an array of {@link Stats} objects. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + */ + stats(params?: StatsParams): Promise>; + /** + * Retrieves the time from the Ably service as milliseconds since the Unix epoch. Clients that do not have access to a sufficiently well maintained time source and wish to issue Ably {@link TokenRequest | `TokenRequest`s} with a more accurate timestamp should use the {@link ClientOptions.queryTime} property instead of this method. + * + * @returns A promise which, upon success, will be fulfilled with the time as milliseconds since the Unix epoch. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + */ + time(): Promise; + + /** + * Publishes a {@link BatchPublishSpec} object to one or more channels, up to a maximum of 100 channels. + * + * @param spec - A {@link BatchPublishSpec} object. + * @returns A promise which, upon success, will be fulfilled with a {@link BatchResult} object containing information about the result of the batch publish for each requested channel. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + */ + batchPublish(spec: BatchPublishSpec): Promise>; + /** + * Publishes one or more {@link BatchPublishSpec} objects to one or more channels, up to a maximum of 100 channels. + * + * @param specs - An array of {@link BatchPublishSpec} objects. + * @returns A promise which, upon success, will be fulfilled with an array of {@link BatchResult} objects containing information about the result of the batch publish for each requested channel for each provided {@link BatchPublishSpec}. This array is in the same order as the provided {@link BatchPublishSpec} array. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + */ + batchPublish( + specs: BatchPublishSpec[], + ): Promise[]>; + /** + * Retrieves the presence state for one or more channels, up to a maximum of 100 channels. Presence state includes the `clientId` of members and their current {@link PresenceAction}. + * + * @param channels - An array of one or more channel names, up to a maximum of 100 channels. + * @returns A promise which, upon success, will be fulfilled with a {@link BatchResult} object containing information about the result of the batch presence request for each requested channel. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + */ + batchPresence(channels: string[]): Promise[]>; + /** + * A {@link Push} object. + */ + push: Push; + /** + * Retrieves a {@link LocalDevice} object that represents the current state of the device as a target for push notifications. + * + * @returns A {@link LocalDevice} object. + */ + device(): LocalDevice; +} + +/** + * A client that extends the functionality of {@link RestClient} and provides additional realtime-specific features. + */ +export declare interface RealtimeClient { + /** + * A client ID, used for identifying this client when publishing messages or for presence purposes. The `clientId` can be any non-empty string, except it cannot contain a `*`. This option is primarily intended to be used in situations where the library is instantiated with a key. A `clientId` may also be implicit in a token used to instantiate the library; an error will be raised if a `clientId` specified here conflicts with the `clientId` implicit in the token. + */ + clientId: string; + /** + * Calls {@link Connection.close | `connection.close()`} and causes the connection to close, entering the closing state. Once closed, the library will not attempt to re-establish the connection without an explicit call to {@link Connection.connect | `connect()`}. + */ + close(): void; + /** + * Calls {@link Connection.connect | `connection.connect()`} and causes the connection to open, entering the connecting state. Explicitly calling `connect()` is unnecessary unless the {@link ClientOptions.autoConnect} property is disabled. + */ + connect(): void; + + /** + * An {@link Auth} object. + */ + auth: Auth; + /** + * A {@link Channels} object. + */ + channels: Channels; + /** + * A {@link Connection} object. + */ + connection: Connection; + /** + * Makes a REST request to a provided path. This is provided as a convenience for developers who wish to use REST API functionality that is either not documented or is not yet included in the public API, without having to directly handle features such as authentication, paging, fallback hosts, MsgPack and JSON support. + * + * @param method - The request method to use, such as `GET`, `POST`. + * @param path - The request path. + * @param version - The version of the Ably REST API to use. See the [REST API reference](https://ably.com/docs/api/rest-api#versioning) for information on versioning. + * @param params - The parameters to include in the URL query of the request. The parameters depend on the endpoint being queried. See the [REST API reference](https://ably.com/docs/api/rest-api) for the available parameters of each endpoint. + * @param body - The JSON body of the request. + * @param headers - Additional HTTP headers to include in the request. + * @returns A promise which, upon success, will be fulfilled with the {@link HttpPaginatedResponse} response object returned by the HTTP request. This response object will contain an empty or JSON-encodable object. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + */ + request( + method: string, + path: string, + version: number, + params?: any, + body?: any[] | any, + headers?: any, + ): Promise>; + /** + * Queries the REST `/stats` API and retrieves your application's usage statistics. Returns a {@link PaginatedResult} object, containing an array of {@link Stats} objects. See the [Stats docs](https://ably.com/docs/general/statistics). + * + * @param params - A set of parameters which are used to specify which statistics should be retrieved. If you do not provide this argument, then this method will use the default parameters described in the {@link StatsParams} interface. + * @returns A promise which, upon success, will be fulfilled with a {@link PaginatedResult} object containing an array of {@link Stats} objects. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + */ + stats(params?: StatsParams): Promise>; + /** + * Retrieves the time from the Ably service as milliseconds since the Unix epoch. Clients that do not have access to a sufficiently well maintained time source and wish to issue Ably {@link TokenRequest | `TokenRequest`s} with a more accurate timestamp should use the {@link ClientOptions.queryTime} property instead of this method. + * + * @returns A promise which, upon success, will be fulfilled with the time as milliseconds since the Unix epoch. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + */ + time(): Promise; + /** + * Publishes a {@link BatchPublishSpec} object to one or more channels, up to a maximum of 100 channels. + * + * @param spec - A {@link BatchPublishSpec} object. + * @returns A promise which, upon success, will be fulfilled with a {@link BatchResult} object containing information about the result of the batch publish for each requested channel. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + */ + batchPublish(spec: BatchPublishSpec): Promise>; + /** + * Publishes one or more {@link BatchPublishSpec} objects to one or more channels, up to a maximum of 100 channels. + * + * @param specs - An array of {@link BatchPublishSpec} objects. + * @returns A promise which, upon success, will be fulfilled with an array of {@link BatchResult} objects containing information about the result of the batch publish for each requested channel for each provided {@link BatchPublishSpec}. This array is in the same order as the provided {@link BatchPublishSpec} array. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + */ + batchPublish( + specs: BatchPublishSpec[], + ): Promise[]>; + /** + * Retrieves the presence state for one or more channels, up to a maximum of 100 channels. Presence state includes the `clientId` of members and their current {@link PresenceAction}. + * + * @param channels - An array of one or more channel names, up to a maximum of 100 channels. + * @returns A promise which, upon success, will be fulfilled with a {@link BatchResult} object containing information about the result of the batch presence request for each requested channel. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + */ + batchPresence(channels: string[]): Promise[]>; + /** + * A {@link Push} object. + */ + push: Push; + /** + * Retrieves a {@link LocalDevice} object that represents the current state of the device as a target for push notifications. + * + * @returns A {@link LocalDevice} object. + */ + device(): LocalDevice; +} + +/** + * Creates Ably {@link TokenRequest} objects and obtains Ably Tokens from Ably to subsequently issue to less trusted clients. + */ +export declare interface Auth { + /** + * A client ID, used for identifying this client when publishing messages or for presence purposes. The `clientId` can be any non-empty string, except it cannot contain a `*`. This option is primarily intended to be used in situations where the library is instantiated with a key. Note that a `clientId` may also be implicit in a token used to instantiate the library. An error is raised if a `clientId` specified here conflicts with the `clientId` implicit in the token. Find out more about [identified clients](https://ably.com/docs/core-features/authentication#identified-clients). + */ + clientId: string; + + /** + * Instructs the library to get a new token immediately. When using the realtime client, it upgrades the current realtime connection to use the new token, or if not connected, initiates a connection to Ably, once the new token has been obtained. Also stores any {@link TokenParams} and {@link AuthOptions} passed in as the new defaults, to be used for all subsequent implicit or explicit token requests. Any {@link TokenParams} and {@link AuthOptions} objects passed in entirely replace, as opposed to being merged with, the current client library saved values. + * + * @param tokenParams - A {@link TokenParams} object. + * @param authOptions - An {@link AuthOptions} object. + * @returns A promise which, upon success, will be fulfilled with a {@link TokenDetails} object. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + */ + authorize(tokenParams?: TokenParams, authOptions?: AuthOptions): Promise; + /** + * Creates and signs an Ably {@link TokenRequest} based on the specified (or if none specified, the client library stored) {@link TokenParams} and {@link AuthOptions}. Note this can only be used when the API `key` value is available locally. Otherwise, the Ably {@link TokenRequest} must be obtained from the key owner. Use this to generate an Ably {@link TokenRequest} in order to implement an Ably Token request callback for use by other clients. Both {@link TokenParams} and {@link AuthOptions} are optional. When omitted or `null`, the default token parameters and authentication options for the client library are used, as specified in the {@link ClientOptions} when the client library was instantiated, or later updated with an explicit `authorize` request. Values passed in are used instead of, rather than being merged with, the default values. To understand why an Ably {@link TokenRequest} may be issued to clients in favor of a token, see [Token Authentication explained](https://ably.com/docs/core-features/authentication/#token-authentication). + * + * @param tokenParams - A {@link TokenParams} object. + * @param authOptions - An {@link AuthOptions} object. + * @returns A promise which, upon success, will be fulfilled with a {@link TokenRequest} object. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + */ + createTokenRequest(tokenParams?: TokenParams, authOptions?: AuthOptions): Promise; + /** + * Calls the `requestToken` REST API endpoint to obtain an Ably Token according to the specified {@link TokenParams} and {@link AuthOptions}. Both {@link TokenParams} and {@link AuthOptions} are optional. When omitted or `null`, the default token parameters and authentication options for the client library are used, as specified in the {@link ClientOptions} when the client library was instantiated, or later updated with an explicit `authorize` request. Values passed in are used instead of, rather than being merged with, the default values. To understand why an Ably {@link TokenRequest} may be issued to clients in favor of a token, see [Token Authentication explained](https://ably.com/docs/core-features/authentication/#token-authentication). + * + * @param TokenParams - A {@link TokenParams} object. + * @param authOptions - An {@link AuthOptions} object. + * @returns A promise which, upon success, will be fulfilled with a {@link TokenDetails} object. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + */ + requestToken(TokenParams?: TokenParams, authOptions?: AuthOptions): Promise; + /** + * Revokes the tokens specified by the provided array of {@link TokenRevocationTargetSpecifier}s. Only tokens issued by an API key that had revocable tokens enabled before the token was issued can be revoked. See the [token revocation docs](https://ably.com/docs/core-features/authentication#token-revocation) for more information. + * + * @param specifiers - An array of {@link TokenRevocationTargetSpecifier} objects. + * @param options - A set of options which are used to modify the revocation request. + * @returns A promise which, upon success, will be fulfilled with a {@link BatchResult} containing information about the result of the token revocation request for each provided [`TokenRevocationTargetSpecifier`]{@link TokenRevocationTargetSpecifier}. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + */ + revokeTokens( + specifiers: TokenRevocationTargetSpecifier[], + options?: TokenRevocationOptions, + ): Promise>; +} + +/** + * Enables the retrieval of the current and historic presence set for a channel. + */ +export declare interface Presence { + /** + * Retrieves the current members present on the channel and the metadata for each member, such as their {@link PresenceAction} and ID. Returns a {@link PaginatedResult} object, containing an array of {@link PresenceMessage} objects. + * + * @param params - A set of parameters which are used to specify which presence members should be retrieved. + * @returns A promise which, upon success, will be fulfilled with a {@link PaginatedResult} object containing an array of {@link PresenceMessage} objects. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + */ + get(params?: RestPresenceParams): Promise>; + /** + * Retrieves a {@link PaginatedResult} object, containing an array of historical {@link PresenceMessage} objects for the channel. If the channel is configured to persist messages, then presence messages can be retrieved from history for up to 72 hours in the past. If not, presence messages can only be retrieved from history for up to two minutes in the past. + * + * @param params - A set of parameters which are used to specify which messages should be retrieved. + * @returns A promise which, upon success, will be fulfilled with a {@link PaginatedResult} object containing an array of {@link PresenceMessage} objects. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + */ + history(params?: RestHistoryParams): Promise>; +} + +/** + * Enables the presence set to be entered and subscribed to, and the historic presence set to be retrieved for a channel. + */ +export declare interface RealtimePresence { + /** + * Indicates whether the presence set synchronization between Ably and the clients on the channel has been completed. Set to `true` when the sync is complete. + */ + syncComplete: boolean; + /** + * Deregisters a specific listener that is registered to receive {@link PresenceMessage} on the channel for a given {@link PresenceAction}. + * + * @param presence - A specific {@link PresenceAction} to deregister the listener for. + * @param listener - An event listener function. + */ + unsubscribe(presence: PresenceAction, listener: messageCallback): void; + /** + * Deregisters a specific listener that is registered to receive {@link PresenceMessage} on the channel for a given array of {@link PresenceAction} objects. + * + * @param presence - An array of {@link PresenceAction} objects to deregister the listener for. + * @param listener - An event listener function. + */ + unsubscribe(presence: Array, listener: messageCallback): void; + /** + * Deregisters any listener that is registered to receive {@link PresenceMessage} on the channel for a specific {@link PresenceAction} + * + * @param presence - A specific {@link PresenceAction} to deregister the listeners for. + */ + unsubscribe(presence: PresenceAction): void; + /** + * Deregisters any listener that is registered to receive {@link PresenceMessage} on the channel for an array of {@link PresenceAction} objects + * + * @param presence - An array of {@link PresenceAction} objects to deregister the listeners for. + */ + unsubscribe(presence: Array): void; + /** + * Deregisters a specific listener that is registered to receive {@link PresenceMessage} on the channel. + * + * @param listener - An event listener function. + */ + unsubscribe(listener: messageCallback): void; + /** + * Deregisters all listeners currently receiving {@link PresenceMessage} for the channel. + */ + unsubscribe(): void; + + /** + * Retrieves the current members present on the channel and the metadata for each member, such as their {@link PresenceAction} and ID. Returns an array of {@link PresenceMessage} objects. + * + * @param params - A set of parameters which are used to specify which presence members should be retrieved. + * @returns A promise which, upon success, will be fulfilled with an array of {@link PresenceMessage} objects. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + */ + get(params?: RealtimePresenceParams): Promise; + /** + * Retrieves a {@link PaginatedResult} object, containing an array of historical {@link PresenceMessage} objects for the channel. If the channel is configured to persist messages, then presence messages can be retrieved from history for up to 72 hours in the past. If not, presence messages can only be retrieved from history for up to two minutes in the past. + * + * @param params - A set of parameters which are used to specify which presence messages should be retrieved. + * @returns A promise which, upon success, will be fulfilled with a {@link PaginatedResult} object containing an array of {@link PresenceMessage} objects. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + */ + history(params?: RealtimeHistoryParams): Promise>; + /** + * Registers a listener that is called each time a {@link PresenceMessage} matching a given {@link PresenceAction}, or an action within an array of {@link PresenceAction | `PresenceAction`s}, is received on the channel, such as a new member entering the presence set. + * + * @param action - A {@link PresenceAction} or an array of {@link PresenceAction | `PresenceAction`s} to register the listener for. + * @param listener - An event listener function. + * @returns A promise which resolves upon success of the channel {@link RealtimeChannel.attach | `attach()`} operation and rejects with an {@link ErrorInfo} object upon its failure. + */ + subscribe(action: PresenceAction | Array, listener?: messageCallback): Promise; + /** + * Registers a listener that is called each time a {@link PresenceMessage} is received on the channel, such as a new member entering the presence set. + * + * @param listener - An event listener function. + * @returns A promise which resolves upon success of the channel {@link RealtimeChannel.attach | `attach()`} operation and rejects with an {@link ErrorInfo} object upon its failure. + */ + subscribe(listener?: messageCallback): Promise; + /** + * Enters the presence set for the channel, optionally passing a `data` payload. A `clientId` is required to be present on a channel. + * + * @param data - The payload associated with the presence member. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + */ + enter(data?: any): Promise; + /** + * Updates the `data` payload for a presence member. If called before entering the presence set, this is treated as an {@link PresenceActions.ENTER} event. + * + * @param data - The payload to update for the presence member. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + */ + update(data?: any): Promise; + /** + * Leaves the presence set for the channel. A client must have previously entered the presence set before they can leave it. + * + * @param data - The payload associated with the presence member. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + */ + leave(data?: any): Promise; + /** + * Enters the presence set of the channel for a given `clientId`. Enables a single client to update presence on behalf of any number of clients using a single connection. The library must have been instantiated with an API key or a token bound to a wildcard `clientId`. + * + * @param clientId - The ID of the client to enter into the presence set. + * @param data - The payload associated with the presence member. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + */ + enterClient(clientId: string, data?: any): Promise; + /** + * Updates the `data` payload for a presence member using a given `clientId`. Enables a single client to update presence on behalf of any number of clients using a single connection. The library must have been instantiated with an API key or a token bound to a wildcard `clientId`. + * + * @param clientId - The ID of the client to update in the presence set. + * @param data - The payload to update for the presence member. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + */ + updateClient(clientId: string, data?: any): Promise; + /** + * Leaves the presence set of the channel for a given `clientId`. Enables a single client to update presence on behalf of any number of clients using a single connection. The library must have been instantiated with an API key or a token bound to a wildcard `clientId`. + * + * @param clientId - The ID of the client to leave the presence set for. + * @param data - The payload associated with the presence member. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + */ + leaveClient(clientId: string, data?: any): Promise; +} + +/** + * Functionality for annotating messages with small pieces of data, such as emoji + * reactions, that the server will roll up into the message as a summary. + */ +export declare interface RealtimeAnnotations { + /** + * Registers a listener that is called each time an {@link Annotation} matching a given type is received on the channel. + * Note that if you want to receive individual realtime annotations (instead of just the rolled-up summaries), you will need to request the annotation_subscribe ChannelMode in ChannelOptions, since they are not delivered by default. In general, most clients will not bother with subscribing to individual annotations, and will instead just look at the summary updates. + * + * @param type - A specific type string or an array of them to register the listener for. + * @param listener - An event listener function. + * @returns A promise which resolves upon success of the channel {@link RealtimeChannel.attach | `attach()`} operation and rejects with an {@link ErrorInfo} object upon its failure. + */ + subscribe(type: string | Array, listener?: messageCallback): Promise; + /** + * Registers a listener that is called each time an {@link Annotation} is received on the channel. + * Note that if you want to receive individual realtime annotations (instead of just the rolled-up summaries), you will need to request the annotation_subscribe ChannelMode in ChannelOptions, since they are not delivered by default. In general, most clients will not bother with subscribing to individual annotations, and will instead just look at the summary updates. + * + * @param listener - An event listener function. + * @returns A promise which resolves upon success of the channel {@link RealtimeChannel.attach | `attach()`} operation and rejects with an {@link ErrorInfo} object upon its failure. + */ + subscribe(listener?: messageCallback): Promise; + /** + * Deregisters a specific listener that is registered to receive {@link Annotation} on the channel for a given type. + * + * @param type - A specific annotation type (or array of types) to deregister the listener for. + * @param listener - An event listener function. + */ + unsubscribe(type: string | Array, listener: messageCallback): void; + /** + * Deregisters any listener that is registered to receive {@link Annotation} on the channel for a specific type. + * + * @param type - A specific annotation type (or array of types) to deregister the listeners for. + */ + unsubscribe(type: string | Array): void; + /** + * Deregisters a specific listener that is registered to receive {@link Annotation} on the channel. + * + * @param listener - An event listener function. + */ + unsubscribe(listener: messageCallback): void; + /** + * Deregisters all listeners currently receiving {@link Annotation} for the channel. + */ + unsubscribe(): void; + /** + * Publish a new annotation for a message. + * + * @param message - The message to annotate. + * @param annotation - The annotation to publish. (Must include at least the `type`; + * other required fields depend on the annotation type). + */ + publish(message: Message, annotation: OutboundAnnotation): Promise; + /** + * Publish a new annotation for a message (alternative form where you only have the + * serial of the message, not a complete Message object) + * + * @param messageSerial - The serial field of the message to annotate. + * @param annotation - The annotation to publish. (Must include at least the `type`; + * other required fields depend on the annotation type). + */ + publish(messageSerial: string, annotation: OutboundAnnotation): Promise; + /** + * Publish an annotation removal request for a message, to remove it from the summary + * summaries. The semantics of the delete (and what fields are required) are different + * for each annotation type; see annotation types documentation for more details. + * + * @param message - The message which has an annotation that you want to delete. + * @param annotation - The annotation deletion request. (Must include at least the + * `type`, other required fields depend on the type). + */ + delete(message: Message, annotation: OutboundAnnotation): Promise; + /** + * Publish an annotation removal request for a message, to remove it from the summary + * summaries. The semantics of the delete (and what fields are required) are different + * for each annotation type; see annotation types documentation for more details. + * + * @param messageSerial - The serial field of the message which has an annotation that + * you want to delete. + * @param annotation - The annotation deletion request. (Must include at least the + * `type`, other required fields depend on the type). + */ + delete(messageSerial: string, annotation: OutboundAnnotation): Promise; + /** + * Get all annotations for a given message (as a paginated result) + * + * @param message - The message to get annotations for. + * @param params - Restrictions on which annotations to get (in particular a limit) + */ + get(message: Message, params: GetAnnotationsParams | null): Promise>; + /** + * Get all annotations for a given message (as a paginated result) (alternative form + * where you only have the serial of the message, not a complete Message object) + * + * @param messageSerial - The `serial` of the message to get annotations for. + * @param params - Restrictions on which annotations to get (in particular a limit) + */ + get(messageSerial: string, params: GetAnnotationsParams | null): Promise>; +} + +/** + * Enables devices to subscribe to push notifications for a channel. + */ +export declare interface PushChannel { + /** + * Subscribes the device to push notifications for the channel. + */ + subscribeDevice(): Promise; + + /** + * Unsubscribes the device from receiving push notifications for the channel. + */ + unsubscribeDevice(): Promise; + + /** + * Subscribes all devices associated with the current device's `clientId` to push notifications for the channel. + */ + subscribeClient(): Promise; + + /** + * Unsubscribes all devices associated with the current device's `clientId` from receiving push notifications for the channel. + */ + unsubscribeClient(): Promise; + + /** + * Retrieves all push subscriptions for the channel. Subscriptions can be filtered using a params object. + * + * @param params - An object containing key-value pairs to filter subscriptions by. Can contain `clientId`, `deviceId` or a combination of both, and a `limit` on the number of subscriptions returned, up to 1,000. + * @returns a {@link PaginatedResult} object containing an array of {@link PushChannelSubscription} objects. + */ + listSubscriptions(params?: Record): Promise>; +} + +/** + * The `ObjectsEvents` namespace describes the possible values of the {@link ObjectsEvent} type. + */ +declare namespace ObjectsEvents { + /** + * The local copy of Objects on a channel is currently being synchronized with the Ably service. + */ + type SYNCING = 'syncing'; + /** + * The local copy of Objects on a channel has been synchronized with the Ably service. + */ + type SYNCED = 'synced'; +} + +/** + * Describes the events emitted by a {@link RealtimeObject} object. + */ +export type ObjectsEvent = ObjectsEvents.SYNCED | ObjectsEvents.SYNCING; + +/** + * Enables the Objects to be read, modified and subscribed to for a channel. + */ +export declare interface RealtimeObject { + /** + * Retrieves a {@link PathObject} for the object on a channel. + * + * A type parameter can be provided to describe the structure of the Objects on the channel. + * + * Example: + * + * ```typescript + * import { LiveCounter } from 'ably'; + * + * type MyObject = { + * myTypedCounter: LiveCounter; + * }; + * + * const myTypedObject = await channel.object.get(); + * ``` + * + * @returns A promise which, upon success, will be fulfilled with a {@link PathObject}. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + * @experimental + */ + get>(): Promise>>; + + /** + * Registers the provided listener for the specified event. If `on()` is called more than once with the same listener and event, the listener is added multiple times to its listener registry. Therefore, as an example, assuming the same listener is registered twice using `on()`, and an event is emitted once, the listener would be invoked twice. + * + * @param event - The named event to listen for. + * @param callback - The event listener. + * @returns A {@link StatusSubscription} object that allows the provided listener to be deregistered from future updates. + * @experimental + */ + on(event: ObjectsEvent, callback: ObjectsEventCallback): StatusSubscription; + + /** + * Removes all registrations that match both the specified listener and the specified event. + * + * @param event - The named event. + * @param callback - The event listener. + * @experimental + */ + off(event: ObjectsEvent, callback: ObjectsEventCallback): void; + + /** + * Deregisters all registrations, for all events and listeners. + * + * @experimental + */ + offAll(): void; +} + +/** + * Primitive types that can be stored in collection types. + * Includes JSON-serializable data so that maps and lists can hold plain JS values. + */ +export type Primitive = + | string + | number + | boolean + | Buffer + | ArrayBuffer + // JSON-serializable primitive values + | JsonArray + | JsonObject; + +/** + * Represents a JSON-encodable value. + */ +export type Json = JsonScalar | JsonArray | JsonObject; + +/** + * Represents a JSON-encodable scalar value. + */ +export type JsonScalar = null | boolean | number | string; + +/** + * Represents a JSON-encodable array. + */ +export type JsonArray = Json[]; + +/** + * Represents a JSON-encodable object. + */ +export type JsonObject = { [prop: string]: Json | undefined }; + +/** + * Unique symbol for nominal typing within TypeScript's structural type system. + * This prevents structural compatibility between LiveObject types. + */ +export declare const __livetype: unique symbol; + +// Branded interfaces that enables TypeScript to distinguish +// between LiveObject types even when they have identical structure (empty interfaces in this case). +// Enables PathObject to dispatch to correct method sets via conditional types. +/** + * A {@link LiveMap} is a collection type that maps string keys to values, which can be either primitive values or other LiveObjects. + */ +export interface LiveMap<_T extends Record = Record> { + /** LiveMap type symbol */ + [__livetype]: 'LiveMap'; +} + +/** + * A {@link LiveCounter} is a numeric type that supports atomic increment and decrement operations. + */ +export interface LiveCounter { + /** LiveCounter type symbol */ + [__livetype]: 'LiveCounter'; +} + +/** + * Type union that matches any LiveObject type that can be mutated, subscribed to, etc. + */ +export type LiveObject = LiveMap | LiveCounter; + +/** + * Type union that defines the base set of allowed types that can be stored in collection types. + * Describes the set of all possible values that can parameterize PathObject. + * This is the canonical union used when a narrower type cannot be inferred. + */ +export type Value = LiveObject | Primitive; + +/** + * CompactedValue transforms LiveObject types into plain JavaScript equivalents. + * LiveMap becomes an object, LiveCounter becomes a number, binary values become base64-encoded strings, other primitives remain unchanged. + */ +export type CompactedValue = + // LiveMap types + [T] extends [LiveMap] + ? { [K in keyof U]: CompactedValue } + : [T] extends [LiveMap | undefined] + ? { [K in keyof U]: CompactedValue } | undefined + : // LiveCounter types + [T] extends [LiveCounter] + ? number + : [T] extends [LiveCounter | undefined] + ? number | undefined + : // Binary types (converted to base64 strings) + [T] extends [ArrayBuffer] + ? string + : [T] extends [ArrayBuffer | undefined] + ? string | undefined + : [T] extends [ArrayBufferView] + ? string + : [T] extends [ArrayBufferView | undefined] + ? string | undefined + : // Other primitive types + [T] extends [Primitive] + ? T + : [T] extends [Primitive | undefined] + ? T + : any; + +/** + * PathObjectBase defines the set of common methods on a PathObject + * that are present regardless of the underlying type. + */ +interface PathObjectBase { + /** + * Get the fully-qualified path string for this PathObject. + * + * Path segments with dots in them are escaped with a backslash. + * For example, a path with segments `['a', 'b.c', 'd']` will be represented as `a.b\.c.d`. + * + * @experimental + */ + path(): string; + + /** + * Registers a listener that is called each time the object or a primitive value at this path is updated. + * + * The provided listener receives a {@link PathObject} representing the updated path, + * and, if applicable, an {@link ObjectMessage} that carried the operation that led to the change. + * + * By default, subscriptions observe nested changes, but you can configure the observation depth + * using the `options` parameter. + * + * A PathObject subscription observes whichever value currently exists at this path. + * The subscription remains active even if the path temporarily does not resolve to any value + * (for example, if an entry is removed from a map). If the object instance at this path changes, + * the subscription automatically switches to observe the new instance and stops observing the old one. + * + * @param listener - An event listener function. + * @param options - Optional subscription configuration. + * @returns A {@link Subscription} object that allows the provided listener to be deregistered from future updates. + * @experimental + */ + subscribe( + listener: EventCallback, + options?: PathObjectSubscriptionOptions, + ): Subscription; + + /** + * Registers a subscription listener and returns an async iterator that yields + * subscription events each time the object or a primitive value at this path is updated. + * + * This method functions in the same way as the regular {@link PathObjectBase.subscribe | PathObject.subscribe()} method, + * but instead returns an async iterator that can be used in a `for await...of` loop for convenience. + * + * @param options - Optional subscription configuration. + * @returns An async iterator that yields {@link PathObjectSubscriptionEvent} objects. + * @experimental + */ + subscribeIterator(options?: PathObjectSubscriptionOptions): AsyncIterableIterator; +} + +/** + * PathObjectCollectionMethods defines the set of common methods on a PathObject + * that are present for any collection type, regardless of the specific underlying type. + */ +interface PathObjectCollectionMethods { + /** + * Collection types support obtaining a PathObject with a fully-qualified string path, + * which is evaluated from the current path. + * Using this method loses rich compile-time type information. + * + * @param path - A fully-qualified path string to navigate to, relative to the current path. + * @returns A {@link PathObject} for the specified path. + * @experimental + */ + at(path: string): PathObject; +} + +/** + * Defines collection methods available on a {@link LiveMapPathObject}. + */ +interface LiveMapPathObjectCollectionMethods = Record> { + /** + * Returns an iterable of key-value pairs for each entry in the map at this path. + * Each value is represented as a {@link PathObject} corresponding to its key. + * + * If the path does not resolve to a map object, returns an empty iterator. + * + * @experimental + */ + entries(): IterableIterator<[keyof T, PathObject]>; + + /** + * Returns an iterable of keys in the map at this path. + * + * If the path does not resolve to a map object, returns an empty iterator. + * + * @experimental + */ + keys(): IterableIterator; + + /** + * Returns an iterable of values in the map at this path. + * Each value is represented as a {@link PathObject}. + * + * If the path does not resolve to a map object, returns an empty iterator. + * + * @experimental + */ + values(): IterableIterator>; + + /** + * Returns the number of entries in the map at this path. + * + * If the path does not resolve to a map object, returns `undefined`. + * + * @experimental + */ + size(): number | undefined; +} + +/** + * A PathObject representing a {@link LiveMap} instance at a specific path. + * The type parameter T describes the expected structure of the map's entries. + */ +export interface LiveMapPathObject = Record> + extends PathObjectBase, + PathObjectCollectionMethods, + LiveMapPathObjectCollectionMethods, + LiveMapOperations { + /** + * Navigate to a child path within the map by obtaining a PathObject for that path. + * The next path segment in a LiveMap is identified with a string key. + * + * @param key - A string key for the next path segment within the map. + * @returns A {@link PathObject} for the specified key. + * @experimental + */ + get(key: K): PathObject; + + /** + * Get the specific map instance currently at this path. + * If the path does not resolve to any specific instance, returns `undefined`. + * + * @returns The {@link LiveMapInstance} at this path, or `undefined` if none exists. + * @experimental + */ + instance(): LiveMapInstance | undefined; + + /** + * Get a JavaScript object representation of the map at this path. + * Binary values are returned as base64-encoded strings. + * Cyclic references are handled through memoization, returning shared compacted + * object references for previously visited objects. This means the value returned + * from `compact()` cannot be directly JSON-stringified if the object may contain cycles. + * + * If the path does not resolve to any specific instance, returns `undefined`. + * + * @experimental + */ + compact(): CompactedValue> | undefined; +} + +/** + * A PathObject representing a {@link LiveCounter} instance at a specific path. + */ +export interface LiveCounterPathObject extends PathObjectBase, LiveCounterOperations { + /** + * Get the current value of the counter instance currently at this path. + * If the path does not resolve to any specific instance, returns `undefined`. + * + * @experimental + */ + value(): number | undefined; + + /** + * Get the specific counter instance currently at this path. + * If the path does not resolve to any specific instance, returns `undefined`. + * + * @returns The {@link LiveCounterInstance} at this path, or `undefined` if none exists. + * @experimental + */ + instance(): LiveCounterInstance | undefined; + + /** + * Get a number representation of the counter at this path. + * If the path does not resolve to any specific instance, returns `undefined`. + * + * @experimental + */ + compact(): CompactedValue | undefined; +} + +/** + * A PathObject representing a primitive value at a specific path. + */ +export interface PrimitivePathObject extends PathObjectBase { + /** + * Get the current value of the primitive currently at this path. + * If the path does not resolve to any specific entry, returns `undefined`. + * + * @experimental + */ + value(): T | undefined; + + /** + * Get a JavaScript object representation of the primitive value at this path. + * Binary values are returned as base64-encoded strings. + * + * If the path does not resolve to any specific entry, returns `undefined`. + * + * @experimental + */ + compact(): CompactedValue | undefined; +} + +/** + * AnyPathObjectCollectionMethods defines all possible methods available on a PathObject + * for the underlying collection types. + */ +interface AnyPathObjectCollectionMethods { + // LiveMap collection methods + + /** + * Returns an iterable of key-value pairs for each entry in the map, if the path resolves to a {@link LiveMap}. + * Each value is represented as a {@link PathObject} corresponding to its key. + * + * If the path does not resolve to a map object, returns an empty iterator. + * + * @experimental + */ + entries>(): IterableIterator<[keyof T, PathObject]>; + + /** + * Returns an iterable of keys in the map, if the path resolves to a {@link LiveMap}. + * + * If the path does not resolve to a map object, returns an empty iterator. + * + * @experimental + */ + keys>(): IterableIterator; + + /** + * Returns an iterable of values in the map, if the path resolves to a {@link LiveMap}. + * Each value is represented as a {@link PathObject}. + * + * If the path does not resolve to a map object, returns an empty iterator. + * + * @experimental + */ + values>(): IterableIterator>; + + /** + * Returns the number of entries in the map, if the path resolves to a {@link LiveMap}. + * + * If the path does not resolve to a map object, returns `undefined`. + * + * @experimental + */ + size(): number | undefined; +} + +/** + * Represents a {@link PathObject} when its underlying type is not known. + * Provides a unified interface that includes all possible methods. + * + * Each method supports type parameters to specify the expected + * underlying type when needed. + */ +export interface AnyPathObject + extends PathObjectBase, + PathObjectCollectionMethods, + AnyPathObjectCollectionMethods, + AnyOperations { + /** + * Navigate to a child path within the collection by obtaining a PathObject for that path. + * The next path segment in a collection is identified with a string key. + * + * @param key - A string key for the next path segment within the collection. + * @returns A {@link PathObject} for the specified key. + * @experimental + */ + get(key: string): PathObject; + + /** + * Get the current value of the LiveCounter or primitive currently at this path. + * If the path does not resolve to any specific entry, returns `undefined`. + * + * @experimental + */ + value(): T | undefined; + + /** + * Get the specific object instance currently at this path. + * If the path does not resolve to any specific instance, returns `undefined`. + * + * @returns The object instance at this path, or `undefined` if none exists. + * @experimental + */ + instance(): Instance | undefined; + + /** + * Get a JavaScript object representation of the object at this path. + * Binary values are returned as base64-encoded strings. + * + * When compacting a {@link LiveMap}, cyclic references are handled through + * memoization, returning shared compacted object references for previously + * visited objects. This means the value returned from `compact()` cannot be + * directly JSON-stringified if the object may contain cycles. + * + * If the path does not resolve to any specific entry, returns `undefined`. + * + * @experimental + */ + compact(): CompactedValue | undefined; +} + +/** + * PathObject wraps a reference to a path starting from the entrypoint object on a channel. + * The type parameter specifies the underlying type defined at that path, + * and is used to infer the correct set of methods available for that type. + * + * @experimental + */ +export type PathObject = [T] extends [LiveMap] + ? LiveMapPathObject + : [T] extends [LiveCounter] + ? LiveCounterPathObject + : [T] extends [Primitive] + ? PrimitivePathObject + : AnyPathObject; + +/** + * BatchContextBase defines the set of common methods on a BatchContext + * that are present regardless of the underlying type. + */ +interface BatchContextBase { + /** + * Get the object ID of the underlying instance. + * + * If the underlying instance at runtime is not a {@link LiveObject}, returns `undefined`. + * + * @experimental + */ + id(): string | undefined; +} + +/** + * Defines collection methods available on a {@link LiveMapBatchContext}. + */ +interface LiveMapBatchContextCollectionMethods = Record> { + /** + * Returns an iterable of key-value pairs for each entry in the map. + * Each value is represented as a {@link BatchContext} corresponding to its key. + * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * + * @experimental + */ + entries(): IterableIterator<[keyof T, BatchContext]>; + + /** + * Returns an iterable of keys in the map. + * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * + * @experimental + */ + keys(): IterableIterator; + + /** + * Returns an iterable of values in the map. + * Each value is represented as a {@link BatchContext}. + * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * + * @experimental + */ + values(): IterableIterator>; + + /** + * Returns the number of entries in the map. + * + * If the underlying instance at runtime is not a map, returns `undefined`. + * + * @experimental + */ + size(): number | undefined; +} + +/** + * LiveMapBatchContext is a batch context wrapper for a LiveMap object. + * The type parameter T describes the expected structure of the map's entries. + */ +export interface LiveMapBatchContext = Record> + extends BatchContextBase, + BatchContextLiveMapOperations, + LiveMapBatchContextCollectionMethods { + /** + * Returns the value associated with a given key as a {@link BatchContext}. + * + * Returns `undefined` if the key doesn't exist in the map, if the referenced {@link LiveObject} has been deleted, + * or if this map object itself has been deleted. + * + * @param key - The key to retrieve the value for. + * @returns A {@link BatchContext} representing a {@link LiveObject}, a primitive type (string, number, boolean, JSON-serializable object or array, or binary data) or `undefined` if the key doesn't exist in a map or the referenced {@link LiveObject} has been deleted. Always `undefined` if this map object is deleted. + * @experimental + */ + get(key: K): BatchContext | undefined; + + /** + * Get a JavaScript object representation of the map instance. + * Binary values are returned as base64-encoded strings. + * Cyclic references are handled through memoization, returning shared compacted + * object references for previously visited objects. This means the value returned + * from `compact()` cannot be directly JSON-stringified if the object may contain cycles. + * + * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. + * + * @experimental + */ + compact(): CompactedValue> | undefined; +} + +/** + * LiveCounterBatchContext is a batch context wrapper for a LiveCounter object. + */ +export interface LiveCounterBatchContext extends BatchContextBase, BatchContextLiveCounterOperations { + /** + * Get the current value of the counter instance. + * If the underlying instance at runtime is not a counter, returns `undefined`. + * + * @experimental + */ + value(): number | undefined; + + /** + * Get a number representation of the counter instance. + * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. + * + * @experimental + */ + compact(): CompactedValue | undefined; +} + +/** + * PrimitiveBatchContext is a batch context wrapper for a primitive value (string, number, boolean, JSON-serializable object or array, or binary data). + */ +export interface PrimitiveBatchContext { + /** + * Get the underlying primitive value. + * If the underlying instance at runtime is not a primitive value, returns `undefined`. + * + * @experimental + */ + value(): T | undefined; + + /** + * Get a JavaScript object representation of the primitive value. + * Binary values are returned as base64-encoded strings. + * + * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. + * + * @experimental + */ + compact(): CompactedValue | undefined; +} + +/** + * AnyBatchContextCollectionMethods defines all possible methods available on an BatchContext object + * for the underlying collection types. + */ +interface AnyBatchContextCollectionMethods { + // LiveMap collection methods + + /** + * Returns an iterable of key-value pairs for each entry in the map. + * Each value is represented as an {@link BatchContext} corresponding to its key. + * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * + * @experimental + */ + entries>(): IterableIterator<[keyof T, BatchContext]>; + + /** + * Returns an iterable of keys in the map. + * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * + * @experimental + */ + keys>(): IterableIterator; + + /** + * Returns an iterable of values in the map. + * Each value is represented as a {@link BatchContext}. + * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * + * @experimental + */ + values>(): IterableIterator>; + + /** + * Returns the number of entries in the map. + * + * If the underlying instance at runtime is not a map, returns `undefined`. + * + * @experimental + */ + size(): number | undefined; +} + +/** + * Represents a {@link BatchContext} when its underlying type is not known. + * Provides a unified interface that includes all possible methods. + * + * Each method supports type parameters to specify the expected + * underlying type when needed. + */ +export interface AnyBatchContext extends BatchContextBase, AnyBatchContextCollectionMethods, BatchContextAnyOperations { + /** + * Navigate to a child entry within the collection by obtaining the {@link BatchContext} at that entry. + * The entry in a collection is identified with a string key. + * + * Returns `undefined` if: + * - The underlying instance at runtime is not a collection object. + * - The specified key does not exist in the collection. + * - The referenced {@link LiveObject} has been deleted. + * - This collection object itself has been deleted. + * + * @param key - The key to retrieve the value for. + * @returns A {@link BatchContext} representing either a {@link LiveObject} or a primitive value (string, number, boolean, JSON-serializable object or array, or binary data), or `undefined` if the underlying instance at runtime is not a collection object, the key does not exist, the referenced {@link LiveObject} has been deleted, or this collection object itself has been deleted. + * @experimental + */ + get(key: string): BatchContext | undefined; + + /** + * Get the current value of the underlying counter or primitive. + * + * If the underlying instance at runtime is neither a counter nor a primitive value, returns `undefined`. + * + * @returns The current value of the underlying primitive or counter, or `undefined` if the value cannot be retrieved. + * @experimental + */ + value(): T | undefined; + + /** + * Get a JavaScript object representation of the object instance. + * Binary values are returned as base64-encoded strings. + * + * When compacting a {@link LiveMap}, cyclic references are handled through + * memoization, returning shared compacted object references for previously + * visited objects. This means the value returned from `compact()` cannot be + * directly JSON-stringified if the object may contain cycles. + * + * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. + * + * @experimental + */ + compact(): CompactedValue | undefined; +} + +/** + * BatchContext wraps a specific object instance or entry in a specific collection + * object instance and provides synchronous operation methods that can be aggregated + * and applied as a single batch operation. + * + * The type parameter specifies the underlying type of the instance, + * and is used to infer the correct set of methods available for that type. + * + * @experimental + */ +export type BatchContext = [T] extends [LiveMap] + ? LiveMapBatchContext + : [T] extends [LiveCounter] + ? LiveCounterBatchContext + : [T] extends [Primitive] + ? PrimitiveBatchContext + : AnyBatchContext; + +/** + * Defines operations available on {@link LiveMapBatchContext}. + */ +export interface BatchContextLiveMapOperations = Record> { + /** + * Adds an operation to the current batch to set a key to a specified value on the underlying + * {@link LiveMapInstance}. All queued operations are sent together in a single message once the + * batch function completes. + * + * If the underlying instance at runtime is not a map, this method throws an error. + * + * This does not modify the underlying data of the map. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. + * + * @param key - The key to set the value for. + * @param value - The value to assign to the key. + * @experimental + */ + set(key: K, value: T[K]): void; + + /** + * Adds an operation to the current batch to remove a key from the underlying + * {@link LiveMapInstance}. All queued operations are sent together in a single message once the + * batch function completes. + * + * If the underlying instance at runtime is not a map, this method throws an error. + * + * This does not modify the underlying data of the map. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. + * + * @param key - The key to remove. + * @experimental + */ + remove(key: keyof T & string): void; +} + +/** + * Defines operations available on {@link LiveCounterBatchContext}. + */ +export interface BatchContextLiveCounterOperations { + /** + * Adds an operation to the current batch to increment the value of the underlying + * {@link LiveCounterInstance}. All queued operations are sent together in a single message once the + * batch function completes. + * + * If the underlying instance at runtime is not a counter, this method throws an error. + * + * This does not modify the underlying data of the counter. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. + * + * @param amount - The amount by which to increase the counter value. If not provided, defaults to 1. + * @experimental + */ + increment(amount?: number): void; + + /** + * An alias for calling {@link BatchContextLiveCounterOperations.increment | increment(-amount)} + * + * @param amount - The amount by which to decrease the counter value. If not provided, defaults to 1. + * @experimental + */ + decrement(amount?: number): void; +} + +/** + * Defines all possible operations available on {@link BatchContext} objects. + */ +export interface BatchContextAnyOperations { + // LiveMap operations + + /** + * Adds an operation to the current batch to set a key to a specified value on the underlying + * {@link LiveMapInstance}. All queued operations are sent together in a single message once the + * batch function completes. + * + * If the underlying instance at runtime is not a map, this method throws an error. + * + * This does not modify the underlying data of the map. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. + * + * @param key - The key to set the value for. + * @param value - The value to assign to the key. + * @experimental + */ + set = Record>(key: keyof T & string, value: T[keyof T]): void; + + /** + * Adds an operation to the current batch to remove a key from the underlying + * {@link LiveMapInstance}. All queued operations are sent together in a single message once the + * batch function completes. + * + * If the underlying instance at runtime is not a map, this method throws an error. + * + * This does not modify the underlying data of the map. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. + * + * @param key - The key to remove. + * @experimental + */ + remove = Record>(key: keyof T & string): void; + + // LiveCounter operations + + /** + * Adds an operation to the current batch to increment the value of the underlying + * {@link LiveCounterInstance}. All queued operations are sent together in a single message once the + * batch function completes. + * + * If the underlying instance at runtime is not a counter, this method throws an error. + * + * This does not modify the underlying data of the counter. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. + * + * @param amount - The amount by which to increase the counter value. If not provided, defaults to 1. + * @experimental + */ + increment(amount?: number): void; + + /** + * An alias for calling {@link BatchContextAnyOperations.increment | increment(-amount)} + * + * @param amount - The amount by which to decrease the counter value. If not provided, defaults to 1. + * @experimental + */ + decrement(amount?: number): void; +} + +/** + * Defines batch operations available on {@link LiveObject | LiveObjects}. + */ +export interface BatchOperations { + /** + * Batch multiple operations together using a batch context, which + * wraps the underlying {@link PathObject} or {@link Instance} from which the batch was called. + * The batch context always contains a resolved instance, even when called from a {@link PathObject}. + * If an instance cannot be resolved from the referenced path, or if the instance is not a {@link LiveObject}, + * this method throws an error. + * + * Batching enables you to group multiple operations together and send them to the Ably service in a single channel message. + * As a result, other clients will receive the changes in a single channel message once the batch function has completed. + * + * The objects' data is not modified inside the batch function. Instead, the objects will be updated + * when the batched operations are applied by the Ably service and echoed back to the client. + * + * @param fn - A synchronous function that receives a {@link BatchContext} used to group operations together. + * @returns A promise which resolves upon success of the batch operation and rejects with an {@link ErrorInfo} object upon its failure. + * @experimental + */ + batch(fn: BatchFunction): Promise; +} + +/** + * Defines operations available on {@link LiveMap} objects. + */ +export interface LiveMapOperations = Record> + extends BatchOperations> { + /** + * Sends an operation to the Ably system to set a key to a specified value on a given {@link LiveMapInstance}, + * or on the map instance resolved from the path when using {@link LiveMapPathObject}. + * + * If called via {@link LiveMapInstance} and the underlying instance at runtime is not a map, + * or if called via {@link LiveMapPathObject} and the map instance at the specified path cannot + * be resolved at the time of the call, this method throws an error. + * + * This does not modify the underlying data of the map. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. + * + * @param key - The key to set the value for. + * @param value - The value to assign to the key. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + * @experimental + */ + set(key: K, value: T[K]): Promise; + + /** + * Sends an operation to the Ably system to remove a key from a given {@link LiveMapInstance}, + * or from the map instance resolved from the path when using {@link LiveMapPathObject}. + * + * If called via {@link LiveMapInstance} and the underlying instance at runtime is not a map, + * or if called via {@link LiveMapPathObject} and the map instance at the specified path cannot + * be resolved at the time of the call, this method throws an error. + * + * This does not modify the underlying data of the map. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. + * + * @param key - The key to remove. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + * @experimental + */ + remove(key: keyof T & string): Promise; +} + +/** + * Defines operations available on {@link LiveCounter} objects. + */ +export interface LiveCounterOperations extends BatchOperations { + /** + * Sends an operation to the Ably system to increment the value of a given {@link LiveCounterInstance}, + * or of the counter instance resolved from the path when using {@link LiveCounterPathObject}. + * + * If called via {@link LiveCounterInstance} and the underlying instance at runtime is not a counter, + * or if called via {@link LiveCounterPathObject} and the counter instance at the specified path cannot + * be resolved at the time of the call, this method throws an error. + * + * This does not modify the underlying data of the counter. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. + * + * @param amount - The amount by which to increase the counter value. If not provided, defaults to 1. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + * @experimental + */ + increment(amount?: number): Promise; + + /** + * An alias for calling {@link LiveCounterOperations.increment | increment(-amount)} + * + * @param amount - The amount by which to decrease the counter value. If not provided, defaults to 1. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + * @experimental + */ + decrement(amount?: number): Promise; +} + +/** + * Defines all possible operations available on {@link LiveObject | LiveObjects}. + */ +export interface AnyOperations { + /** + * Batch multiple operations together using a batch context, which + * wraps the underlying {@link PathObject} or {@link Instance} from which the batch was called. + * The batch context always contains a resolved instance, even when called from a {@link PathObject}. + * If an instance cannot be resolved from the referenced path, or if the instance is not a {@link LiveObject}, + * this method throws an error. + * + * Batching enables you to group multiple operations together and send them to the Ably service in a single channel message. + * As a result, other clients will receive the changes in a single channel message once the batch function has completed. + * + * The objects' data is not modified inside the batch function. Instead, the objects will be updated + * when the batched operations are applied by the Ably service and echoed back to the client. + * + * @param fn - A synchronous function that receives a {@link BatchContext} used to group operations together. + * @returns A promise which resolves upon success of the batch operation and rejects with an {@link ErrorInfo} object upon its failure. + * @experimental + */ + batch(fn: BatchFunction): Promise; + + // LiveMap operations + + /** + * Sends an operation to the Ably system to set a key to a specified value on the underlying map when using {@link AnyInstance}, + * or on the map instance resolved from the path when using {@link AnyPathObject}. + * + * If called via {@link AnyInstance} and the underlying instance at runtime is not a map, + * or if called via {@link AnyPathObject} and the map instance at the specified path cannot + * be resolved at the time of the call, this method throws an error. + * + * This does not modify the underlying data of the map. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. + * + * @param key - The key to set the value for. + * @param value - The value to assign to the key. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + * @experimental + */ + set = Record>(key: keyof T & string, value: T[keyof T]): Promise; + + /** + * Sends an operation to the Ably system to remove a key from the underlying map when using {@link AnyInstance}, + * or from the map instance resolved from the path when using {@link AnyPathObject}. + * + * If called via {@link AnyInstance} and the underlying instance at runtime is not a map, + * or if called via {@link AnyPathObject} and the map instance at the specified path cannot + * be resolved at the time of the call, this method throws an error. + * + * This does not modify the underlying data of the map. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. + * + * @param key - The key to remove. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + * @experimental + */ + remove = Record>(key: keyof T & string): Promise; + + // LiveCounter operations + + /** + * Sends an operation to the Ably system to increment the value of the underlying counter when using {@link AnyInstance}, + * or of the counter instance resolved from the path when using {@link AnyPathObject}. + * + * If called via {@link AnyInstance} and the underlying instance at runtime is not a counter, + * or if called via {@link AnyPathObject} and the counter instance at the specified path cannot + * be resolved at the time of the call, this method throws an error. + * + * This does not modify the underlying data of the counter. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. + * + * @param amount - The amount by which to increase the counter value. If not provided, defaults to 1. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + * @experimental + */ + increment(amount?: number): Promise; + + /** + * An alias for calling {@link AnyOperations.increment | increment(-amount)} + * + * @param amount - The amount by which to decrease the counter value. If not provided, defaults to 1. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + * @experimental + */ + decrement(amount?: number): Promise; +} + +/** + * InstanceBase defines the set of common methods on an Instance + * that are present regardless of the underlying type specified in the type parameter T. + */ +interface InstanceBase { + /** + * Get the object ID of this instance. + * + * If the underlying instance at runtime is not a {@link LiveObject}, returns `undefined`. + * + * @experimental + */ + id(): string | undefined; + + /** + * Registers a listener that is called each time this instance is updated. + * + * If the underlying instance at runtime is not a {@link LiveObject}, this method throws an error. + * + * The provided listener receives an {@link Instance} representing the updated object, + * and, if applicable, an {@link ObjectMessage} that carried the operation that led to the change. + * + * Instance subscriptions track a specific object instance regardless of its location. + * The subscription follows the instance if it is moved within the broader structure + * (for example, between map entries). + * + * If the instance is deleted from the channel object entirely (i.e., tombstoned), + * the listener is called with the corresponding delete operation before + * automatically deregistering. + * + * @param listener - An event listener function. + * @returns A {@link Subscription} object that allows the provided listener to be deregistered from future updates. + * @experimental + */ + subscribe(listener: EventCallback>): Subscription; + + /** + * Registers a subscription listener and returns an async iterator that yields + * subscription events each time this instance is updated. + * + * This method functions in the same way as the regular {@link InstanceBase.subscribe | Instance.subscribe()} method, + * but instead returns an async iterator that can be used in a `for await...of` loop for convenience. + * + * @returns An async iterator that yields {@link InstanceSubscriptionEvent} objects. + * @experimental + */ + subscribeIterator(): AsyncIterableIterator>; +} + +/** + * Defines collection methods available on a {@link LiveMapInstance}. + */ +interface LiveMapInstanceCollectionMethods = Record> { + /** + * Returns an iterable of key-value pairs for each entry in the map. + * Each value is represented as an {@link Instance} corresponding to its key. + * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * + * @experimental + */ + entries(): IterableIterator<[keyof T, Instance]>; + + /** + * Returns an iterable of keys in the map. + * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * + * @experimental + */ + keys(): IterableIterator; + + /** + * Returns an iterable of values in the map. + * Each value is represented as an {@link Instance}. + * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * + * @experimental + */ + values(): IterableIterator>; + + /** + * Returns the number of entries in the map. + * + * If the underlying instance at runtime is not a map, returns `undefined`. + * + * @experimental + */ + size(): number | undefined; +} + +/** + * LiveMapInstance represents an Instance of a LiveMap object. + * The type parameter T describes the expected structure of the map's entries. + */ +export interface LiveMapInstance = Record> + extends InstanceBase>, + LiveMapInstanceCollectionMethods, + LiveMapOperations { + /** + * Returns the value associated with a given key as an {@link Instance}. + * + * If the associated value is a primitive, returns a {@link PrimitiveInstance} + * that serves as a snapshot of the primitive value and does not reflect subsequent + * changes to the value at that key. + * + * Returns `undefined` if the key doesn't exist in the map, if the referenced {@link LiveObject} has been deleted, + * or if this map object itself has been deleted. + * + * @param key - The key to retrieve the value for. + * @returns An {@link Instance} representing a {@link LiveObject}, a primitive type (string, number, boolean, JSON-serializable object or array, or binary data) or `undefined` if the key doesn't exist in a map or the referenced {@link LiveObject} has been deleted. Always `undefined` if this map object is deleted. + * @experimental + */ + get(key: K): Instance | undefined; + + /** + * Get a JavaScript object representation of the map instance. + * Binary values are returned as base64-encoded strings. + * Cyclic references are handled through memoization, returning shared compacted + * object references for previously visited objects. This means the value returned + * from `compact()` cannot be directly JSON-stringified if the object may contain cycles. + * + * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. + * + * @experimental + */ + compact(): CompactedValue> | undefined; +} + +/** + * LiveCounterInstance represents an Instance of a LiveCounter object. + */ +export interface LiveCounterInstance extends InstanceBase, LiveCounterOperations { + /** + * Get the current value of the counter instance. + * If the underlying instance at runtime is not a counter, returns `undefined`. + * + * @experimental + */ + value(): number | undefined; + + /** + * Get a number representation of the counter instance. + * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. + * + * @experimental + */ + compact(): CompactedValue | undefined; +} + +/** + * PrimitiveInstance represents a snapshot of a primitive value (string, number, boolean, JSON-serializable object or array, or binary data) + * that was stored at a key within a collection type. + */ +export interface PrimitiveInstance { + /** + * Get the primitive value represented by this instance. + * This reflects the value at the corresponding key in the collection at the time this instance was obtained. + * + * If the underlying instance at runtime is not a primitive value, returns `undefined`. + * + * @experimental + */ + value(): T | undefined; + + /** + * Get a JavaScript object representation of the primitive value. + * Binary values are returned as base64-encoded strings. + * + * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. + * + * @experimental + */ + compact(): CompactedValue | undefined; +} + +/** + * AnyInstanceCollectionMethods defines all possible methods available on an Instance + * for the underlying collection types. + */ +interface AnyInstanceCollectionMethods { + // LiveMap collection methods + + /** + * Returns an iterable of key-value pairs for each entry in the map. + * Each value is represented as an {@link Instance} corresponding to its key. + * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * + * @experimental + */ + entries>(): IterableIterator<[keyof T, Instance]>; + + /** + * Returns an iterable of keys in the map. + * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * + * @experimental + */ + keys>(): IterableIterator; + + /** + * Returns an iterable of values in the map. + * Each value is represented as a {@link Instance}. + * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * + * @experimental + */ + values>(): IterableIterator>; + + /** + * Returns the number of entries in the map. + * + * If the underlying instance at runtime is not a map, returns `undefined`. + * + * @experimental + */ + size(): number | undefined; +} + +/** + * Represents an {@link Instance} when its underlying type is not known. + * Provides a unified interface that includes all possible methods. + * + * Each method supports type parameters to specify the expected + * underlying type when needed. + */ +export interface AnyInstance extends InstanceBase, AnyInstanceCollectionMethods, AnyOperations { + /** + * Navigate to a child entry within the collection by obtaining the {@link Instance} at that entry. + * The entry in a collection is identified with a string key. + * + * Returns `undefined` if: + * - The underlying instance at runtime is not a collection object. + * - The specified key does not exist in the collection. + * - The referenced {@link LiveObject} has been deleted. + * - This collection object itself has been deleted. + * + * @param key - The key to get the child entry for. + * @returns An {@link Instance} representing either a {@link LiveObject} or a primitive value (string, number, boolean, JSON-serializable object or array, or binary data), or `undefined` if the underlying instance at runtime is not a collection object, the key does not exist, the referenced {@link LiveObject} has been deleted, or this collection object itself has been deleted. + * @experimental + */ + get(key: string): Instance | undefined; + + /** + * Get the current value of the underlying counter or primitive. + * + * If the underlying value is a primitive, this reflects the value at the corresponding key + * in the collection at the time this instance was obtained. + * + * If the underlying instance at runtime is neither a counter nor a primitive value, returns `undefined`. + * + * @experimental + */ + value(): T | undefined; + + /** + * Get a JavaScript object representation of the object instance. + * Binary values are returned as base64-encoded strings. + * + * When compacting a {@link LiveMap}, cyclic references are handled through + * memoization, returning shared compacted object references for previously + * visited objects. This means the value returned from `compact()` cannot be + * directly JSON-stringified if the object may contain cycles. + * + * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. + * + * @experimental + */ + compact(): CompactedValue | undefined; +} + +/** + * Instance wraps a specific object instance or entry in a specific collection object instance. + * The type parameter specifies the underlying type of the instance, + * and is used to infer the correct set of methods available for that type. + * + * @experimental + */ +export type Instance = [T] extends [LiveMap] + ? LiveMapInstance + : [T] extends [LiveCounter] + ? LiveCounterInstance + : [T] extends [Primitive] + ? PrimitiveInstance + : AnyInstance; + +/** + * The event object passed to a {@link PathObject} subscription listener. + */ +export type PathObjectSubscriptionEvent = { + /** The {@link PathObject} representing the updated path. */ + object: PathObject; + /** The {@link ObjectMessage} that carried the operation that led to the change, if applicable. */ + message?: ObjectMessage; +}; + +/** + * Options that can be provided to {@link PathObjectBase.subscribe | PathObject.subscribe}. + */ +export interface PathObjectSubscriptionOptions { + /** + * The number of levels deep to observe changes in nested children. + * + * - If `undefined` (default), there is no depth limit, and changes at any depth + * within nested children will be observed. + * - A depth of `1` (the minimum) means that only changes to the object at the subscribed path + * itself will be observed, not changes to its children. + */ + depth?: number; +} + +/** + * The event object passed to an {@link Instance} subscription listener. + */ +export type InstanceSubscriptionEvent = { + /** The {@link Instance} representing the updated object. */ + object: Instance; + /** The {@link ObjectMessage} that carried the operation that led to the change, if applicable. */ + message?: ObjectMessage; +}; + +/** + * The namespace containing the different types of object operation actions. + */ +declare namespace ObjectOperationActions { + /** + * Object operation action for a creating a map object. + */ + type MAP_CREATE = 'map.create'; + /** + * Object operation action for setting a key pair in a map object. + */ + type MAP_SET = 'map.set'; + /** + * Object operation action for removing a key from a map object. + */ + type MAP_REMOVE = 'map.remove'; + /** + * Object operation action for creating a counter object. + */ + type COUNTER_CREATE = 'counter.create'; + /** + * Object operation action for incrementing a counter object. + */ + type COUNTER_INC = 'counter.inc'; + /** + * Object operation action for deleting an object. + */ + type OBJECT_DELETE = 'object.delete'; +} + +/** + * The possible values of the `action` field of an {@link ObjectOperation}. + */ +export type ObjectOperationAction = + | ObjectOperationActions.MAP_CREATE + | ObjectOperationActions.MAP_SET + | ObjectOperationActions.MAP_REMOVE + | ObjectOperationActions.COUNTER_CREATE + | ObjectOperationActions.COUNTER_INC + | ObjectOperationActions.OBJECT_DELETE; + +/** + * The namespace containing the different types of map object semantics. + */ +declare namespace ObjectsMapSemanticsNamespace { + /** + * Last-write-wins conflict-resolution semantics. + */ + type LWW = 'lww'; +} + +/** + * The possible values of the `semantics` field of an {@link ObjectsMap}. + */ +export type ObjectsMapSemantics = ObjectsMapSemanticsNamespace.LWW; + +/** + * An object message that carried an operation. + */ +export interface ObjectMessage { + /** + * Unique ID assigned by Ably to this object message. + */ + id: string; + /** + * The client ID of the publisher of this object message (if any). + */ + clientId?: string; + /** + * The connection ID of the publisher of this object message (if any). + */ + connectionId?: string; + /** + * Timestamp of when the object message was received by Ably, as milliseconds since the Unix epoch. + */ + timestamp: number; + /** + * The name of the channel the object message was published to. + */ + channel: string; + /** + * Describes an operation that was applied to an object. + */ + operation: ObjectOperation; + /** + * An opaque string that uniquely identifies this object message. + */ + serial?: string; + /** + * A timestamp from the {@link serial} field. + */ + serialTimestamp?: number; + /** + * An opaque string that uniquely identifies the Ably site the object message was published to. + */ + siteCode?: string; + /** + * A JSON object of arbitrary key-value pairs that may contain metadata, and/or ancillary payloads. Valid payloads include `headers`. + */ + extras?: { + /** + * A set of key–value pair headers included with this object message. + */ + headers?: Record; + [key: string]: any; + }; +} + +/** + * An operation that was applied to an object on a channel. + */ +export interface ObjectOperation { + /** The operation action, one of the {@link ObjectOperationAction} enum values. */ + action: ObjectOperationAction; + /** The ID of the object the operation was applied to. */ + objectId: string; + /** The payload for the operation if it is a mutation operation on a map object. */ + mapOp?: ObjectsMapOp; + /** The payload for the operation if it is a mutation operation on a counter object. */ + counterOp?: ObjectsCounterOp; + /** + * The payload for the operation if the action is {@link ObjectOperationActions.MAP_CREATE}. + * Defines the initial value of the map object. + */ + map?: ObjectsMap; + /** + * The payload for the operation if the action is {@link ObjectOperationActions.COUNTER_CREATE}. + * Defines the initial value of the counter object. + */ + counter?: ObjectsCounter; +} + +/** + * Describes an operation that was applied to a map object. + */ +export interface ObjectsMapOp { + /** The key that the operation was applied to. */ + key: string; + /** The data assigned to the key if the operation is {@link ObjectOperationActions.MAP_SET}. */ + data?: ObjectData; +} + +/** + * Describes an operation that was applied to a counter object. + */ +export interface ObjectsCounterOp { + /** The value added to the counter. */ + amount: number; +} + +/** + * Describes the initial value of a map object. + */ +export interface ObjectsMap { + /** The conflict-resolution semantics used by the map object, one of the {@link ObjectsMapSemantics} enum values. */ + semantics?: ObjectsMapSemantics; + /** The map entries, indexed by key. */ + entries?: Record; +} + +/** + * Describes a value at a specific key in a map object. + */ +export interface ObjectsMapEntry { + /** Indicates whether the map entry has been removed. */ + tombstone?: boolean; + /** The {@link ObjectMessage.serial} value of the last operation applied to the map entry. */ + timeserial?: string; + /** A timestamp derived from the {@link timeserial} field. Present only if {@link tombstone} is `true`. */ + serialTimestamp?: number; + /** The value associated with this map entry. */ + data?: ObjectData; +} + +/** + * Describes the initial value of a counter object. + */ +export interface ObjectsCounter { + /** The value of the counter. */ + count?: number; +} + +/** + * Represents a value in an object on a channel. + */ +export interface ObjectData { + /** A reference to another object. */ + objectId?: string; + /** A decoded primitive value. */ + value?: Primitive; +} + +/** + * Enables messages to be published and historic messages to be retrieved for a channel. + */ +export declare interface Channel { + /** + * The channel name. + */ + name: string; + + /** + * A {@link Presence} object. + */ + presence: Presence; + /** + * {@link RestAnnotations} + */ + annotations: RestAnnotations; + /** + * A {@link PushChannel} object. + */ + push: PushChannel; + /** + * Retrieves a {@link PaginatedResult} object, containing an array of historical {@link InboundMessage} objects for the channel. If the channel is configured to persist messages, then messages can be retrieved from history for up to 72 hours in the past. If not, messages can only be retrieved from history for up to two minutes in the past. + * + * @param params - A set of parameters which are used to specify which messages should be retrieved. + * @returns A promise which, upon success, will be fulfilled with a {@link PaginatedResult} object containing an array of {@link InboundMessage} objects. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + */ + history(params?: RestHistoryParams): Promise>; + /** + * Publishes an array of messages to the channel. + * + * @param messages - An array of {@link Message} objects. + * @param options - Optional parameters, such as [`quickAck`](https://faqs.ably.com/why-are-some-rest-publishes-on-a-channel-slow-and-then-typically-faster-on-subsequent-publishes) sent as part of the query string. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + */ + publish(messages: Message[], options?: PublishOptions): Promise; + /** + * Publishes a message to the channel. + * + * @param message - A {@link Message} object. + * @param options - Optional parameters, such as [`quickAck`](https://faqs.ably.com/why-are-some-rest-publishes-on-a-channel-slow-and-then-typically-faster-on-subsequent-publishes) sent as part of the query string. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + */ + publish(message: Message, options?: PublishOptions): Promise; + /** + * Publishes a single message to the channel with the given event name and payload. + * + * @param name - The name of the message. + * @param data - The payload of the message. + * @param options - Optional parameters, such as [`quickAck`](https://faqs.ably.com/why-are-some-rest-publishes-on-a-channel-slow-and-then-typically-faster-on-subsequent-publishes) sent as part of the query string. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + */ + publish(name: string, data: any, options?: PublishOptions): Promise; + /** + * Retrieves a {@link ChannelDetails} object for the channel, which includes status and occupancy metrics. + * + * @returns A promise which, upon success, will be fulfilled a {@link ChannelDetails} object. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + */ + status(): Promise; +} + +/** + * Functionality for annotating messages with small pieces of data, such as emoji + * reactions, that the server will roll up into the message as a summary. + */ +export declare interface RestAnnotations { + /** + * Publish a new annotation for a message. + * + * @param message - The message to annotate. + * @param annotation - The annotation to publish. (Must include at least the `type`. + * Assumed to be an annotation.create if no action is specified) + */ + publish(message: Message, annotation: OutboundAnnotation): Promise; + /** + * Publish a new annotation for a message (alternative form where you only have the + * serial of the message, not a complete Message object) + * + * @param messageSerial - The serial field of the message to annotate. + * @param annotation - The annotation to publish. (Must include at least the `type`. + * Assumed to be an annotation.create if no action is specified) + */ + publish(messageSerial: string, annotation: OutboundAnnotation): Promise; + /** + * Get all annotations for a given message (as a paginated result) + * + * @param message - The message to get annotations for. + * @param params - Restrictions on which annotations to get (in particular a limit) + */ + get(message: Message, params: GetAnnotationsParams | null): Promise>; + /** + * Get all annotations for a given message (as a paginated result) (alternative form + * where you only have the serial of the message, not a complete Message object) + * + * @param messageSerial - The `serial` of the message to get annotations for. + * @param params - Restrictions on which annotations to get (in particular a limit) + */ + get(messageSerial: string, params: GetAnnotationsParams | null): Promise>; +} + +/** + * Enables messages to be published and subscribed to. Also enables historic messages to be retrieved and provides access to the {@link RealtimePresence} object of a channel. + */ +export declare interface RealtimeChannel extends EventEmitter { + /** + * The channel name. + */ + readonly name: string; + /** + * An {@link ErrorInfo} object describing the last error which occurred on the channel, if any. + */ + errorReason: ErrorInfo; + /** + * The current {@link ChannelState} of the channel. + */ + readonly state: ChannelState; + /** + * Optional [channel parameters](https://ably.com/docs/realtime/channels/channel-parameters/overview) that configure the behavior of the channel. + */ + params: ChannelParams; + /** + * An array of {@link ResolvedChannelMode} objects. + */ + modes: ResolvedChannelMode[]; + /** + * Deregisters the given listener for the specified event name. This removes an earlier event-specific subscription. + * + * @param event - The event name. + * @param listener - An event listener function. + */ + unsubscribe(event: string, listener: messageCallback): void; + /** + * Deregisters the given listener from all event names in the array. + * + * @param events - An array of event names. + * @param listener - An event listener function. + */ + unsubscribe(events: Array, listener: messageCallback): void; + /** + * Deregisters all listeners for the given event name. + * + * @param event - The event name. + */ + unsubscribe(event: string): void; + /** + * Deregisters all listeners for all event names in the array. + * + * @param events - An array of event names. + */ + unsubscribe(events: Array): void; + /** + * Deregisters all listeners to messages on this channel that match the supplied filter. + * + * @param filter - A {@link MessageFilter}. + * @param listener - An event listener function. + */ + unsubscribe(filter: MessageFilter, listener?: messageCallback): void; + /** + * Deregisters the given listener (for any/all event names). This removes an earlier subscription. + * + * @param listener - An event listener function. + */ + unsubscribe(listener: messageCallback): void; + /** + * Deregisters all listeners to messages on this channel. This removes all earlier subscriptions. + */ + unsubscribe(): void; + + /** + * A {@link RealtimePresence} object. + */ + presence: RealtimePresence; + /** + * A {@link PushChannel} object. + */ + push: PushChannel; + /** + * A {@link RealtimeAnnotations} object. + */ + annotations: RealtimeAnnotations; + /** + * A {@link RealtimeObject} object. + */ + object: RealtimeObject; + /** + * Attach to this channel ensuring the channel is created in the Ably system and all messages published on the channel are received by any channel listeners registered using {@link RealtimeChannel.subscribe | `subscribe()`}. Any resulting channel state change will be emitted to any listeners registered using the {@link EventEmitter.on | `on()`} or {@link EventEmitter.once | `once()`} methods. As a convenience, `attach()` is called implicitly if {@link RealtimeChannel.subscribe | `subscribe()`} for the channel is called, or {@link RealtimePresence.enter | `enter()`} or {@link RealtimePresence.subscribe | `subscribe()`} are called on the {@link RealtimePresence} object for this channel. + * + * @returns A promise which, upon success, if the channel became attached will be fulfilled with a {@link ChannelStateChange} object. If the channel was already attached the promise will be fulfilled with `null`. Upon failure, the promise will be rejected with an {@link ErrorInfo} object. + */ + attach(): Promise; + /** + * Detach from this channel. Any resulting channel state change is emitted to any listeners registered using the {@link EventEmitter.on | `on()`} or {@link EventEmitter.once | `once()`} methods. Once all clients globally have detached from the channel, the channel will be released in the Ably service within two minutes. + * + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + */ + detach(): Promise; + /** + * Retrieves a {@link PaginatedResult} object, containing an array of historical {@link InboundMessage} objects for the channel. If the channel is configured to persist messages, then messages can be retrieved from history for up to 72 hours in the past. If not, messages can only be retrieved from history for up to two minutes in the past. + * + * @param params - A set of parameters which are used to specify which presence members should be retrieved. + * @returns A promise which, upon success, will be fulfilled with a {@link PaginatedResult} object containing an array of {@link InboundMessage} objects. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + */ + history(params?: RealtimeHistoryParams): Promise>; + /** + * Sets the {@link ChannelOptions} for the channel. + * + * @param options - A {@link ChannelOptions} object. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + */ + setOptions(options: ChannelOptions): Promise; + /** + * Registers a listener for messages with a given event name on this channel. The caller supplies a listener function, which is called each time one or more matching messages arrives on the channel. + * + * @param event - The event name. + * @param listener - An event listener function. + * @returns A promise which, upon successful attachment to the channel, will be fulfilled with a {@link ChannelStateChange} object. If the channel was already attached the promise will be resolved with `null`. Upon failure, the promise will be rejected with an {@link ErrorInfo} object. + */ + subscribe(event: string, listener?: messageCallback): Promise; + /** + * Registers a listener for messages on this channel for multiple event name values. + * + * @param events - An array of event names. + * @param listener - An event listener function. + * @returns A promise which, upon successful attachment to the channel, will be fulfilled with a {@link ChannelStateChange} object. If the channel was already attached the promise will be resolved with `null`. Upon failure, the promise will be rejected with an {@link ErrorInfo} object. + */ + subscribe(events: Array, listener?: messageCallback): Promise; + /** + * {@label WITH_MESSAGE_FILTER} + * + * Registers a listener for messages on this channel that match the supplied filter. + * + * @param filter - A {@link MessageFilter}. + * @param listener - An event listener function. + * @returns A promise which, upon successful attachment to the channel, will be fulfilled with a {@link ChannelStateChange} object. If the channel was already attached the promise will be resolved with `null`. Upon failure, the promise will be rejected with an {@link ErrorInfo} object. + */ + subscribe(filter: MessageFilter, listener?: messageCallback): Promise; + /** + * Registers a listener for messages on this channel. The caller supplies a listener function, which is called each time one or more messages arrives on the channel. + * + * @param callback - An event listener function. + * @returns A promise which, upon successful attachment to the channel, will be fulfilled with a {@link ChannelStateChange} object. If the channel was already attached the promise will be resolved with `null`. Upon failure, the promise will be rejected with an {@link ErrorInfo} object. + */ + subscribe(callback: messageCallback): Promise; + /** + * Publishes a single message to the channel with the given event name and payload. When publish is called with this client library, it won't attempt to implicitly attach to the channel, so long as [transient publishing](https://ably.com/docs/realtime/channels#transient-publish) is available in the library. Otherwise, the client will implicitly attach. + * + * @param name - The event name. + * @param data - The message payload. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + */ + publish(name: string, data: any): Promise; + /** + * Publishes an array of messages to the channel. When publish is called with this client library, it won't attempt to implicitly attach to the channel. + * + * @param messages - An array of {@link Message} objects. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + */ + publish(messages: Message[]): Promise; + /** + * Publish a message to the channel. When publish is called with this client library, it won't attempt to implicitly attach to the channel. + * + * @param message - A {@link Message} object. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + */ + publish(message: Message): Promise; + /** + * If the channel is already in the given state, returns a promise which immediately resolves to `null`. Else, calls {@link EventEmitter.once | `once()`} to return a promise which resolves the next time the channel transitions to the given state. + * + * @param targetState - The channel state to wait for. + */ + whenState(targetState: ChannelState): Promise; +} + +/** + * Optional parameters for message publishing. + */ +export type PublishOptions = { + /** + * See [here](https://faqs.ably.com/why-are-some-rest-publishes-on-a-channel-slow-and-then-typically-faster-on-subsequent-publishes). + */ + quickAck?: boolean; + /** + * Support any publish options that may be added serverside without needing + * typings changes. + */ + [k: string]: string | number | boolean | undefined; +}; + +/** + * Contains properties to filter messages with when calling {@link RealtimeChannel.subscribe | `RealtimeChannel.subscribe()`}. + */ +export type MessageFilter = { + /** + * Filters messages by a specific message `name`. + */ + name?: string; + /** + * Filters messages by a specific `extras.ref.timeserial` value. + */ + refTimeserial?: string; + /** + * Filters messages by a specific `extras.ref.type` value. + */ + refType?: string; + /** + * Filters messages based on whether they contain an `extras.ref`. + */ + isRef?: boolean; + /** + * Filters messages by a specific message `clientId`. + */ + clientId: string; +}; + +/** + * Creates and destroys {@link Channel} and {@link RealtimeChannel} objects. + */ +export declare interface Channels { + /** + * Creates a new {@link Channel} or {@link RealtimeChannel} object, with the specified {@link ChannelOptions}, or returns the existing channel object. + * + * @param name - The channel name. + * @param channelOptions - A {@link ChannelOptions} object. + * @returns A {@link Channel} or {@link RealtimeChannel} object. + */ + get(name: string, channelOptions?: ChannelOptions): T; + /** + * Creates a new {@link Channel} or {@link RealtimeChannel} object, with the specified channel {@link DeriveOptions} + * and {@link ChannelOptions}, or returns the existing channel object. + * + * @experimental This is a preview feature and may change in a future non-major release. + * This experimental method allows you to create custom realtime data feeds by selectively subscribing + * to receive only part of the data from the channel. + * See the [announcement post](https://pages.ably.com/subscription-filters-preview) for more information. + * @param name - The channel name. + * @param deriveOptions - A {@link DeriveOptions} object. + * @param channelOptions - A {@link ChannelOptions} object. + * @returns A {@link RealtimeChannel} object. + */ + getDerived(name: string, deriveOptions: DeriveOptions, channelOptions?: ChannelOptions): T; + /** + * Releases a {@link Channel} or {@link RealtimeChannel} object, deleting it, and enabling it to be garbage collected. To release a channel, the {@link ChannelState} must be `INITIALIZED`, `DETACHED`, or `FAILED`. + * + * @param name - The channel name. + */ + release(name: string): void; + /** + * All of the channels that exist in this `Channels` object. + * + * Channels are added here when created using {@link get}, and removed when released using {@link release}. + */ + all: Record; +} + +/** The summary entry for aggregated annotations that use the distinct.v1 + * aggregation method. */ +export interface SummaryDistinctValues { + [key: string]: SummaryClientIdList; +} + +/** The summary entry for aggregated annotations that use the unique.v1 + * aggregation method. */ +export interface SummaryUniqueValues { + [key: string]: SummaryClientIdList; +} + +/** The summary entry for aggregated annotations that use the multiple.v1 + * aggregation method. */ +export interface SummaryMultipleValues { + [key: string]: SummaryClientIdCounts; +} + +/** The summary entry for aggregated annotations that use the flag.v1 + * aggregation method; also the per-name value for some other aggregation methods. */ +export interface SummaryClientIdList { + /** The total number of clients who have published an annotation with this name (or + * type, depending on context). */ + total: number; + /** A list of the clientIds of all clients who have published an annotation with this name (or + * type, depending on context). */ + clientIds: string[]; + /** Whether the list of clientIds has been clipped due to exceeding the maximum number of + * clients. */ + clipped: boolean; +} + +/** The per-name value for the multiple.v1 aggregation method. */ +export interface SummaryClientIdCounts { + /** The sum of the counts from all clients who have published an annotation with this + * name */ + total: number; + /** A list of the clientIds of all clients who have published an annotation with this + * name, and the count each of them have contributed. */ + clientIds: { [key: string]: number }; + /** The sum of the counts from all unidentified clients who have published an annotation with this + * name, and so who are not included in the clientIds list */ + totalUnidentified: number; + /** Whether the list of clientIds has been clipped due to exceeding the maximum number of + * clients. */ + clipped: boolean; + /** The total number of distinct clientIds in the map (equal to length of map if clipped is false). */ + totalClientIds: number; +} + +/** The summary entry for aggregated annotations that use the total.v1 + * aggregation method. */ +export interface SummaryTotal { + /** The total number of clients who have published an annotation with this name (or + * type, depending on context). */ + total: number; +} + +/** The different possible values of the Message.summary map. */ +export type SummaryEntry = + | SummaryDistinctValues + | SummaryUniqueValues + | SummaryMultipleValues + | SummaryClientIdList + | SummaryTotal; + +/** + * Contains an individual message that is sent to, or received from, Ably. + */ +export interface Message { + /** + * The client ID of the publisher of this message. + */ + clientId?: string; + /** + * The connection ID of the publisher of this message. + */ + connectionId?: string; + /** + * The message payload, if provided. + */ + data?: any; + /** + * This is typically empty, as all messages received from Ably are automatically decoded client-side using this value. However, if the message encoding cannot be processed, this attribute contains the remaining transformations not applied to the `data` payload. + */ + encoding?: string; + /** + * A JSON object of arbitrary key-value pairs that may contain metadata, and/or ancillary payloads. Valid payloads include `push`, `delta`, `ref` and `headers`. + */ + extras?: any; + /** + * An ID associated with the message. Clients may set this field explicitly when publishing a message to enable + * idempotent publishing. If not set, this will be generated by the server. + * + * For the canonical identifier of the message on the server, see `serial`. + */ + id?: string; + /** + * The event name. + */ + name?: string; + /** + * Timestamp of when the message was received by Ably, as milliseconds since the Unix epoch. + * (This is the timestamp of the current version of the message) + */ + timestamp?: number; + /** + * The action type of the message, one of the {@link MessageAction} enum values. + */ + action?: MessageAction; + /** + * This message's unique serial (an identifier that will be the same in all future + * updates of this message). + */ + serial?: string; + /** + * The latest version of the message, containing version-specific metadata. + */ + version?: MessageVersion; + /** + * Allows a REST client to publish a message on behalf of a Realtime client. If you set this to the {@link Connection.key | private connection key} of a Realtime connection when publishing a message using a {@link RestClient}, the message will be published on behalf of that Realtime client. This property is only populated by a client performing a publish, and will never be populated on an inbound message. + */ + connectionKey?: string; + /** + * Annotations associated with this message. + */ + annotations?: MessageAnnotations; +} + +/** + * An annotation to a message, received from Ably + */ +export interface Annotation { + /** + * Unique ID assigned by Ably to this annotation. + */ + id: string; + /** + * The client ID of the publisher of this annotation (if any). + */ + clientId?: string; + /** + * The name of this annotation. This is the field that most annotation aggregations will + * operate on. For example, using "distinct.v1" aggregation (specified in the type), the + * message summary will show a list of clients who have published an annotation with + * each distinct annotation.name. + */ + name?: string; + /** + * An optional count, only relevant to certain aggregation types, see aggregation types + * documentation for more info. + */ + count?: number; + /** + * An arbitrary publisher-provided payload, if provided. Not aggregated. + */ + data?: any; + /** + * The encoding of the payload; typically empty as the data is decoded client-side back + * into the original data type. + */ + encoding?: string; + /** + * Timestamp of when the annotation was received by Ably, as milliseconds since the Unix epoch. + */ + timestamp: number; + /** + * The action, whether this is an annotation being added or removed, one of the {@link AnnotationAction} enum values. + */ + action: AnnotationAction; + /** + * This message's unique serial (lexicographically totally ordered). + */ + serial: string; + /** + * The serial of the message (of type message.create) that this annotation is annotating. + */ + messageSerial: string; + /** + * The type of annotation it is, typically some name together with an aggregation + * method; for example: "emoji:distinct.v1". Handled opaquely by the SDK and validated serverside. + */ + type: string; + /** + * A JSON object for metadata and/or ancillary payloads. + */ + extras: any; +} + +/** + * A variant of the Annotation type customized for those fields which need to be populated + * by the user when publishing an annotation. + */ +export type OutboundAnnotation = Partial & { + /** + * The type of annotation it is, typically some name together with an aggregation + * method; for example: "emoji:distinct.v1". Handled opaquely by the SDK and validated serverside. + */ + type: string; +}; + +/** + * Contains the details regarding the current version of the message - including when it was updated and by whom. + */ +export interface MessageVersion { + /** + * A unique identifier for the version of the message, lexicographically-comparable with other versions (that + * share the same `Message.serial`). Will differ from the `Message.serial` only if the message has been + * updated or deleted. + */ + serial?: string; + /** + * The timestamp of the message version. + * + * If the `Message.action` is `message.create`, this will equal the `Message.timestamp`. + */ + timestamp?: number; + /** + * The client ID of the client that updated the message to this version. + */ + clientId?: string; + /** + * The description provided by the client that updated the message to this version. + */ + description?: string; + /** + * A JSON object of string key-value pairs that may contain metadata associated with the operation to update + * the message to this version. + */ + metadata?: Record; +} + +/** + * Contains information about annotations associated with a particular message. + */ +export interface MessageAnnotations { + /** + * A summary of all the annotations that have been made to the message. Will always be + * populated for a message.summary, and may be populated for any other type (in + * particular a message retrieved from REST history will have its latest summary + * included). + * The keys of the map are the annotation types. The exact structure of the value of + * each key depends on the aggregation part of the annotation type, e.g. for a type of + * reaction:distinct.v1, the value will be a DistinctValues object. New aggregation + * methods might be added serverside, hence the 'unknown' part of the sum type. + */ + summary: Record; +} + +/** + * The namespace containing the different types of message actions. + */ +declare namespace MessageActions { + /** + * Message action for a newly created message. + */ + type MESSAGE_CREATE = 'message.create'; + /** + * Message action for an updated message. The `serial` field identifies the message of which this is + * an update. The update will have a newer `version` compared with the original message.create message. + */ + type MESSAGE_UPDATE = 'message.update'; + /** + * Message action for a deleted message. The `serial` field identifies the message which is being deleted. + * The delete will have a newer `version` compared with the original message.create message. + */ + type MESSAGE_DELETE = 'message.delete'; + /** + * Message action for a meta-message (a message originating from ably rather than being explicitly + * published on a channel), containing eg inband channel occupancy events or some other information + * requested by channel param. + */ + type META = 'meta'; + /** + * Message action for a message containing the latest rolled-up summary of annotations + * that have been made to this message. Like an update, but only updates the summary, so + * the message.serial is the serial of the message for which this is a summary. + */ + type MESSAGE_SUMMARY = 'message.summary'; +} + +/** + * Describes the possible action types used on an {@link Message}. + */ +export type MessageAction = + | MessageActions.MESSAGE_CREATE + | MessageActions.MESSAGE_UPDATE + | MessageActions.MESSAGE_DELETE + | MessageActions.META + | MessageActions.MESSAGE_SUMMARY; + +/** + * The namespace containing the different types of annotation actions. + */ +declare namespace AnnotationActions { + /** + * Annotation action for a created annotation. + */ + type ANNOTATION_CREATE = 'annotation.create'; + /** + * Annotation action for a deleted annotation. + */ + type ANNOTATION_DELETE = 'annotation.delete'; +} + +/** + * The possible values of the 'action' field of an {@link Annotation}. + */ +export type AnnotationAction = AnnotationActions.ANNOTATION_CREATE | AnnotationActions.ANNOTATION_DELETE; + +/** + * A message received from Ably. + */ +export type InboundMessage = Omit & + Required>; + +/** + * Static utilities related to messages. + */ +export interface MessageStatic { + /** + * A static factory method to create an `InboundMessage` object from a deserialized InboundMessage-like object encoded using Ably's wire protocol. + * + * @param JsonObject - A `InboundMessage`-like deserialized object. + * @param channelOptions - A {@link ChannelOptions} object. If you have an encrypted channel, use this to allow the library to decrypt the data. + * @returns A promise which will be fulfilled with an `InboundMessage` object. + */ + fromEncoded: (JsonObject: any, channelOptions?: ChannelOptions) => Promise; + /** + * A static factory method to create an array of `InboundMessage` objects from an array of deserialized InboundMessage-like object encoded using Ably's wire protocol. + * + * @param JsonArray - An array of `InboundMessage`-like deserialized objects. + * @param channelOptions - A {@link ChannelOptions} object. If you have an encrypted channel, use this to allow the library to decrypt the data. + * @returns A promise which will be fulfilled with an array of {@link InboundMessage} objects. + */ + fromEncodedArray: (JsonArray: any[], channelOptions?: ChannelOptions) => Promise; +} + +/** + * Contains an individual presence update sent to, or received from, Ably. + */ +export declare interface PresenceMessage { + /** + * The type of {@link PresenceAction} the `PresenceMessage` is for. + */ + action: PresenceAction; + /** + * The ID of the client that published the `PresenceMessage`. + */ + clientId: string; + /** + * The ID of the connection associated with the client that published the `PresenceMessage`. + */ + connectionId: string; + /** + * The payload of the `PresenceMessage`. + */ + data: any; + /** + * This will typically be empty as all presence messages received from Ably are automatically decoded client-side using this value. However, if the message encoding cannot be processed, this attribute will contain the remaining transformations not applied to the data payload. + */ + encoding: string; + /** + * A JSON object of arbitrary key-value pairs that may contain metadata, and/or ancillary payloads. Valid payloads include `headers`. + */ + extras: any; + /** + * A unique ID assigned to each `PresenceMessage` by Ably. + */ + id: string; + /** + * The time the `PresenceMessage` was received by Ably, as milliseconds since the Unix epoch. + */ + timestamp: number; +} + +/** + * Static utilities related to presence messages. + */ +export interface PresenceMessageStatic { + /** + * Decodes and decrypts a deserialized `PresenceMessage`-like object using the cipher in {@link ChannelOptions}. Any residual transforms that cannot be decoded or decrypted will be in the `encoding` property. Intended for users receiving messages from a source other than a REST or Realtime channel (for example a queue) to avoid having to parse the encoding string. + * + * @param JsonObject - The deserialized `PresenceMessage`-like object to decode and decrypt. + * @param channelOptions - A {@link ChannelOptions} object containing the cipher. + */ + fromEncoded: (JsonObject: any, channelOptions?: ChannelOptions) => Promise; + /** + * Decodes and decrypts an array of deserialized `PresenceMessage`-like object using the cipher in {@link ChannelOptions}. Any residual transforms that cannot be decoded or decrypted will be in the `encoding` property. Intended for users receiving messages from a source other than a REST or Realtime channel (for example a queue) to avoid having to parse the encoding string. + * + * @param JsonArray - An array of deserialized `PresenceMessage`-like objects to decode and decrypt. + * @param channelOptions - A {@link ChannelOptions} object containing the cipher. + */ + fromEncodedArray: (JsonArray: any[], channelOptions?: ChannelOptions) => Promise; + + /** + * Initialises a `PresenceMessage` from a `PresenceMessage`-like object. + * + * @param values - The values to intialise the `PresenceMessage` from. + */ + fromValues(values: Partial>): PresenceMessage; +} + +/** + * Static utilities related to annotations. + */ +export interface AnnotationStatic { + /** + * Decodes a deserialized `Annotation`-like object. Any residual transforms that cannot be decoded or decrypted will be in the `encoding` property. Intended for users receiving messages from a source other than a REST or Realtime channel (for example a queue) to avoid having to parse the encoding string. + * + * @param JsonObject - The deserialized `Annotation`-like object to decode and decrypt. + * @param channelOptions - A {@link ChannelOptions} object containing the current channel options. + */ + fromEncoded: (JsonObject: any, channelOptions?: ChannelOptions) => Promise; + /** + * Decodes an array of deserialized `Annotation`-like objects. Any residual transforms that cannot be decoded or decrypted will be in the `encoding` property. Intended for users receiving messages from a source other than a REST or Realtime channel (for example a queue) to avoid having to parse the encoding string. + * + * @param JsonArray - An array of deserialized `Annotation`-like objects to decode and decrypt. + * @param channelOptions - A {@link ChannelOptions} object containing the current channel options. + */ + fromEncodedArray: (JsonArray: any[], channelOptions?: ChannelOptions) => Promise; +} + +/** + * Cipher Key used in {@link CipherParamOptions}. If set to a `string`, the value must be base64 encoded. + */ +export type CipherKeyParam = ArrayBuffer | Uint8Array | string; // if string must be base64-encoded +/** + * The type of the key returned by {@link Crypto.generateRandomKey}. Typed differently depending on platform (`Buffer` in Node.js, `ArrayBuffer` elsewhere). + */ +export type CipherKey = ArrayBuffer | Buffer; + +/** + * Contains the properties used to generate a {@link CipherParams} object. + */ +export type CipherParamOptions = { + /** + * The private key used to encrypt and decrypt payloads. + */ + key: CipherKeyParam; + /** + * The algorithm to use for encryption. Only `AES` is supported. + */ + algorithm?: 'aes'; + /** + * The length of the key in bits; for example 128 or 256. + */ + keyLength?: number; + /** + * The cipher mode. Only `CBC` is supported. + */ + mode?: 'cbc'; +}; + +/** + * Contains the properties required to configure the encryption of {@link Message} payloads. + */ +export interface Crypto { + /** + * Generates a random key to be used in the encryption of the channel. If the language cryptographic randomness primitives are blocking or async, a callback is used. The callback returns a generated binary key. + * + * @param keyLength - The length of the key, in bits, to be generated. If not specified, this is equal to the default `keyLength` of the default algorithm: for AES this is 256 bits. + * @returns A promise which, upon success, will be fulfilled with the generated key as a binary, for example, a byte array. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + */ + generateRandomKey(keyLength?: number): Promise; + /** + * Returns a {@link CipherParams} object, using the default values for any fields not supplied by the {@link CipherParamOptions} object. + * + * @param params - A {@link CipherParamOptions} object. + * @returns A {@link CipherParams} object, using the default values for any fields not supplied. + */ + getDefaultParams(params: CipherParamOptions): CipherParams; +} + +/** + * Enables the management of a connection to Ably. + */ +export declare interface Connection + extends EventEmitter { + /** + * An {@link ErrorInfo} object describing the last error received if a connection failure occurs. + */ + errorReason: ErrorInfo; + /** + * A unique public identifier for this connection, used to identify this member. + */ + id?: string; + /** + * A unique private connection key used to recover or resume a connection, assigned by Ably. This private connection key can also be used by other REST clients to publish on behalf of this client. See the [publishing over REST on behalf of a realtime client docs](https://ably.com/docs/rest/channels#publish-on-behalf) for more info. (If you want to explicitly recover a connection in a different SDK instance, see createRecoveryKey() instead) + */ + key?: string; + /** + * createRecoveryKey method returns a string that can be used by another client to recover this connection's state in the recover client options property. See [connection state recover options](https://ably.com/docs/connect/states?lang=javascript#connection-state-recovery) for more information. + */ + createRecoveryKey(): string | null; + /** + * The current {@link ConnectionState} of the connection. + */ + readonly state: ConnectionState; + /** + * Causes the connection to close, entering the {@link ConnectionStates.CLOSING} state. Once closed, the library does not attempt to re-establish the connection without an explicit call to {@link Connection.connect | `connect()`}. + */ + close(): void; + /** + * Explicitly calling `connect()` is unnecessary unless the `autoConnect` attribute of the {@link ClientOptions} object is `false`. Unless already connected or connecting, this method causes the connection to open, entering the {@link ConnectionStates.CONNECTING} state. + */ + connect(): void; + + /** + * When connected, sends a heartbeat ping to the Ably server and executes the callback with any error and the response time in milliseconds when a heartbeat ping request is echoed from the server. This can be useful for measuring true round-trip latency to the connected Ably server. + * + * @returns A promise which, upon success, will be fulfilled with the response time in milliseconds. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + */ + ping(): Promise; + /** + * If the connection is already in the given state, returns a promise which immediately resolves to `null`. Else, calls {@link EventEmitter.once | `once()`} to return a promise which resolves the next time the connection transitions to the given state. + * + * @param targetState - The connection state to wait for. + */ + whenState(targetState: ConnectionState): Promise; +} + +/** + * Contains application statistics for a specified time interval and time period. + */ +export declare interface Stats { + /** + * The UTC time at which the time period covered begins. If `unit` is set to `minute` this will be in the format `YYYY-mm-dd:HH:MM`, if `hour` it will be `YYYY-mm-dd:HH`, if `day` it will be `YYYY-mm-dd:00` and if `month` it will be `YYYY-mm-01:00`. + */ + intervalId: string; + /** + * For entries that are still in progress, such as the current month: the last sub-interval included in this entry (in format yyyy-mm-dd:hh:mm:ss), else undefined. + */ + inProgress?: string; + /** + * The statistics for this time interval and time period. See the JSON schema which the {@link Stats.schema | `schema`} property points to for more information. + */ + entries: Partial>; + /** + * The URL of a [JSON Schema](https://json-schema.org/) which describes the structure of this `Stats` object. + */ + schema: string; + /** + * The ID of the Ably application the statistics are for. + */ + appId: string; +} + +/** + * Contains a page of results for message or presence history, stats, or REST presence requests. A `PaginatedResult` response from a REST API paginated query is also accompanied by metadata that indicates the relative queries available to the `PaginatedResult` object. + */ +export declare interface PaginatedResult { + /** + * Contains the current page of results; for example, an array of {@link InboundMessage} or {@link PresenceMessage} objects for a channel history request. + */ + items: T[]; + /** + * Returns a new `PaginatedResult` for the first page of results. + * + * @returns A promise which, upon success, will be fulfilled with a page of results for message and presence history, stats, and REST presence requests. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + */ + first(): Promise>; + /** + * Returns a new `PaginatedResult` loaded with the next page of results. If there are no further pages, then `null` is returned. + * + * @returns A promise which, upon success, will be fulfilled with a page of results for message and presence history, stats, and REST presence requests. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + */ + next(): Promise | null>; + /** + * Returns the `PaginatedResult` for the current page of results. + */ + current(): Promise>; + /** + * Returns `true` if there are more pages available by calling next and returns `false` if this page is the last page available. + * + * @returns Whether or not there are more pages of results. + */ + hasNext(): boolean; + /** + * Returns `true` if this page is the last page and returns `false` if there are more pages available by calling next available. + * + * @returns Whether or not this is the last page of results. + */ + isLast(): boolean; +} + +/** + * A superset of {@link PaginatedResult} which represents a page of results plus metadata indicating the relative queries available to it. `HttpPaginatedResponse` additionally carries information about the response to an HTTP request. + */ +export declare interface HttpPaginatedResponse extends PaginatedResult { + /** + * The HTTP status code of the response. + */ + statusCode: number; + /** + * Whether `statusCode` indicates success. This is equivalent to `200 <= statusCode < 300`. + */ + success: boolean; + /** + * The error code if the `X-Ably-Errorcode` HTTP header is sent in the response. + */ + errorCode: number; + /** + * The error message if the `X-Ably-Errormessage` HTTP header is sent in the response. + */ + errorMessage: string; + /** + * The headers of the response. + */ + headers: any; +} + +/** + * Enables a device to be registered and deregistered from receiving push notifications. + */ +export declare interface Push { + /** + * A {@link PushAdmin} object. + */ + admin: PushAdmin; + + /** + * Activates the device for push notifications. Subsequently registers the device with Ably and stores the deviceIdentityToken in local storage. + * + * @param registerCallback - A function passed to override the default implementation to register the local device for push activation. + * @param updateFailedCallback - A callback to be invoked when the device registration failed to update. + */ + activate(registerCallback?: RegisterCallback, updateFailedCallback?: ErrorCallback): Promise; + + /** + * Deactivates the device from receiving push notifications. + * + * @param deregisterCallback - A function passed to override the default implementation to deregister the local device for push activation. + */ + deactivate(deregisterCallback?: DeregisterCallback): Promise; +} + +/** + * Contains the device identity token and secret of a device. + */ +export declare interface LocalDevice { + /** + * A unique ID generated by the device. + */ + id: string; + /** + * A unique device secret generated by the Ably SDK. + */ + deviceSecret: string; + /** + * A unique device identity token that the device uses to authenticate itself with Ably. + */ + deviceIdentityToken?: string; + + /** + * Retrieves push subscriptions active for the local device. + * + * @returns a {@link PaginatedResult} object containing an array of {@link PushChannelSubscription} objects for each push channel subscription active for the local device. + */ + listSubscriptions(): Promise>; +} + +/** + * Enables the management of device registrations and push notification subscriptions. Also enables the publishing of push notifications to devices. + */ +export declare interface PushAdmin { + /** + * A {@link PushDeviceRegistrations} object. + */ + deviceRegistrations: PushDeviceRegistrations; + /** + * A {@link PushChannelSubscriptions} object. + */ + channelSubscriptions: PushChannelSubscriptions; + /** + * Sends a push notification directly to a device, or a group of devices sharing the same `clientId`. + * + * @param recipient - A JSON object containing the recipient details using `clientId`, `deviceId` or the underlying notifications service. + * @param payload - A JSON object containing the push notification payload. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + */ + publish(recipient: any, payload: any): Promise; +} + +/** + * Enables the management of push notification registrations with Ably. + */ +export declare interface PushDeviceRegistrations { + /** + * Registers or updates a {@link DeviceDetails} object with Ably. Returns the new, or updated {@link DeviceDetails} object. + * + * @param deviceDetails - The {@link DeviceDetails} object to create or update. + * @returns A promise which, upon success, will be fulfilled with a {@link DeviceDetails} object. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + */ + save(deviceDetails: DeviceDetails): Promise; + /** + * Retrieves the {@link DeviceDetails} of a device registered to receive push notifications using its `deviceId`. + * + * @param deviceId - The unique ID of the device. + * @returns A promise which, upon success, will be fulfilled with a {@link DeviceDetails} object. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + */ + get(deviceId: string): Promise; + /** + * Retrieves the {@link DeviceDetails} of a device registered to receive push notifications using the `id` property of a {@link DeviceDetails} object. + * + * @param deviceDetails - The {@link DeviceDetails} object containing the `id` property of the device. + * @returns A promise which, upon success, will be fulfilled with a {@link DeviceDetails} object. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + */ + get(deviceDetails: DeviceDetails): Promise; + /** + * Retrieves all devices matching the filter `params` provided. Returns a {@link PaginatedResult} object, containing an array of {@link DeviceDetails} objects. + * + * @param params - An object containing key-value pairs to filter devices by. + * @returns A promise which, upon success, will be fulfilled with a {@link PaginatedResult} object containing an array of {@link DeviceDetails} objects. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + */ + list(params: DeviceRegistrationParams): Promise>; + /** + * Removes a device registered to receive push notifications from Ably using its `deviceId`. + * + * @param deviceId - The unique ID of the device. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + */ + remove(deviceId: string): Promise; + /** + * Removes a device registered to receive push notifications from Ably using the `id` property of a {@link DeviceDetails} object. + * + * @param deviceDetails - The {@link DeviceDetails} object containing the `id` property of the device. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + */ + remove(deviceDetails: DeviceDetails): Promise; + /** + * Removes all devices registered to receive push notifications from Ably matching the filter `params` provided. + * + * @param params - An object containing key-value pairs to filter devices by. This object’s {@link DeviceRegistrationParams.limit} property will be ignored. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + */ + removeWhere(params: DeviceRegistrationParams): Promise; +} + +/** + * Enables device push channel subscriptions. + */ +export declare interface PushChannelSubscriptions { + /** + * Subscribes a device, or a group of devices sharing the same `clientId` to push notifications on a channel. Returns a {@link PushChannelSubscription} object. + * + * @param subscription - A {@link PushChannelSubscription} object. + * @returns A promise which, upon success, will be fulfilled with a {@link PushChannelSubscription} object describing the new or updated subscriptions. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + */ + save(subscription: PushChannelSubscription): Promise; + /** + * Retrieves all push channel subscriptions matching the filter `params` provided. Returns a {@link PaginatedResult} object, containing an array of {@link PushChannelSubscription} objects. + * + * @param params - An object containing key-value pairs to filter subscriptions by. + * @returns A promise which, upon success, will be fulfilled with a {@link PaginatedResult} object containing an array of {@link PushChannelSubscription} objects. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + */ + list(params: PushChannelSubscriptionParams): Promise>; + /** + * Retrieves all channels with at least one device subscribed to push notifications. Returns a {@link PaginatedResult} object, containing an array of channel names. + * + * @param params - An object containing key-value pairs to filter channels by. + * @returns A promise which, upon success, will be fulfilled with a {@link PaginatedResult} object containing an array of channel names. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + */ + listChannels(params: PushChannelsParams): Promise>; + /** + * Unsubscribes a device, or a group of devices sharing the same `clientId` from receiving push notifications on a channel. + * + * @param subscription - A {@link PushChannelSubscription} object. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + */ + remove(subscription: PushChannelSubscription): Promise; + /** + * Unsubscribes all devices from receiving push notifications on a channel that match the filter `params` provided. + * + * @param params - An object containing key-value pairs to filter subscriptions by. Can contain `channel`, and optionally either `clientId` or `deviceId`. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + */ + removeWhere(params: PushChannelSubscriptionParams): Promise; +} + +/** + * A client that offers a simple stateless API to interact directly with Ably's REST API. + */ +export declare class Rest implements RestClient { + /** + * Construct a client object using an Ably {@link ClientOptions} object. + * + * @param options - A {@link ClientOptions} object to configure the client connection to Ably. + */ + constructor(options: ClientOptions); + /** + * Constructs a client object using an Ably API key or token string. + * + * @param keyOrToken - The Ably API key or token string used to validate the client. + */ + constructor(keyOrToken: string); + /** + * The cryptographic functions available in the library. + */ + static Crypto: Crypto; + /** + * Static utilities related to messages. + */ + static Message: MessageStatic; + /** + * Static utilities related to presence messages. + */ + static PresenceMessage: PresenceMessageStatic; + /** + * Static utilities related to annotations. + */ + static Annotation: AnnotationStatic; + + // Requirements of RestClient + + auth: Auth; + channels: Channels; + request( + method: string, + path: string, + version: number, + params?: any, + body?: any[] | any, + headers?: any, + ): Promise>; + stats(params?: StatsParams): Promise>; + time(): Promise; + batchPublish(spec: BatchPublishSpec): Promise>; + batchPublish( + specs: BatchPublishSpec[], + ): Promise[]>; + batchPresence(channels: string[]): Promise[]>; + push: Push; + device(): LocalDevice; +} + +/** + * A client that extends the functionality of {@link Rest} and provides additional realtime-specific features. + */ +export declare class Realtime implements RealtimeClient { + /** + * Construct a client object using an Ably {@link ClientOptions} object. + * + * @param options - A {@link ClientOptions} object to configure the client connection to Ably. + */ + constructor(options: ClientOptions); + /** + * Constructs a client object using an Ably API key or token string. + * + * @param keyOrToken - The Ably API key or token string used to validate the client. + */ + constructor(keyOrToken: string); + /** + * The cryptographic functions available in the library. + */ + static Crypto: Crypto; + /** + * Static utilities related to messages. + */ + static Message: MessageStatic; + /** + * Static utilities related to presence messages. + */ + static PresenceMessage: PresenceMessageStatic; + /** + * Static utilities related to annotations. + */ + static Annotation: AnnotationStatic; + + // Requirements of RealtimeClient + + clientId: string; + close(): void; + connect(): void; + auth: Auth; + channels: Channels; + connection: Connection; + request( + method: string, + path: string, + version: number, + params?: any, + body?: any[] | any, + headers?: any, + ): Promise>; + stats(params?: StatsParams): Promise>; + time(): Promise; + batchPublish(spec: BatchPublishSpec): Promise>; + batchPublish( + specs: BatchPublishSpec[], + ): Promise[]>; + batchPresence(channels: string[]): Promise[]>; + push: Push; + device(): LocalDevice; +} + +/** + * A generic Ably error object that contains an Ably-specific status code, and a generic status code. Errors returned from the Ably server are compatible with the `ErrorInfo` structure and should result in errors that inherit from `ErrorInfo`. + */ +export declare class ErrorInfo extends Error { + /** + * Ably [error code](https://github.com/ably/ably-common/blob/main/protocol/errors.json). + */ + code: number; + /** + * Additional message information, where available. + */ + message: string; + /** + * HTTP Status Code corresponding to this error, where applicable. + */ + statusCode: number; + /** + * The underlying cause of the error, where applicable. + */ + cause?: string | Error | ErrorInfo; + + /** + * Construct an ErrorInfo object. + * + * @param message - A string describing the error. + * @param code - Ably [error code](https://github.com/ably/ably-common/blob/main/protocol/errors.json). + * @param statusCode - HTTP Status Code corresponding to this error. + * @param cause - The underlying cause of the error. + */ + constructor(message: string, code: number, statusCode: number, cause?: string | Error | ErrorInfo); +}