Skip to content

refactor(presence): Create branded JsonDeserialized type #24641

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions packages/framework/presence/api-report/presence.alpha.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,12 @@ export namespace InternalTypes {
// @system (undocumented)
export interface ValueOptionalState<TValue> extends ValueStateMetadata {
// (undocumented)
value?: JsonDeserialized<TValue>;
value?: InternalUtilityTypes.OpaqueJsonDeserialized<TValue>;
}
// @system (undocumented)
export interface ValueRequiredState<TValue> extends ValueStateMetadata {
// (undocumented)
value: JsonDeserialized<TValue>;
value: InternalUtilityTypes.OpaqueJsonDeserialized<TValue>;
}
// @system (undocumented)
export interface ValueStateMetadata {
Expand All @@ -127,16 +127,35 @@ export namespace InternalTypes {

// @alpha @system
export namespace InternalUtilityTypes {
// @system
export class BrandedType<Brand> {
// (undocumented)
static [Symbol.hasInstance](value: never): value is never;
protected constructor();
// (undocumented)
protected readonly brand: (dummy: never) => Brand;
}
// @system
export type IsNotificationListener<Event> = Event extends (...args: infer P) => void ? InternalUtilityTypes_2.IfSameType<P, JsonSerializable<P> & JsonDeserialized<P>, true, false> : false;
// @system (undocumented)
export class JsonDeserializedBrand<T> extends BrandedType<T> {
}
// @system
export type JsonDeserializedParameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? JsonDeserialized<P> : never;
// @system (undocumented)
export class JsonSerializableBrand<T> {
}
// @system
export type JsonSerializableParameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? JsonSerializable<P> : never;
// @system
export type NotificationListeners<E> = {
[P in string & keyof E as IsNotificationListener<E[P]> extends true ? P : never]: E[P];
};
// @system (undocumented)
export type OpaqueJsonDeserialized<T> = JsonDeserializedBrand<T>;
// @system (undocumented)
export type OpaqueJsonSerializable<T> = JsonSerializableBrand<T>;
{};
}

// @alpha
Expand Down
6 changes: 4 additions & 2 deletions packages/framework/presence/src/exposedInternalTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -29,14 +31,14 @@ export namespace InternalTypes {
* @system
*/
export interface ValueOptionalState<TValue> extends ValueStateMetadata {
value?: JsonDeserialized<TValue>;
value?: InternalUtilityTypes.OpaqueJsonDeserialized<TValue>;
}

/**
* @system
*/
export interface ValueRequiredState<TValue> extends ValueStateMetadata {
value: JsonDeserialized<TValue>;
value: InternalUtilityTypes.OpaqueJsonDeserialized<TValue>;
}

/**
Expand Down
70 changes: 70 additions & 0 deletions packages/framework/presence/src/exposedUtilityTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import type {
DeepReadonly,
InternalUtilityTypes as CoreInternalUtilityTypes,
JsonDeserialized,
JsonSerializable,
Expand Down Expand Up @@ -76,4 +77,73 @@ export namespace InternalUtilityTypes {
) => any
? JsonSerializable<P>
: never;

/**
* Base branded type
*
* @system
*/
export declare class BrandedType<Brand> {
protected readonly brand: (dummy: never) => Brand;
protected constructor();
public static [Symbol.hasInstance](value: never): value is never;
}

/**
* @system
*/
declare class JsonDeserializedBrand<T> extends BrandedType<T> {
private readonly EncodedValue: T;
private constructor();
}

/**
* @system
*/
// export type JsonDeserializedHandle<T> = Tagged<JsonDeserialized<T>, "JsonDeserialized">;
export type OpaqueJsonDeserialized<T> = JsonDeserializedBrand<T>;

/**
* @system
*/
export declare class JsonSerializableBrand<T> {
private readonly JsonSerializable: JsonSerializable<T>;
}

/**
* @system
*/
// export type JsonDeserializedHandle<T> = Tagged<JsonDeserialized<T>, "JsonDeserialized">;
export type OpaqueJsonSerializable<T> = JsonSerializableBrand<T>;
}

/**
* Cast a JsonDeserialized value to its branded version.
*
* @system
*/
export function brandJson<T>(
value: JsonDeserialized<T>,
): InternalUtilityTypes.OpaqueJsonDeserialized<T> {
return value as InternalUtilityTypes.OpaqueJsonDeserialized<T>;
}

/**
* Cast a branded JsonDeserialized value back to its unbranded version.
*
* @system
*/
export function unbrandJson<T>(
value: InternalUtilityTypes.OpaqueJsonDeserialized<T>,
): JsonDeserialized<T> {
return value as JsonDeserialized<T>;
}

/**
* Converts a JsonDeserializedHandle to a deeply readonly JsonDeserialized value.
*/
export function asDeeplyReadonlyFromJsonHandle<T>(
value: InternalUtilityTypes.OpaqueJsonDeserialized<T>,
): DeepReadonly<JsonDeserialized<T>> {
return asDeeplyReadonlyFromJsonHandle<T>(value);
}
24 changes: 16 additions & 8 deletions packages/framework/presence/src/latestMapValueManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -266,22 +271,25 @@ class ValueMapImpl<T, K extends string | number> implements StateMap<K, T> {
): 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<JsonDeserialized<T>> | 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;
}
public set(key: K, value: JsonSerializable<T> & JsonDeserialized<T>): 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;
}
Expand Down Expand Up @@ -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 },
});
}
Expand Down Expand Up @@ -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<T, Keys>;
postUpdateActions.push(() => this.events.emit("remoteItemUpdated", updatedItem));
allUpdates.items.set(key, { value: itemValue, metadata });
} else if (hadPriorValue !== undefined) {
Expand Down Expand Up @@ -539,7 +547,7 @@ export function latestMap<
value.items[key] = {
rev: 0,
timestamp,
value: initialValues[key],
value: brandJson(initialValues[key]),
};
}
}
Expand Down
21 changes: 15 additions & 6 deletions packages/framework/presence/src/latestValueManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -112,13 +114,13 @@ class LatestValueManagerImpl<T, Key extends string>
}

public get local(): DeepReadonly<JsonDeserialized<T>> {
return asDeeplyReadonly(this.value.value);
return asDeeplyReadonly(unbrandJson(this.value.value));
}

public set local(value: JsonSerializable<T> & JsonDeserialized<T>) {
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,
});
Expand All @@ -132,7 +134,7 @@ class LatestValueManagerImpl<T, Key extends string>
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 },
};
}
Expand All @@ -153,7 +155,7 @@ class LatestValueManagerImpl<T, Key extends string>
throw new Error("No entry for clientId");
}
return {
value: asDeeplyReadonly(clientState.value),
value: asDeeplyReadonly(unbrandJson(clientState.value)),
metadata: { revision: clientState.rev, timestamp: Date.now() },
};
}
Expand All @@ -174,7 +176,7 @@ class LatestValueManagerImpl<T, Key extends string>
() =>
this.events.emit("remoteUpdated", {
attendee,
value: asDeeplyReadonly(value.value),
value: asDeeplyReadonly(unbrandJson(value.value)),
metadata: { revision: value.rev, timestamp: value.timestamp },
}),
];
Expand Down Expand Up @@ -211,10 +213,17 @@ export function latest<T extends object | null, Key extends string = string>(

// 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<T>)
: // FIXME: Why isn't this directly castable?
(shallowCloneObject(
local,
) as unknown as InternalUtilityTypes.OpaqueJsonDeserialized<T>);
const value: InternalTypes.ValueRequiredState<T> = {
rev: 0,
timestamp: Date.now(),
value: local === null ? local : shallowCloneObject(local),
value: internalValue,
};
const factory = (
key: Key,
Expand Down
23 changes: 16 additions & 7 deletions packages/framework/presence/src/notificationsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, Presence } from "./presence.js";
import { datastoreFromHandle, type StateDatastore } from "./stateDatastore.js";
Expand Down Expand Up @@ -177,7 +177,11 @@ class NotificationsManagerImpl<
{
rev: 0,
timestamp: 0,
value: { name, args: [...(args as JsonTypeWith<never>[])] },
value: {
name,
args: [...(args as JsonTypeWith<never>[])],
// FIXME: Why doesn't as cast work?
} as unknown as InternalUtilityTypes.OpaqueJsonDeserialized<InternalTypes.NotificationType>,
ignoreUnmonitored: true,
},
// This is a notification, so we want to send it immediately.
Expand All @@ -190,7 +194,11 @@ class NotificationsManagerImpl<
{
rev: 0,
timestamp: 0,
value: { name, args: [...(args as JsonTypeWith<never>[])] },
value: {
name,
args: [...(args as JsonTypeWith<never>[])],
// FIXME: Why doesn't as cast work?
} as unknown as InternalUtilityTypes.OpaqueJsonDeserialized<InternalTypes.NotificationType>,
ignoreUnmonitored: true,
},
// This is a notification, so we want to send it immediately.
Expand Down Expand Up @@ -237,22 +245,23 @@ class NotificationsManagerImpl<
_received: number,
value: InternalTypes.ValueRequiredState<InternalTypes.NotificationType>,
): PostUpdateAction[] {
const unbrandedValue = unbrandJson(value.value);
const postUpdateActions: PostUpdateAction[] = [];
const eventName = value.value.name as keyof Listeners<NotificationSubscriptions<T>>;
const eventName = unbrandedValue.name as keyof Listeners<NotificationSubscriptions<T>>;
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<T>[typeof eventName]
>;
postUpdateActions.push(() => this.notificationsInternal.emit(eventName, ...args));
} else {
postUpdateActions.push(() =>
this.events.emit(
"unattendedNotification",
value.value.name,
unbrandedValue.name,
attendee,
...value.value.args,
...unbrandedValue.args,
),
);
}
Expand Down
5 changes: 3 additions & 2 deletions packages/framework/presence/src/systemWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading