diff --git a/examples/utils/migration-tools/README.md b/examples/utils/migration-tools/README.md index cb1d78e5331f..0f38228afd22 100644 --- a/examples/utils/migration-tools/README.md +++ b/examples/utils/migration-tools/README.md @@ -2,60 +2,87 @@ This package contains tools for migrating data from one version to another, used by Fluid examples. They are not currently intended for use in production scenarios. -Use of the migration tools imposes several requirements on the container code and application, detailed here. +## `IMigrator` -## Implementing `IMigratableModel` +Migration is performed by using the `IMigrator`. The `IMigrator` can be inspected to discover the state of migration (`migrationState`), provides events to listen to as the state transitions, and has the method to kick off a migration (`proposeVersion()`). -Your data model must implement `IMigratableModel` to be migrated using the migration tools. +```ts +if (migrator.migrationState === "collaborating") { + migrator.proposeVersion("2.0"); +} +``` -This includes: -1. A `version` string to identify the model version. -1. Methods to export and import data, and to detect if the model supports a given data format: - 1. `importData: (initialData: ImportType) => Promise` - 1. `exportData: () => Promise` - 1. `supportsDataFormat: (initialData: unknown) => initialData is ImportType` -1. A `dispose` method to clean up the container - most likely calling `IContainer.dispose`. +To ensure no data is lost when moving between containers, you should stop making edits after the `"stopping"` event is raised. After this point, it's no longer guaranteed that changes will be included in the migration. -## Implementing the composite runtime pattern +```ts +migrator.events.on("stopping", () => { + // ...disable input in your UI +}); +``` -See documentation for the composite runtime pattern [here](./src/compositeRuntime/README.md). +Once the `"migrated"` event is raised, you can inspect the `migrationResult` property to find the result of the migration. If the container author used the `makeSeparateContainerMigrationCallback()` helper, this will contain the container ID of the new, migrated container. -The migration tools expect to find an `IMigratableModel` by accessing and calling a `getModel()` function provided on the `entryPoint`. They also expect to find an `IMigrationTool` by accessing a `migrationTool` member of the `entryPoint`. These requirements are most easily satisfied by using the composite runtime pattern. +```ts +migrator.events.on("migrated", () => { + console.log(`The new container ID is: ${migrator.migrationResult}`); +}); +``` + +## Requirements for use + +Accessing and using the `IMigrator` imposes several requirements on the container code and application, detailed in the following sections. + +### Implementing the composite runtime pattern as the container code author + +See documentation for the composite runtime pattern [here](./src/compositeRuntime/README.md). -`getModel()` is a function that takes an `IContainer` to aid in producing the `IMigratableModel`. This is because the contract of `IMigratableModel` likely requires functionality from `IContainer` (especially `IContainer.dispose()`). +The migrator is provided via the composite runtime pattern using the provided `makeMigratorEntryPointPiece()`. When using this tool, +the host will be able to access the `IMigrator` by calling `getMigrator()` on the container entryPoint (`container.getEntryPoint()`). -### Defining the entry point piece +#### Defining an example model entry point piece ```ts const rootDatastoreAlias = "my-root-datastore"; export const getModelEntryPointPiece: IEntryPointPiece = { - name: "getModel", + name: "model", // This is the name that the host will find the root datastore under in the entrypoint registryEntries: [MyRootDatastoreFactory.registryEntry], onCreate: async (runtime: IContainerRuntime): Promise => { const rootDatastore = await runtime.createDataStore(MyRootDatastoreFactory.type); await rootDatastore.trySetAlias(rootDatastoreAlias); }, onLoad: async (runtime: IContainerRuntime): Promise => {}, - createPiece: async (runtime: IContainerRuntime): Promise<(container: IContainer) => Promise> => { + createPiece: async (runtime: IContainerRuntime): Promise => { const entryPointHandle = await containerRuntime.getAliasedDataStoreEntryPoint(rootDatastoreAlias); if (entryPointHandle === undefined) { throw new Error(`Default dataStore [${rootDatastoreAlias}] must exist`); } - // Entry points are typed as FluidObject and must be cast. Here we know it's a MyRootDatastore since - // we created it just above. Type validation can be added here if desired. - const rootDatastore = entryPointHandle.get() as Promise; - // MigratableAppModel (defined by the container code author) must implement IMigratableModel. - // Note that we're returning a function of type (container: IContainer) => Promise, - // where the FluidObject is expected to be an IMigratableModel. - return async (container: IContainer) => new MigratableAppModel(rootDatastore, container); + return entryPointHandle.get(); }, }; ``` +#### Using `makeMigratorEntryPointPiece()` + ```ts +// Entry points are typed as FluidObject and must be cast. Here we know it's a MyRootDatastore since +// we (the container code author) created it just above. Type validation can be added here if desired. +const getModelFromContainer = async (container: IContainer): Promise => { + const entryPoint = (await container.getEntryPoint()) as { + model: ModelType; // Note "model" matches up with the name we defined on the entry point piece above. + }; + + return entryPoint.model; +}; + +const exportDataCallback = async (container: IContainer): Promise => { + const rootDatastore = await getModelFromContainer(container); + // This assumes that we've implemented an exportData() method on MyRootDatastore. + return rootDatastore.exportData(); +}; + // In the IRuntimeFactory public async instantiateRuntime( context: IContainerContext, @@ -63,33 +90,55 @@ public async instantiateRuntime( ): Promise { const compositeEntryPoint = new CompositeEntryPoint(); compositeEntryPoint.addEntryPointPiece(getModelEntryPointPiece); - // migrationToolEntryPointPiece is provided by the migration-tools package - compositeEntryPoint.addEntryPointPiece(migrationToolEntryPointPiece); + // makeMigratorEntryPointPiece is provided by the migration-tools package + const migratorEntryPointPiece = makeMigratorEntryPointPiece(exportDataCallback); + compositeEntryPoint.addEntryPointPiece(migratorEntryPointPiece); return loadCompositeRuntime(context, existing, compositeEntryPoint, this.runtimeOptions); } ``` -### `migrationToolEntryPointPiece` - -This package additionally provides a `migrationToolEntryPointPiece` which is an off-the-shelf implementation of the piece to provide the `IMigrationTool`. With these provided pieces, you're only responsible for implementing the `IMigratableModel` piece with your data model. +### Calling `getMigrator()` as the host -## `Migrator` +The host must provide certain functionality to the migrator (as callback functions) that the container code author doesn't have access to or knowledge +about. In particular, these portions require access to the loader layer, and also knowledge about the future container code +that is being migrated to. -Finally, to actually execute the migration we provide the `Migrator` class. This takes a `SimpleLoader` (see below), the initially loaded model, migration tool, and container ID (TODO: can we simplify this handoff), as well as an optional `DataTransformationCallback` (see below). The migrator provides a collection of APIs to observe the state of the migration, as well as to acquire the new container after migration completes. (TODO: should the migrate() API also live here?) +The migration-tools package makes helper functions available to simplify creation of these callback functions in basic scenarios. Calling `getMigrator()` then returns an `IMigrator`. -TODO: Detail usage of the Migrator - -### `SimpleLoader` - -See documentation for `SimpleLoader` [here](./src/simpleLoader/README.md). `SimpleLoader` is used in place of a `Loader` and is used by the `Migrator`. - -### Code loader +```ts +// makeCreateDetachedContainerCallback is provided by the migration-tools package +const createDetachedCallback = makeCreateDetachedContainerCallback( + loader, + createTinyliciousCreateNewRequest, +); + +const importDataCallback: ImportDataCallback = async ( + destinationContainer: IContainer, + exportedData: unknown, +) => { + const destinationModel = await getModelFromContainer(destinationContainer); + // Note that if the data needs to be transformed from the old export format to some new import format, + // this is where it could be done. + // This assumes that we've implemented an importData() method on MyRootDatastore2. + await destinationModel.importData(exportedData); +}; -To migrate between two different code versions, you must also provide a code loader to the `SimpleLoader` that is capable of loading those two respective code versions. This uses the usual `ICodeDetailsLoader` interface. +// makeSeparateContainerMigrationCallback is provided by the migration-tools package +const migrationCallback = makeSeparateContainerMigrationCallback( + createDetachedCallback, + importDataCallback, +); + +const { getMigrator } = (await container.getEntryPoint()) as IMigratorEntryPoint; +const migrator: IMigrator = await getMigrator( + async () => loader.resolve({ url: id }), + migrationCallback, +); +``` -### `DataTransformationCallback` +### Providing a code loader as the host -If your old and new code share an import/export format, you don't need a `DataTransformationCallback`. But if the import/export format has changed between versions, you can provide this callback to the `Migrator` and it will be called with the old exported data. This callback is responsible for transforming the data to the new format and returning the transformed data. +To migrate between two different code versions, the host must also provide a code loader that is capable of loading those two respective code versions. There is nothing new here, but if you've been statically loading your code (i.e. via `StaticCodeLoader`) you'll need to start performing real code loading. diff --git a/examples/utils/migration-tools/api-report/migration-tools.alpha.api.md b/examples/utils/migration-tools/api-report/migration-tools.alpha.api.md index 955be232e4b6..9bb1c014b3b3 100644 --- a/examples/utils/migration-tools/api-report/migration-tools.alpha.api.md +++ b/examples/utils/migration-tools/api-report/migration-tools.alpha.api.md @@ -4,22 +4,23 @@ ```ts -// @alpha (undocumented) +// @alpha export class CompositeEntryPoint { - // (undocumented) readonly addEntryPointPiece: (entryPointPiece: IEntryPointPiece) => void; - // (undocumented) readonly onCreate: (runtime: IContainerRuntime) => Promise; - // (undocumented) readonly onLoad: (runtime: IContainerRuntime) => Promise; - // (undocumented) readonly provideEntryPoint: (runtime: IContainerRuntime) => Promise>; - // (undocumented) get registryEntries(): NamedFluidDataStoreRegistryEntries; } // @alpha -export type DataTransformationCallback = (exportedData: unknown, modelVersion: string) => Promise; +export type CreateDetachedContainerCallback = (version: string) => Promise<{ + container: IContainer; + attach: () => Promise; +}>; + +// @alpha +export type ExportDataCallback = (sourceContainer: IContainer) => Promise; // @alpha export interface IAcceptedMigrationDetails { @@ -27,155 +28,60 @@ export interface IAcceptedMigrationDetails { newVersion: string; } -// @alpha (undocumented) +// @alpha export interface IEntryPointPiece { - // (undocumented) readonly createPiece: (runtime: IContainerRuntime) => Promise; - // (undocumented) readonly name: string; - // (undocumented) readonly onCreate: (runtime: IContainerRuntime) => Promise; - // (undocumented) readonly onLoad: (runtime: IContainerRuntime) => Promise; - // (undocumented) readonly registryEntries: NamedFluidDataStoreRegistryEntries; } // @alpha -export interface IImportExportModel { - exportData: () => Promise; - importData: (initialData: ImportType) => Promise; - supportsDataFormat: (initialData: unknown) => initialData is ImportType; -} - -// @alpha -export interface IMigratableModel extends IVersionedModel, IImportExportModel { - dispose(): void; -} - -// @alpha (undocumented) -export interface IMigrationTool { +export interface IMigrator { readonly acceptedMigration: IAcceptedMigrationDetails | undefined; - completeMigrationTask(): void; - // (undocumented) - readonly connected: boolean; - // (undocumented) - readonly events: IEventProvider; - finalizeMigration(id: string): Promise; - haveMigrationTask(): boolean; + readonly events: IEventProvider; + readonly migrationResult: unknown | undefined; readonly migrationState: MigrationState; - readonly newContainerId: string | undefined; readonly proposedVersion: string | undefined; proposeVersion: (newVersion: string) => void; - volunteerForMigration(): Promise; -} - -// @alpha (undocumented) -export interface IMigrationToolEvents extends IEvent { - // (undocumented) - (event: "stopping" | "migrating" | "migrated", listener: () => void): any; - // (undocumented) - (event: "connected" | "disconnected", listener: () => void): any; - // (undocumented) - (event: "disposed", listener: () => void): any; } -// @alpha (undocumented) -export interface IMigrator { - readonly currentModel: IMigratableModel; - readonly currentModelId: string; - // (undocumented) - readonly events: IEventProvider; - readonly migrationState: MigrationState; +// @alpha +export interface IMigratorEntryPoint { + getMigrator: (loadSourceContainerCallback: LoadSourceContainerCallback, migrationCallback: MigrationCallback) => Promise; } -// @alpha (undocumented) +// @alpha export interface IMigratorEvents extends IEvent { - // (undocumented) - (event: "migrated" | "migrating", listener: () => void): any; - // (undocumented) - (event: "migrationNotSupported", listener: (version: string) => void): any; -} - -// @alpha (undocumented) -export interface ISimpleLoader { - createDetached(version: string): Promise<{ - container: IContainer; - attach: () => Promise; - }>; - loadExisting(id: string): Promise; - supportsVersion(version: string): Promise; + (event: "stopping" | "migrating" | "migrated", listener: () => void): void; } // @alpha -export interface IVersionedModel { - readonly version: string; -} +export type ImportDataCallback = (destinationContainer: IContainer, exportedData: unknown) => Promise; // @alpha export const loadCompositeRuntime: (context: IContainerContext, existing: boolean, compositeEntryPoint: CompositeEntryPoint, runtimeOptions?: IContainerRuntimeOptions) => Promise; // @alpha -export type MigrationState = "collaborating" | "stopping" | "migrating" | "migrated"; +export type LoadSourceContainerCallback = () => Promise; -// @alpha (undocumented) -export const migrationToolEntryPointPiece: IEntryPointPiece; - -// @alpha (undocumented) -export class MigrationToolFactory implements IFluidDataStoreFactory { - // (undocumented) - get IFluidDataStoreFactory(): IFluidDataStoreFactory; - // (undocumented) - instantiateDataStore(context: IFluidDataStoreContext, existing: boolean): Promise; - // (undocumented) - get type(): string; -} +// @alpha +export const makeCreateDetachedContainerCallback: (loaderProps: ILoaderProps, generateCreateNewRequest: () => IRequest) => CreateDetachedContainerCallback; // @alpha -export class Migrator implements IMigrator { - constructor(simpleLoader: ISimpleLoader, initialMigratable: IMigratableModel, initialMigrationTool: IMigrationTool, initialId: string, dataTransformationCallback?: DataTransformationCallback | undefined); - // (undocumented) - get connected(): boolean; - // (undocumented) - get currentMigrationTool(): IMigrationTool; - // (undocumented) - get currentModel(): IMigratableModel; - // (undocumented) - get currentModelId(): string; - // (undocumented) - get events(): IEventProvider; - // (undocumented) - get migrationState(): MigrationState; -} +export const makeMigratorEntryPointPiece: (exportDataCallback: ExportDataCallback) => IEntryPointPiece; -// @alpha (undocumented) -export class SessionStorageSimpleLoader implements ISimpleLoader { - constructor(codeLoader: ICodeDetailsLoader, logger?: ITelemetryBaseLogger | undefined); - // (undocumented) - createDetached(version: string): Promise<{ - container: IContainer; - attach: () => Promise; - }>; - // (undocumented) - loadExisting(id: string): Promise; - // (undocumented) - supportsVersion(version: string): Promise; -} +// @alpha +export const makeSeparateContainerMigrationCallback: (createDetachedContainerCallback: CreateDetachedContainerCallback, importDataCallback: ImportDataCallback) => MigrationCallback; -// @alpha (undocumented) -export class SimpleLoader implements ISimpleLoader { - constructor(props: Pick & { - generateCreateNewRequest: () => IRequest; - }); - // (undocumented) - createDetached(version: string): Promise<{ - container: IContainer; - attach: () => Promise; - }>; - // (undocumented) - loadExisting(id: string): Promise; - // (undocumented) - supportsVersion(version: string): Promise; -} +// @alpha +export type MigrationCallback = (version: string, exportedData: unknown) => Promise; + +// @alpha +export type MigrationState = "collaborating" | "stopping" | "migrating" | "migrated"; + +// @alpha +export type SeparateContainerMigrationResult = string; ``` diff --git a/examples/utils/migration-tools/package.json b/examples/utils/migration-tools/package.json index 6d3f98b96f86..7fa6d1c84526 100644 --- a/examples/utils/migration-tools/package.json +++ b/examples/utils/migration-tools/package.json @@ -78,7 +78,6 @@ "@fluidframework/routerlicious-driver": "workspace:~", "@fluidframework/runtime-definitions": "workspace:~", "@fluidframework/server-local-server": "^5.0.0", - "@fluidframework/task-manager": "workspace:~", "@fluidframework/tinylicious-driver": "workspace:~", "uuid": "^9.0.0" }, diff --git a/examples/utils/migration-tools/src/compositeRuntime/README.md b/examples/utils/migration-tools/src/compositeRuntime/README.md index 1fdfdd502d0b..22bcbe081588 100644 --- a/examples/utils/migration-tools/src/compositeRuntime/README.md +++ b/examples/utils/migration-tools/src/compositeRuntime/README.md @@ -51,8 +51,9 @@ public async instantiateRuntime( ): Promise { const compositeEntryPoint = new CompositeEntryPoint(); compositeEntryPoint.addEntryPointPiece(rootDatastoreEntryPointPiece); - // migrationToolEntryPointPiece is provided by the migration-tools package - compositeEntryPoint.addEntryPointPiece(migrationToolEntryPointPiece); + // makeMigratorEntryPointPiece is provided by the migration-tools package + const migratorEntryPointPiece = makeMigratorEntryPointPiece(exportDataCallback); + compositeEntryPoint.addEntryPointPiece(migratorEntryPointPiece); return loadCompositeRuntime(context, existing, compositeEntryPoint, this.runtimeOptions); } ``` @@ -61,8 +62,11 @@ public async instantiateRuntime( ```ts // Entry points are typed as FluidObject and must be cast. Type validation can be added here if desired. -const { rootDatastore, migrationTool } = (await container.getEntryPoint()) as { +const { rootDatastore, getMigrator } = (await container.getEntryPoint()) as { rootDatastore: MyRootDatastore; - migrationTool: IMigrationTool; + getMigrator: ( + loadSourceContainerCallback: LoadSourceContainerCallback, + migrationCallback: MigrationCallback, + ) => Promise; }; ``` diff --git a/examples/utils/migration-tools/src/compositeRuntime/interfaces.ts b/examples/utils/migration-tools/src/compositeRuntime/interfaces.ts index c3b24d0137d4..ca286be03e51 100644 --- a/examples/utils/migration-tools/src/compositeRuntime/interfaces.ts +++ b/examples/utils/migration-tools/src/compositeRuntime/interfaces.ts @@ -8,12 +8,29 @@ import type { FluidObject } from "@fluidframework/core-interfaces"; import type { NamedFluidDataStoreRegistryEntries } from "@fluidframework/runtime-definitions/internal"; /** + * The IEntryPointPiece provides the functionality backing a portion of the overall composite entry point. * @alpha */ export interface IEntryPointPiece { + /** + * The name of the piece, which corresponds to the eventual name of the property on the entryPoint + * where it will be made available. + */ readonly name: string; + /** + * The registry entries that should be added to the container runtime to support this entryPoint piece. + */ readonly registryEntries: NamedFluidDataStoreRegistryEntries; + /** + * Actions to take on the initial creation of the container. + */ readonly onCreate: (runtime: IContainerRuntime) => Promise; + /** + * Actions to take on every load of the container. + */ readonly onLoad: (runtime: IContainerRuntime) => Promise; + /** + * A function which produces the object to be made available on the entry point. + */ readonly createPiece: (runtime: IContainerRuntime) => Promise; } diff --git a/examples/utils/migration-tools/src/compositeRuntime/loadCompositeRuntime.ts b/examples/utils/migration-tools/src/compositeRuntime/loadCompositeRuntime.ts index 922ebf252ba2..e80796fc8c37 100644 --- a/examples/utils/migration-tools/src/compositeRuntime/loadCompositeRuntime.ts +++ b/examples/utils/migration-tools/src/compositeRuntime/loadCompositeRuntime.ts @@ -17,20 +17,27 @@ import type { import type { IEntryPointPiece } from "./interfaces.js"; -// TODO: CompositeEntryPoint isn't really the right name - this is more like CompositeContainerContents -// or CompositeContainerCode? +// TODO: CompositeEntryPoint isn't really the right name - this is more like CompositeContainerCode? /** + * CompositeEntryPoint is a class that allows building up a container's contents as multiple distinct + * pieces. These pieces are then made available on the container's entryPoint (container.getEntryPoint()). * @alpha */ export class CompositeEntryPoint { private readonly _entryPointPieces: IEntryPointPiece[] = []; // TODO: Consider taking a "name" argument here, and don't include "name" on the IEntryPointPiece // Or maybe allow a default name from the piece but allow override here? + /** + * Add a piece that will appear on the entry point. + */ public readonly addEntryPointPiece = (entryPointPiece: IEntryPointPiece): void => { // TODO: Consider validating no conflicts (e.g. name already exists, registry entry collision) this._entryPointPieces.push(entryPointPiece); }; + /** + * Get the combined registry entries from all pieces. + */ public get registryEntries(): NamedFluidDataStoreRegistryEntries { const registryEntries: NamedFluidDataStoreRegistryEntry2[] = []; for (const entryPointPiece of this._entryPointPieces) { @@ -39,18 +46,27 @@ export class CompositeEntryPoint { return registryEntries; } + /** + * Run all of the onCreate scripts of all pieces. + */ public readonly onCreate = async (runtime: IContainerRuntime): Promise => { for (const entryPointPiece of this._entryPointPieces) { await entryPointPiece.onCreate(runtime); } }; + /** + * Run all of the onLoad scripts of all pieces. + */ public readonly onLoad = async (runtime: IContainerRuntime): Promise => { for (const entryPointPiece of this._entryPointPieces) { await entryPointPiece.onLoad(runtime); } }; + /** + * Assemble and provide the entry point. To be passed to the ContainerRuntime. + */ public readonly provideEntryPoint = async ( runtime: IContainerRuntime, ): Promise> => { @@ -63,7 +79,8 @@ export class CompositeEntryPoint { } /** - * TODO: Make lint happy + * loadCompositeRuntime should be used in place of ContainerRuntime.loadRuntime() in your container runtime + * factory to produce a runtime with the provided composite entryPoint. * @alpha */ export const loadCompositeRuntime = async ( diff --git a/examples/utils/migration-tools/src/index.ts b/examples/utils/migration-tools/src/index.ts index c8ff95a1e3d9..1f558446bc5e 100644 --- a/examples/utils/migration-tools/src/index.ts +++ b/examples/utils/migration-tools/src/index.ts @@ -16,24 +16,19 @@ export { } from "./compositeRuntime/index.js"; export { IAcceptedMigrationDetails, - IMigrationTool, - IMigrationToolEvents, MigrationState, - migrationToolEntryPointPiece, - MigrationToolFactory, } from "./migrationTool/index.js"; export { - DataTransformationCallback, - getModelAndMigrationToolFromContainer, - IImportExportModel, - IMigratableModel, + CreateDetachedContainerCallback, + ExportDataCallback, IMigrator, + IMigratorEntryPoint, IMigratorEvents, - IVersionedModel, - Migrator, + ImportDataCallback, + LoadSourceContainerCallback, + makeCreateDetachedContainerCallback, + makeSeparateContainerMigrationCallback, + makeMigratorEntryPointPiece, + MigrationCallback, + SeparateContainerMigrationResult, } from "./migrator/index.js"; -export { - ISimpleLoader, - SessionStorageSimpleLoader, - SimpleLoader, -} from "./simpleLoader/index.js"; diff --git a/examples/utils/migration-tools/src/migrationTool/index.ts b/examples/utils/migration-tools/src/migrationTool/index.ts index 1e5ac40f786b..eab6edc56b97 100644 --- a/examples/utils/migration-tools/src/migrationTool/index.ts +++ b/examples/utils/migration-tools/src/migrationTool/index.ts @@ -10,4 +10,3 @@ export { MigrationState, } from "./interfaces.js"; export { MigrationToolFactory } from "./migrationTool.js"; -export { migrationToolEntryPointPiece } from "./migrationToolEntryPointPiece.js"; diff --git a/examples/utils/migration-tools/src/migrationTool/interfaces.ts b/examples/utils/migration-tools/src/migrationTool/interfaces.ts index 04240b788734..5044de2d4952 100644 --- a/examples/utils/migration-tools/src/migrationTool/interfaces.ts +++ b/examples/utils/migration-tools/src/migrationTool/interfaces.ts @@ -8,9 +8,10 @@ import type { IEvent, IEventProvider } from "@fluidframework/core-interfaces"; /** * The collaboration session may be in one of four states: * * collaborating - normal collaboration is ongoing. The client may send data. - * * stopping - a proposal to migrate has been made, but not accepted yet. The client must stop sending data. + * * stopping - a proposal to migrate has been made, but not accepted yet. The client should stop sending + * data, as it's no longer guaranteed to be included in the migration. * * migrating - a proposal to migrate has been accepted. The data is currently being migrated. - * * migrated - migration has completed and the new container is available. + * * migrated - migration has completed and the migration result is available. * @alpha */ export type MigrationState = "collaborating" | "stopping" | "migrating" | "migrated"; @@ -31,37 +32,22 @@ export interface IAcceptedMigrationDetails { migrationSequenceNumber: number; } -/** - * @alpha - */ export interface IMigrationToolEvents extends IEvent { (event: "stopping" | "migrating" | "migrated", listener: () => void); (event: "connected" | "disconnected", listener: () => void); (event: "disposed", listener: () => void); } -/** - * @alpha - */ export interface IMigrationTool { + /** + * Event emitter object. + */ readonly events: IEventProvider; - readonly connected: boolean; /** * The current state of migration. */ readonly migrationState: MigrationState; - - /** - * The container id where the migrated content can be found, if the migration has fully completed. - */ - readonly newContainerId: string | undefined; - /** - * Set the container id where the migrated content can be found, finalizing the migration. - * @param id - the container id - */ - finalizeMigration(id: string): Promise; - /** * The version string of the proposed new version to use, if one has been proposed. */ @@ -71,25 +57,23 @@ export interface IMigrationTool { */ readonly acceptedMigration: IAcceptedMigrationDetails | undefined; /** - * Propose a new version to use. - * @param newVersion - the version string + * The result of the migration (e.g. the new container ID), if the migration has fully completed. */ - proposeVersion: (newVersion: string) => void; + readonly migrationResult: unknown | undefined; /** - * Volunteer to perform the migration. - * @returns A promise which resolves true when the local client has been selected to perform the migration. - * resolves false if the migration was already completed by another client. + * Propose a new version to use. + * @param newVersion - the version string */ - volunteerForMigration(): Promise; + proposeVersion: (newVersion: string) => void; /** - * Whether the local client is selected to perform the migration. + * Whether the client is currently connected. */ - haveMigrationTask(): boolean; - + readonly connected: boolean; /** - * Completes the migration task to indicate to other clients the migration is complete. + * Set the result of the migration, finalizing the migration. + * @param migrationResult - the result of the migration, e.g. the new container id */ - completeMigrationTask(): void; + finalizeMigration(migrationResult: unknown): Promise; } diff --git a/examples/utils/migration-tools/src/migrationTool/migrationTool.ts b/examples/utils/migration-tools/src/migrationTool/migrationTool.ts index 888ba7a25894..0d9ecd9e2078 100644 --- a/examples/utils/migration-tools/src/migrationTool/migrationTool.ts +++ b/examples/utils/migration-tools/src/migrationTool/migrationTool.ts @@ -25,7 +25,6 @@ import type { IFluidDataStoreContext, IFluidDataStoreFactory, } from "@fluidframework/runtime-definitions/internal"; -import { ITaskManager, TaskManager } from "@fluidframework/task-manager/internal"; import type { IAcceptedMigrationDetails, @@ -36,11 +35,9 @@ import type { const consensusRegisterCollectionId = "consensus-register-collection"; const pactMapId = "pact-map"; -const taskManagerId = "task-manager"; const newVersionKey = "newVersion"; -const migrateTaskName = "migrate"; -const newContainerIdKey = "newContainerId"; +const migrationResultKey = "migrationResult"; class MigrationTool implements IMigrationTool { private _disposed = false; @@ -66,7 +63,7 @@ class MigrationTool implements IMigrationTool { } public get migrationState(): MigrationState { - if (this.newContainerId !== undefined) { + if (this.migrationResult !== undefined) { return "migrated"; } else if (this.acceptedMigration !== undefined) { return "migrating"; @@ -78,16 +75,15 @@ class MigrationTool implements IMigrationTool { } } - public get newContainerId(): string | undefined { - return this.consensusRegisterCollection.read(newContainerIdKey); + public get migrationResult(): unknown | undefined { + return this.consensusRegisterCollection.read(migrationResultKey); } public constructor( - // TODO: Does this need a full runtime? Can we instead specify exactly what the data object requires? + // TODO: Consider just specifying what the data object requires rather than taking a full runtime. private readonly runtime: IFluidDataStoreRuntime, - private readonly consensusRegisterCollection: IConsensusRegisterCollection, + private readonly consensusRegisterCollection: IConsensusRegisterCollection, private readonly pactMap: IPactMap, - private readonly taskManager: ITaskManager, ) { if (this.runtime.disposed) { this.dispose(); @@ -101,6 +97,10 @@ class MigrationTool implements IMigrationTool { }); this.pactMap.on("pending", (key: string) => { if (key === newVersionKey) { + // TODO: Consider doing something dramatic here to park the container. If the host gets the + // Migrator it's not really necessary (they have all the info they need to show readonly UI, + // stop sending changes, etc.) but this would add some extra protection in case some host isn't + // watching their Migrator. this._events.emit("stopping"); } }); @@ -112,23 +112,21 @@ class MigrationTool implements IMigrationTool { }); this.consensusRegisterCollection.on("atomicChanged", (key: string) => { - if (key === newContainerIdKey) { + if (key === migrationResultKey) { this._events.emit("migrated"); } }); } } - public async finalizeMigration(id: string): Promise { + public async finalizeMigration(migrationResult: unknown): Promise { // Only permit a single container to be set as a migration destination. - if (this.consensusRegisterCollection.read(newContainerIdKey) !== undefined) { - throw new Error("New container was already established"); - } + assert(this.migrationResult === undefined, "Migration was already finalized"); // Using a consensus data structure is important here, because other clients might race us to set the new // value. All clients must agree on the final value even in these race conditions so everyone ends up in the // same final container. - await this.consensusRegisterCollection.write(newContainerIdKey, id); + await this.consensusRegisterCollection.write(migrationResultKey, migrationResult); } public get proposedVersion(): string | undefined { @@ -140,11 +138,10 @@ class MigrationTool implements IMigrationTool { if (migrationDetails === undefined) { return undefined; } - if (migrationDetails.value === undefined) { - throw new Error( - "Expect migration version to be specified if migration has been accepted", - ); - } + assert( + migrationDetails.value !== undefined, + "Expect migration version to be specified if migration has been accepted", + ); return { newVersion: migrationDetails.value, migrationSequenceNumber: migrationDetails.acceptedSequenceNumber, @@ -152,30 +149,14 @@ class MigrationTool implements IMigrationTool { } public readonly proposeVersion = (newVersion: string): void => { - // Don't permit changes to the version after a new one has already been accepted. - // TODO: Consider whether we should throw on trying to set when a pending proposal exists -- currently - // the PactMap will silently drop these on the floor. - if (this.acceptedMigration !== undefined) { - throw new Error("New version was already accepted"); - } + // Don't permit changes to the version after a new one has already been proposed. + assert(this.proposedVersion === undefined, "A proposal was already made"); // Note that the accepted proposal could come from another client (e.g. two clients try to propose // simultaneously). this.pactMap.set(newVersionKey, newVersion); }; - public async volunteerForMigration(): Promise { - return this.taskManager.volunteerForTask(migrateTaskName); - } - - public haveMigrationTask(): boolean { - return this.taskManager.assigned(migrateTaskName); - } - - public completeMigrationTask(): void { - this.taskManager.complete(migrateTaskName); - } - /** * Called when the host container closes and disposes itself */ @@ -188,12 +169,10 @@ class MigrationTool implements IMigrationTool { const consensusRegisterCollectionFactory = ConsensusRegisterCollection.getFactory(); const pactMapFactory = PactMap.getFactory(); -const taskManagerFactory = TaskManager.getFactory(); const migrationToolSharedObjectRegistry = new Map([ [consensusRegisterCollectionFactory.type, consensusRegisterCollectionFactory], [pactMapFactory.type, pactMapFactory], - [taskManagerFactory.type, taskManagerFactory], ]); /** @@ -218,19 +197,16 @@ export class MigrationToolFactory implements IFluidDataStoreFactory { context, migrationToolSharedObjectRegistry, existing, - // We have to provide a callback here to get an entryPoint, otherwise we would just omit it if we could always get an entryPoint. async () => instance, ); let consensusRegisterCollection: IConsensusRegisterCollection; let pactMap: IPactMap; - let taskManager: ITaskManager; if (existing) { consensusRegisterCollection = (await runtime.getChannel( consensusRegisterCollectionId, )) as IConsensusRegisterCollection; pactMap = (await runtime.getChannel(pactMapId)) as IPactMap; - taskManager = (await runtime.getChannel(taskManagerId)) as ITaskManager; } else { consensusRegisterCollection = runtime.createChannel( consensusRegisterCollectionId, @@ -239,21 +215,11 @@ export class MigrationToolFactory implements IFluidDataStoreFactory { consensusRegisterCollection.bindToContext(); pactMap = runtime.createChannel(pactMapId, pactMapFactory.type) as IPactMap; pactMap.bindToContext(); - taskManager = runtime.createChannel( - taskManagerId, - taskManagerFactory.type, - ) as ITaskManager; - taskManager.bindToContext(); } // By this point, we've performed any async work required to get the dependencies of the MigrationTool, // so just a normal sync constructor will work fine (no followup async initialize()). - const instance = new MigrationTool( - runtime, - consensusRegisterCollection, - pactMap, - taskManager, - ); + const instance = new MigrationTool(runtime, consensusRegisterCollection, pactMap); return runtime; } diff --git a/examples/utils/migration-tools/src/migrationTool/migrationToolEntryPointPiece.ts b/examples/utils/migration-tools/src/migrationTool/migrationToolEntryPointPiece.ts deleted file mode 100644 index 7fdac2e3c430..000000000000 --- a/examples/utils/migration-tools/src/migrationTool/migrationToolEntryPointPiece.ts +++ /dev/null @@ -1,52 +0,0 @@ -/*! - * Copyright (c) Microsoft Corporation and contributors. All rights reserved. - * Licensed under the MIT License. - */ - -import type { IContainerRuntime } from "@fluidframework/container-runtime-definitions/internal"; -import type { FluidObject } from "@fluidframework/core-interfaces"; - -import type { IEntryPointPiece } from "../compositeRuntime/index.js"; - -import { MigrationToolFactory } from "./migrationTool.js"; - -const migrationToolEntryPointPieceName = "migrationTool"; - -const migrationToolRegistryKey = "migration-tool"; -const migrationToolFactory = new MigrationToolFactory(); - -const migrationToolId = "migration-tool"; - -async function getDataStoreEntryPoint( - containerRuntime: IContainerRuntime, - alias: string, -): Promise { - const entryPointHandle = await containerRuntime.getAliasedDataStoreEntryPoint(alias); - - if (entryPointHandle === undefined) { - throw new Error(`Default dataStore [${alias}] must exist`); - } - - return entryPointHandle.get(); -} - -/** - * @alpha - */ -export const migrationToolEntryPointPiece: IEntryPointPiece = { - name: migrationToolEntryPointPieceName, - registryEntries: [[migrationToolRegistryKey, Promise.resolve(migrationToolFactory)]], - onCreate: async (runtime: IContainerRuntime): Promise => { - const migrationTool = await runtime.createDataStore(migrationToolRegistryKey); - await migrationTool.trySetAlias(migrationToolId); - }, - onLoad: async (runtime: IContainerRuntime): Promise => { - // Force the MigrationTool to instantiate in all cases. The PactMap it uses must be loaded and running in - // order to respond with accept ops, and without this call the MigrationTool won't be instantiated on the - // summarizer client. - await getDataStoreEntryPoint(runtime, migrationToolId); - }, - createPiece: async (runtime: IContainerRuntime): Promise => { - return getDataStoreEntryPoint(runtime, migrationToolId); - }, -}; diff --git a/examples/utils/migration-tools/src/migrator/index.ts b/examples/utils/migration-tools/src/migrator/index.ts index 31ccaa798dde..95e695f25d8b 100644 --- a/examples/utils/migration-tools/src/migrator/index.ts +++ b/examples/utils/migration-tools/src/migrator/index.ts @@ -4,14 +4,19 @@ */ export { - DataTransformationCallback, - IImportExportModel, - IMigratableModel, + ExportDataCallback, IMigrator, + IMigratorEntryPoint, IMigratorEvents, - IVersionedModel, + LoadSourceContainerCallback, + MigrationCallback, } from "./interfaces.js"; +export { makeMigratorEntryPointPiece } from "./makeMigratorEntryPointPiece.js"; +export { Migrator } from "./migrator.js"; export { - getModelAndMigrationToolFromContainer, - Migrator, -} from "./migrator.js"; + CreateDetachedContainerCallback, + ImportDataCallback, + makeCreateDetachedContainerCallback, + makeSeparateContainerMigrationCallback, + SeparateContainerMigrationResult, +} from "./separateContainerCallbackHelpers.js"; diff --git a/examples/utils/migration-tools/src/migrator/interfaces.ts b/examples/utils/migration-tools/src/migrator/interfaces.ts index 10616bed4267..da8810c4bd7d 100644 --- a/examples/utils/migration-tools/src/migrator/interfaces.ts +++ b/examples/utils/migration-tools/src/migrator/interfaces.ts @@ -3,121 +3,95 @@ * Licensed under the MIT License. */ +import type { IContainer } from "@fluidframework/container-definitions/internal"; import type { IEvent, IEventProvider } from "@fluidframework/core-interfaces"; -import type { MigrationState } from "../migrationTool/index.js"; +import type { IAcceptedMigrationDetails, MigrationState } from "../migrationTool/index.js"; -// #region IMigratableModel +// #region Migrator callbacks /** - * A model with a detectable version. - * - * @remarks - * It's appropriate to use this version to deduce the more specific type of model. + * Callback that should take the given container and export its data in some format. * @alpha */ -export interface IVersionedModel { - /** - * The string version of the model, matching the version of the container code it's paired with. - */ - readonly version: string; -} - +export type ExportDataCallback = (sourceContainer: IContainer) => Promise; /** - * A model that can import data of ImportType when in detached state, and can also export its data to ExportType. + * Callback provided to load the source container that data will be exported from. Should be a separately + * loaded container to avoid including local changes. * @alpha */ -export interface IImportExportModel { - /** - * importData must be called after initialization but before modifying or attaching the model (i.e. can only - * be called on an unaltered, detached model). - */ - importData: (initialData: ImportType) => Promise; - - /** - * Export the data from the model. Can be passed into importData() for a new container to replicate the data. - */ - exportData: () => Promise; +export type LoadSourceContainerCallback = () => Promise; +/** + * Callback provided to take desired migration steps after migration has been agreed upon and data has been + * exported. Typically creating a new container and importing the data into it. + * @alpha + */ +export type MigrationCallback = (version: string, exportedData: unknown) => Promise; - /** - * Permit format checking in a generic manner - without knowing the type of our data or the type of the model, - * we can still check whether the model supports that data. - */ - supportsDataFormat: (initialData: unknown) => initialData is ImportType; -} +// #region Entry point -// TODO: Is there a better way to express the unknown format here? I think I'd prefer to put the burden of calling -// supportsDataFormat() on the callers of importData() (and allow implementers of IMigratableModel to assume -// importData() is called with valid data). /** - * A model which supports migration via the MigrationTool and Migrator. - * - * @privateRemarks - * A migratable model must have an observable version, which is used to determine if migration is required and to - * identify the source and destination container codes. - * - * It must also support import/export, as this is the mechanism that MigrationTool and Migrator use to perform the - * migration. - * - * Lastly, it should provide dispose capabilities for two purposes: (1) The Migrator will spawn a temporary model - * to export the data, which should be cleaned up after export and (2) After migration is complete, the old model - * is likely no longer needed and should be cleaned up. + * The partial type of the entrypoint provided when makeMigratorEntryPointPiece is used. * @alpha */ -export interface IMigratableModel - extends IVersionedModel, - IImportExportModel { +export interface IMigratorEntryPoint { /** - * Dispose the model, rendering it inoperable and closing connections. - * - * @privateRemarks - * This is required on the interface because the Migrator will make its own instance of the model for export, - * and needs to clean that model up after the export is done. + * Retrieve the IMigrator from the container. It will use the provided callbacks to load the source + * container for data export and perform the migration. */ - dispose(): void; + getMigrator: ( + loadSourceContainerCallback: LoadSourceContainerCallback, + migrationCallback: MigrationCallback, + ) => Promise; } // #region IMigrator /** - * The DataTransformationCallback gives an opportunity to modify the exported data before attempting an import - * to the new model. The modelVersion is also provided to inform the appropriate transformation to perform. - * It is async to permit network calls or lazy-loading the transform logic within the function. - * @alpha - */ -export type DataTransformationCallback = ( - exportedData: unknown, - modelVersion: string, -) => Promise; - -/** + * Events emitted by the IMigrator. * @alpha */ export interface IMigratorEvents extends IEvent { - (event: "migrated" | "migrating", listener: () => void); - (event: "migrationNotSupported", listener: (version: string) => void); + /** + * As the migrator progresses between migration states, it emits the corresponding event. + */ + (event: "stopping" | "migrating" | "migrated", listener: () => void): void; } /** + * A tool used to propose and monitor container migration. * @alpha */ export interface IMigrator { + /** + * Event emitter object. + */ readonly events: IEventProvider; /** - * The currently monitored migratable model. As the Migrator completes a migration, it will swap in the new - * migrated model and emit a "migrated" event. + * The current state of migration. */ - readonly currentModel: IMigratableModel; + readonly migrationState: MigrationState; /** - * The container id of the current model. + * The version string of the proposed new version to use, if one has been proposed. */ - readonly currentModelId: string; + readonly proposedVersion: string | undefined; /** - * The migration state of the current model. Note that since we swap out for the new model as soon as migration - * completes, we'll only ever see this as collaborating or migrating, never migrated. + * The details of the accepted migration, if one has been accepted. + * TODO: Consider hiding this - currently just used for debug output in the example. */ - readonly migrationState: MigrationState; + readonly acceptedMigration: IAcceptedMigrationDetails | undefined; + + /** + * The result of the migration, if complete. Likely the container ID of the new container. + */ + readonly migrationResult: unknown | undefined; + + /** + * Propose a new version to use. + * @param newVersion - the version string + */ + proposeVersion: (newVersion: string) => void; } diff --git a/examples/utils/migration-tools/src/migrator/makeMigratorEntryPointPiece.ts b/examples/utils/migration-tools/src/migrator/makeMigratorEntryPointPiece.ts new file mode 100644 index 000000000000..003549705f3b --- /dev/null +++ b/examples/utils/migration-tools/src/migrator/makeMigratorEntryPointPiece.ts @@ -0,0 +1,82 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import type { IContainerRuntime } from "@fluidframework/container-runtime-definitions/internal"; +import type { FluidObject } from "@fluidframework/core-interfaces"; + +import type { IEntryPointPiece } from "../compositeRuntime/index.js"; +import { MigrationToolFactory, type IMigrationTool } from "../migrationTool/index.js"; + +import type { + ExportDataCallback, + LoadSourceContainerCallback, + MigrationCallback, +} from "./interfaces.js"; +import { Migrator } from "./migrator.js"; + +const migratorEntryPointPieceName = "getMigrator"; + +const migrationToolRegistryKey = "migration-tool"; +const migrationToolId = "migration-tool"; +const migrationToolFactory = new MigrationToolFactory(); + +async function getDataStoreEntryPoint( + containerRuntime: IContainerRuntime, + alias: string, +): Promise { + const entryPointHandle = await containerRuntime.getAliasedDataStoreEntryPoint(alias); + + if (entryPointHandle === undefined) { + throw new Error(`Default dataStore [${alias}] must exist`); + } + + return entryPointHandle.get(); +} + +/** + * Create the entry point piece. This function is called by the container author, who can provide appropriate access + * to the container data through the ExportDataCallback. + * @alpha + */ +export const makeMigratorEntryPointPiece = ( + exportDataCallback: ExportDataCallback, +): IEntryPointPiece => { + return { + name: migratorEntryPointPieceName, + registryEntries: [[migrationToolRegistryKey, Promise.resolve(migrationToolFactory)]], + onCreate: async (runtime: IContainerRuntime): Promise => { + const migrationTool = await runtime.createDataStore(migrationToolRegistryKey); + await migrationTool.trySetAlias(migrationToolId); + }, + onLoad: async (runtime: IContainerRuntime): Promise => { + // Force the MigrationTool to instantiate in all cases. The PactMap it uses must be loaded and running in + // order to respond with accept ops, and without this call the MigrationTool won't be instantiated on the + // summarizer client. + await getDataStoreEntryPoint(runtime, migrationToolId); + }, + createPiece: async (runtime: IContainerRuntime): Promise => { + // The callback parameters of this returned function cannot be known/performed by the container code author, + // so we rely on the host to provide them. Both require the loader layer (at least for current patterns), and + // migrationCallback additionally will depend on the details of the future version of the code we eventually + // migrate to. + return async ( + loadSourceContainerCallback: LoadSourceContainerCallback, + migrationCallback: MigrationCallback, + ) => { + const migrationTool = (await getDataStoreEntryPoint( + runtime, + migrationToolId, + )) as IMigrationTool; + const migrator = new Migrator( + migrationTool, + loadSourceContainerCallback, + exportDataCallback, + migrationCallback, + ); + return migrator; + }; + }, + }; +}; diff --git a/examples/utils/migration-tools/src/migrator/migrator.ts b/examples/utils/migration-tools/src/migrator/migrator.ts index ce71a6c66291..6bd387740ceb 100644 --- a/examples/utils/migration-tools/src/migrator/migrator.ts +++ b/examples/utils/migration-tools/src/migrator/migrator.ts @@ -7,86 +7,69 @@ import { TypedEventEmitter } from "@fluid-internal/client-utils"; import type { IContainer } from "@fluidframework/container-definitions/internal"; import type { IEventProvider } from "@fluidframework/core-interfaces"; import { assert } from "@fluidframework/core-utils/internal"; +import type { ISequencedDocumentMessage } from "@fluidframework/driver-definitions/internal"; -import type { IMigrationTool, MigrationState } from "../migrationTool/index.js"; -import { type ISimpleLoader, waitForAtLeastSequenceNumber } from "../simpleLoader/index.js"; +import type { + IAcceptedMigrationDetails, + IMigrationTool, + MigrationState, +} from "../migrationTool/index.js"; import type { - DataTransformationCallback, - IMigratableModel, + ExportDataCallback, IMigrator, IMigratorEvents, + LoadSourceContainerCallback, + MigrationCallback, } from "./interfaces.js"; -// TODO: This probably shouldn't be exported, consider having the migrator get its own model/tool out. /** - * The purpose of the model pattern and the model loader is to wrap the IContainer in a more useful object and - * interface. This demo uses a convention of the entrypoint providing a getModelAndMigrationTool method to do so. - * It does this with the expectation that the model has been bundled with the container code. - * - * Other strategies to obtain the wrapping model could also work fine here - for example a standalone model code - * loader that separately fetches model code and wraps the container from the outside. - * @internal + * Get a promise that will resolve once the container has advanced to at least the given sequence number + * @param container - the container to observe + * @param sequenceNumber - the sequence number we want to load to at minimum */ -export const getModelAndMigrationToolFromContainer = async ( +const waitForAtLeastSequenceNumber = async ( container: IContainer, -): Promise<{ model: ModelType; migrationTool: IMigrationTool }> => { - // TODO: Fix typing here - const entryPoint = (await container.getEntryPoint()) as { - getModel: (container: IContainer) => Promise; - migrationTool: IMigrationTool; - }; - // If the user tries to use this model loader with an incompatible container runtime, we want to give them - // a comprehensible error message. So distrust the type by default and do some basic type checking. - if (typeof entryPoint.getModel !== "function") { - throw new TypeError("Incompatible container runtime: doesn't provide getModel"); - } - const model = await entryPoint.getModel(container); - if (typeof model !== "object") { - throw new TypeError("Incompatible container runtime: doesn't provide model"); - } - if (typeof entryPoint.migrationTool !== "object") { - throw new TypeError("Incompatible container runtime: doesn't provide migrationTool"); - } - return { model, migrationTool: entryPoint.migrationTool }; -}; - -/** - * As the Migrator migrates, it updates its reference to the current version of the model. - * This interface describes the characteristics of the model that it's tracking in a single object, - * which will be swapped out atomically as the migration happens. - */ -interface MigratableParts { - model: IMigratableModel; - migrationTool: IMigrationTool; - id: string; -} + sequenceNumber: number, +): Promise => + new Promise((resolve) => { + if (sequenceNumber <= container.deltaManager.lastSequenceNumber) { + resolve(); + } + const callbackOps = (message: ISequencedDocumentMessage): void => { + if (sequenceNumber <= message.sequenceNumber) { + resolve(); + container.deltaManager.off("op", callbackOps); + } + }; + container.deltaManager.on("op", callbackOps); + }); /** - * The Migrator maintains a reference to the current model, and interacts with it (and its MigrationTool) - * to detect, observe, trigger, and execute migration as appropriate. - * @alpha + * The Migrator monitors and interacts with its IMigrationTool to handle and trigger migration. It is designed + * to be a one-time-use tool that is provided as part of the (old) container code bundle, through the container + * entryPoint. It makes minimal assumptions about what the eventual new container might look like as a + * future-proofing strategy. */ export class Migrator implements IMigrator { - private _currentMigratable: MigratableParts; - public get currentModel(): IMigratableModel { - return this._currentMigratable.model; + public get migrationResult(): unknown | undefined { + return this.migrationTool.migrationResult; } - public get currentMigrationTool(): IMigrationTool { - return this._currentMigratable.migrationTool; + public get migrationState(): MigrationState { + return this.migrationTool.migrationState; } - public get currentModelId(): string { - return this._currentMigratable.id; + private get connected(): boolean { + return this.migrationTool.connected; } - public get migrationState(): MigrationState { - return this.currentMigrationTool.migrationState; + public get proposedVersion(): string | undefined { + return this.migrationTool.proposedVersion; } - public get connected(): boolean { - return this.currentMigrationTool.connected; + public get acceptedMigration(): IAcceptedMigrationDetails | undefined { + return this.migrationTool.acceptedMigration; } private readonly _events = new TypedEventEmitter(); @@ -94,308 +77,107 @@ export class Migrator implements IMigrator { return this._events; } - /** - * If migration is in progress, the promise that will resolve when it completes. Mutually exclusive with - * _migratedLoadP promise. - */ - private _migrationP: Promise | undefined; + private readonly echoStopping = (): void => { + this._events.emit("stopping"); + }; + private readonly echoMigrating = (): void => { + this._events.emit("migrating"); + }; + private readonly echoMigrated = (): void => { + this._events.emit("migrated"); + }; - /** - * If loading the migrated container is in progress, the promise that will resolve when it completes. Mutually - * exclusive with _migrationP promise. - */ - private _migratedLoadP: Promise | undefined; + // On disposal, unregister all listeners + private readonly onMigrationToolDisposed = (): void => { + this.migrationTool.events.off("stopping", this.echoStopping); + this.migrationTool.events.off("migrating", this.echoMigrating); + this.migrationTool.events.off("migrated", this.echoMigrated); - // TODO: Better typing, decide if we can just retain attach() - /** - * Detached model that is ready to attach. This is stored for retry scenarios. - */ - private _preparedDetachedModel: - | { container: IContainer; attach: () => Promise } - | undefined; + this.migrationTool.events.off("migrating", this.performMigration); - /** - * After attaching the prepared model, but before we have written its ID into the current model, we'll store the ID - * here to support retry scenarios. - */ - private _preparedModelId: string | undefined; + this.migrationTool.events.off("disposed", this.onMigrationToolDisposed); + }; public constructor( - private readonly simpleLoader: ISimpleLoader, - initialMigratable: IMigratableModel, - initialMigrationTool: IMigrationTool, - initialId: string, - private readonly dataTransformationCallback?: DataTransformationCallback, + private readonly migrationTool: IMigrationTool, + private readonly loadSourceContainerCallback: LoadSourceContainerCallback, + private readonly exportDataCallback: ExportDataCallback, + private readonly migrationCallback: MigrationCallback, ) { - this._currentMigratable = { - model: initialMigratable, - migrationTool: initialMigrationTool, - id: initialId, - }; - this.takeAppropriateActionForCurrentMigratable(); - } - - /** - * This method makes no assumptions about the state of the current migratable - this is particularly important - * for the case that we just finished loading a migrated container, but that migrated container is also either - * in the process of migrating or already migrated (and thus we need to load again). It is not safe to assume - * that a freshly-loaded migrated container is in collaborating state. - */ - private readonly takeAppropriateActionForCurrentMigratable = (): void => { - const migrationState = this.currentMigrationTool.migrationState; + // Echo the events from the MigrationTool, these are the source of truth and can proceed regardless of + // whatever the local Migrator is doing. + this.migrationTool.events.on("stopping", this.echoStopping); + this.migrationTool.events.on("migrating", this.echoMigrating); + this.migrationTool.events.on("migrated", this.echoMigrated); + + // Detect the current migration state and set up listeners to observe changes. + const migrationState = this.migrationTool.migrationState; if (migrationState === "migrating") { - this.ensureMigrating(); - } else if (migrationState === "migrated") { - this.ensureLoading(); - } else { - this.currentMigrationTool.events.once( - "migrating", - this.takeAppropriateActionForCurrentMigratable, - ); - } - }; - - private readonly ensureMigrating = (): void => { - // ensureMigrating() is called when we reach the "migrating" state. This should likely only happen once, but - // can happen multiple times if we disconnect during the migration process. - - if (!this.connected) { - // If we are not connected we should wait until we reconnect and try again. Note: we re-enter the state - // machine, since it's possible another client has already completed the migration by the time we reconnect. - this.currentMigrationTool.events.once( - "connected", - this.takeAppropriateActionForCurrentMigratable, - ); - return; - } - - if (this._migrationP !== undefined) { - return; + this.performMigration(); + } else if (migrationState === "collaborating" || migrationState === "stopping") { + this.migrationTool.events.once("migrating", this.performMigration); } + // Do nothing if already migrated - if (this._migratedLoadP !== undefined) { - throw new Error("Cannot perform migration, we are currently trying to load"); - } + this.migrationTool.events.on("disposed", this.onMigrationToolDisposed); + } - const migrationTool = this.currentMigrationTool; - const acceptedMigration = migrationTool.acceptedMigration; - if (acceptedMigration === undefined) { - throw new Error("Expect an accepted migration before migration starts"); + public readonly proposeVersion = (newVersion: string): void => { + if (this.proposedVersion !== undefined) { + throw new Error("A proposal was already made"); } + this.migrationTool.proposeVersion(newVersion); + }; - const doTheMigration = async (): Promise => { - // doTheMigration() is called at the start of migration and should only resolve in two cases. First, is if - // either the local or another client successfully completes the migration. Second, is if we disconnect - // during the migration process. In both cases we should re-enter the state machine and take the - // appropriate action (see then() block below). - - const prepareTheMigration = async (): Promise => { - // It's possible that our modelLoader is older and doesn't understand the new acceptedMigration. - // Currently this fails the migration gracefully and emits an event so the app developer can know - // they're stuck. Ideally the app developer would find a way to acquire a new ModelLoader and move - // forward, or at least advise the end user to refresh the page or something. - // TODO: Does the app developer have everything they need to dispose gracefully when recovering with - // a new MigratableModelLoader? - // TODO: Does the above TODO still matter now that this uses SimpleLoader? - const migrationSupported = await this.simpleLoader.supportsVersion( - acceptedMigration.newVersion, - ); - if (!migrationSupported) { - this._events.emit("migrationNotSupported", acceptedMigration.newVersion); - this._migrationP = undefined; - return; - } - - const detachedContainer = await this.simpleLoader.createDetached( - acceptedMigration.newVersion, - ); - const { model: detachedModel } = - await getModelAndMigrationToolFromContainer( - detachedContainer.container, - ); - const migratedModel = detachedModel; - - // Here we load the model to at least the acceptance sequence number and export. We do this with a - // separately loaded model to ensure we don't include any local un-ack'd changes. Late-arriving messages - // may or may not make it into the migrated data, there is no guarantee either way. - // TODO: Consider making this a read-only client - const container = await this.simpleLoader.loadExisting(this.currentModelId); - await waitForAtLeastSequenceNumber( - container, - acceptedMigration.migrationSequenceNumber, - ); - // TODO: verify IMigratableModel - const { model: exportModel } = - await getModelAndMigrationToolFromContainer(container); - const exportedData = await exportModel.exportData(); - exportModel.dispose(); - - // TODO: Is there a reasonable way to validate at proposal time whether we'll be able to get the - // exported data into a format that the new model can import? If we can determine it early, then - // clients with old MigratableModelLoaders can use that opportunity to dispose early and try to get new - // MigratableModelLoaders. - let transformedData: unknown; - if (migratedModel.supportsDataFormat(exportedData)) { - // If the migrated model already supports the data format, go ahead with the migration. - transformedData = exportedData; - // eslint-disable-next-line unicorn/no-negated-condition - } else if (this.dataTransformationCallback !== undefined) { - // Otherwise, try using the dataTransformationCallback if provided to get the exported data into - // a format that we can import. - try { - transformedData = await this.dataTransformationCallback( - exportedData, - migratedModel.version, - ); - } catch { - // TODO: This implies that the contract is to throw if the data can't be transformed, which - // isn't great. How should the dataTransformationCallback indicate failure? - this._events.emit("migrationNotSupported", acceptedMigration.newVersion); - this._migrationP = undefined; - return; - } - } else { - // We can't get the data into a format that we can import, give up. - this._events.emit("migrationNotSupported", acceptedMigration.newVersion); - this._migrationP = undefined; - return; - } - await migratedModel.importData(transformedData); - - // Store the detached model for later use and retry scenarios - this._preparedDetachedModel = detachedContainer; - }; - - const completeTheMigration = async (): Promise => { - assert( - this._preparedDetachedModel !== undefined, - "this._preparedDetachedModel should be defined", - ); - - // Volunteer to complete the migration. - let isAssigned: boolean; - try { - isAssigned = await this.currentMigrationTool.volunteerForMigration(); - } catch { - // volunteerForMigration() will throw an error on disconnection. In this case, we should exit and - // re-enter the state machine which will wait until we reconnect. - // Note: while we wait to reconnect it is possible that another client will have already completed - // the migration. - assert(!this.connected, "We should be disconnected"); - return; - } - - if (this.currentMigrationTool.newContainerId !== undefined) { - // If newContainerId is already set, then another client already completed the migration. - return; - } - - assert(isAssigned, "We should be assigned the migration task"); - - if (this._preparedModelId === undefined) { - this._preparedModelId = await this._preparedDetachedModel.attach(); - } - - // Check to make sure we still have the task assignment. - if (!this.currentMigrationTool.haveMigrationTask()) { - // Exit early if we lost the task assignment, we are most likely disconnected. - return; - } - - await migrationTool.finalizeMigration(this._preparedModelId); - - this.currentMigrationTool.completeMigrationTask(); - }; - - // Prepare the detached model if we haven't already. - if (this._preparedDetachedModel === undefined) { - await prepareTheMigration(); + private readonly performMigration = (): void => { + (async (): Promise => { + const acceptedMigration = this.migrationTool.acceptedMigration; + assert( + acceptedMigration !== undefined, + "Expect an accepted migration before migration starts", + ); + // Delay performing the migration until we are connected. It's possible that we'll find the migration has already + // completed before we finish connecting, and in that case we want to avoid doing anything. + if (!this.connected) { + await new Promise((resolve) => { + this.migrationTool.events.once("connected", () => { + resolve(); + }); + }); } - // Ensure another client has not already completed the migration. - if (this.migrationState !== "migrating") { + // Do nothing if the migration has already completed. + if (this.migrationTool.migrationResult !== undefined) { return; } - await completeTheMigration(); - }; - - this._events.emit("migrating"); - - this._migrationP = doTheMigration() - .then(() => { - // We assume that if we resolved that either the migration was completed or we disconnected. - // In either case, we should re-enter the state machine to take the appropriate action. - if (this.connected) { - // We assume if we are still connected after exiting the loop, then we should be in the "migrated" - // state. The following assert validates this assumption. - assert( - this.currentMigrationTool.newContainerId !== undefined, - "newContainerId should be defined", - ); - } - this._migrationP = undefined; - this.takeAppropriateActionForCurrentMigratable(); - }) - .catch(console.error); - }; - - private readonly ensureLoading = (): void => { - // We assume ensureLoading() is called a single time after we reach the "migrated" state. - - if (this._migratedLoadP !== undefined) { - return; - } - - if (this._migrationP !== undefined) { - throw new Error("Cannot start loading the migrated before migration is complete"); - } - - const migrationTool = this.currentMigrationTool; - const acceptedMigration = migrationTool.acceptedMigration; - if (acceptedMigration === undefined) { - throw new Error("Expect an accepted version before migration starts"); - } - - const migratedId = migrationTool.newContainerId; - if (migratedId === undefined) { - throw new Error("Migration ended without a new container being created"); - } - - const doTheLoad = async (): Promise => { - // doTheLoad() should only be called once. It will resolve once we complete loading. - - const migrationSupported = await this.simpleLoader.supportsVersion( - acceptedMigration.newVersion, + // Load the container to at least the acceptance sequence number and export. We do this with a + // separate container to ensure we don't include any local un-ack'd changes. Late-arriving messages + // may or may not make it into the migrated data, there is no guarantee either way. + // TODO: Consider making this a read-only client + // TODO: Consider more aggressive checks on whether the migration finished and early disconnect, or an abort signal. + const sourceContainer = await this.loadSourceContainerCallback(); + await waitForAtLeastSequenceNumber( + sourceContainer, + acceptedMigration.migrationSequenceNumber, ); - if (!migrationSupported) { - this._events.emit("migrationNotSupported", acceptedMigration.newVersion); - this._migratedLoadP = undefined; + const exportedData = await this.exportDataCallback(sourceContainer); + sourceContainer.dispose(); + + // Exit early if someone else finished the migration while we were exporting. + if (this.migrationTool.migrationResult !== undefined) { return; } - const migratedContainer = await this.simpleLoader.loadExisting(migratedId); - const { model: migratedModel, migrationTool: migratedMigrationTool } = - await getModelAndMigrationToolFromContainer(migratedContainer); - // Note: I'm choosing not to dispose the old migratable here, and instead allow the lifecycle management - // of the migratable to be the responsibility of whoever created the Migrator (and handed it its first - // migratable). It could also be fine to dispose here, just need to have an explicit contract to clarify - // who is responsible for managing that. - this._currentMigratable = { - model: migratedModel, - migrationTool: migratedMigrationTool, - id: migratedId, - }; - this._events.emit("migrated", migratedModel, migratedId); - this._migratedLoadP = undefined; - - // Reset retry values - this._preparedDetachedModel = undefined; - this._preparedModelId = undefined; - // Only once we've completely finished with the old migratable, start on the new one. - this.takeAppropriateActionForCurrentMigratable(); - }; + const migrationResult = await this.migrationCallback( + acceptedMigration.newVersion, + exportedData, + ); - this._migratedLoadP = doTheLoad().catch(console.error); + // Confirm that no one else finished the migration already before trying to finalize. + if (this.migrationTool.migrationResult === undefined) { + await this.migrationTool.finalizeMigration(migrationResult); + } + })().catch(console.error); }; } diff --git a/examples/utils/migration-tools/src/migrator/separateContainerCallbackHelpers.ts b/examples/utils/migration-tools/src/migrator/separateContainerCallbackHelpers.ts new file mode 100644 index 000000000000..68b104641201 --- /dev/null +++ b/examples/utils/migration-tools/src/migrator/separateContainerCallbackHelpers.ts @@ -0,0 +1,95 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import type { IContainer } from "@fluidframework/container-definitions/internal"; +import { + createDetachedContainer, + type ILoaderProps, +} from "@fluidframework/container-loader/internal"; +import type { IRequest } from "@fluidframework/core-interfaces"; + +import type { MigrationCallback } from "./interfaces.js"; + +/** + * These callback helpers are useful if you are migrating _TO_ this version of the platform. If the platform changes significantly + * in the future (e.g. loader API changes, create new flow changes), then you would likely want to use an updated set of callbacks + * from the version of the platform you are migrating to instead. + */ + +/** + * A callback for creating a detached container. We need to have an encapsulated attach(), since the + * normal IContainer.attach() parameters vary between services. + * @alpha + */ +export type CreateDetachedContainerCallback = ( + version: string, +) => Promise<{ container: IContainer; attach: () => Promise }>; + +/** + * A callback for importing the exported data into the new destinationContainer. You must implement this with the specifics of + * your import flow. + * @alpha + */ +export type ImportDataCallback = ( + destinationContainer: IContainer, + exportedData: unknown, +) => Promise; + +/** + * When using the makeSeparateContainerMigrationCallback(), the migration result will be a string with the container ID of + * the new container. + * @alpha + */ +export type SeparateContainerMigrationResult = string; + +/** + * Make an encapsulated createDetached callback for use with makeSeparateContainerMigrationCallback. This is split off to + * isolate the loader-specific API calls and the service-specific URL create new request format. + * @alpha + */ +export const makeCreateDetachedContainerCallback = ( + loaderProps: ILoaderProps, + generateCreateNewRequest: () => IRequest, +): CreateDetachedContainerCallback => { + return async ( + version: string, + ): Promise<{ container: IContainer; attach: () => Promise }> => { + const container = await createDetachedContainer({ + ...loaderProps, + codeDetails: { package: version }, + }); + const attach = async (): Promise => { + await container.attach(generateCreateNewRequest()); + if (container.resolvedUrl === undefined) { + throw new Error("Resolved Url not available on attached container"); + } + return container.resolvedUrl.id; + }; + return { container, attach }; + }; +}; + +/** + * Make a typical separate container migration callback. It needs to be told how to create the new detached container and also + * how to import the data into that container. The migrationResult it generates is the container ID of the new container. + * @alpha + */ +export const makeSeparateContainerMigrationCallback = ( + createDetachedContainerCallback: CreateDetachedContainerCallback, + importDataCallback: ImportDataCallback, +): MigrationCallback => { + const migrationCallback = async ( + version: string, + exportedData: unknown, + ): Promise => { + const { container: destinationContainer, attach } = + await createDetachedContainerCallback(version); + await importDataCallback(destinationContainer, exportedData); + const newContainerId = await attach(); + destinationContainer.dispose(); + return newContainerId; + }; + return migrationCallback; +}; diff --git a/examples/utils/migration-tools/src/simpleLoader/README.md b/examples/utils/migration-tools/src/simpleLoader/README.md deleted file mode 100644 index c27436aa5d84..000000000000 --- a/examples/utils/migration-tools/src/simpleLoader/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# `SimpleLoader` - -This package provides a `SimpleLoader` class, which wraps a `Loader` with a simpler interface. This simpler interface is used by the `Migrator` during migration. - -```ts -// Creating the SimpleLoader using Tinylicious -const loader = new SimpleLoader({ - urlResolver: new InsecureTinyliciousUrlResolver(), - documentServiceFactory: new RouterliciousDocumentServiceFactory( - new InsecureTinyliciousTokenProvider(), - ), - codeLoader: new DemoCodeLoader(), - generateCreateNewRequest: createTinyliciousCreateNewRequest, -}); - -// Creating and attaching a new container -const { container, attach } = await loader.createDetached("one"); -id = await attach(); - -// Loading an existing container -const container = await loader.loadExisting(id); -``` - -TODO: Can the `Migrator` take a normal `Loader` and wrap it itself to avoid teaching a new concept here? diff --git a/examples/utils/migration-tools/src/simpleLoader/index.ts b/examples/utils/migration-tools/src/simpleLoader/index.ts deleted file mode 100644 index 0d05de6cb4d0..000000000000 --- a/examples/utils/migration-tools/src/simpleLoader/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/*! - * Copyright (c) Microsoft Corporation and contributors. All rights reserved. - * Licensed under the MIT License. - */ - -export type { ISimpleLoader } from "./interfaces.js"; -export { SessionStorageSimpleLoader } from "./sessionStorageSimpleLoader.js"; -export { SimpleLoader, waitForAtLeastSequenceNumber } from "./simpleLoader.js"; diff --git a/examples/utils/migration-tools/src/simpleLoader/interfaces.ts b/examples/utils/migration-tools/src/simpleLoader/interfaces.ts deleted file mode 100644 index 01b1d938f017..000000000000 --- a/examples/utils/migration-tools/src/simpleLoader/interfaces.ts +++ /dev/null @@ -1,35 +0,0 @@ -/*! - * Copyright (c) Microsoft Corporation and contributors. All rights reserved. - * Licensed under the MIT License. - */ - -import type { IContainer } from "@fluidframework/container-definitions/internal"; - -/** - * @alpha - */ -export interface ISimpleLoader { - /** - * Check if the ISimpleLoader knows how to instantiate the provided container code version. - * It is async to permit dynamic code loading - e.g. referring to a remote service to determine if the requested - * version is available. - * @param version - the container code version to check - */ - supportsVersion(version: string): Promise; - - /** - * Create a detached container using the specified version of container code. - * Returns an object containing the detached container plus an attach callback. When invoked, the attach callback - * returns a promise that will resolve after attach has completed with the id of the container. - * @param version - the container code version to create a container for - */ - createDetached( - version: string, - ): Promise<{ container: IContainer; attach: () => Promise }>; - - /** - * Load the container with the given id. - * @param id - the id of the container to load - */ - loadExisting(id: string): Promise; -} diff --git a/examples/utils/migration-tools/src/simpleLoader/sessionStorageSimpleLoader.ts b/examples/utils/migration-tools/src/simpleLoader/sessionStorageSimpleLoader.ts deleted file mode 100644 index 3556c16c4786..000000000000 --- a/examples/utils/migration-tools/src/simpleLoader/sessionStorageSimpleLoader.ts +++ /dev/null @@ -1,62 +0,0 @@ -/*! - * Copyright (c) Microsoft Corporation and contributors. All rights reserved. - * Licensed under the MIT License. - */ - -import { - type ICodeDetailsLoader, - type IContainer, -} from "@fluidframework/container-definitions/internal"; -import type { ITelemetryBaseLogger } from "@fluidframework/core-interfaces"; -import { - createLocalResolverCreateNewRequest, - LocalDocumentServiceFactory, - LocalResolver, - LocalSessionStorageDbFactory, -} from "@fluidframework/local-driver/internal"; -import { LocalDeltaConnectionServer } from "@fluidframework/server-local-server"; -import { v4 as uuid } from "uuid"; - -import type { ISimpleLoader } from "./interfaces.js"; -import { SimpleLoader } from "./simpleLoader.js"; - -const urlResolver = new LocalResolver(); - -const localServer = LocalDeltaConnectionServer.create(new LocalSessionStorageDbFactory()); - -/** - * @alpha - */ -export class SessionStorageSimpleLoader implements ISimpleLoader { - public constructor( - private readonly codeLoader: ICodeDetailsLoader, - private readonly logger?: ITelemetryBaseLogger, - ) {} - - public async supportsVersion(version: string): Promise { - return true; - } - - public async createDetached( - version: string, - ): Promise<{ container: IContainer; attach: () => Promise }> { - const loader = new SimpleLoader({ - urlResolver, - documentServiceFactory: new LocalDocumentServiceFactory(localServer), - codeLoader: this.codeLoader, - logger: this.logger, - generateCreateNewRequest: () => createLocalResolverCreateNewRequest(uuid()), - }); - return loader.createDetached(version); - } - public async loadExisting(id: string): Promise { - const loader = new SimpleLoader({ - urlResolver, - documentServiceFactory: new LocalDocumentServiceFactory(localServer), - codeLoader: this.codeLoader, - logger: this.logger, - generateCreateNewRequest: () => createLocalResolverCreateNewRequest(uuid()), - }); - return loader.loadExisting(`${window.location.origin}/${id}`); - } -} diff --git a/examples/utils/migration-tools/src/simpleLoader/simpleLoader.ts b/examples/utils/migration-tools/src/simpleLoader/simpleLoader.ts deleted file mode 100644 index eb99d9eca182..000000000000 --- a/examples/utils/migration-tools/src/simpleLoader/simpleLoader.ts +++ /dev/null @@ -1,115 +0,0 @@ -/*! - * Copyright (c) Microsoft Corporation and contributors. All rights reserved. - * Licensed under the MIT License. - */ - -import { type IContainer, LoaderHeader } from "@fluidframework/container-definitions/internal"; -import { - createDetachedContainer, - ILoaderProps, - loadExistingContainer, -} from "@fluidframework/container-loader/internal"; -import type { IRequest } from "@fluidframework/core-interfaces"; -import type { ISequencedDocumentMessage } from "@fluidframework/driver-definitions/internal"; - -import type { ISimpleLoader } from "./interfaces.js"; - -/** - * Get a promise that will resolve once the container has advanced to at least the given sequence number - * @param container - the container to observe - * @param sequenceNumber - the sequence number we want to load to at minimum - */ -export const waitForAtLeastSequenceNumber = async ( - container: IContainer, - sequenceNumber: number, -): Promise => - new Promise((resolve) => { - if (sequenceNumber <= container.deltaManager.lastSequenceNumber) { - resolve(); - } - const callbackOps = (message: ISequencedDocumentMessage): void => { - if (sequenceNumber <= message.sequenceNumber) { - resolve(); - container.deltaManager.off("op", callbackOps); - } - }; - container.deltaManager.on("op", callbackOps); - }); - -/** - * @alpha - */ -export class SimpleLoader implements ISimpleLoader { - private readonly loaderProps: ILoaderProps; - private readonly generateCreateNewRequest: () => IRequest; - - // TODO: See if there's a nicer way to parameterize the createNew request. - // Here we specifically pick just the loader props we know we need to keep API exposure low. Fine to add more - // here if we determine they're needed, but they should be picked explicitly (e.g. avoid "scope"). - public constructor( - props: Pick< - ILoaderProps, - "urlResolver" | "documentServiceFactory" | "codeLoader" | "logger" - > & { - generateCreateNewRequest: () => IRequest; - }, - ) { - this.loaderProps = { - urlResolver: props.urlResolver, - documentServiceFactory: props.documentServiceFactory, - codeLoader: props.codeLoader, - logger: props.logger, - }; - this.generateCreateNewRequest = props.generateCreateNewRequest; - } - - public async supportsVersion(version: string): Promise { - // To answer the question of whether we support a given version, we would need to query the codeLoader - // to see if it thinks it can load the requested version. But for now, ICodeDetailsLoader doesn't have - // a supports() method. We could attempt a load and catch the error, but it might not be desirable to - // load code just to check. It might be desirable to add a supports() method to ICodeDetailsLoader. - return true; - } - - // It would be preferable for attaching to look more like service.attach(container) rather than returning an attach - // callback here, but this callback at least allows us to keep the method off the container interface. - // TODO: Consider making the version param optional, and in that case having a mechanism to query the codeLoader - // for the latest/default version to use? - public async createDetached( - version: string, - ): Promise<{ container: IContainer; attach: () => Promise }> { - const container = await createDetachedContainer({ - ...this.loaderProps, - codeDetails: { package: version }, - }); - // The attach callback lets us defer the attach so the caller can do whatever initialization pre-attach, - // without leaking out the loader, service, etc. We also return the container ID here so we don't have - // to stamp it on something that would rather not know it (e.g. the container). - const attach = async (): Promise => { - await container.attach(this.generateCreateNewRequest()); - if (container.resolvedUrl === undefined) { - throw new Error("Resolved Url not available on attached container"); - } - return container.resolvedUrl.id; - }; - return { container, attach }; - } - - public async loadExisting(id: string): Promise { - return loadExistingContainer({ - ...this.loaderProps, - request: { - url: id, - headers: { - [LoaderHeader.loadMode]: { - // Here we use "all" to ensure we are caught up before returning. This is particularly important - // for direct-link scenarios, where the user might have a direct link to a data object that was - // just attached (i.e. the "attach" op and the "set" of the handle into some map is in the - // trailing ops). If we don't fully process those ops, the expected object won't be found. - opsBeforeReturn: "all", - }, - }, - }, - }); - } -} diff --git a/examples/version-migration/separate-container/package.json b/examples/version-migration/separate-container/package.json index 866403ce7ef7..2526e67cea78 100644 --- a/examples/version-migration/separate-container/package.json +++ b/examples/version-migration/separate-container/package.json @@ -49,12 +49,14 @@ "@fluidframework/core-interfaces": "workspace:~", "@fluidframework/driver-definitions": "workspace:~", "@fluidframework/driver-utils": "workspace:~", + "@fluidframework/local-driver": "workspace:~", "@fluidframework/map": "workspace:~", "@fluidframework/register-collection": "workspace:~", "@fluidframework/request-handler": "workspace:~", "@fluidframework/routerlicious-driver": "workspace:~", "@fluidframework/runtime-utils": "workspace:~", "@fluidframework/sequence": "workspace:~", + "@fluidframework/server-local-server": "^5.0.0", "@fluidframework/task-manager": "workspace:~", "@fluidframework/telemetry-utils": "workspace:~", "@fluidframework/tinylicious-driver": "workspace:~", diff --git a/examples/version-migration/separate-container/src/dataTransform.ts b/examples/version-migration/separate-container/src/dataTransform.ts index 61e4935b7f2e..d079d68a73e2 100644 --- a/examples/version-migration/separate-container/src/dataTransform.ts +++ b/examples/version-migration/separate-container/src/dataTransform.ts @@ -3,8 +3,6 @@ * Licensed under the MIT License. */ -import type { DataTransformationCallback } from "@fluid-example/migration-tools/internal"; - export interface IParsedInventoryItemData { name: string; quantity: number; @@ -110,10 +108,10 @@ function transformToTwo(stringData: string): string { * (1-\>2, 2-\>3, 3-\>4, etc.). This way only one new transform function needs to be produced and tested for each new * format used. */ -export const inventoryListDataTransformationCallback: DataTransformationCallback = async ( +export const inventoryListDataTransformationCallback = async ( exportedData: unknown, modelVersion: string, -) => { +): Promise => { if (typeof exportedData !== "string") { throw new TypeError("Unexpected data format"); } diff --git a/examples/version-migration/separate-container/src/migratableModel.ts b/examples/version-migration/separate-container/src/migratableModel.ts new file mode 100644 index 000000000000..c8c95244268a --- /dev/null +++ b/examples/version-migration/separate-container/src/migratableModel.ts @@ -0,0 +1,67 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +// #region IMigratableModel + +/** + * TODO: These interfaces are holdover from when they were part of the migration-tools package. They are fine, + * but overkill for this example. Consider simplifying to make the example easier to understand. + */ + +/** + * A model with a detectable version. + * + * @remarks + * It's appropriate to use this version to deduce the more specific type of model. + */ +export interface IVersionedModel { + /** + * The string version of the model, matching the version of the container code it's paired with. + */ + readonly version: string; +} + +/** + * A model that can import data of ImportType when in detached state, and can also export its data to ExportType. + */ +export interface IImportExportModel { + /** + * importData must be called after initialization but before modifying or attaching the model (i.e. can only + * be called on an unaltered, detached model). + */ + importData: (initialData: ImportType) => Promise; + + /** + * Export the data from the model. Can be passed into importData() for a new container to replicate the data. + */ + exportData: () => Promise; + + /** + * Permit format checking in a generic manner - without knowing the type of our data or the type of the model, + * we can still check whether the model supports that data. + */ + supportsDataFormat: (initialData: unknown) => initialData is ImportType; +} + +// TODO: Is there a better way to express the unknown format here? I think I'd prefer to put the burden of calling +// supportsDataFormat() on the callers of importData() (and allow implementers of IMigratableModel to assume +// importData() is called with valid data). +/** + * A model which supports migration via the MigrationTool and Migrator. + * + * @privateRemarks + * A migratable model must have an observable version, which is used to determine if migration is required and to + * identify the source and destination container codes. + * + * It must also support import/export, as this is the mechanism that MigrationTool and Migrator use to perform the + * migration. + * + * Lastly, it should provide dispose capabilities for two purposes: (1) The Migrator will spawn a temporary model + * to export the data, which should be cleaned up after export and (2) After migration is complete, the old model + * is likely no longer needed and should be cleaned up. + */ +export interface IMigratableModel + extends IVersionedModel, + IImportExportModel {} diff --git a/examples/version-migration/separate-container/src/modelVersion1/appModel.ts b/examples/version-migration/separate-container/src/modelVersion1/appModel.ts index a29d58058cea..3d454e58e813 100644 --- a/examples/version-migration/separate-container/src/modelVersion1/appModel.ts +++ b/examples/version-migration/separate-container/src/modelVersion1/appModel.ts @@ -3,11 +3,8 @@ * Licensed under the MIT License. */ -import type { IMigratableModel } from "@fluid-example/migration-tools/internal"; -import { AttachState } from "@fluidframework/container-definitions"; -import { IContainer } from "@fluidframework/container-definitions/internal"; - import { parseStringDataVersionOne, readVersion } from "../dataTransform.js"; +import type { IMigratableModel } from "../migratableModel.js"; import type { IInventoryList, IInventoryListAppModel } from "../modelInterfaces.js"; // This type represents a stronger expectation than just any string - it needs to be in the right format. @@ -23,10 +20,7 @@ export class InventoryListAppModel implements IInventoryListAppModel, IMigratabl // To be used by the consumer of the model to pair with an appropriate view. public readonly version = "one"; - public constructor( - public readonly inventoryList: IInventoryList, - private readonly container: IContainer, - ) {} + public constructor(public readonly inventoryList: IInventoryList) {} public readonly supportsDataFormat = ( initialData: unknown, @@ -34,12 +28,7 @@ export class InventoryListAppModel implements IInventoryListAppModel, IMigratabl return typeof initialData === "string" && readVersion(initialData) === "one"; }; - // Ideally, prevent this from being called after the container has been modified at all -- i.e. only support - // importing data into a completely untouched InventoryListAppModel. public readonly importData = async (initialData: unknown): Promise => { - if (this.container.attachState !== AttachState.Detached) { - throw new Error("Cannot set initial data after attach"); - } if (!this.supportsDataFormat(initialData)) { throw new Error("Data format not supported"); } @@ -59,8 +48,4 @@ export class InventoryListAppModel implements IInventoryListAppModel, IMigratabl }); return `version:one\n${inventoryItemStrings.join("\n")}`; }; - - public dispose(): void { - this.container.dispose(); - } } diff --git a/examples/version-migration/separate-container/src/modelVersion1/containerCode.ts b/examples/version-migration/separate-container/src/modelVersion1/containerCode.ts index 1fb144783c7f..2603a5bb3587 100644 --- a/examples/version-migration/separate-container/src/modelVersion1/containerCode.ts +++ b/examples/version-migration/separate-container/src/modelVersion1/containerCode.ts @@ -6,17 +6,45 @@ import { CompositeEntryPoint, loadCompositeRuntime, - migrationToolEntryPointPiece, + makeMigratorEntryPointPiece, } from "@fluid-example/migration-tools/internal"; import type { + IContainer, IContainerContext, IRuntime, IRuntimeFactory, } from "@fluidframework/container-definitions/internal"; import type { IContainerRuntimeOptions } from "@fluidframework/container-runtime/internal"; +import type { IMigratableModel } from "../migratableModel.js"; + import { modelEntryPointPiece } from "./modelEntryPointPiece.js"; +/** + * Helper function for casting the container's entrypoint to the expected type. Does a little extra + * type checking for added safety. + */ +const getModelFromContainer = async (container: IContainer): Promise => { + const entryPoint = (await container.getEntryPoint()) as { + model: ModelType; + }; + + // If the user tries to use this with an incompatible container runtime, we want to give them + // a comprehensible error message. So distrust the type by default and do some basic type checking. + // TODO: Now that this all lives in the container code we can probably make some stronger type assumptions. + if (typeof entryPoint.model !== "object") { + throw new TypeError("Incompatible container runtime: doesn't provide model"); + } + + return entryPoint.model; +}; + +const exportDataCallback = async (container: IContainer): Promise => { + // TODO: verify IMigratableModel + const exportModel = await getModelFromContainer(container); + return exportModel.exportData(); +}; + /** * @internal */ @@ -46,7 +74,8 @@ export class InventoryListContainerRuntimeFactory implements IRuntimeFactory { ): Promise { const compositeEntryPoint = new CompositeEntryPoint(); compositeEntryPoint.addEntryPointPiece(modelEntryPointPiece); - compositeEntryPoint.addEntryPointPiece(migrationToolEntryPointPiece); + const migratorEntryPointPiece = makeMigratorEntryPointPiece(exportDataCallback); + compositeEntryPoint.addEntryPointPiece(migratorEntryPointPiece); return loadCompositeRuntime(context, existing, compositeEntryPoint, this.runtimeOptions); } } diff --git a/examples/version-migration/separate-container/src/modelVersion1/modelEntryPointPiece.ts b/examples/version-migration/separate-container/src/modelVersion1/modelEntryPointPiece.ts index df50a911842d..23c6c5b7ef80 100644 --- a/examples/version-migration/separate-container/src/modelVersion1/modelEntryPointPiece.ts +++ b/examples/version-migration/separate-container/src/modelVersion1/modelEntryPointPiece.ts @@ -3,10 +3,9 @@ * Licensed under the MIT License. */ -// TODO: Note that this would theoretically come from some model loading package, not migration-tools. +// TODO: Note that this would theoretically come from some composite entry point package, not migration-tools. // Maybe move back into example-utils for the short-term import type { IEntryPointPiece } from "@fluid-example/migration-tools/internal"; -import type { IContainer } from "@fluidframework/container-definitions/internal"; import type { IContainerRuntime } from "@fluidframework/container-runtime-definitions/internal"; import type { FluidObject } from "@fluidframework/core-interfaces"; @@ -15,7 +14,7 @@ import type { IInventoryList } from "../modelInterfaces.js"; import { InventoryListAppModel } from "./appModel.js"; import { InventoryListInstantiationFactory } from "./inventoryList.js"; -const modelEntryPointPieceName = "getModel"; +const modelEntryPointPieceName = "model"; const inventoryListAlias = "default-inventory-list"; @@ -32,14 +31,12 @@ async function getDataStoreEntryPoint( return entryPointHandle.get(); } -const createPiece = async ( - runtime: IContainerRuntime, -): Promise<(container: IContainer) => Promise> => { - return async (container: IContainer) => - new InventoryListAppModel( - (await getDataStoreEntryPoint(runtime, inventoryListAlias)) as IInventoryList, - container, - ); +const createPiece = async (runtime: IContainerRuntime): Promise => { + const inventoryList = (await getDataStoreEntryPoint( + runtime, + inventoryListAlias, + )) as IInventoryList; + return new InventoryListAppModel(inventoryList); }; export const modelEntryPointPiece: IEntryPointPiece = { diff --git a/examples/version-migration/separate-container/src/modelVersion2/appModel.ts b/examples/version-migration/separate-container/src/modelVersion2/appModel.ts index 83384aced95c..3b1cea76ce7a 100644 --- a/examples/version-migration/separate-container/src/modelVersion2/appModel.ts +++ b/examples/version-migration/separate-container/src/modelVersion2/appModel.ts @@ -3,11 +3,8 @@ * Licensed under the MIT License. */ -import type { IMigratableModel } from "@fluid-example/migration-tools/internal"; -import { AttachState } from "@fluidframework/container-definitions"; -import { IContainer } from "@fluidframework/container-definitions/internal"; - import { parseStringDataVersionTwo, readVersion } from "../dataTransform.js"; +import type { IMigratableModel } from "../migratableModel.js"; import type { IInventoryList, IInventoryListAppModel } from "../modelInterfaces.js"; // This type represents a stronger expectation than just any string - it needs to be in the right format. @@ -23,10 +20,7 @@ export class InventoryListAppModel implements IInventoryListAppModel, IMigratabl // To be used by the consumer of the model to pair with an appropriate view. public readonly version = "two"; - public constructor( - public readonly inventoryList: IInventoryList, - private readonly container: IContainer, - ) {} + public constructor(public readonly inventoryList: IInventoryList) {} public readonly supportsDataFormat = ( initialData: unknown, @@ -34,12 +28,7 @@ export class InventoryListAppModel implements IInventoryListAppModel, IMigratabl return typeof initialData === "string" && readVersion(initialData) === "two"; }; - // Ideally, prevent this from being called after the container has been modified at all -- i.e. only support - // importing data into a completely untouched InventoryListAppModel. public readonly importData = async (initialData: unknown): Promise => { - if (this.container.attachState !== AttachState.Detached) { - throw new Error("Cannot set initial data after attach"); - } if (!this.supportsDataFormat(initialData)) { throw new Error("Data format not supported"); } @@ -59,8 +48,4 @@ export class InventoryListAppModel implements IInventoryListAppModel, IMigratabl }); return `version:two\n${inventoryItemStrings.join("\n")}`; }; - - public dispose(): void { - this.container.dispose(); - } } diff --git a/examples/version-migration/separate-container/src/modelVersion2/containerCode.ts b/examples/version-migration/separate-container/src/modelVersion2/containerCode.ts index 1fb144783c7f..254162a5ca47 100644 --- a/examples/version-migration/separate-container/src/modelVersion2/containerCode.ts +++ b/examples/version-migration/separate-container/src/modelVersion2/containerCode.ts @@ -6,17 +6,46 @@ import { CompositeEntryPoint, loadCompositeRuntime, - migrationToolEntryPointPiece, + makeMigratorEntryPointPiece, } from "@fluid-example/migration-tools/internal"; import type { + IContainer, IContainerContext, IRuntime, IRuntimeFactory, } from "@fluidframework/container-definitions/internal"; import type { IContainerRuntimeOptions } from "@fluidframework/container-runtime/internal"; +import type { IMigratableModel } from "../migratableModel.js"; + import { modelEntryPointPiece } from "./modelEntryPointPiece.js"; +/** + * Helper function for casting the container's entrypoint to the expected type. Does a little extra + * type checking for added safety. + */ +const getModelFromContainer = async (container: IContainer): Promise => { + const entryPoint = (await container.getEntryPoint()) as { + model: ModelType; + }; + + // If the user tries to use this with an incompatible container runtime, we want to give them + // a comprehensible error message. So distrust the type by default and do some basic type checking. + // TODO: Now that this all lives in the container code we can probably make some stronger type assumptions. + if (typeof entryPoint.model !== "object") { + throw new TypeError("Incompatible container runtime: doesn't provide model"); + } + + return entryPoint.model; +}; + +// TODO: Can/should we access the model more directly than going through the IContainer? +const exportDataCallback = async (container: IContainer): Promise => { + // TODO: verify IMigratableModel + const exportModel = await getModelFromContainer(container); + return exportModel.exportData(); +}; + /** * @internal */ @@ -46,7 +75,8 @@ export class InventoryListContainerRuntimeFactory implements IRuntimeFactory { ): Promise { const compositeEntryPoint = new CompositeEntryPoint(); compositeEntryPoint.addEntryPointPiece(modelEntryPointPiece); - compositeEntryPoint.addEntryPointPiece(migrationToolEntryPointPiece); + const migratorEntryPointPiece = makeMigratorEntryPointPiece(exportDataCallback); + compositeEntryPoint.addEntryPointPiece(migratorEntryPointPiece); return loadCompositeRuntime(context, existing, compositeEntryPoint, this.runtimeOptions); } } diff --git a/examples/version-migration/separate-container/src/modelVersion2/modelEntryPointPiece.ts b/examples/version-migration/separate-container/src/modelVersion2/modelEntryPointPiece.ts index 06e67c8fb1ea..23c6c5b7ef80 100644 --- a/examples/version-migration/separate-container/src/modelVersion2/modelEntryPointPiece.ts +++ b/examples/version-migration/separate-container/src/modelVersion2/modelEntryPointPiece.ts @@ -3,19 +3,18 @@ * Licensed under the MIT License. */ -// TODO: Note that this would theoretically come from some model loading package, not migration-tools. +// TODO: Note that this would theoretically come from some composite entry point package, not migration-tools. // Maybe move back into example-utils for the short-term import type { IEntryPointPiece } from "@fluid-example/migration-tools/internal"; -import type { IContainer } from "@fluidframework/container-definitions/internal"; import type { IContainerRuntime } from "@fluidframework/container-runtime-definitions/internal"; import type { FluidObject } from "@fluidframework/core-interfaces"; -import type { IInventoryList, IInventoryListAppModel } from "../modelInterfaces.js"; +import type { IInventoryList } from "../modelInterfaces.js"; import { InventoryListAppModel } from "./appModel.js"; import { InventoryListInstantiationFactory } from "./inventoryList.js"; -const modelEntryPointPieceName = "getModel"; +const modelEntryPointPieceName = "model"; const inventoryListAlias = "default-inventory-list"; @@ -32,14 +31,12 @@ async function getDataStoreEntryPoint( return entryPointHandle.get(); } -const createPiece = async ( - runtime: IContainerRuntime, -): Promise<(container: IContainer) => Promise> => { - return async (container: IContainer) => - new InventoryListAppModel( - (await getDataStoreEntryPoint(runtime, inventoryListAlias)) as IInventoryList, - container, - ); +const createPiece = async (runtime: IContainerRuntime): Promise => { + const inventoryList = (await getDataStoreEntryPoint( + runtime, + inventoryListAlias, + )) as IInventoryList; + return new InventoryListAppModel(inventoryList); }; export const modelEntryPointPiece: IEntryPointPiece = { diff --git a/examples/version-migration/separate-container/src/start.ts b/examples/version-migration/separate-container/src/start.ts index 47a6893889a6..9eb93fb3cb54 100644 --- a/examples/version-migration/separate-container/src/start.ts +++ b/examples/version-migration/separate-container/src/start.ts @@ -4,15 +4,20 @@ */ import type { - IMigratableModel, - IMigrationTool, - IVersionedModel, + IMigrator, + IMigratorEntryPoint, + ImportDataCallback, + SeparateContainerMigrationResult, } from "@fluid-example/migration-tools/internal"; import { - getModelAndMigrationToolFromContainer, - Migrator, - SimpleLoader, + makeCreateDetachedContainerCallback, + makeSeparateContainerMigrationCallback, } from "@fluid-example/migration-tools/internal"; +import type { IContainer } from "@fluidframework/container-definitions/internal"; +import { + type ILoaderProps, + loadExistingContainer, +} from "@fluidframework/container-loader/internal"; import { RouterliciousDocumentServiceFactory } from "@fluidframework/routerlicious-driver/internal"; import { InsecureTinyliciousTokenProvider, @@ -25,6 +30,7 @@ import { createRoot, type Root } from "react-dom/client"; import { inventoryListDataTransformationCallback } from "./dataTransform.js"; import { DemoCodeLoader } from "./demoCodeLoader.js"; +import type { IMigratableModel, IVersionedModel } from "./migratableModel.js"; import type { IInventoryListAppModel } from "./modelInterfaces.js"; import { DebugView, InventoryListAppView } from "./view/index.js"; @@ -44,10 +50,60 @@ const isIInventoryListAppModel = ( const getUrlForContainerId = (containerId: string): string => `/#${containerId}`; +const loaderProps: ILoaderProps = { + urlResolver: new InsecureTinyliciousUrlResolver(), + documentServiceFactory: new RouterliciousDocumentServiceFactory( + new InsecureTinyliciousTokenProvider(), + ), + codeLoader: new DemoCodeLoader(), +}; + +const createDetachedCallback = makeCreateDetachedContainerCallback( + loaderProps, + createTinyliciousCreateNewRequest, +); + +const importDataCallback: ImportDataCallback = async ( + destinationContainer: IContainer, + exportedData: unknown, +) => { + const destinationModel = await getModelFromContainer(destinationContainer); + // If the migrated model already supports the data format, go ahead with the migration. + // Otherwise, try using the dataTransformationCallback if provided to get the exported data into + // a format that we can import. + // TODO: Error paths in case the format isn't ingestible. + const transformedData = destinationModel.supportsDataFormat(exportedData) + ? exportedData + : await inventoryListDataTransformationCallback(exportedData, destinationModel.version); + await destinationModel.importData(transformedData); +}; +const migrationCallback = makeSeparateContainerMigrationCallback( + createDetachedCallback, + importDataCallback, +); + +/** + * Helper function for casting the container's entrypoint to the expected type. Does a little extra + * type checking for added safety. + */ +const getModelFromContainer = async (container: IContainer): Promise => { + const entryPoint = (await container.getEntryPoint()) as { + model: ModelType; + }; + + // If the user tries to use this with an incompatible container runtime, we want to give them + // a comprehensible error message. So distrust the type by default and do some basic type checking. + if (typeof entryPoint.model !== "object") { + throw new TypeError("Incompatible container runtime: doesn't provide model"); + } + + return entryPoint.model; +}; + let appRoot: Root | undefined; let debugRoot: Root | undefined; -const renderModel = (model: IVersionedModel, migrationTool: IMigrationTool): void => { +const renderModel = (model: IVersionedModel, migrator: IMigrator): void => { // This demo uses the same view for both versions 1 & 2 - if we wanted to use different views for different model // versions, we could check its version here and select the appropriate view. Or we could even write ourselves a // view code loader to pull in the view dynamically based on the version we discover. @@ -57,7 +113,7 @@ const renderModel = (model: IVersionedModel, migrationTool: IMigrationTool): voi appRoot.unmount(); } appRoot = createRoot(appDiv); - appRoot.render(createElement(InventoryListAppView, { model, migrationTool })); + appRoot.render(createElement(InventoryListAppView, { model, migrator })); // The DebugView is just for demo purposes, to manually control code proposal and inspect the state. const debugDiv = document.querySelector("#debug") as HTMLDivElement; @@ -68,7 +124,7 @@ const renderModel = (model: IVersionedModel, migrationTool: IMigrationTool): voi debugRoot.render( createElement(DebugView, { model, - migrationTool, + migrator, getUrlForContainerId, }), ); @@ -77,84 +133,52 @@ const renderModel = (model: IVersionedModel, migrationTool: IMigrationTool): voi } }; -async function start(): Promise { - const loader = new SimpleLoader({ - urlResolver: new InsecureTinyliciousUrlResolver(), - documentServiceFactory: new RouterliciousDocumentServiceFactory( - new InsecureTinyliciousTokenProvider(), - ), - codeLoader: new DemoCodeLoader(), - generateCreateNewRequest: createTinyliciousCreateNewRequest, +const setupContainer = async ( + id: string, + alreadyLoadedContainer?: IContainer | undefined, +): Promise => { + // The first createDetached flow ends up with a live container reference that we want to retain rather + // than disposing it and loading a second time. In all other cases we'll do the actual load here. + const container = + alreadyLoadedContainer ?? + (await loadExistingContainer({ ...loaderProps, request: { url: id } })); + const model = await getModelFromContainer(container); + + // In this example, our container code mixes in an IMigratorEntryPoint to the container entryPoint. The getMigrator + // function lets us construct an IMigrator by providing the necessary external tools it needs to operate. The IMigrator + // is an object we can use to watch migration status, propose a migration, and discover the migration result. + const { getMigrator } = (await container.getEntryPoint()) as IMigratorEntryPoint; + const migrator: IMigrator = await getMigrator( + // Note that the LoadSourceContainerCallback must load a new instance of the container. We cannot simply return the + // container reference we already got above since it may contain local un-ack'd changes. + async () => loadExistingContainer({ ...loaderProps, request: { url: id } }), + migrationCallback, + ); + migrator.events.on("migrated", () => { + const newContainerId = migrator.migrationResult as SeparateContainerMigrationResult; + container.dispose(); + setupContainer(newContainerId).catch(console.error); }); + renderModel(model, migrator); + updateTabForId(id); +}; + +async function start(): Promise { let id: string; - let model: IMigratableModel; - let migrationTool: IMigrationTool; + let container: IContainer | undefined; if (location.hash.length === 0) { // Choosing to create with the "old" version for demo purposes, so we can demo the upgrade flow. // Normally we would create with the most-recent version. - const { container, attach } = await loader.createDetached("one"); - const modelAndMigrationTool = - await getModelAndMigrationToolFromContainer(container); - model = modelAndMigrationTool.model; - migrationTool = modelAndMigrationTool.migrationTool; - id = await attach(); + const createDetachedResult = await createDetachedCallback("one"); + container = createDetachedResult.container; + id = await createDetachedResult.attach(); } else { id = location.hash.slice(1); - const container = await loader.loadExisting(id); - const modelAndMigrationTool = - await getModelAndMigrationToolFromContainer(container); - model = modelAndMigrationTool.model; - migrationTool = modelAndMigrationTool.migrationTool; } - // The Migrator takes the starting state (model and id) and watches for a migration proposal. It encapsulates - // the migration logic and just lets us know when a new model is loaded and available (with the "migrated" event). - // It also takes a dataTransformationCallback to help in transforming data export format to be compatible for - // import with newly created models. - const migrator = new Migrator( - loader, - model, - migrationTool, - id, - inventoryListDataTransformationCallback, - ); - migrator.events.on("migrated", () => { - model.dispose(); - model = migrator.currentModel; - migrationTool = migrator.currentMigrationTool; - renderModel(model, migrationTool); - updateTabForId(migrator.currentModelId); - }); - // If the loader doesn't know how to load the container code required for migration, it emits "migrationNotSupported". - // For example, this might be hit if another client has a newer loader and proposes a version our - // loader doesn't know about. - // However, this will never be hit in this demo since we have a finite set of container codes to support. If the - // code loader pulls in the appropriate code dynamically, this might also never be hit since all clients - // are theoretically referencing the same code library. - migrator.events.on("migrationNotSupported", (version: string) => { - // To move forward, we would need to acquire a loader capable of loading the given code, retry the - // load, and set up a new Migrator with the new loader. - console.error( - `Tried to migrate to version ${version} which is not supported by the current loader`, - ); - }); - - // This would be a good point to trigger normal upgrade logic - we're fully set up for migration, can inspect the - // model, and haven't rendered yet. We could even migrate multiple times if necessary (e.g. if daisy-chaining is - // required). E.g. something like: - // let versionToPropose: string; - // while (versionToPropose = await getMigrationTargetFromSomeService(model.version)) { - // model.proposeVersion(versionToPropose); - // await new Promise((resolve) => { - // migrator.once("migrated", resolve); - // }); - // } - // In this demo however, we trigger the proposal through the debug buttons. - - renderModel(model, migrationTool); - updateTabForId(id); + await setupContainer(id, container); } await start(); diff --git a/examples/version-migration/separate-container/src/view/appView.tsx b/examples/version-migration/separate-container/src/view/appView.tsx index 7dd433d8c260..69608cc1d1e5 100644 --- a/examples/version-migration/separate-container/src/view/appView.tsx +++ b/examples/version-migration/separate-container/src/view/appView.tsx @@ -3,7 +3,7 @@ * Licensed under the MIT License. */ -import type { IMigrationTool } from "@fluid-example/migration-tools/internal"; +import type { IMigrator } from "@fluid-example/migration-tools/internal"; import React, { useEffect, useState } from "react"; import type { IInventoryListAppModel } from "../modelInterfaces.js"; @@ -12,9 +12,8 @@ import { InventoryListView } from "./inventoryView.js"; export interface IInventoryListAppViewProps { model: IInventoryListAppModel; - // TODO: All we really want here is a "readonly" indicator - maybe don't need the full IMigrationTool interface. - // Would maybe be better to grab that info from the Migrator rather than the MigrationTool anyway? - migrationTool: IMigrationTool; + // TODO: All we really want here is a "readonly" indicator - maybe don't need the full IMigrator interface. + migrator: IMigrator; } /** @@ -26,26 +25,26 @@ export interface IInventoryListAppViewProps { export const InventoryListAppView: React.FC = ( props: IInventoryListAppViewProps, ) => { - const { model, migrationTool } = props; + const { model, migrator } = props; const [disableInput, setDisableInput] = useState( - migrationTool.migrationState !== "collaborating", + migrator.migrationState !== "collaborating", ); useEffect(() => { const migrationStateChangedHandler = (): void => { - setDisableInput(migrationTool.migrationState !== "collaborating"); + setDisableInput(migrator.migrationState !== "collaborating"); }; - migrationTool.events.on("stopping", migrationStateChangedHandler); - migrationTool.events.on("migrating", migrationStateChangedHandler); - migrationTool.events.on("migrated", migrationStateChangedHandler); + migrator.events.on("stopping", migrationStateChangedHandler); + migrator.events.on("migrating", migrationStateChangedHandler); + migrator.events.on("migrated", migrationStateChangedHandler); migrationStateChangedHandler(); return () => { - migrationTool.events.off("stopping", migrationStateChangedHandler); - migrationTool.events.off("migrating", migrationStateChangedHandler); - migrationTool.events.off("migrated", migrationStateChangedHandler); + migrator.events.off("stopping", migrationStateChangedHandler); + migrator.events.off("migrating", migrationStateChangedHandler); + migrator.events.off("migrated", migrationStateChangedHandler); }; - }, [migrationTool]); + }, [migrator]); return ; }; diff --git a/examples/version-migration/separate-container/src/view/debugView.tsx b/examples/version-migration/separate-container/src/view/debugView.tsx index 8619c90b1ea6..94aeff30fdc5 100644 --- a/examples/version-migration/separate-container/src/view/debugView.tsx +++ b/examples/version-migration/separate-container/src/view/debugView.tsx @@ -3,53 +3,50 @@ * Licensed under the MIT License. */ -import type { - IMigratableModel, - IMigrationTool, - MigrationState, -} from "@fluid-example/migration-tools/internal"; +import type { IMigrator, MigrationState } from "@fluid-example/migration-tools/internal"; import React, { useEffect, useState } from "react"; +import type { IMigratableModel } from "../migratableModel.js"; import type { IInventoryListAppModel } from "../modelInterfaces.js"; export interface IDebugViewProps { model: IInventoryListAppModel & IMigratableModel; - migrationTool: IMigrationTool; + migrator: IMigrator; getUrlForContainerId?: (containerId: string) => string; } export const DebugView: React.FC = (props: IDebugViewProps) => { - const { model, migrationTool, getUrlForContainerId } = props; + const { model, migrator, getUrlForContainerId } = props; const [disableControls, setDisableControls] = useState( - migrationTool.migrationState !== "collaborating", + migrator.migrationState !== "collaborating", ); useEffect(() => { const migrationStateChangedHandler = (): void => { - setDisableControls(migrationTool.migrationState !== "collaborating"); + setDisableControls(migrator.migrationState !== "collaborating"); }; - migrationTool.events.on("stopping", migrationStateChangedHandler); - migrationTool.events.on("migrating", migrationStateChangedHandler); - migrationTool.events.on("migrated", migrationStateChangedHandler); + migrator.events.on("stopping", migrationStateChangedHandler); + migrator.events.on("migrating", migrationStateChangedHandler); + migrator.events.on("migrated", migrationStateChangedHandler); migrationStateChangedHandler(); return () => { - migrationTool.events.off("stopping", migrationStateChangedHandler); - migrationTool.events.off("migrating", migrationStateChangedHandler); - migrationTool.events.off("migrated", migrationStateChangedHandler); + migrator.events.off("stopping", migrationStateChangedHandler); + migrator.events.off("migrating", migrationStateChangedHandler); + migrator.events.off("migrated", migrationStateChangedHandler); }; - }, [migrationTool]); + }, [migrator]); return (

Debug info

@@ -59,52 +56,53 @@ export const DebugView: React.FC = (props: IDebugViewProps) => interface IMigrationStatusViewProps { readonly model: IMigratableModel; - readonly migrationTool: IMigrationTool; + readonly migrator: IMigrator; readonly getUrlForContainerId?: (containerId: string) => string; } const MigrationStatusView: React.FC = ( props: IMigrationStatusViewProps, ) => { - const { model, migrationTool, getUrlForContainerId } = props; + const { model, migrator, getUrlForContainerId } = props; const [migrationState, setMigrationState] = useState( - migrationTool.migrationState, + migrator.migrationState, ); useEffect(() => { const migrationStateChangedHandler = (): void => { - setMigrationState(migrationTool.migrationState); + setMigrationState(migrator.migrationState); }; - migrationTool.events.on("stopping", migrationStateChangedHandler); - migrationTool.events.on("migrating", migrationStateChangedHandler); - migrationTool.events.on("migrated", migrationStateChangedHandler); + migrator.events.on("stopping", migrationStateChangedHandler); + migrator.events.on("migrating", migrationStateChangedHandler); + migrator.events.on("migrated", migrationStateChangedHandler); migrationStateChangedHandler(); return () => { - migrationTool.events.off("stopping", migrationStateChangedHandler); - migrationTool.events.off("migrating", migrationStateChangedHandler); - migrationTool.events.off("migrated", migrationStateChangedHandler); + migrator.events.off("stopping", migrationStateChangedHandler); + migrator.events.off("migrating", migrationStateChangedHandler); + migrator.events.off("migrated", migrationStateChangedHandler); }; - }, [migrationTool]); + }, [migrator]); const proposedVersionStatus = - migrationTool.proposedVersion === undefined + migrator.proposedVersion === undefined ? "No proposed version for migration yet" - : `Proposed version to migrate to: ${migrationTool.proposedVersion}`; + : `Proposed version to migrate to: ${migrator.proposedVersion}`; const acceptedVersionStatus = - migrationTool.acceptedMigration === undefined + migrator.acceptedMigration === undefined ? "No accepted version for migration yet" - : `Accepted version to migrate to: ${migrationTool.acceptedMigration.newVersion} @ sequenceNumber: ${migrationTool.acceptedMigration.migrationSequenceNumber}`; + : `Accepted version to migrate to: ${migrator.acceptedMigration.newVersion} @ sequenceNumber: ${migrator.acceptedMigration.migrationSequenceNumber}`; const migratedContainerStatus = ((): JSX.Element => { - if (migrationTool.newContainerId === undefined) { + const migrationResult = migrator.migrationResult as string; + if (migrationResult === undefined) { return <>No migrated container yet; } const navToNewContainer = (): void => { - if (migrationTool.newContainerId !== undefined && getUrlForContainerId !== undefined) { - location.href = getUrlForContainerId(migrationTool.newContainerId); + if (migrationResult !== undefined && getUrlForContainerId !== undefined) { + location.href = getUrlForContainerId(migrationResult); location.reload(); } }; @@ -113,13 +111,10 @@ const MigrationStatusView: React.FC = ( // Otherwise just use the string representation of the container id. const migratedReference = getUrlForContainerId === undefined ? ( - migrationTool.newContainerId + migrationResult ) : ( - - {migrationTool.newContainerId} + + {migrationResult} ); diff --git a/examples/version-migration/separate-container/tests/index.ts b/examples/version-migration/separate-container/tests/index.ts new file mode 100644 index 000000000000..b32f62384a7d --- /dev/null +++ b/examples/version-migration/separate-container/tests/index.ts @@ -0,0 +1,237 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { + type IMigrator, + type IMigratorEntryPoint, + type ImportDataCallback, + makeCreateDetachedContainerCallback, + makeSeparateContainerMigrationCallback, + type SeparateContainerMigrationResult, +} from "@fluid-example/migration-tools/internal"; +import type { IContainer } from "@fluidframework/container-definitions/internal"; +import { + loadExistingContainer, + type ILoaderProps, +} from "@fluidframework/container-loader/internal"; +import { + createLocalResolverCreateNewRequest, + LocalDocumentServiceFactory, + LocalResolver, + LocalSessionStorageDbFactory, +} from "@fluidframework/local-driver/internal"; +import { LocalDeltaConnectionServer } from "@fluidframework/server-local-server"; +import { v4 as uuid } from "uuid"; + +import { createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; + +import { inventoryListDataTransformationCallback } from "../src/dataTransform.js"; +import { DemoCodeLoader } from "../src/demoCodeLoader.js"; +import type { IMigratableModel, IVersionedModel } from "../src/migratableModel.js"; +import type { IInventoryListAppModel } from "../src/modelInterfaces.js"; +import { DebugView, InventoryListAppView } from "../src/view/index.js"; + +// Store the top-level containers on the window so our tests can more easily observe the migration happening +// eslint-disable-next-line @typescript-eslint/dot-notation +window["containers"] = []; +// Store the migrators on the window so our tests can more easily observe the migration happening +// eslint-disable-next-line @typescript-eslint/dot-notation +window["migrators"] = []; + +const updateTabForId = (id: string) => { + // Update the URL with the actual ID + location.hash = id; + + // Put the ID in the tab title + document.title = id; +}; + +const isIInventoryListAppModel = ( + model: IVersionedModel, +): model is IInventoryListAppModel & IMigratableModel => { + return model.version === "one" || model.version === "two"; +}; + +const getUrlForContainerId = (containerId: string) => `/#${containerId}`; + +const urlResolver = new LocalResolver(); +const localServer = LocalDeltaConnectionServer.create(new LocalSessionStorageDbFactory()); + +const searchParams = new URLSearchParams(location.search); +const testMode = searchParams.get("testMode") !== null; +const loaderProps: ILoaderProps = { + urlResolver, + documentServiceFactory: new LocalDocumentServiceFactory(localServer), + codeLoader: new DemoCodeLoader(testMode), +}; + +const createDetachedCallback = makeCreateDetachedContainerCallback(loaderProps, () => + createLocalResolverCreateNewRequest(uuid()), +); + +const importDataCallback: ImportDataCallback = async ( + destinationContainer: IContainer, + exportedData: unknown, +) => { + const destinationModel = await getModelFromContainer(destinationContainer); + // If the migrated model already supports the data format, go ahead with the migration. + // Otherwise, try using the dataTransformationCallback if provided to get the exported data into + // a format that we can import. + // TODO: Error paths in case the format isn't ingestible. + const transformedData = destinationModel.supportsDataFormat(exportedData) + ? exportedData + : await inventoryListDataTransformationCallback(exportedData, destinationModel.version); + await destinationModel.importData(transformedData); +}; +const migrationCallback = makeSeparateContainerMigrationCallback( + createDetachedCallback, + importDataCallback, +); + +/** + * Helper function for casting the container's entrypoint to the expected type. Does a little extra + * type checking for added safety. + */ +const getModelFromContainer = async (container: IContainer): Promise => { + const entryPoint = (await container.getEntryPoint()) as { + model: ModelType; + }; + + // If the user tries to use this with an incompatible container runtime, we want to give them + // a comprehensible error message. So distrust the type by default and do some basic type checking. + if (typeof entryPoint.model !== "object") { + throw new TypeError("Incompatible container runtime: doesn't provide model"); + } + + return entryPoint.model; +}; + +/** + * This is a helper function for loading the page. It's required because getting the Fluid Container + * requires making async calls. + */ +export async function createContainerAndRenderInElement(element: HTMLDivElement) { + let id: string; + let container: IContainer | undefined; + + if (location.hash.length === 0) { + // Choosing to create with the "old" version for demo purposes, so we can demo the upgrade flow. + // Normally we would create with the most-recent version. + const createDetachedResult = await createDetachedCallback("one"); + container = createDetachedResult.container; + id = await createDetachedResult.attach(); + } else { + id = location.hash.slice(1); + } + + const appDiv = document.createElement("div"); + const debugDiv = document.createElement("div"); + + let appRoot: Root | undefined; + let debugRoot: Root | undefined; + + const renderModel = (model: IVersionedModel, migrator: IMigrator): void => { + // This demo uses the same view for both versions 1 & 2 - if we wanted to use different views for different model + // versions, we could check its version here and select the appropriate view. Or we could even write ourselves a + // view code loader to pull in the view dynamically based on the version we discover. + if (isIInventoryListAppModel(model)) { + if (appRoot !== undefined) { + appRoot.unmount(); + } + appRoot = createRoot(appDiv); + appRoot.render(createElement(InventoryListAppView, { model, migrator })); + + // The DebugView is just for demo purposes, to manually control code proposal and inspect the state. + if (debugRoot !== undefined) { + debugRoot.unmount(); + } + debugRoot = createRoot(debugDiv); + debugRoot.render( + createElement(DebugView, { + model, + migrator, + getUrlForContainerId, + }), + ); + } else { + throw new Error(`Don't know how to render version ${model.version}`); + } + }; + + const setupContainer = async ( + id: string, + alreadyLoadedContainer?: IContainer | undefined, + ): Promise => { + // The first createDetached flow ends up with a live container reference that we want to retain rather + // than disposing it and loading a second time. In all other cases we'll do the actual load here. + const container = + alreadyLoadedContainer ?? + (await loadExistingContainer({ + ...loaderProps, + request: { url: `${window.location.origin}/${id}` }, + })); + const model = await getModelFromContainer(container); + + // In this example, our container code mixes in an IMigratorEntryPoint to the container entryPoint. The getMigrator + // function lets us construct an IMigrator by providing the necessary external tools it needs to operate. The IMigrator + // is an object we can use to watch migration status, propose a migration, and discover the migration result. + const { getMigrator } = (await container.getEntryPoint()) as IMigratorEntryPoint; + const migrator: IMigrator = await getMigrator( + // Note that the LoadSourceContainerCallback must load a new instance of the container. We cannot simply return the + // container reference we already got above since it may contain local un-ack'd changes. + async () => + loadExistingContainer({ + ...loaderProps, + request: { url: `${window.location.origin}/${id}` }, + }), + migrationCallback, + ); + // eslint-disable-next-line @typescript-eslint/dot-notation + window["containers"].push(container); + // eslint-disable-next-line @typescript-eslint/dot-notation + window["migrators"].push(migrator); + migrator.events.on("migrated", () => { + const newContainerId = migrator.migrationResult as SeparateContainerMigrationResult; + container.dispose(); + setupContainer(newContainerId).catch(console.error); + }); + + renderModel(model, migrator); + updateTabForId(id); + }; + + await setupContainer(id, container); + + element.append(appDiv, debugDiv); +} + +/** + * For local testing we have two div's that we are rendering into independently. + */ +async function setup() { + const leftElement = document.getElementById("sbs-left") as HTMLDivElement; + if (leftElement === null) { + throw new Error("sbs-left does not exist"); + } + await createContainerAndRenderInElement(leftElement); + const rightElement = document.getElementById("sbs-right") as HTMLDivElement; + if (rightElement === null) { + throw new Error("sbs-right does not exist"); + } + await createContainerAndRenderInElement(rightElement); + + // Setting "fluidStarted" is just for our test automation + // eslint-disable-next-line @typescript-eslint/dot-notation + window["fluidStarted"] = true; +} + +setup().catch((e) => { + console.error(e); + console.log( + "%cThere were issues setting up and starting the in memory Fluid Server", + "font-size:30px", + ); +}); diff --git a/examples/version-migration/separate-container/tests/index.tsx b/examples/version-migration/separate-container/tests/index.tsx deleted file mode 100644 index be8cbfec7754..000000000000 --- a/examples/version-migration/separate-container/tests/index.tsx +++ /dev/null @@ -1,159 +0,0 @@ -/*! - * Copyright (c) Microsoft Corporation and contributors. All rights reserved. - * Licensed under the MIT License. - */ - -import { - getModelAndMigrationToolFromContainer, - IMigratableModel, - IMigrationTool, - IVersionedModel, - Migrator, - SessionStorageSimpleLoader, -} from "@fluid-example/migration-tools/internal"; - -import { createElement } from "react"; -import { createRoot, type Root } from "react-dom/client"; - -import { inventoryListDataTransformationCallback } from "../src/dataTransform.js"; -import { DemoCodeLoader } from "../src/demoCodeLoader.js"; -import type { IInventoryListAppModel } from "../src/modelInterfaces.js"; -import { DebugView, InventoryListAppView } from "../src/view/index.js"; - -const updateTabForId = (id: string) => { - // Update the URL with the actual ID - location.hash = id; - - // Put the ID in the tab title - document.title = id; -}; - -const isIInventoryListAppModel = ( - model: IVersionedModel, -): model is IInventoryListAppModel & IMigratableModel => { - return model.version === "one" || model.version === "two"; -}; - -const getUrlForContainerId = (containerId: string) => `/#${containerId}`; - -// Store the migrators on the window so our tests can more easily observe the migration happening -// eslint-disable-next-line @typescript-eslint/dot-notation -window["migrators"] = []; - -/** - * This is a helper function for loading the page. It's required because getting the Fluid Container - * requires making async calls. - */ -export async function createContainerAndRenderInElement(element: HTMLDivElement) { - const searchParams = new URLSearchParams(location.search); - const testMode = searchParams.get("testMode") !== null; - const loader = new SessionStorageSimpleLoader(new DemoCodeLoader(testMode)); - let id: string; - let model: IMigratableModel; - let migrationTool: IMigrationTool; - - if (location.hash.length === 0) { - // Choosing to create with the "old" version for demo purposes, so we can demo the upgrade flow. - // Normally we would create with the most-recent version. - const { container, attach } = await loader.createDetached("one"); - const modelAndMigrationTool = - await getModelAndMigrationToolFromContainer(container); - model = modelAndMigrationTool.model; - migrationTool = modelAndMigrationTool.migrationTool; - id = await attach(); - } else { - id = location.hash.slice(1); - const container = await loader.loadExisting(id); - const modelAndMigrationTool = - await getModelAndMigrationToolFromContainer(container); - model = modelAndMigrationTool.model; - migrationTool = modelAndMigrationTool.migrationTool; - } - - const appDiv = document.createElement("div"); - const debugDiv = document.createElement("div"); - - let appRoot: Root | undefined; - let debugRoot: Root | undefined; - - const render = (model: IVersionedModel, migrationTool: IMigrationTool) => { - // This demo uses the same view for both versions 1 & 2 - if we wanted to use different views for different model - // versions, we could check its version here and select the appropriate view. Or we could even write ourselves a - // view code loader to pull in the view dynamically based on the version we discover. - if (isIInventoryListAppModel(model)) { - if (appRoot !== undefined) { - appRoot.unmount(); - } - appRoot = createRoot(appDiv); - appRoot.render(createElement(InventoryListAppView, { model, migrationTool })); - - // The DebugView is just for demo purposes, to manually control code proposal and inspect the state. - if (debugRoot !== undefined) { - debugRoot.unmount(); - } - debugRoot = createRoot(debugDiv); - debugRoot.render( - createElement(DebugView, { - model, - migrationTool, - getUrlForContainerId, - }), - ); - } else { - throw new Error(`Don't know how to render version ${model.version}`); - } - }; - - const migrator = new Migrator( - loader, - model, - migrationTool, - id, - inventoryListDataTransformationCallback, - ); - migrator.events.on("migrated", () => { - model.dispose(); - model = migrator.currentModel; - migrationTool = migrator.currentMigrationTool; - render(model, migrationTool); - updateTabForId(migrator.currentModelId); - }); - - // eslint-disable-next-line @typescript-eslint/dot-notation - window["migrators"].push(migrator); - - // update the browser URL and the window title with the actual container ID - updateTabForId(id); - // Render it - render(model, migrationTool); - - element.append(appDiv, debugDiv); - - // Setting "fluidStarted" is just for our test automation - // eslint-disable-next-line @typescript-eslint/dot-notation - window["fluidStarted"] = true; -} - -/** - * For local testing we have two div's that we are rendering into independently. - */ -async function setup() { - const leftElement = document.getElementById("sbs-left") as HTMLDivElement; - if (leftElement === null) { - throw new Error("sbs-left does not exist"); - } - await createContainerAndRenderInElement(leftElement); - const rightElement = document.getElementById("sbs-right") as HTMLDivElement; - if (rightElement === null) { - throw new Error("sbs-right does not exist"); - } - await createContainerAndRenderInElement(rightElement); -} - -setup().catch((e) => { - console.error(e); - console.log( - "%cThere were issues setting up and starting the in memory Fluid Server", - "font-size:30px", - ); -}); diff --git a/examples/version-migration/separate-container/tests/separateContainer.test.ts b/examples/version-migration/separate-container/tests/separateContainer.test.ts index 5f813bfefc4c..b78984f0d2a6 100644 --- a/examples/version-migration/separate-container/tests/separateContainer.test.ts +++ b/examples/version-migration/separate-container/tests/separateContainer.test.ts @@ -129,10 +129,8 @@ describe("separate-container migration", () => { await page.evaluate(async () => { // This is reaching a bit, but we just need to watch it for test purposes. const leftQuorum = ( - (window["migrators"] as IMigrator[])[0].currentModel as unknown as { - container: IContainer; - } - ).container.getQuorum(); + (window["containers"] as IContainer[])[0] as unknown as IContainer + ).getQuorum(); const alreadyHasSummarizer = [...leftQuorum.getMembers().values()].some( (sequencedClient) => sequencedClient.client.details.type === "summarizer", ); @@ -159,14 +157,16 @@ describe("separate-container migration", () => { // Get a promise that will resolve when both sides have finished migration const migrationP = page.evaluate(async () => { - const migrationPs = (window["migrators"] as IMigrator[]).map(async (migrator) => { + for (const container of window["containers"] as IContainer[]) { // Since we expect this to run before the button click below, nothing should have migrated. // However, we are getting flaky errors and want to rule out the possibility that the puppeteer interaction // is somehow permitting these to occur out of order. Throwing here will cause the returned migrationP - // promise to immediately reject (since Promise.all rejects as soon as the first rejection occurs). - if (migrator.currentModel.version !== "one") { + // promise to immediately reject. + if (container.getSpecifiedCodeDetails()?.package !== "one") { throw new Error("Unexpected early migration!"); } + } + const migrationPs = (window["migrators"] as IMigrator[]).map(async (migrator) => { return new Promise((resolve) => { migrator.events.once("migrated", resolve); }); diff --git a/examples/version-migration/separate-container/webpack.test.cjs b/examples/version-migration/separate-container/webpack.test.cjs index 20c34a4e9d58..ddf4d78d5884 100644 --- a/examples/version-migration/separate-container/webpack.test.cjs +++ b/examples/version-migration/separate-container/webpack.test.cjs @@ -10,7 +10,7 @@ const webpack = require("webpack"); module.exports = (env) => { return { entry: { - app: "./tests/index.tsx", + app: "./tests/index.ts", }, resolve: { extensionAlias: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62d996db4c04..f9f8d8e20813 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4755,9 +4755,6 @@ importers: '@fluidframework/server-local-server': specifier: ^5.0.0 version: 5.0.0 - '@fluidframework/task-manager': - specifier: workspace:~ - version: link:../../../packages/dds/task-manager '@fluidframework/tinylicious-driver': specifier: workspace:~ version: link:../../../packages/drivers/tinylicious-driver @@ -5308,6 +5305,9 @@ importers: '@fluidframework/driver-utils': specifier: workspace:~ version: link:../../../packages/loader/driver-utils + '@fluidframework/local-driver': + specifier: workspace:~ + version: link:../../../packages/drivers/local-driver '@fluidframework/map': specifier: workspace:~ version: link:../../../packages/dds/map @@ -5326,6 +5326,9 @@ importers: '@fluidframework/sequence': specifier: workspace:~ version: link:../../../packages/dds/sequence + '@fluidframework/server-local-server': + specifier: ^5.0.0 + version: 5.0.0 '@fluidframework/task-manager': specifier: workspace:~ version: link:../../../packages/dds/task-manager