diff --git a/packages/framework/presence/api-report/presence.alpha.api.md b/packages/framework/presence/api-report/presence.alpha.api.md index 116ba0484450..c4859bb3d70d 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?: InternalUtilityTypes.OpaqueJsonDeserialized; } // @system (undocumented) export interface ValueRequiredState extends ValueStateMetadata { // (undocumented) - value: JsonDeserialized; + value: InternalUtilityTypes.OpaqueJsonDeserialized; } // @system (undocumented) export interface ValueStateMetadata { @@ -130,16 +130,35 @@ export namespace InternalTypes { // @alpha @system export namespace InternalUtilityTypes { + // @system + export class BrandedType { + // (undocumented) + static [Symbol.hasInstance](value: never): value is never; + protected constructor(); + // (undocumented) + protected readonly brand: (dummy: never) => Brand; + } // @system export type IsNotificationListener = Event extends (...args: infer P) => void ? InternalUtilityTypes_2.IfSameType & JsonDeserialized

, true, false> : false; + // @system (undocumented) + export class JsonDeserializedBrand extends BrandedType { + } // @system export type JsonDeserializedParameters any> = T extends (...args: infer P) => any ? JsonDeserialized

: never; + // @system (undocumented) + export class JsonSerializableBrand { + } // @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]; }; + // @system (undocumented) + export type OpaqueJsonDeserialized = JsonDeserializedBrand; + // @system (undocumented) + export type OpaqueJsonSerializable = JsonSerializableBrand; + {}; } // @beta diff --git a/packages/framework/presence/src/exposedInternalTypes.ts b/packages/framework/presence/src/exposedInternalTypes.ts index 46e457098129..e12fdd028262 100644 --- a/packages/framework/presence/src/exposedInternalTypes.ts +++ b/packages/framework/presence/src/exposedInternalTypes.ts @@ -8,6 +8,8 @@ import type { JsonSerializable, } from "@fluidframework/core-interfaces/internal/exposedUtilityTypes"; +import type { InternalUtilityTypes } from "./exposedUtilityTypes.js"; + /** * Collection of value types that are not intended to be used/imported * directly outside of this package. @@ -29,14 +31,14 @@ export namespace InternalTypes { * @system */ export interface ValueOptionalState extends ValueStateMetadata { - value?: JsonDeserialized; + value?: InternalUtilityTypes.OpaqueJsonDeserialized; } /** * @system */ export interface ValueRequiredState extends ValueStateMetadata { - value: JsonDeserialized; + value: InternalUtilityTypes.OpaqueJsonDeserialized; } /** diff --git a/packages/framework/presence/src/exposedUtilityTypes.ts b/packages/framework/presence/src/exposedUtilityTypes.ts index a6df51d9f084..6b6bc8b997d9 100644 --- a/packages/framework/presence/src/exposedUtilityTypes.ts +++ b/packages/framework/presence/src/exposedUtilityTypes.ts @@ -4,6 +4,7 @@ */ import type { + DeepReadonly, InternalUtilityTypes as CoreInternalUtilityTypes, JsonDeserialized, JsonSerializable, @@ -76,4 +77,73 @@ export namespace InternalUtilityTypes { ) => any ? JsonSerializable

: never; + + /** + * Base branded type + * + * @system + */ + export declare class BrandedType { + protected readonly brand: (dummy: never) => Brand; + protected constructor(); + public static [Symbol.hasInstance](value: never): value is never; + } + + /** + * @system + */ + declare class JsonDeserializedBrand extends BrandedType { + private readonly EncodedValue: T; + private constructor(); + } + + /** + * @system + */ + // export type JsonDeserializedHandle = Tagged, "JsonDeserialized">; + export type OpaqueJsonDeserialized = JsonDeserializedBrand; + + /** + * @system + */ + export declare class JsonSerializableBrand { + private readonly JsonSerializable: JsonSerializable; + } + + /** + * @system + */ + // export type JsonDeserializedHandle = Tagged, "JsonDeserialized">; + export type OpaqueJsonSerializable = JsonSerializableBrand; +} + +/** + * Cast a JsonDeserialized value to its branded version. + * + * @system + */ +export function brandJson( + value: JsonDeserialized, +): InternalUtilityTypes.OpaqueJsonDeserialized { + return value as InternalUtilityTypes.OpaqueJsonDeserialized; +} + +/** + * Cast a branded JsonDeserialized value back to its unbranded version. + * + * @system + */ +export function unbrandJson( + value: InternalUtilityTypes.OpaqueJsonDeserialized, +): JsonDeserialized { + return value as JsonDeserialized; +} + +/** + * Converts a JsonDeserializedHandle to a deeply readonly JsonDeserialized value. + */ +export function asDeeplyReadonlyFromJsonHandle( + value: InternalUtilityTypes.OpaqueJsonDeserialized, +): DeepReadonly> { + return asDeeplyReadonlyFromJsonHandle(value); } diff --git a/packages/framework/presence/src/latestMapValueManager.ts b/packages/framework/presence/src/latestMapValueManager.ts index 17e17bf6b8f6..6a2f066c440f 100644 --- a/packages/framework/presence/src/latestMapValueManager.ts +++ b/packages/framework/presence/src/latestMapValueManager.ts @@ -15,6 +15,11 @@ import type { import type { BroadcastControls, BroadcastControlSettings } from "./broadcastControls.js"; import { OptionalBroadcastControl } from "./broadcastControls.js"; import type { InternalTypes } from "./exposedInternalTypes.js"; +import { + asDeeplyReadonlyFromJsonHandle, + brandJson, + // type InternalUtilityTypes, +} from "./exposedUtilityTypes.js"; import type { PostUpdateAction, ValueManager } from "./internalTypes.js"; import { asDeeplyReadonly, objectEntries, objectKeys } from "./internalUtils.js"; import type { LatestClientData, LatestData, LatestMetadata } from "./latestValueTypes.js"; @@ -266,12 +271,15 @@ 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(asDeeplyReadonlyFromJsonHandle(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 + asDeeplyReadonlyFromJsonHandle(this.value.items[key]!.value!); } public has(key: K): boolean { return this.value.items[key]?.value !== undefined; @@ -279,9 +287,9 @@ class ValueMapImpl implements StateMap { public set(key: K, value: JsonSerializable & JsonDeserialized): this { 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: brandJson(value) }; } - this.updateItem(key, value); + this.updateItem(key, brandJson(value)); this.emitter.emit("localItemUpdated", { key, value: asDeeplyReadonly(value) }); return this; } @@ -413,7 +421,7 @@ class LatestMapRawValueManagerImpl< const value = item.value; if (value !== undefined) { items.set(key, { - value: asDeeplyReadonly(value), + value: asDeeplyReadonlyFromJsonHandle(value), metadata: { revision: item.rev, timestamp: item.timestamp }, }); } @@ -464,13 +472,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 = asDeeplyReadonlyFromJsonHandle(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) { @@ -539,7 +547,7 @@ export function latestMap< value.items[key] = { rev: 0, timestamp, - value: initialValues[key], + value: brandJson(initialValues[key]), }; } } diff --git a/packages/framework/presence/src/latestValueManager.ts b/packages/framework/presence/src/latestValueManager.ts index 090c6f8f8d4b..c03b797dbdc6 100644 --- a/packages/framework/presence/src/latestValueManager.ts +++ b/packages/framework/presence/src/latestValueManager.ts @@ -15,6 +15,8 @@ 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 { InternalUtilityTypes } from "./exposedUtilityTypes.js"; +import { unbrandJson, brandJson } from "./exposedUtilityTypes.js"; import type { PostUpdateAction, ValueManager } from "./internalTypes.js"; import { asDeeplyReadonly, objectEntries } from "./internalUtils.js"; import type { LatestClientData, LatestData } from "./latestValueTypes.js"; @@ -112,13 +114,13 @@ class LatestValueManagerImpl } public get local(): DeepReadonly> { - return asDeeplyReadonly(this.value.value); + return asDeeplyReadonly(unbrandJson(this.value.value)); } public set local(value: JsonSerializable & JsonDeserialized) { this.value.rev += 1; this.value.timestamp = Date.now(); - this.value.value = value; + this.value.value = brandJson(value); this.datastore.localUpdate(this.key, this.value, { allowableUpdateLatencyMs: this.controls.allowableUpdateLatencyMs, }); @@ -132,7 +134,7 @@ class LatestValueManagerImpl if (attendeeId !== allKnownStates.self) { yield { attendee: this.datastore.lookupClient(attendeeId), - value: asDeeplyReadonly(value.value), + value: asDeeplyReadonly(unbrandJson(value.value)), metadata: { revision: value.rev, timestamp: value.timestamp }, }; } @@ -153,7 +155,7 @@ class LatestValueManagerImpl throw new Error("No entry for clientId"); } return { - value: asDeeplyReadonly(clientState.value), + value: asDeeplyReadonly(unbrandJson(clientState.value)), metadata: { revision: clientState.rev, timestamp: Date.now() }, }; } @@ -174,7 +176,7 @@ class LatestValueManagerImpl () => this.events.emit("remoteUpdated", { attendee, - value: asDeeplyReadonly(value.value), + value: asDeeplyReadonly(unbrandJson(value.value)), metadata: { revision: value.rev, timestamp: value.timestamp }, }), ]; @@ -211,10 +213,17 @@ export function latest( // Latest takes ownership of the initial local value but makes a shallow // copy for basic protection. + const internalValue = + local === null + ? (local as unknown as InternalUtilityTypes.OpaqueJsonDeserialized) + : // FIXME: Why isn't this directly castable? + (shallowCloneObject( + local, + ) as unknown as InternalUtilityTypes.OpaqueJsonDeserialized); const value: InternalTypes.ValueRequiredState = { rev: 0, timestamp: Date.now(), - value: local === null ? local : shallowCloneObject(local), + value: internalValue, }; const factory = ( key: Key, diff --git a/packages/framework/presence/src/notificationsManager.ts b/packages/framework/presence/src/notificationsManager.ts index 12857b260599..5994967f7868 100644 --- a/packages/framework/presence/src/notificationsManager.ts +++ b/packages/framework/presence/src/notificationsManager.ts @@ -8,7 +8,7 @@ import type { Listeners, Listenable, Off } from "@fluidframework/core-interfaces import type { JsonTypeWith } from "@fluidframework/core-interfaces/internal"; import type { InternalTypes } from "./exposedInternalTypes.js"; -import type { InternalUtilityTypes } from "./exposedUtilityTypes.js"; +import { unbrandJson, type InternalUtilityTypes } from "./exposedUtilityTypes.js"; import type { PostUpdateAction, ValueManager } from "./internalTypes.js"; import type { Attendee, PresenceWithNotifications as Presence } from "./presence.js"; import { datastoreFromHandle, type StateDatastore } from "./stateDatastore.js"; @@ -177,7 +177,11 @@ class NotificationsManagerImpl< { rev: 0, timestamp: 0, - value: { name, args: [...(args as JsonTypeWith[])] }, + value: { + name, + args: [...(args as JsonTypeWith[])], + // FIXME: Why doesn't as cast work? + } as unknown as InternalUtilityTypes.OpaqueJsonDeserialized, ignoreUnmonitored: true, }, // This is a notification, so we want to send it immediately. @@ -190,7 +194,11 @@ class NotificationsManagerImpl< { rev: 0, timestamp: 0, - value: { name, args: [...(args as JsonTypeWith[])] }, + value: { + name, + args: [...(args as JsonTypeWith[])], + // FIXME: Why doesn't as cast work? + } as unknown as InternalUtilityTypes.OpaqueJsonDeserialized, ignoreUnmonitored: true, }, // This is a notification, so we want to send it immediately. @@ -237,12 +245,13 @@ class NotificationsManagerImpl< _received: number, value: InternalTypes.ValueRequiredState, ): PostUpdateAction[] { + const unbrandedValue = unbrandJson(value.value); const postUpdateActions: PostUpdateAction[] = []; - const eventName = value.value.name as keyof Listeners>; + const eventName = unbrandedValue.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, ...unbrandedValue.args] as Parameters< NotificationSubscriptions[typeof eventName] >; postUpdateActions.push(() => this.notificationsInternal.emit(eventName, ...args)); @@ -250,9 +259,9 @@ class NotificationsManagerImpl< postUpdateActions.push(() => this.events.emit( "unattendedNotification", - value.value.name, + unbrandedValue.name, attendee, - ...value.value.args, + ...unbrandedValue.args, ), ); } diff --git a/packages/framework/presence/src/systemWorkspace.ts b/packages/framework/presence/src/systemWorkspace.ts index b4675e4221fe..fd6ad823d78a 100644 --- a/packages/framework/presence/src/systemWorkspace.ts +++ b/packages/framework/presence/src/systemWorkspace.ts @@ -9,6 +9,7 @@ import { assert } from "@fluidframework/core-utils/internal"; import type { ClientConnectionId } from "./baseTypes.js"; import type { InternalTypes } from "./exposedInternalTypes.js"; +import { unbrandJson, brandJson } from "./exposedUtilityTypes.js"; import type { PostUpdateAction } from "./internalTypes.js"; import type { Attendee, AttendeesEvents, AttendeeId, Presence } from "./presence.js"; import { AttendeeStatus } from "./presence.js"; @@ -133,7 +134,7 @@ class SystemWorkspaceImpl implements PresenceStatesInternal, SystemWorkspace { for (const [clientConnectionId, value] of Object.entries( remoteDatastore.clientToSessionId, )) { - const attendeeId = value.value; + const attendeeId = unbrandJson(value.value); const { attendee, isJoining } = this.ensureAttendee( attendeeId, clientConnectionId, @@ -169,7 +170,7 @@ class SystemWorkspaceImpl implements PresenceStatesInternal, SystemWorkspace { this.datastore.clientToSessionId[clientConnectionId] = { rev: this.selfAttendee.order++, timestamp: Date.now(), - value: this.selfAttendee.attendeeId, + value: brandJson(this.selfAttendee.attendeeId), }; // Mark 'Connected' remote attendees connections as stale