From e71c6b9a1012e8b233956d3505af6999ad88bb99 Mon Sep 17 00:00:00 2001 From: Mark Fields Date: Thu, 11 Sep 2025 22:19:33 +0000 Subject: [PATCH 1/6] Copilot: Initial multFormatDSF --- .../multiFormatDataStoreFactory.ts | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 packages/framework/aqueduct/src/data-object-factories/multiFormatDataStoreFactory.ts 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..3dbe5cce695f --- /dev/null +++ b/packages/framework/aqueduct/src/data-object-factories/multiFormatDataStoreFactory.ts @@ -0,0 +1,123 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import type { FluidObject } from "@fluidframework/core-interfaces"; +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"; + +/** + * 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 { + /** + * Initialize a brand-new data store to this format (only invoked on descriptor[0] when !existing). + */ + create?(runtime: IFluidDataStoreRuntime): Promise | void; + /** + * 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(runtime: IFluidDataStoreRuntime): Promise | TEntryPoint; +} + +/** + * 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; + + public constructor( + type: string, + descriptors: readonly MultiFormatModelDescriptor[], + sharedObjects?: readonly IChannelFactory[], + runtimeClass: typeof FluidDataStoreRuntime = FluidDataStoreRuntime, + policies?: Partial, + ) { + if (type === "") { + throw new Error("type must be a non-empty string"); + } + if (descriptors.length === 0) { + throw new Error("At least one model descriptor must be supplied"); + } + this.type = type; + this.descriptors = descriptors; + this.runtimeClass = runtimeClass; + this.policies = policies; + this.sharedObjectRegistry = new Map(sharedObjects?.map((ext) => [ext.type, ext])); + } + + // 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 runtime = new this.runtimeClass( + context, + this.sharedObjectRegistry, + existing, + async (rt: IFluidDataStoreRuntime): Promise => { + // Select descriptor lazily when entry point requested. + if (selected === undefined) { + if (existing) { + for (const d of this.descriptors) { + try { + const match = await (d.probe?.(rt) ?? false); + if (match) { + selected = d; + break; + } + } catch { + // Swallow probe errors and continue trying other descriptors. + } + } + } + // Fallback / new data store path: use first descriptor + selected ??= this.descriptors[0]; + } + if (selected === undefined) { + throw new Error("No model descriptor selected"); + } + return selected.get(rt); + }, + this.policies, + ); + + // For a new data store, initialize using the first descriptor before returning the runtime. + if (!existing) { + const first = this.descriptors[0]; + if (first === undefined) { + throw new Error("Invariant: descriptors array unexpectedly empty"); + } + await first.create?.(runtime); + } + + return runtime; + } +} From 9726e1c2462635477855671c0958af6b92f03581 Mon Sep 17 00:00:00 2001 From: Mark Fields Date: Thu, 11 Sep 2025 22:19:46 +0000 Subject: [PATCH 2/6] Initial manual CR --- .../multiFormatDataStoreFactory.ts | 130 ++++++++++++------ 1 file changed, 90 insertions(+), 40 deletions(-) diff --git a/packages/framework/aqueduct/src/data-object-factories/multiFormatDataStoreFactory.ts b/packages/framework/aqueduct/src/data-object-factories/multiFormatDataStoreFactory.ts index 3dbe5cce695f..9ed66e813ddf 100644 --- a/packages/framework/aqueduct/src/data-object-factories/multiFormatDataStoreFactory.ts +++ b/packages/framework/aqueduct/src/data-object-factories/multiFormatDataStoreFactory.ts @@ -4,9 +4,18 @@ */ 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 { + IFluidDataStoreRuntime, + IChannelFactory, +} from "@fluidframework/datastore-definitions/internal"; +import type { + IFluidDataStoreFactory, + IFluidDataStoreContext, + IFluidDataStorePolicies, + IFluidDataStoreChannel, +} from "@fluidframework/runtime-definitions/internal"; /** * Descriptor that supplies (or can create) a model format within a multi-format data store. @@ -19,15 +28,46 @@ export interface MultiFormatModelDescriptor | void; + create(runtime: IFluidDataStoreRuntime): Promise | void; /** * Return true if this descriptor's format matches the persisted contents of the runtime. */ - probe?(runtime: IFluidDataStoreRuntime): Promise | boolean; + probe(runtime: IFluidDataStoreRuntime): Promise | boolean; /** * Provide the entry point object for this model. */ get(runtime: IFluidDataStoreRuntime): 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 { + /** + * Data store type identifier (must match registry entry). + */ + readonly type: string; + /** + * Ordered list of model descriptors (first used for creation; probed in order for existing). + */ + readonly modelDescriptors: readonly MultiFormatModelDescriptor[]; + /** + * Optional runtime class (defaults to `FluidDataStoreRuntime`). + */ + readonly runtimeClass?: typeof FluidDataStoreRuntime; + /** + * Optional policies to apply to the underlying data store runtime. + */ + readonly policies?: Partial; } /** @@ -47,24 +87,27 @@ export class MultiFormatDataStoreFactory implements IFluidDataStoreFactory { private readonly runtimeClass: typeof FluidDataStoreRuntime; private readonly policies?: Partial; - public constructor( - type: string, - descriptors: readonly MultiFormatModelDescriptor[], - sharedObjects?: readonly IChannelFactory[], - runtimeClass: typeof FluidDataStoreRuntime = FluidDataStoreRuntime, - policies?: Partial, - ) { + public constructor(props: MultiFormatDataStoreFactoryProps) { + const { type, modelDescriptors, runtimeClass, policies } = props; if (type === "") { throw new Error("type must be a non-empty string"); } - if (descriptors.length === 0) { + if (modelDescriptors.length === 0) { throw new Error("At least one model descriptor must be supplied"); } this.type = type; - this.descriptors = descriptors; - this.runtimeClass = runtimeClass; + this.descriptors = modelDescriptors; + this.runtimeClass = runtimeClass ?? FluidDataStoreRuntime; this.policies = policies; - this.sharedObjectRegistry = new Map(sharedObjects?.map((ext) => [ext.type, ext])); + // 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 ?? []) { + if (!this.sharedObjectRegistry.has(so.type)) { + this.sharedObjectRegistry.set(so.type, so); + } + } + } } // Provider pattern convenience (mirrors other factories in the codebase) @@ -77,45 +120,52 @@ export class MultiFormatDataStoreFactory implements IFluidDataStoreFactory { 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(rt); + } + + if (existing) { + for (const d of this.descriptors) { + try { + 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(rt); + }; const runtime = new this.runtimeClass( context, this.sharedObjectRegistry, existing, - async (rt: IFluidDataStoreRuntime): Promise => { - // Select descriptor lazily when entry point requested. - if (selected === undefined) { - if (existing) { - for (const d of this.descriptors) { - try { - const match = await (d.probe?.(rt) ?? false); - if (match) { - selected = d; - break; - } - } catch { - // Swallow probe errors and continue trying other descriptors. - } - } - } - // Fallback / new data store path: use first descriptor - selected ??= this.descriptors[0]; - } - if (selected === undefined) { - throw new Error("No model descriptor selected"); - } - return selected.get(rt); - }, + provideEntryPoint, this.policies, ); // 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?.(runtime); + await first.create(runtime); } return runtime; From c05a14eddf96125f042bd4c2626b585d95433313 Mon Sep 17 00:00:00 2001 From: Mark Fields Date: Fri, 12 Sep 2025 20:16:15 +0000 Subject: [PATCH 3/6] Copilot: Update demo --- .../src/data-object-factories/index.ts | 1 + .../multiFormatDataStoreFactory.ts | 1 + packages/framework/aqueduct/src/demo.ts | 209 ++++++++++++++++++ 3 files changed, 211 insertions(+) create mode 100644 packages/framework/aqueduct/src/demo.ts diff --git a/packages/framework/aqueduct/src/data-object-factories/index.ts b/packages/framework/aqueduct/src/data-object-factories/index.ts index 82980362b0ca..ae5f2f8d4df4 100644 --- a/packages/framework/aqueduct/src/data-object-factories/index.ts +++ b/packages/framework/aqueduct/src/data-object-factories/index.ts @@ -14,3 +14,4 @@ export { MigrationDataObjectFactory, type MigrationDataObjectFactoryProps, } from "./migrationDataObjectFactory.js"; +export { MultiFormatDataStoreFactory } from "./multiFormatDataStoreFactory.js"; diff --git a/packages/framework/aqueduct/src/data-object-factories/multiFormatDataStoreFactory.ts b/packages/framework/aqueduct/src/data-object-factories/multiFormatDataStoreFactory.ts index 9ed66e813ddf..741f81654d5f 100644 --- a/packages/framework/aqueduct/src/data-object-factories/multiFormatDataStoreFactory.ts +++ b/packages/framework/aqueduct/src/data-object-factories/multiFormatDataStoreFactory.ts @@ -130,6 +130,7 @@ export class MultiFormatDataStoreFactory implements IFluidDataStoreFactory { 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; 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); +} From 406fdc99875579275065e0325361975403823087 Mon Sep 17 00:00:00 2001 From: Mark Fields Date: Wed, 17 Sep 2025 03:40:35 +0000 Subject: [PATCH 4/6] (optional) update diceRoller to be a more symmetrical example to pattern off of --- .../src/container/diceRoller/diceRoller.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) 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; } } From 525e9817e09271f710d580dfdf57846d89a93415 Mon Sep 17 00:00:00 2001 From: Mark Fields Date: Wed, 17 Sep 2025 06:59:03 +0000 Subject: [PATCH 5/6] Some improvements, and starting to think about ergonomics --- .../src/data-object-factories/index.ts | 6 +- .../multiFormatDataStoreFactory.ts | 43 ++++++---- .../aqueduct/src/data-objects/dataObject.ts | 24 +++++- .../aqueduct/src/test/aqueduct.spec.ts | 78 +++++++++++++++++++ 4 files changed, 134 insertions(+), 17 deletions(-) diff --git a/packages/framework/aqueduct/src/data-object-factories/index.ts b/packages/framework/aqueduct/src/data-object-factories/index.ts index ae5f2f8d4df4..e18d657d4908 100644 --- a/packages/framework/aqueduct/src/data-object-factories/index.ts +++ b/packages/framework/aqueduct/src/data-object-factories/index.ts @@ -14,4 +14,8 @@ export { MigrationDataObjectFactory, type MigrationDataObjectFactoryProps, } from "./migrationDataObjectFactory.js"; -export { MultiFormatDataStoreFactory } from "./multiFormatDataStoreFactory.js"; +export { + MultiFormatDataStoreFactory, + type MultiFormatDataStoreFactoryProps, + type MultiFormatModelDescriptor, +} from "./multiFormatDataStoreFactory.js"; diff --git a/packages/framework/aqueduct/src/data-object-factories/multiFormatDataStoreFactory.ts b/packages/framework/aqueduct/src/data-object-factories/multiFormatDataStoreFactory.ts index 741f81654d5f..9cd7fcba1169 100644 --- a/packages/framework/aqueduct/src/data-object-factories/multiFormatDataStoreFactory.ts +++ b/packages/framework/aqueduct/src/data-object-factories/multiFormatDataStoreFactory.ts @@ -1,3 +1,4 @@ +/* eslint-disable import/no-internal-modules -- //* TEMP */ /*! * Copyright (c) Microsoft Corporation and contributors. All rights reserved. * Licensed under the MIT License. @@ -17,6 +18,11 @@ import type { IFluidDataStoreChannel, } from "@fluidframework/runtime-definitions/internal"; +import type { PureDataObject } from "../data-objects/pureDataObject.js"; +import type { DataObjectTypes } from "../data-objects/types.js"; + +import type { DataObjectFactoryProps } from "./pureDataObjectFactory.js"; + /** * Descriptor that supplies (or can create) a model format within a multi-format data store. * @@ -28,7 +34,7 @@ export interface MultiFormatModelDescriptor | void; + create(runtime: IFluidDataStoreRuntime): 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. */ @@ -51,25 +57,31 @@ export interface MultiFormatModelDescriptor + extends Omit, "ctor" | "sharedObjects"> { /** * Ordered list of model descriptors (first used for creation; probed in order for existing). */ readonly modelDescriptors: readonly MultiFormatModelDescriptor[]; - /** - * Optional runtime class (defaults to `FluidDataStoreRuntime`). - */ - readonly runtimeClass?: typeof FluidDataStoreRuntime; - /** - * Optional policies to apply to the underlying data store runtime. - */ - readonly policies?: Partial; } +//* WIP +// function pureDataObjectToModelDescriptor< +// TObj extends PureDataObject & TEntryPoint, //* Default Generic type param ok? +// TEntryPoint extends FluidObject = FluidObject, //* Needed? This would be the union / universal type I think +// >( +// ctor: new (runtime: IFluidDataStoreRuntime) => TObj, +// ): MultiFormatModelDescriptor { +// return { +// create: async (runtime) => { +// const dataObject = new ctor(runtime); +// await dataObject.finishInitialization(false /* existing */); +// }, +// probe: async (runtime) => {}, +// get: async (runtime) => {}, +// }; +// } + /** * A minimal multi-format data store factory. * @@ -103,6 +115,7 @@ export class MultiFormatDataStoreFactory implements IFluidDataStoreFactory { 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); } @@ -156,7 +169,7 @@ export class MultiFormatDataStoreFactory implements IFluidDataStoreFactory { this.sharedObjectRegistry, existing, provideEntryPoint, - this.policies, + this.policies, //* TODO: How do we union these? ); // For a new data store, initialize using the first descriptor before returning the runtime. diff --git a/packages/framework/aqueduct/src/data-objects/dataObject.ts b/packages/framework/aqueduct/src/data-objects/dataObject.ts index 63b9f2a2ba80..de32cdd2dbe4 100644 --- a/packages/framework/aqueduct/src/data-objects/dataObject.ts +++ b/packages/framework/aqueduct/src/data-objects/dataObject.ts @@ -9,8 +9,13 @@ import { SharedDirectory, } from "@fluidframework/map/internal"; +// import type { MultiFormatModelDescriptor } from "../data-object-factories/index.js"; + import { PureDataObject } from "./pureDataObject.js"; -import type { DataObjectTypes } from "./types.js"; +import type { + DataObjectTypes, + //* IDataObjectProps +} from "./types.js"; /** * ID of the root ISharedDirectory. Every DataObject contains this ISharedDirectory and adds further DDSes underneath it. @@ -18,6 +23,22 @@ import type { DataObjectTypes } from "./types.js"; */ export const dataObjectRootDirectoryId = "root"; +//* WIP +// export function DataObjectModelDescriptor< +// TObj extends DataObject, +// I extends DataObjectTypes = DataObjectTypes, +// >(ctor: new (props: IDataObjectProps) => TObj): MultiFormatModelDescriptor { +// return { +// create(runtime) { +// const obj = new ctor({ +// runtime, +// context: runtime.objectsRoutingContext, +// existing: false, +// }); +// }, +// }; +// } + /** * DataObject is a base data store that is primed with a root directory. It * ensures that it is created and ready before you can access it. @@ -52,6 +73,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/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", () => {}); From 3510af64bac3047e2b14f98e332e7ef08bc1b9eb Mon Sep 17 00:00:00 2001 From: Mark Fields Date: Thu, 18 Sep 2025 17:12:29 +0000 Subject: [PATCH 6/6] Clean up --- .../src/data-object-factories/index.ts | 1 + .../multiFormatDataStoreFactory.ts | 58 +++++++++-------- .../pureDataObjectModelDescriptor.ts | 64 +++++++++++++++++++ .../aqueduct/src/data-objects/dataObject.ts | 23 +------ 4 files changed, 98 insertions(+), 48 deletions(-) create mode 100644 packages/framework/aqueduct/src/data-object-factories/pureDataObjectModelDescriptor.ts diff --git a/packages/framework/aqueduct/src/data-object-factories/index.ts b/packages/framework/aqueduct/src/data-object-factories/index.ts index e18d657d4908..61969e446102 100644 --- a/packages/framework/aqueduct/src/data-object-factories/index.ts +++ b/packages/framework/aqueduct/src/data-object-factories/index.ts @@ -19,3 +19,4 @@ export { 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 index 9cd7fcba1169..d2d45daf8eb9 100644 --- a/packages/framework/aqueduct/src/data-object-factories/multiFormatDataStoreFactory.ts +++ b/packages/framework/aqueduct/src/data-object-factories/multiFormatDataStoreFactory.ts @@ -18,11 +18,14 @@ import type { IFluidDataStoreChannel, } from "@fluidframework/runtime-definitions/internal"; -import type { PureDataObject } from "../data-objects/pureDataObject.js"; -import type { DataObjectTypes } from "../data-objects/types.js"; +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. * @@ -30,11 +33,14 @@ import type { DataObjectFactoryProps } from "./pureDataObjectFactory.js"; * - `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 { +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(runtime: IFluidDataStoreRuntime): Promise; //* Would be nice to be able to have it synchronous? + 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. */ @@ -42,7 +48,7 @@ export interface MultiFormatModelDescriptor | TEntryPoint; + 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. @@ -65,23 +71,6 @@ export interface MultiFormatDataStoreFactoryProps( -// ctor: new (runtime: IFluidDataStoreRuntime) => TObj, -// ): MultiFormatModelDescriptor { -// return { -// create: async (runtime) => { -// const dataObject = new ctor(runtime); -// await dataObject.finishInitialization(false /* existing */); -// }, -// probe: async (runtime) => {}, -// get: async (runtime) => {}, -// }; -// } - /** * A minimal multi-format data store factory. * @@ -98,9 +87,10 @@ export class MultiFormatDataStoreFactory implements IFluidDataStoreFactory { 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 } = props; + const { type, modelDescriptors, runtimeClass, policies, optionalProviders } = props; if (type === "") { throw new Error("type must be a non-empty string"); } @@ -111,6 +101,7 @@ export class MultiFormatDataStoreFactory implements IFluidDataStoreFactory { 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) { @@ -137,7 +128,12 @@ export class MultiFormatDataStoreFactory implements IFluidDataStoreFactory { // Select descriptor lazily when entry point requested. if (selected !== undefined) { // Already selected for this runtime; return its entry point immediately. - return selected.get(rt); + return selected.get({ + context, + runtime: rt, + providers: this.optionalProviders ?? {}, + initProps: {}, //* TODO: Plumb this through + }); } if (existing) { @@ -161,7 +157,12 @@ export class MultiFormatDataStoreFactory implements IFluidDataStoreFactory { 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(rt); + return selected.get({ + context, + runtime: rt, + providers: this.optionalProviders ?? {}, + initProps: {}, //* TODO: Plumb this through + }); }; const runtime = new this.runtimeClass( @@ -179,7 +180,12 @@ export class MultiFormatDataStoreFactory implements IFluidDataStoreFactory { if (first === undefined) { throw new Error("Invariant: descriptors array unexpectedly empty"); } - await first.create(runtime); + 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 de32cdd2dbe4..831003fa4671 100644 --- a/packages/framework/aqueduct/src/data-objects/dataObject.ts +++ b/packages/framework/aqueduct/src/data-objects/dataObject.ts @@ -9,13 +9,8 @@ import { SharedDirectory, } from "@fluidframework/map/internal"; -// import type { MultiFormatModelDescriptor } from "../data-object-factories/index.js"; - import { PureDataObject } from "./pureDataObject.js"; -import type { - DataObjectTypes, - //* IDataObjectProps -} from "./types.js"; +import type { DataObjectTypes } from "./types.js"; /** * ID of the root ISharedDirectory. Every DataObject contains this ISharedDirectory and adds further DDSes underneath it. @@ -23,22 +18,6 @@ import type { */ export const dataObjectRootDirectoryId = "root"; -//* WIP -// export function DataObjectModelDescriptor< -// TObj extends DataObject, -// I extends DataObjectTypes = DataObjectTypes, -// >(ctor: new (props: IDataObjectProps) => TObj): MultiFormatModelDescriptor { -// return { -// create(runtime) { -// const obj = new ctor({ -// runtime, -// context: runtime.objectsRoutingContext, -// existing: false, -// }); -// }, -// }; -// } - /** * DataObject is a base data store that is primed with a root directory. It * ensures that it is created and ready before you can access it.