diff --git a/packages/framework/presence/api-report/presence.alpha.api.md b/packages/framework/presence/api-report/presence.alpha.api.md index 2ed0ce115573..9d3bce57488e 100644 --- a/packages/framework/presence/api-report/presence.alpha.api.md +++ b/packages/framework/presence/api-report/presence.alpha.api.md @@ -112,12 +112,12 @@ export namespace InternalTypes { // @system (undocumented) export interface ValueOptionalState extends ValueStateMetadata { // (undocumented) - value?: JsonDeserialized; + value?: OpaqueJsonDeserialized; } // @system (undocumented) export interface ValueRequiredState extends ValueStateMetadata { // (undocumented) - value: JsonDeserialized; + value: OpaqueJsonDeserialized; } // @system (undocumented) export interface ValueStateMetadata { @@ -128,26 +128,12 @@ export namespace InternalTypes { } } -// @alpha @system -export namespace InternalUtilityTypes { - // @system - export type IsNotificationListener = Event extends (...args: infer P) => void ? InternalUtilityTypes_2.IfSameType & JsonDeserialized

, true, false> : false; - // @system - export type JsonDeserializedParameters any> = T extends (...args: infer P) => any ? JsonDeserialized

: never; - // @system - export type JsonSerializableParameters any> = T extends (...args: infer P) => any ? JsonSerializable

: never; - // @system - export type NotificationListeners = { - [P in string & keyof E as IsNotificationListener extends true ? P : never]: E[P]; - }; -} - // @beta export function latest(args: LatestArguments): InternalTypes.ManagerFactory, LatestRaw>; -// @beta +// @beta @input export interface LatestArguments { - local: JsonSerializable & JsonDeserialized & (object | null); + local: JsonSerializable & JsonDeserialized; settings?: BroadcastControlSettings | undefined; } @@ -159,16 +145,16 @@ export interface LatestClientData extends LatestData { // @beta @sealed export interface LatestData { metadata: LatestMetadata; - value: DeepReadonly>; + value: DeepReadonly>; } // @beta export function latestMap(args?: LatestMapArguments): InternalTypes.ManagerFactory, LatestMapRaw>; -// @beta +// @beta @input export interface LatestMapArguments { local?: { - [K in Keys]: JsonSerializable & JsonDeserialized; + [K in Keys]: JsonSerializable_2 & JsonDeserialized_2; }; settings?: BroadcastControlSettings | undefined; } @@ -210,7 +196,7 @@ export interface LatestMapRawEvents { }) => void; // @eventProperty localItemUpdated: (updatedItem: { - value: DeepReadonly & JsonDeserialized>; + value: DeepReadonly & JsonDeserialized_2>; key: K; }) => void; // @eventProperty @@ -234,8 +220,8 @@ export interface LatestRaw { getRemote(attendee: Attendee): LatestData; getRemotes(): IterableIterator>; getStateAttendees(): Attendee[]; - get local(): DeepReadonly>; - set local(value: JsonSerializable & JsonDeserialized); + get local(): DeepReadonly_2>; + set local(value: JsonDeserialized); readonly presence: Presence; } @@ -243,7 +229,7 @@ export interface LatestRaw { export interface LatestRawEvents { // @eventProperty localUpdated: (update: { - value: DeepReadonly & JsonDeserialized>; + value: DeepReadonly_2 & JsonDeserialized>; }) => void; // @eventProperty remoteUpdated: (update: LatestClientData) => void; @@ -251,8 +237,8 @@ export interface LatestRawEvents { // @alpha @sealed export interface NotificationEmitter> { - broadcast>(notificationName: K, ...args: Parameters): void; - unicast>(notificationName: K, targetAttendee: Attendee, ...args: Parameters): void; + broadcast>(notificationName: K, ...args: Parameters): void; + unicast>(notificationName: K, targetAttendee: Attendee, ...args: Parameters): void; } // @alpha @sealed @@ -335,11 +321,11 @@ export const StateFactory: { export interface StateMap { clear(): void; delete(key: K): boolean; - forEach(callbackfn: (value: DeepReadonly>, key: K, map: StateMap) => void, thisArg?: unknown): void; - get(key: K): DeepReadonly> | undefined; + forEach(callbackfn: (value: DeepReadonly>, key: K, map: StateMap) => void, thisArg?: unknown): void; + get(key: K): DeepReadonly> | undefined; has(key: K): boolean; keys(): IterableIterator; - set(key: K, value: JsonSerializable & JsonDeserialized): this; + set(key: K, value: JsonSerializable_2 & JsonDeserialized_2): this; readonly size: number; } diff --git a/packages/framework/presence/api-report/presence.beta.api.md b/packages/framework/presence/api-report/presence.beta.api.md index 240e944302dd..7152ae3e25f9 100644 --- a/packages/framework/presence/api-report/presence.beta.api.md +++ b/packages/framework/presence/api-report/presence.beta.api.md @@ -99,12 +99,12 @@ export namespace InternalTypes { // @system (undocumented) export interface ValueOptionalState extends ValueStateMetadata { // (undocumented) - value?: JsonDeserialized; + value?: OpaqueJsonDeserialized; } // @system (undocumented) export interface ValueRequiredState extends ValueStateMetadata { // (undocumented) - value: JsonDeserialized; + value: OpaqueJsonDeserialized; } // @system (undocumented) export interface ValueStateMetadata { @@ -118,9 +118,9 @@ export namespace InternalTypes { // @beta export function latest(args: LatestArguments): InternalTypes.ManagerFactory, LatestRaw>; -// @beta +// @beta @input export interface LatestArguments { - local: JsonSerializable & JsonDeserialized & (object | null); + local: JsonSerializable & JsonDeserialized; settings?: BroadcastControlSettings | undefined; } @@ -132,16 +132,16 @@ export interface LatestClientData extends LatestData { // @beta @sealed export interface LatestData { metadata: LatestMetadata; - value: DeepReadonly>; + value: DeepReadonly>; } // @beta export function latestMap(args?: LatestMapArguments): InternalTypes.ManagerFactory, LatestMapRaw>; -// @beta +// @beta @input export interface LatestMapArguments { local?: { - [K in Keys]: JsonSerializable & JsonDeserialized; + [K in Keys]: JsonSerializable_2 & JsonDeserialized_2; }; settings?: BroadcastControlSettings | undefined; } @@ -183,7 +183,7 @@ export interface LatestMapRawEvents { }) => void; // @eventProperty localItemUpdated: (updatedItem: { - value: DeepReadonly & JsonDeserialized>; + value: DeepReadonly & JsonDeserialized_2>; key: K; }) => void; // @eventProperty @@ -207,8 +207,8 @@ export interface LatestRaw { getRemote(attendee: Attendee): LatestData; getRemotes(): IterableIterator>; getStateAttendees(): Attendee[]; - get local(): DeepReadonly>; - set local(value: JsonSerializable & JsonDeserialized); + get local(): DeepReadonly_2>; + set local(value: JsonDeserialized); readonly presence: Presence; } @@ -216,7 +216,7 @@ export interface LatestRaw { export interface LatestRawEvents { // @eventProperty localUpdated: (update: { - value: DeepReadonly & JsonDeserialized>; + value: DeepReadonly_2 & JsonDeserialized>; }) => void; // @eventProperty remoteUpdated: (update: LatestClientData) => void; @@ -253,11 +253,11 @@ export const StateFactory: { export interface StateMap { clear(): void; delete(key: K): boolean; - forEach(callbackfn: (value: DeepReadonly>, key: K, map: StateMap) => void, thisArg?: unknown): void; - get(key: K): DeepReadonly> | undefined; + forEach(callbackfn: (value: DeepReadonly>, key: K, map: StateMap) => void, thisArg?: unknown): void; + get(key: K): DeepReadonly> | undefined; has(key: K): boolean; keys(): IterableIterator; - set(key: K, value: JsonSerializable & JsonDeserialized): this; + set(key: K, value: JsonSerializable_2 & JsonDeserialized_2): this; readonly size: number; } diff --git a/packages/framework/presence/src/exposedInternalTypes.ts b/packages/framework/presence/src/exposedInternalTypes.ts index 46e457098129..801e9499ffef 100644 --- a/packages/framework/presence/src/exposedInternalTypes.ts +++ b/packages/framework/presence/src/exposedInternalTypes.ts @@ -6,7 +6,8 @@ import type { JsonDeserialized, JsonSerializable, -} from "@fluidframework/core-interfaces/internal/exposedUtilityTypes"; + OpaqueJsonDeserialized, +} from "@fluidframework/core-interfaces/internal"; /** * Collection of value types that are not intended to be used/imported @@ -29,14 +30,14 @@ export namespace InternalTypes { * @system */ export interface ValueOptionalState extends ValueStateMetadata { - value?: JsonDeserialized; + value?: OpaqueJsonDeserialized; } /** * @system */ export interface ValueRequiredState extends ValueStateMetadata { - value: JsonDeserialized; + value: OpaqueJsonDeserialized; } /** diff --git a/packages/framework/presence/src/exposedUtilityTypes.ts b/packages/framework/presence/src/exposedUtilityTypes.ts index a6df51d9f084..a80408d75172 100644 --- a/packages/framework/presence/src/exposedUtilityTypes.ts +++ b/packages/framework/presence/src/exposedUtilityTypes.ts @@ -7,30 +7,33 @@ import type { InternalUtilityTypes as CoreInternalUtilityTypes, JsonDeserialized, JsonSerializable, -} from "@fluidframework/core-interfaces/internal/exposedUtilityTypes"; +} from "@fluidframework/core-interfaces/internal"; /** * Collection of utility types that are not intended to be used/imported * directly outside of this package. * - * @alpha + * @internal * @system */ // eslint-disable-next-line @typescript-eslint/no-namespace export namespace InternalUtilityTypes { /** - * `true` iff the given type is an acceptable shape for a notification. + * `IfListener` iff the given type is an acceptable shape for a notification. + * `Else` otherwise. * * @system */ - export type IsNotificationListener = Event extends (...args: infer P) => void + export type IfNotificationListener = Event extends ( + ...args: infer P + ) => void ? CoreInternalUtilityTypes.IfSameType< P, JsonSerializable

& JsonDeserialized

, - true, - false + IfListener, + Else > - : false; + : Else; /** * Used to specify the kinds of notifications emitted by a {@link NotificationListenable}. @@ -52,7 +55,7 @@ export namespace InternalUtilityTypes { * @system */ export type NotificationListeners = { - [P in string & keyof E as IsNotificationListener extends true ? P : never]: E[P]; + [P in keyof E as IfNotificationListener]: E[P]; }; /** @@ -60,7 +63,7 @@ export namespace InternalUtilityTypes { * * @system */ - export type JsonDeserializedParameters any> = T extends ( + export type JsonDeserializedParameters any> = T extends ( ...args: infer P ) => any ? JsonDeserialized

@@ -71,7 +74,7 @@ export namespace InternalUtilityTypes { * * @system */ - export type JsonSerializableParameters any> = T extends ( + export type JsonSerializableParameters any> = T extends ( ...args: infer P ) => any ? JsonSerializable

diff --git a/packages/framework/presence/src/internalUtils.ts b/packages/framework/presence/src/internalUtils.ts index eb741cbe8e46..8045b3bf4a6a 100644 --- a/packages/framework/presence/src/internalUtils.ts +++ b/packages/framework/presence/src/internalUtils.ts @@ -3,7 +3,13 @@ * Licensed under the MIT License. */ -import type { DeepReadonly } from "@fluidframework/core-interfaces/internal"; +import type { + DeepReadonly, + JsonDeserialized, + JsonSerializable, + OpaqueJsonDeserialized, + OpaqueJsonSerializable, +} from "@fluidframework/core-interfaces/internal"; /** * Returns union of types of values in a record. @@ -75,8 +81,39 @@ export function getOrCreateRecord(value: T): DeepReadonly { return value as DeepReadonly; } + +export function asDeeplyReadonlyDeserializedJson( + value: OpaqueJsonDeserialized, +): DeepReadonly>; +export function asDeeplyReadonlyDeserializedJson( + value: OpaqueJsonDeserialized | undefined, +): DeepReadonly> | undefined; +/** + * Does nothing helper to apply deep immutability to a value's opaque Json type revealing the Json type. + */ +export function asDeeplyReadonlyDeserializedJson( + value: OpaqueJsonDeserialized | undefined, +): DeepReadonly> | undefined { + return value as DeepReadonly> | undefined; +} + +/** + * Does nothing helper to reveal the Json type from a value's opaque Json type. + */ +export function asDeserializedJson(value: OpaqueJsonDeserialized): JsonDeserialized { + return value as JsonDeserialized; +} + +/** + * Does nothing helper to automatically cast Json type to Opaque Json type. + */ +export function fullySerializableToOpaqueJson( + value: JsonSerializable & JsonDeserialized, +): OpaqueJsonSerializable & OpaqueJsonDeserialized { + return value as OpaqueJsonSerializable & OpaqueJsonDeserialized; +} diff --git a/packages/framework/presence/src/latestMapValueManager.ts b/packages/framework/presence/src/latestMapValueManager.ts index 44e1adff0f22..22b566785578 100644 --- a/packages/framework/presence/src/latestMapValueManager.ts +++ b/packages/framework/presence/src/latestMapValueManager.ts @@ -16,7 +16,13 @@ import type { BroadcastControls, BroadcastControlSettings } from "./broadcastCon import { OptionalBroadcastControl } from "./broadcastControls.js"; import type { InternalTypes } from "./exposedInternalTypes.js"; import type { PostUpdateAction, ValueManager } from "./internalTypes.js"; -import { asDeeplyReadonly, objectEntries, objectKeys } from "./internalUtils.js"; +import { + asDeeplyReadonly, + objectEntries, + objectKeys, + fullySerializableToOpaqueJson, + asDeeplyReadonlyDeserializedJson, +} from "./internalUtils.js"; import type { LatestClientData, LatestData, LatestMetadata } from "./latestValueTypes.js"; import type { AttendeeId, Attendee, Presence, SpecificAttendee } from "./presence.js"; import { datastoreFromHandle, type StateDatastore } from "./stateDatastore.js"; @@ -286,23 +292,31 @@ class ValueMapImpl implements StateMap { ): void { for (const [key, item] of objectEntries(this.value.items)) { if (item.value !== undefined) { - callbackfn(asDeeplyReadonly(item.value), key, this); + callbackfn(asDeeplyReadonlyDeserializedJson(item.value), key, this); } } } public get(key: K): DeepReadonly> | undefined { - return asDeeplyReadonly(this.value.items[key]?.value); + return this.value.items[key]?.value === undefined + ? undefined + : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- ternary ensures this is non-null + asDeeplyReadonlyDeserializedJson(this.value.items[key]!.value!); } public has(key: K): boolean { return this.value.items[key]?.value !== undefined; } - public set(key: K, value: JsonSerializable & JsonDeserialized): this { + public set(key: K, inValue: JsonSerializable & JsonDeserialized): this { + const value = fullySerializableToOpaqueJson(inValue); if (!(key in this.value.items)) { this.countDefined += 1; - this.value.items[key] = { rev: 0, timestamp: 0, value }; + this.value.items[key] = { + rev: 0, + timestamp: 0, + value, + } satisfies InternalTypes.ValueOptionalState; } this.updateItem(key, value); - this.emitter.emit("localItemUpdated", { key, value: asDeeplyReadonly(value) }); + this.emitter.emit("localItemUpdated", { key, value: asDeeplyReadonly(inValue) }); return this; } public get size(): number { @@ -433,7 +447,7 @@ class LatestMapRawValueManagerImpl< const value = item.value; if (value !== undefined) { items.set(key, { - value: asDeeplyReadonly(value), + value: asDeeplyReadonlyDeserializedJson(value), metadata: { revision: item.rev, timestamp: item.timestamp }, }); } @@ -484,13 +498,13 @@ class LatestMapRawValueManagerImpl< currentState.items[key] = item; const metadata = { revision: item.rev, timestamp: item.timestamp }; if (item.value !== undefined) { - const itemValue = asDeeplyReadonly(item.value); + const itemValue = asDeeplyReadonlyDeserializedJson(item.value); const updatedItem = { attendee, key, value: itemValue, metadata, - }; + } satisfies LatestMapItemUpdatedClientData; postUpdateActions.push(() => this.events.emit("remoteItemUpdated", updatedItem)); allUpdates.items.set(key, { value: itemValue, metadata }); } else if (hadPriorValue !== undefined) { @@ -512,6 +526,7 @@ class LatestMapRawValueManagerImpl< /** * Arguments that are passed to the {@link StateFactory.latestMap} function. * + * @input * @beta */ export interface LatestMapArguments { @@ -559,7 +574,7 @@ export function latestMap< value.items[key] = { rev: 0, timestamp, - value: initialValues[key], + value: fullySerializableToOpaqueJson(initialValues[key]), }; } } diff --git a/packages/framework/presence/src/latestValueManager.ts b/packages/framework/presence/src/latestValueManager.ts index 08d0fc16732c..b4aebc456867 100644 --- a/packages/framework/presence/src/latestValueManager.ts +++ b/packages/framework/presence/src/latestValueManager.ts @@ -9,14 +9,19 @@ import type { DeepReadonly, JsonDeserialized, JsonSerializable, -} from "@fluidframework/core-interfaces/internal/exposedUtilityTypes"; +} from "@fluidframework/core-interfaces/internal"; import { shallowCloneObject } from "@fluidframework/core-utils/internal"; import type { BroadcastControls, BroadcastControlSettings } from "./broadcastControls.js"; import { OptionalBroadcastControl } from "./broadcastControls.js"; import type { InternalTypes } from "./exposedInternalTypes.js"; import type { PostUpdateAction, ValueManager } from "./internalTypes.js"; -import { asDeeplyReadonly, objectEntries } from "./internalUtils.js"; +import { + asDeeplyReadonly, + asDeeplyReadonlyDeserializedJson, + fullySerializableToOpaqueJson, + objectEntries, +} from "./internalUtils.js"; import type { LatestClientData, LatestData } from "./latestValueTypes.js"; import type { Attendee, Presence } from "./presence.js"; import { datastoreFromHandle, type StateDatastore } from "./stateDatastore.js"; @@ -78,7 +83,7 @@ export interface LatestRaw { * setting, if needed. No comparison is done to detect changes; all sets are transmitted. */ get local(): DeepReadonly>; - set local(value: JsonSerializable & JsonDeserialized); + set local(value: JsonDeserialized); /** * Iterable access to remote clients' values. @@ -114,18 +119,20 @@ class LatestValueManagerImpl } public get local(): DeepReadonly> { - return asDeeplyReadonly(this.value.value); + return asDeeplyReadonlyDeserializedJson(this.value.value); } - public set local(value: JsonSerializable & JsonDeserialized) { + public set local(value: JsonDeserialized & JsonSerializable) { this.value.rev += 1; this.value.timestamp = Date.now(); - this.value.value = value; + this.value.value = fullySerializableToOpaqueJson(value); this.datastore.localUpdate(this.key, this.value, { allowableUpdateLatencyMs: this.controls.allowableUpdateLatencyMs, }); - this.events.emit("localUpdated", { value: asDeeplyReadonly(value) }); + this.events.emit("localUpdated", { + value: asDeeplyReadonly(value), + }); } public *getRemotes(): IterableIterator> { @@ -134,7 +141,7 @@ class LatestValueManagerImpl if (attendeeId !== allKnownStates.self) { yield { attendee: this.datastore.presence.attendees.getAttendee(attendeeId), - value: asDeeplyReadonly(value.value), + value: asDeeplyReadonlyDeserializedJson(value.value), metadata: { revision: value.rev, timestamp: value.timestamp }, }; } @@ -155,7 +162,7 @@ class LatestValueManagerImpl throw new Error("No entry for clientId"); } return { - value: asDeeplyReadonly(clientState.value), + value: asDeeplyReadonlyDeserializedJson(clientState.value), metadata: { revision: clientState.rev, timestamp: Date.now() }, }; } @@ -176,24 +183,35 @@ class LatestValueManagerImpl () => this.events.emit("remoteUpdated", { attendee, - value: asDeeplyReadonly(value.value), + value: asDeeplyReadonlyDeserializedJson(value.value), metadata: { revision: value.rev, timestamp: value.timestamp }, }), ]; } } +/** + * Shallow clone an object that might be null. + * + * @param value - The object to clone + * @returns A shallow clone of the input value + * @internal + */ +export function shallowCloneNullableObject(value: T): T { + return value === null ? value : shallowCloneObject(value); +} + /** * Arguments that are passed to the {@link StateFactory.latest} function. * + * @input * @beta */ export interface LatestArguments { /** * The initial value of the local state. */ - // eslint-disable-next-line @rushstack/no-new-null - local: JsonSerializable & JsonDeserialized & (object | null); + local: JsonSerializable & JsonDeserialized; /** * See {@link BroadcastControlSettings}. @@ -213,10 +231,11 @@ export function latest( // Latest takes ownership of the initial local value but makes a shallow // copy for basic protection. + const opaqueLocal = fullySerializableToOpaqueJson(local); const value: InternalTypes.ValueRequiredState = { rev: 0, timestamp: Date.now(), - value: local === null ? local : shallowCloneObject(local), + value: shallowCloneNullableObject(opaqueLocal), }; const factory = ( key: Key, diff --git a/packages/framework/presence/src/notificationsManager.ts b/packages/framework/presence/src/notificationsManager.ts index 12857b260599..49d2d24c3c52 100644 --- a/packages/framework/presence/src/notificationsManager.ts +++ b/packages/framework/presence/src/notificationsManager.ts @@ -10,6 +10,7 @@ import type { JsonTypeWith } from "@fluidframework/core-interfaces/internal"; import type { InternalTypes } from "./exposedInternalTypes.js"; import type { InternalUtilityTypes } from "./exposedUtilityTypes.js"; import type { PostUpdateAction, ValueManager } from "./internalTypes.js"; +import { asDeserializedJson, fullySerializableToOpaqueJson } from "./internalUtils.js"; import type { Attendee, PresenceWithNotifications as Presence } from "./presence.js"; import { datastoreFromHandle, type StateDatastore } from "./stateDatastore.js"; import { brandIVM } from "./valueManager.js"; @@ -100,7 +101,7 @@ export interface NotificationEmitter>( + broadcast>( notificationName: K, ...args: Parameters ): void; @@ -111,7 +112,7 @@ export interface NotificationEmitter>( + unicast>( notificationName: K, targetAttendee: Attendee, ...args: Parameters @@ -171,26 +172,32 @@ class NotificationsManagerImpl< public readonly events = createEmitter(); public readonly emit: NotificationEmitter = { - broadcast: (name, ...args) => { + broadcast: (name: string, ...args) => { this.datastore.localUpdate( this.key, { rev: 0, timestamp: 0, - value: { name, args: [...(args as JsonTypeWith[])] }, + value: fullySerializableToOpaqueJson({ + name, + args: [...(args as JsonTypeWith[])], + }), ignoreUnmonitored: true, }, // This is a notification, so we want to send it immediately. { allowableUpdateLatencyMs: 0 }, ); }, - unicast: (name, targetAttendee, ...args) => { + unicast: (name: string, targetAttendee, ...args) => { this.datastore.localUpdate( this.key, { rev: 0, timestamp: 0, - value: { name, args: [...(args as JsonTypeWith[])] }, + value: fullySerializableToOpaqueJson({ + name, + args: [...(args as JsonTypeWith[])], + }), ignoreUnmonitored: true, }, // This is a notification, so we want to send it immediately. @@ -202,7 +209,6 @@ class NotificationsManagerImpl< // Workaround for types private readonly notificationsInternal = createEmitter>(); - // @ts-expect-error TODO public readonly notifications: NotificationListenable = this.notificationsInternal; public constructor( @@ -235,25 +241,21 @@ class NotificationsManagerImpl< public update( attendee: Attendee, _received: number, - value: InternalTypes.ValueRequiredState, + updateValue: InternalTypes.ValueRequiredState, ): PostUpdateAction[] { const postUpdateActions: PostUpdateAction[] = []; - const eventName = value.value.name as keyof Listeners>; + const value = asDeserializedJson(updateValue.value); + const eventName = value.name as keyof Listeners>; if (this.notificationsInternal.hasListeners(eventName)) { // Without schema validation, we don't know that the args are the correct type. // For now we assume the user is sending the correct types and there is no corruption along the way. - const args = [attendee, ...value.value.args] as Parameters< + const args = [attendee, ...value.args] as Parameters< NotificationSubscriptions[typeof eventName] >; postUpdateActions.push(() => this.notificationsInternal.emit(eventName, ...args)); } else { postUpdateActions.push(() => - this.events.emit( - "unattendedNotification", - value.value.name, - attendee, - ...value.value.args, - ), + this.events.emit("unattendedNotification", value.name, attendee, ...value.args), ); } return postUpdateActions; diff --git a/packages/framework/presence/src/presenceDatastoreManager.ts b/packages/framework/presence/src/presenceDatastoreManager.ts index bf1e4b696051..9ebe2fa192aa 100644 --- a/packages/framework/presence/src/presenceDatastoreManager.ts +++ b/packages/framework/presence/src/presenceDatastoreManager.ts @@ -177,7 +177,8 @@ export class PresenceDatastoreManagerImpl implements PresenceDatastoreManager { content: { sendTimestamp: Date.now(), avgLatency: this.averageLatency, - data: this.datastore, + // FIXME: remove cast + data: this.datastore as any, updateProviders, }, }); @@ -327,7 +328,10 @@ export class PresenceDatastoreManagerImpl implements PresenceDatastoreManager { }, } satisfies OutboundDatastoreUpdateMessage["content"]; this.queuedData = undefined; - this.runtime.submitSignal({ type: datastoreUpdateMessageType, content: newMessage }); + this.runtime.submitSignal({ + type: datastoreUpdateMessageType, + content: newMessage, + }); } private broadcastAllKnownState(): void { @@ -337,7 +341,8 @@ export class PresenceDatastoreManagerImpl implements PresenceDatastoreManager { sendTimestamp: Date.now(), avgLatency: this.averageLatency, isComplete: true, - data: this.datastore, + // FIXME: remove cast + data: this.datastore as any, }, }); this.refreshBroadcastRequested = false; diff --git a/packages/framework/presence/src/systemWorkspace.ts b/packages/framework/presence/src/systemWorkspace.ts index 342de7818f5e..a483d1869824 100644 --- a/packages/framework/presence/src/systemWorkspace.ts +++ b/packages/framework/presence/src/systemWorkspace.ts @@ -10,6 +10,7 @@ import { assert } from "@fluidframework/core-utils/internal"; import type { ClientConnectionId } from "./baseTypes.js"; import type { InternalTypes } from "./exposedInternalTypes.js"; import type { PostUpdateAction } from "./internalTypes.js"; +import { asDeserializedJson, fullySerializableToOpaqueJson } from "./internalUtils.js"; import type { Attendee, AttendeesEvents, AttendeeId, Presence } from "./presence.js"; import { AttendeeStatus } from "./presence.js"; import type { PresenceStatesInternal } from "./presenceStates.js"; @@ -131,7 +132,7 @@ class SystemWorkspaceImpl implements PresenceStatesInternal, SystemWorkspace { for (const [clientConnectionId, value] of Object.entries( remoteDatastore.clientToSessionId, )) { - const attendeeId = value.value; + const attendeeId = asDeserializedJson(value.value); const { attendee, isJoining } = this.ensureAttendee( attendeeId, clientConnectionId, @@ -146,8 +147,7 @@ class SystemWorkspaceImpl implements PresenceStatesInternal, SystemWorkspace { postUpdateActions.push(() => this.events.emit("attendeeConnected", attendee)); } - const knownSessionId: InternalTypes.ValueRequiredState | undefined = - this.datastore.clientToSessionId[clientConnectionId]; + const knownSessionId = this.datastore.clientToSessionId[clientConnectionId]; if (knownSessionId === undefined) { this.datastore.clientToSessionId[clientConnectionId] = value; } else { @@ -167,7 +167,7 @@ class SystemWorkspaceImpl implements PresenceStatesInternal, SystemWorkspace { this.datastore.clientToSessionId[clientConnectionId] = { rev: this.selfAttendee.order++, timestamp: Date.now(), - value: this.selfAttendee.attendeeId, + value: fullySerializableToOpaqueJson(this.selfAttendee.attendeeId), }; // Mark 'Connected' remote attendees connections as stale diff --git a/packages/framework/presence/src/test/latestValueManager.spec.ts b/packages/framework/presence/src/test/latestValueManager.spec.ts index 7afc32adf988..f1cabca79eb2 100644 --- a/packages/framework/presence/src/test/latestValueManager.spec.ts +++ b/packages/framework/presence/src/test/latestValueManager.spec.ts @@ -84,6 +84,22 @@ describe("Presence", () => { assert.deepStrictEqual(workspace.states.nullable.local, null); }); + it("can set and get inferred nullable type as initial value", () => { + // Setup + // Use a function to generate the initial value so that TypeScript + // can't statically infer the type as exactly null. + function generateInitialValue(): { x: number; y: number } | null { + return { x: 0, y: 0 }; + } + const initialValue = generateInitialValue(); + // Act + const workspace = presence.states.getWorkspace(testWorkspaceName, { + nullable: StateFactory.latest({ local: initialValue }), + }); + // Verify + assert.deepStrictEqual(workspace.states.nullable.local, initialValue); + }); + it(".presence provides Presence it was created under", () => { const workspace = presence.states.getWorkspace(testWorkspaceName, { camera: StateFactory.latest({ local: { x: 0, y: 0, z: 0 } }),