diff --git a/examples/view-integration/external-views/src/container/diceRoller/diceRoller.ts b/examples/view-integration/external-views/src/container/diceRoller/diceRoller.ts index 6aefbe1f0931..484e42db92a3 100644 --- a/examples/view-integration/external-views/src/container/diceRoller/diceRoller.ts +++ b/examples/view-integration/external-views/src/container/diceRoller/diceRoller.ts @@ -71,9 +71,11 @@ export class DiceRollerFactory implements IFluidDataStoreFactory { context: IFluidDataStoreContext, existing: boolean, ): Promise { + // This is the goo from the runtime. + let map: ISharedMap; + const provideEntryPoint = async (entryPointRuntime: IFluidDataStoreRuntime) => { - const map = (await entryPointRuntime.getChannel(mapId)) as ISharedMap; - return new DiceRoller(map); + return diceRoller; }; const runtime: FluidDataStoreRuntime = new FluidDataStoreRuntime( @@ -83,12 +85,18 @@ export class DiceRollerFactory implements IFluidDataStoreFactory { provideEntryPoint, ); - if (!existing) { - const map = runtime.createChannel(mapId, mapFactory.type) as ISharedMap; + if (existing) { + map = (await runtime.getChannel(mapId)) as ISharedMap; + } else { + map = runtime.createChannel(mapId, mapFactory.type) as ISharedMap; map.set(diceValueKey, 1); map.bindToContext(); } + // The EntryPoint + const diceRoller = new DiceRoller(map); + + // return [runtime, diceRoller]; return runtime; } } diff --git a/packages/framework/aqueduct/src/data-object-factories/index.ts b/packages/framework/aqueduct/src/data-object-factories/index.ts index 82980362b0ca..61969e446102 100644 --- a/packages/framework/aqueduct/src/data-object-factories/index.ts +++ b/packages/framework/aqueduct/src/data-object-factories/index.ts @@ -14,3 +14,9 @@ export { MigrationDataObjectFactory, type MigrationDataObjectFactoryProps, } from "./migrationDataObjectFactory.js"; +export { + MultiFormatDataStoreFactory, + type MultiFormatDataStoreFactoryProps, + type MultiFormatModelDescriptor, +} from "./multiFormatDataStoreFactory.js"; +export { pureDataObjectModelDescriptor } from "./pureDataObjectModelDescriptor.js"; diff --git a/packages/framework/aqueduct/src/data-object-factories/multiFormatDataStoreFactory.ts b/packages/framework/aqueduct/src/data-object-factories/multiFormatDataStoreFactory.ts new file mode 100644 index 000000000000..d2d45daf8eb9 --- /dev/null +++ b/packages/framework/aqueduct/src/data-object-factories/multiFormatDataStoreFactory.ts @@ -0,0 +1,193 @@ +/* eslint-disable import/no-internal-modules -- //* TEMP */ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import type { FluidObject } from "@fluidframework/core-interfaces"; +import { assert } from "@fluidframework/core-utils/internal"; +import { FluidDataStoreRuntime } from "@fluidframework/datastore/internal"; +import type { + IFluidDataStoreRuntime, + IChannelFactory, +} from "@fluidframework/datastore-definitions/internal"; +import type { + IFluidDataStoreFactory, + IFluidDataStoreContext, + IFluidDataStorePolicies, + IFluidDataStoreChannel, +} from "@fluidframework/runtime-definitions/internal"; + +import type { DataObjectTypes, IDataObjectProps } from "../data-objects/types.js"; + +import type { DataObjectFactoryProps } from "./pureDataObjectFactory.js"; + +//* TODO: Do a thorough pass over PureDataObjectFactory and MultiFormatDataStoreFactory to ensure feature parity +//* e.g. MFDSF is missing the Runtime mixin +//* The idea is that if you use a single pureDataObjectModelDescriptor with this Factory, it's equivalent to PureDataObjectFactory + +/** + * Descriptor that supplies (or can create) a model format within a multi-format data store. + * + * - `create`: Called only for the first descriptor when newly created (i.e. !existing) to establish initial DDSes/schema. + * - `probe`: Used when loading an existing data store; first descriptor whose probe returns true is selected. + * - `get`: Returns (or produces) the entry point Fluid object after selection. + */ +export interface MultiFormatModelDescriptor< + TEntryPoint extends FluidObject = FluidObject, + I extends DataObjectTypes = DataObjectTypes, +> { + /** + * Initialize a brand-new data store to this format (only invoked on descriptor[0] when !existing). + */ + create(props: IDataObjectProps): Promise; //* Would be nice to be able to have it synchronous? + /** + * Return true if this descriptor's format matches the persisted contents of the runtime. + */ + probe(runtime: IFluidDataStoreRuntime): Promise | boolean; + /** + * Provide the entry point object for this model. + */ + get(props: IDataObjectProps): Promise | TEntryPoint; + /** + * Shared object (DDS) factories required specifically for this model format. Multiple descriptors can + * contribute shared objects; duplicates (by type) are ignored with first-wins semantics. + * + * @remarks + * It's recommended to use delay-loaded factories for DDSes only needed for a specific format (esp old formats) + */ + readonly sharedObjects?: readonly IChannelFactory[]; +} + +/** + * Parameter object accepted by `MultiFormatDataStoreFactory` constructor (mirrors the style of + * `DataObjectFactoryProps` while focusing only on multi-format aspects for now). + */ +export interface MultiFormatDataStoreFactoryProps + extends Omit, "ctor" | "sharedObjects"> { + /** + * Ordered list of model descriptors (first used for creation; probed in order for existing). + */ + readonly modelDescriptors: readonly MultiFormatModelDescriptor[]; +} + +/** + * A minimal multi-format data store factory. + * + * It defers format selection until the entry point is requested. For an existing data store it runs the + * supplied descriptors' `probe` functions in order and picks the first that matches. For a new data store + * it eagerly invokes `create` on the first descriptor (if present) and then uses that descriptor. + * + * Future work: accept a richer set of options similar to `DataObjectFactoryProps`. + */ +export class MultiFormatDataStoreFactory implements IFluidDataStoreFactory { + public readonly type: string; + + private readonly descriptors: readonly MultiFormatModelDescriptor[]; + private readonly sharedObjectRegistry: Map; + private readonly runtimeClass: typeof FluidDataStoreRuntime; + private readonly policies?: Partial; + private readonly optionalProviders?: FluidObject; //* TODO: Figure out how to express this + + public constructor(props: MultiFormatDataStoreFactoryProps) { + const { type, modelDescriptors, runtimeClass, policies, optionalProviders } = props; + if (type === "") { + throw new Error("type must be a non-empty string"); + } + if (modelDescriptors.length === 0) { + throw new Error("At least one model descriptor must be supplied"); + } + this.type = type; + this.descriptors = modelDescriptors; + this.runtimeClass = runtimeClass ?? FluidDataStoreRuntime; + this.policies = policies; + this.optionalProviders = optionalProviders; + // Build combined shared object registry (first descriptor wins on duplicates) + this.sharedObjectRegistry = new Map(); + for (const d of modelDescriptors) { + for (const so of d.sharedObjects ?? []) { + //* BEWARE: collisions could be theoretically possible. Maybe via configuredSharedTree + if (!this.sharedObjectRegistry.has(so.type)) { + this.sharedObjectRegistry.set(so.type, so); + } + } + } + } + + // Provider pattern convenience (mirrors other factories in the codebase) + public get IFluidDataStoreFactory(): this { + return this; + } + + public async instantiateDataStore( + context: IFluidDataStoreContext, + existing: boolean, + ): Promise { + let selected: MultiFormatModelDescriptor | undefined; // chosen descriptor (per-instance) + const provideEntryPoint = async (rt: IFluidDataStoreRuntime): Promise => { + // Select descriptor lazily when entry point requested. + if (selected !== undefined) { + // Already selected for this runtime; return its entry point immediately. + return selected.get({ + context, + runtime: rt, + providers: this.optionalProviders ?? {}, + initProps: {}, //* TODO: Plumb this through + }); + } + + if (existing) { + for (const d of this.descriptors) { + try { + //* Await ensureFactoriesLoaded for the descriptor first to support delay-loading + const match = await d.probe(rt); + if (match) { + selected = d; + break; + } + } catch { + // Swallow probe errors and continue trying other descriptors. + } + } + } else { + // New data store path: use first descriptor + selected = this.descriptors[0]; + } + //* TODO: Switch to an error with errorType. + assert(selected !== undefined, "Should have found a model selector"); + + //* TODO: Switch probe style to return the object directly rather than a boolean followed by this .get call? + return selected.get({ + context, + runtime: rt, + providers: this.optionalProviders ?? {}, + initProps: {}, //* TODO: Plumb this through + }); + }; + + const runtime = new this.runtimeClass( + context, + this.sharedObjectRegistry, + existing, + provideEntryPoint, + this.policies, //* TODO: How do we union these? + ); + + // For a new data store, initialize using the first descriptor before returning the runtime. + if (!existing) { + const first = this.descriptors[0]; + //* TODO: Update the type to express that there's at least one + if (first === undefined) { + throw new Error("Invariant: descriptors array unexpectedly empty"); + } + await first.create({ + context, + runtime, + providers: this.optionalProviders ?? {}, + initProps: {}, //* TODO: Plumb this through + }); + } + + return runtime; + } +} diff --git a/packages/framework/aqueduct/src/data-object-factories/pureDataObjectModelDescriptor.ts b/packages/framework/aqueduct/src/data-object-factories/pureDataObjectModelDescriptor.ts new file mode 100644 index 000000000000..5bd4e62946f4 --- /dev/null +++ b/packages/framework/aqueduct/src/data-object-factories/pureDataObjectModelDescriptor.ts @@ -0,0 +1,64 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import type { FluidObject } from "@fluidframework/core-interfaces"; +import type { + IChannelFactory, + IFluidDataStoreRuntime, +} from "@fluidframework/datastore-definitions/internal"; + +import type { + PureDataObject, + DataObjectTypes, + IDataObjectProps, +} from "../data-objects/index.js"; + +import type { MultiFormatModelDescriptor } from "./multiFormatDataStoreFactory.js"; + +/** + * Creates a {@link MultiFormatModelDescriptor} for a {@link PureDataObject} ctor. + * + * When supplied as the sole descriptor to a {@link MultiFormatDataStoreFactory} the resulting data store behaves + * equivalently (for typical usage) to one produced by {@link PureDataObjectFactory} for the same `ctor` & shared objects: + * + * - New data store creation eagerly constructs the object and runs its first-time initialization before attachment. + * - Loading an existing data store constructs the object lazily when the entry point is first requested. + * - Subsequent `get` calls return the same instance. + */ +export function pureDataObjectModelDescriptor< + TObj extends PureDataObject & FluidObject, + I extends DataObjectTypes = DataObjectTypes, +>( + ctor: new (props: IDataObjectProps) => TObj, + sharedObjects?: readonly IChannelFactory[], +): MultiFormatModelDescriptor { + // Map runtime => instantiated data object. Each runtime instance corresponds to one data store instance. + const instances = new WeakMap(); + + return { + async create(props: IDataObjectProps): Promise { + const instance = new ctor(props); + instances.set(props.runtime, instance); + // For new data stores run first-time initialization before attachment (mirrors PureDataObjectFactory behavior). + await instance.finishInitialization(false); + }, + // Single-format helpers can always report a positive probe. If multiple descriptors are used callers should + // provide a more selective probe implementation. + probe(): boolean { + return true; + }, + async get(props: IDataObjectProps): Promise { + let instance = instances.get(props.runtime); + if (instance === undefined) { + // Existing data store path: lazily construct & complete existing initialization on first access. + instance = new ctor(props); + instances.set(props.runtime, instance); + await instance.finishInitialization(true); + } + return instance; + }, + sharedObjects, + }; +} diff --git a/packages/framework/aqueduct/src/data-objects/dataObject.ts b/packages/framework/aqueduct/src/data-objects/dataObject.ts index 63b9f2a2ba80..831003fa4671 100644 --- a/packages/framework/aqueduct/src/data-objects/dataObject.ts +++ b/packages/framework/aqueduct/src/data-objects/dataObject.ts @@ -52,6 +52,7 @@ export abstract class DataObject< * Caller is responsible for ensuring this is only invoked once. */ public override async initializeInternal(existing: boolean): Promise { + //* TODO: Reimplement in terms of intialize primitives and let super.initializeInternal do the stitching together if (existing) { // data store has a root directory so we just need to set it before calling initializingFromExisting this.internalRoot = (await this.runtime.getChannel( diff --git a/packages/framework/aqueduct/src/demo.ts b/packages/framework/aqueduct/src/demo.ts new file mode 100644 index 000000000000..e02081e4c74f --- /dev/null +++ b/packages/framework/aqueduct/src/demo.ts @@ -0,0 +1,209 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import type { + IFluidDataStoreRuntime, + IChannelFactory, +} from "@fluidframework/datastore-definitions/internal"; +import type { ISharedDirectory } from "@fluidframework/map/internal"; +import type { IContainerRuntimeBase } from "@fluidframework/runtime-definitions/internal"; +import type { ISharedObject } from "@fluidframework/shared-object-base/internal"; +import { + SchemaFactory, + SharedTree, + TreeViewConfiguration, + type ITree, + type TreeView, +} from "@fluidframework/tree/internal"; + +import type { IDelayLoadChannelFactory } from "./channel-factories/index.js"; +import { MultiFormatDataStoreFactory } from "./data-object-factories/index.js"; +// MultiFormatModelDescriptor is not exported publicly; re-declare minimal shape needed locally. +interface MultiFormatModelDescriptor { + sharedObjects?: readonly IChannelFactory[]; // Subset used for demo + probe(runtime: IFluidDataStoreRuntime): Promise | boolean; + create(runtime: IFluidDataStoreRuntime): Promise | void; + get(runtime: IFluidDataStoreRuntime): Promise | TEntryPoint; +} +// eslint-disable-next-line import/no-internal-modules +import { rootDirectoryDescriptor } from "./data-objects/dataObject.js"; +// eslint-disable-next-line import/no-internal-modules +import { treeChannelId } from "./data-objects/treeDataObject.js"; + +//* NOTE: For illustration purposes. This will need to be properly created in the app +declare const treeDelayLoadFactory: IDelayLoadChannelFactory; + +const schemaIdentifier = "edc30555-e3ce-4214-b65b-ec69830e506e"; +const sf = new SchemaFactory(`${schemaIdentifier}.MigrationDemo`); + +class DemoSchema extends sf.object("DemoSchema", { + arbitraryKeys: sf.map([sf.string, sf.boolean]), +}) {} + +const demoTreeConfiguration = new TreeViewConfiguration({ + // root node schema + schema: DemoSchema, +}); + +// (Taken from the prototype in the other app repo) +interface ViewWithDirOrTree { + readonly getArbitraryKey: (key: string) => string | boolean | undefined; + readonly setArbitraryKey: (key: string, value: string | boolean) => void; + readonly deleteArbitraryKey: (key: string) => void; + readonly getRoot: () => + | { + isDirectory: true; + root: ISharedDirectory; + } + | { + isDirectory: false; + root: ITree; + }; +} + +interface TreeModel extends ViewWithDirOrTree { + readonly getRoot: () => { + isDirectory: false; + root: ITree; + }; +} + +interface DirModel extends ViewWithDirOrTree { + readonly getRoot: () => { + isDirectory: true; + root: ISharedDirectory; + }; +} + +const wrapTreeView = ( + tree: ITree, + func: (treeView: TreeView) => T, +): T => { + const treeView = tree.viewWith(demoTreeConfiguration); + // Initialize the root of the tree if it is not already initialized. + if (treeView.compatibility.canInitialize) { + treeView.initialize(new DemoSchema({ arbitraryKeys: [] })); + } + const value = func(treeView); + treeView.dispose(); + return value; +}; + +function makeDirModel(root: ISharedDirectory): DirModel { + return { + getRoot: () => ({ isDirectory: true, root }), + getArbitraryKey: (key) => root.get(key), + setArbitraryKey: (key, value) => root.set(key, value), + deleteArbitraryKey: (key) => root.delete(key), + }; +} + +function makeTreeModel(tree: ITree): TreeModel { + return { + getRoot: () => ({ isDirectory: false, root: tree }), + getArbitraryKey: (key) => { + return wrapTreeView(tree, (treeView) => { + return treeView.root.arbitraryKeys.get(key); + }); + }, + setArbitraryKey: (key, value) => { + return wrapTreeView(tree, (treeView) => { + treeView.root.arbitraryKeys.set(key, value); + }); + }, + deleteArbitraryKey: (key) => { + wrapTreeView(tree, (treeView) => { + treeView.root.arbitraryKeys.delete(key); + }); + }, + }; +} + +// Build Multi-Format model descriptors: prefer SharedTree, fall back to SharedDirectory +// NOTE: These descriptors conform to MultiFormatModelDescriptor shape used by MultiFormatDataStoreFactory. +const treeDescriptor: MultiFormatModelDescriptor = { + sharedObjects: [treeDelayLoadFactory], + probe: async (runtime: IFluidDataStoreRuntime) => { + try { + const tree = await runtime.getChannel(treeChannelId); + return SharedTree.is(tree); + } catch { + return false; + } + }, + create: (runtime: IFluidDataStoreRuntime) => { + const tree = runtime.createChannel( + treeChannelId, + SharedTree.getFactory().type, + ) as unknown as ITree & ISharedObject; + tree.bindToContext(); + }, + get: async (runtime: IFluidDataStoreRuntime) => { + const channel = await runtime.getChannel(treeChannelId); + if (!SharedTree.is(channel)) { + throw new Error("Expected SharedTree channel when resolving treeDescriptor entry point"); + } + return makeTreeModel(channel as unknown as ITree); + }, +}; + +const dirDescriptor: MultiFormatModelDescriptor = { + sharedObjects: rootDirectoryDescriptor.sharedObjects?.alwaysLoaded, + probe: async (runtime: IFluidDataStoreRuntime) => { + const result = await rootDirectoryDescriptor.probe( + runtime as unknown as IFluidDataStoreRuntime, + ); + return result !== undefined; + }, + create: (runtime: IFluidDataStoreRuntime) => { + rootDirectoryDescriptor.create(runtime as unknown as IFluidDataStoreRuntime); + }, + get: async (runtime: IFluidDataStoreRuntime) => { + const result = await rootDirectoryDescriptor.probe( + runtime as unknown as IFluidDataStoreRuntime, + ); + if (!result) { + throw new Error("Directory model probe failed during get()"); + } + return makeDirModel(result.root); + }, +}; + +// Union type of possible model views returned by the multi-format entry point +type MultiFormatModel = DirModel | TreeModel; + +// Create a multi-format factory +const multiFormatFactory = new MultiFormatDataStoreFactory({ + type: "DirOrTree", + modelDescriptors: [treeDescriptor, dirDescriptor], +}); + +/** + * Create a new detached multi-format data store instance and return its model view (Tree preferred, Directory fallback). + * Caller must attach a handle referencing the returned model to bind it into the container graph. + */ +export async function demoCreate( + containerRuntime: IContainerRuntimeBase, +): Promise { + const context = containerRuntime.createDetachedDataStore([multiFormatFactory.type]); + const runtime = await multiFormatFactory.instantiateDataStore(context, false); + const model = (await runtime.entryPoint.get()) as MultiFormatModel; + // The types line up with IProvideFluidDataStoreFactory & IFluidDataStoreChannel via factory + runtime + await context.attachRuntime( + multiFormatFactory as unknown as Parameters[0], + runtime as unknown as Parameters[1], + ); + return model; +} + +/** + * Read an arbitrary key from either model variant (directory or tree). + */ +export async function demoGetKey( + model: MultiFormatModel, + key: string, +): Promise { + return model.getArbitraryKey(key); +} diff --git a/packages/framework/aqueduct/src/test/aqueduct.spec.ts b/packages/framework/aqueduct/src/test/aqueduct.spec.ts index 72d47def029f..b6ca381603d3 100644 --- a/packages/framework/aqueduct/src/test/aqueduct.spec.ts +++ b/packages/framework/aqueduct/src/test/aqueduct.spec.ts @@ -3,5 +3,83 @@ * Licensed under the MIT License. */ +import type { IValueChanged } from "@fluidframework/map/internal"; + +import { + DataObjectFactory, + // MultiFormatDataStoreFactory, +} from "../data-object-factories/index.js"; +import { DataObject } from "../data-objects/index.js"; + +const diceValueKey = "diceValue"; + +/** + * IDiceRoller describes the public API surface for our dice roller Fluid object. + */ +export interface IDiceRoller { + /** + * Get the dice value as a number. + */ + readonly value: number; + + /** + * Roll the dice. Will cause a "diceRolled" event to be emitted. + */ + roll: () => void; + + /** + * The diceRolled event will fire whenever someone rolls the device, either locally or remotely. + */ + on(event: "diceRolled", listener: () => void): this; +} + +/** + * The DiceRoller is our implementation of the IDiceRoller interface. + * @internal + */ +export class DiceRoller extends DataObject implements IDiceRoller { + public static readonly Name = "@fluid-example/dice-roller"; + + public static readonly factory = new DataObjectFactory({ + //* modelDescriptors: [], + type: DiceRoller.Name, + ctor: DiceRoller, + }); + + /** + * initializingFirstTime is called only once, it is executed only by the first client to open the + * Fluid object and all work will resolve before the view is presented to any user. + * + * This method is used to perform Fluid object setup, which can include setting an initial schema or initial values. + */ + protected async initializingFirstTime(): Promise { + this.root.set(diceValueKey, 1); + } + + protected async hasInitialized(): Promise { + this.root.on("valueChanged", (changed: IValueChanged) => { + if (changed.key === diceValueKey) { + this.emit("diceRolled"); + } + }); + } + + public get value(): number { + return this.root.get(diceValueKey) as number; + } + + public readonly roll = (): void => { + const rollValue = Math.floor(Math.random() * 6) + 1; + this.root.set(diceValueKey, rollValue); + }; +} + +/** + * The DataObjectFactory declares the Fluid object and defines any additional distributed data structures. + * To add a SharedSequence, SharedMap, or any other structure, put it in the array below. + * @internal + */ +export const DiceRollerInstantiationFactory = DiceRoller.factory; + // Build pipeline breaks without this file ("No test files found") describe("aqueduct-placeholder", () => {});