Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions .changeset/angry-wings-battle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
---
"@fluidframework/tree": minor
"fluid-framework": minor
"__section": tree
---
Schema snapshot compatibility checker

This change adds alpha APIs for creating snapshots of view schema and testing their compatibility for the purposes
of schema migrations.

New APIs:

- `checkCompatibility` - Checks the compatibility of the view schema which created the document against the view schema
being used to open it.
- `importCompatibilitySchemaSnapshot` - Parse a JSON representation of a tree schema into a concrete schema.
- `exportCompatibilitySchemaSnapshot` - Returns a JSON representation of the tree schema for snapshot compatibility checking.

#### Example: Current view schema vs. historical view schema

An application author is developing an app that has a schema for storing 2D Points.
They wish to maintain backwards compatibility in future versions and avoid changing their view schema in a way that breaks
this behavior.
When introducing a new initial schema, they persists a snapshot using `exportCompatibilitySchemaSnapshot`:

```ts
const factory = new SchemaFactory("test");

// The past view schema, for the purposes of illustration. This wouldn't normally appear as a concrete schema in the test
// checking compatibility, but rather would be loaded from a snapshot.
class Point2D extends factory.object("Point", {
x: factory.number,
y: factory.number,
}) {}
const viewSchema = new TreeViewConfiguration({ schema: Point2D });
const encodedSchema = JSON.stringify(exportCompatibilitySchemaSnapshot(viewSchema));
fs.writeFileSync("PointSchema.json", encodedSchema);
```

Next they create a regression test to ensure that the current view schema can read content written by the original view
schema (`SchemaCompatibilityStatus.canUpgrade`). Initially `currentViewSchema === Point2D`:

```ts
const encodedSchema = JSON.parse(fs.readFileSync("PointSchema.json", "utf8"));
const oldViewSchema = importCompatibilitySchemaSnapshot(encodedSchema);

// Check to see if the document created by the historical view schema can be opened with the current view schema
const compatibilityStatus = checkCompatibility(oldViewSchema, currentViewSchema);

// Check to see if the document created by the historical view schema can be opened with the current view schema
const backwardsCompatibilityStatus = checkCompatibility(oldViewSchema, currentViewSchema);

// z is not present in Point2D, so the schema must be upgraded
assert.equal(backwardsCompatibilityStatus.canView, false);

// The schema can be upgraded to add the new optional field
assert.equal(backwardsCompatibilityStatus.canUpgrade, true);
```

Additionally, they a regression test to ensure that older view schemas can read content written by the current view
schema (`SchemaCompatibilityStatus.canView`):

```ts
// Test what the old version of the application would do with a tree using the new schema:
const forwardsCompatibilityStatus = checkCompatibility(currentViewSchema, oldViewSchema);

// If the old schema set allowUnknownOptionalFields, this would be true, but since it did not,
// this assert will fail, detecting the forwards compatibility break:
// this means these two versions of the application cannot collaborate on content using these schema.
assert.equal(forwardsCompatibilityStatus.canView, true);
```

Later in the application development cycle, the application author decides they want to change their Point2D to
a Point3D, adding an extra field:

```ts
// Build the current view schema
const schemaFactory = new SchemaFactory("test");
class Point3D extends schemaFactory.object("Point", {
x: factory.number,
y: factory.number,

// The current schema has a new optional field that was not present on Point2D
z: factory.optional(factory.number),
}) {}
```

The test first compatibility test will pass as the Point2D schema is upgradeable to a Point3D schema.
However, the second compatibility test fill fail as an application using the Point2D view schema cannot collaborate on
content authored using the Point3D schema.
9 changes: 9 additions & 0 deletions packages/dds/tree/api-report/tree.alpha.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ export interface BranchableTree extends ViewableTree {
rebase(branch: TreeBranchFork): void;
}

// @alpha
export function checkCompatibility(viewWhichCreatedStoredSchema: TreeViewConfiguration, view: TreeViewConfiguration): Omit<SchemaCompatibilityStatus, "canInitialize">;

// @alpha
export function cloneWithReplacements(root: unknown, rootKey: string, replacer: (key: string, value: unknown) => {
clone: boolean;
Expand Down Expand Up @@ -233,6 +236,9 @@ export function enumFromStrings<TScope extends string, const Members extends rea
// @alpha
export function evaluateLazySchema<T extends TreeNodeSchema>(value: LazyItem<T>): T;

// @alpha
export function exportCompatibilitySchemaSnapshot(config: Pick<TreeViewConfiguration, "schema">): JsonCompatibleReadOnly;

// @public @system
type ExtractItemType<Item extends LazyItem> = Item extends () => infer Result ? Result : Item;

Expand Down Expand Up @@ -414,6 +420,9 @@ export type ImplicitAllowedTypes = AllowedTypes | TreeNodeSchema;
// @public
export type ImplicitFieldSchema = FieldSchema | ImplicitAllowedTypes;

// @alpha
export function importCompatibilitySchemaSnapshot(config: JsonCompatibleReadOnly): TreeViewConfiguration;

// @alpha
export function independentInitializedView<const TSchema extends ImplicitFieldSchema>(config: TreeViewConfiguration<TSchema>, options: ForestOptions & ICodecOptions, content: ViewContent): TreeViewAlpha<TSchema>;

Expand Down
3 changes: 3 additions & 0 deletions packages/dds/tree/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,9 @@ export {
type SimpleAllowedTypeAttributes,
encodeSimpleSchema,
decodeSimpleSchema,
exportCompatibilitySchemaSnapshot,
importCompatibilitySchemaSnapshot,
checkCompatibility,
} from "./simple-tree/index.js";
export {
SharedTree,
Expand Down
5 changes: 5 additions & 0 deletions packages/dds/tree/src/simple-tree/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,8 @@ export {
encodeSimpleSchema,
decodeSimpleSchema,
} from "./simpleSchemaCodec.js";
export {
exportCompatibilitySchemaSnapshot,
importCompatibilitySchemaSnapshot,
checkCompatibility,
} from "./snapshotCompatibilityChecker.js";
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/*!
* Copyright (c) Microsoft Corporation and contributors. All rights reserved.
* Licensed under the MIT License.
*/

import type { JsonCompatibleReadOnly } from "../../util/index.js";
import { toStoredSchema } from "../toStoredSchema.js";
import { TreeViewConfigurationAlpha, TreeViewConfiguration } from "./configuration.js";
import { SchemaCompatibilityTester } from "./schemaCompatibilityTester.js";
import { generateSchemaFromSimpleSchema } from "./schemaFromSimple.js";
import { decodeSimpleSchema, encodeSimpleSchema } from "./simpleSchemaCodec.js";
import type { SchemaCompatibilityStatus } from "./tree.js";
import { toSimpleTreeSchema } from "./viewSchemaToSimpleSchema.js";

/**
* Compute the compatibility of using `view` to {@link ViewableTree.viewWith | view a tree} who's {@link ITreeAlpha.exportSimpleSchema | stored schema} could be derived from `viewWhichCreatedStoredSchema` via either {@link TreeView.initialize} or {@link TreeView.upgradeSchema}.
*
* @remarks See {@link SchemaCompatibilityStatus} for details on the compatibility results.
*
* @example This example demonstrates checking the compatibility of a historical schema against a current schema.
* In this case, the historical schema is a Point2D object with x and y fields, while the current schema is a Point3D object
* that adds an optional z field.
*
* ```ts
* // This snapshot is assumed to be the same as Point3D, except missing `z`.
* const encodedSchema = JSON.parse(fs.readFileSync("PointSchema.json", "utf8"));
* const oldViewSchema = importCompatibilitySchemaSnapshot(encodedSchema);
*
* // Build the current view schema
* class Point3D extends factory.object("Point", {
* x: factory.number,
* y: factory.number,
*
* // The current schema has a new optional field that was not present on Point2D
* z: factory.optional(factory.number),
* }) {}
* const currentViewSchema = new TreeViewConfiguration({ schema: Point3D });
*
* // Check to see if the document created by the historical view schema can be opened with the current view schema
* const backwardsCompatibilityStatus = checkCompatibility(oldViewSchema, currentViewSchema);
*
* // z is not present in Point2D, so the schema must be upgraded
* assert.equal(backwardsCompatibilityStatus.canView, false);
*
* // The schema can be upgraded to add the new optional field
* assert.equal(backwardsCompatibilityStatus.canUpgrade, true);
*
* // Test what the old version of the application would do with a tree using the new schema:
* const forwardsCompatibilityStatus = checkCompatibility(currentViewSchema, oldViewSchema);
*
* // If the old schema set allowUnknownOptionalFields, this would be true, but since it did not,
* // this assert will fail, detecting the forwards compatibility break:
* // this means these two versions of the application cannot collaborate on content using these schema.
* assert.equal(forwardsCompatibilityStatus.canView, true);
* ```
*
* @param viewWhichCreatedStoredSchema - From which to derive the stored schema, as if it initialized or upgraded a tree via {@link TreeView}.
* @param view - The view being tested to see if it could view tree created or initialized using `viewWhichCreatedStoredSchema`.
* @returns The compatibility status.
*
* @alpha
*/
export function checkCompatibility(
viewWhichCreatedStoredSchema: TreeViewConfiguration,
view: TreeViewConfiguration,
): Omit<SchemaCompatibilityStatus, "canInitialize"> {
const viewAsAlpha = new TreeViewConfigurationAlpha({ schema: view.schema });
const stored = toStoredSchema(viewWhichCreatedStoredSchema.schema, {
includeStaged: () => true,
});
const tester = new SchemaCompatibilityTester(viewAsAlpha);
return tester.checkCompatibility(stored);
}

/**
* Returns a JSON compatible representation of the tree schema for snapshot compatibility checking.
*
* Snapshots can be loaded by the same or newer package versions, but not necessarily older versions.
*
* @see {@link importCompatibilitySchemaSnapshot} which loads these snapshots.
*
* @param config - The schema to snapshot. Only the schema field of the `TreeViewConfiguration` is used.
* @returns The JSON representation of the schema.
*
* @example This example creates and persists a snapshot of a Point2D schema.
*
* ```ts
* const schemaFactory = new SchemaFactory("test");
* class Point2D extends schemaFactory.object("Point", {
* x: factory.number,
* y: factory.number,
* }) {}
* const viewSchema = new TreeViewConfiguration({ schema: Point2D });
* const encodedSchema = JSON.stringify(exportCompatibilitySchemaSnapshot(viewSchema));
* fs.writeFileSync("PointSchema.json", encodedSchema);
* ```
*
* @alpha
*/
export function exportCompatibilitySchemaSnapshot(
config: Pick<TreeViewConfiguration, "schema">,
): JsonCompatibleReadOnly {
const simpleSchema = toSimpleTreeSchema(config.schema, true);
return encodeSimpleSchema(simpleSchema);
}

/**
* Parse the format exported by {@link exportCompatibilitySchemaSnapshot} into a schema.
*
* Can load snapshots created by the same or older package versions, but not necessarily newer versions.
*
* @see {@link exportCompatibilitySchemaSnapshot} which creates these snapshots.
*
* @param config - The JSON representation of the schema.
* @returns The schema. Only the schema field of the {@link TreeViewConfiguration} is populated.
* @throws Will throw a usage error if the encoded schema is not in the expected format.
*
* @example This example loads and parses a snapshot of a Point2D schema.
*
* ```ts;
* const oldViewSchema = importCompatibilitySchemaSnapshot(fs.readFileSync("PointSchema.json", "utf8"));
* ```
*
* @alpha
*/
export function importCompatibilitySchemaSnapshot(
config: JsonCompatibleReadOnly,
): TreeViewConfiguration {
const simpleSchema = decodeSimpleSchema(config);
const viewSchema = generateSchemaFromSimpleSchema(simpleSchema);

// We construct a TreeViewConfiguration here with the default parameters. The default set of validation parameters are fine for
// a schema produced by `generateSchemaFromSimpleSchema`.
return new TreeViewConfiguration({ schema: viewSchema.root });
}
3 changes: 3 additions & 0 deletions packages/dds/tree/src/simple-tree/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,4 +283,7 @@ export type { LeafSchema } from "./leafNodeSchema.js";
export {
encodeSimpleSchema,
decodeSimpleSchema,
exportCompatibilitySchemaSnapshot,
importCompatibilitySchemaSnapshot,
checkCompatibility,
} from "./api/index.js";
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/*!
* Copyright (c) Microsoft Corporation and contributors. All rights reserved.
* Licensed under the MIT License.
*/

import {
checkCompatibility,
normalizeFieldSchema,
importCompatibilitySchemaSnapshot,
SchemaFactory,
exportCompatibilitySchemaSnapshot,
TreeViewConfiguration,
type SchemaCompatibilityStatus,
} from "../../../simple-tree/index.js";
import { strict as assert } from "node:assert";

describe("snapshotCompatibilityChecker", () => {
it("parse and snapshot can roundtrip schema", () => {
const factory = new SchemaFactory("test");
const Schema = factory.optional(factory.string, {});

const view = new TreeViewConfiguration({ schema: Schema });
const snapshot = exportCompatibilitySchemaSnapshot(view);
const parsedView = importCompatibilitySchemaSnapshot(snapshot);

const normalizedView = normalizeFieldSchema(view.schema);

assert.equal(normalizedView.allowedTypeSet.size, 1);
assert.equal(
normalizedView.allowedTypesIdentifiers.has("com.fluidframework.leaf.string"),
true,
);
});

it("checkCompatibility detects incompatible schemas", () => {
const factory = new SchemaFactory("test");
class Point2D extends factory.object("Point", {
x: factory.number,
y: factory.number,
}) {}
class Point3D extends factory.object("Point", {
x: factory.number,
y: factory.number,
z: factory.optional(factory.number),
}) {}

const storedAsView = new TreeViewConfiguration({ schema: Point2D });
const view = new TreeViewConfiguration({ schema: Point3D });
const compatibility = checkCompatibility(storedAsView, view);

const expected: Omit<SchemaCompatibilityStatus, "canInitialize"> = {
canView: false,
canUpgrade: true,
isEquivalent: false,
};
assert.deepEqual(compatibility, expected);
});

it("checkCompatibility detects compatible schemas", () => {
const factory = new SchemaFactory("test");

// The past view schema, for the purposes of illustration. This wouldn't normally appear as a concrete schema in the test
// checking compatibility, but rather would be loaded from a snapshot.
class Point2D extends factory.object("Point", {
x: factory.number,
y: factory.number,
}) {}
const viewSchema = new TreeViewConfiguration({ schema: Point2D });
const encodedSchema = JSON.stringify(exportCompatibilitySchemaSnapshot(viewSchema));

// Load the past view schema from the snapshot (in-memory for the purposes of this test)
// This snapshot is assumed to be the same as Point3D, except missing `z`.
const oldViewSchema = importCompatibilitySchemaSnapshot(JSON.parse(encodedSchema));

// Build the current view schema
class Point3D extends factory.object("Point", {
x: factory.number,
y: factory.number,

// The current schema has a new optional field that was not present on Point2D
z: factory.optional(factory.number),
}) {}
const currentViewSchema = new TreeViewConfiguration({ schema: Point3D });

// Check to see if the document created by the historical view schema can be opened with the current view schema
const backwardsCompatibilityStatus = checkCompatibility(oldViewSchema, currentViewSchema);

// z is not present in Point2D, so the schema must be upgraded
assert.equal(backwardsCompatibilityStatus.canView, false);

// The schema can be upgraded to add the new optional field
assert.equal(backwardsCompatibilityStatus.canUpgrade, true);

// Test what the old version of the application would do with a tree using the new schema:
const forwardsCompatibilityStatus = checkCompatibility(currentViewSchema, oldViewSchema);

// If the old schema set allowUnknownOptionalFields, this would be true, but since it did not,
// we assert that there is forwards compatibility break:
// this means these two versions of the application cannot collaborate on content using these schema.
assert.equal(forwardsCompatibilityStatus.canView, false);
});
});
Loading
Loading