Skip to content

Commit e5be416

Browse files
TommyBrosmanCopilotJosmithrCraigMacomber
authored
Shared Tree: Snapshot compatibility testing APIs (#25861)
This change introduces a number of APIs for encoding schemas as snapshots and testing them for compatibility. ## Description The following alpha APIs have been added: - `checkCompatibility` - `importCompatibilitySchemaSnapshot` - `exportCompatibilitySchemaSnapshot` See the changeset for details on how to use them in tests. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Joshua Smithrud <54606601+Josmithr@users.noreply.github.com> Co-authored-by: Craig Macomber (Microsoft) <42876482+CraigMacomber@users.noreply.github.com>
1 parent f451ff8 commit e5be416

File tree

8 files changed

+355
-0
lines changed

8 files changed

+355
-0
lines changed

.changeset/angry-wings-battle.md

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
---
2+
"@fluidframework/tree": minor
3+
"fluid-framework": minor
4+
"__section": tree
5+
---
6+
Schema snapshot compatibility checker
7+
8+
This change adds alpha APIs for creating snapshots of view schema and testing their compatibility for the purposes
9+
of schema migrations.
10+
11+
New APIs:
12+
13+
- `checkCompatibility` - Checks the compatibility of the view schema which created the document against the view schema
14+
being used to open it.
15+
- `importCompatibilitySchemaSnapshot` - Parse a JSON representation of a tree schema into a concrete schema.
16+
- `exportCompatibilitySchemaSnapshot` - Returns a JSON representation of the tree schema for snapshot compatibility checking.
17+
18+
#### Example: Current view schema vs. historical view schema
19+
20+
An application author is developing an app that has a schema for storing 2D Points.
21+
They wish to maintain backwards compatibility in future versions and avoid changing their view schema in a way that breaks
22+
this behavior.
23+
When introducing a new initial schema, they persists a snapshot using `exportCompatibilitySchemaSnapshot`:
24+
25+
```ts
26+
const factory = new SchemaFactory("test");
27+
28+
// The past view schema, for the purposes of illustration. This wouldn't normally appear as a concrete schema in the test
29+
// checking compatibility, but rather would be loaded from a snapshot.
30+
class Point2D extends factory.object("Point", {
31+
x: factory.number,
32+
y: factory.number,
33+
}) {}
34+
const viewSchema = new TreeViewConfiguration({ schema: Point2D });
35+
const encodedSchema = JSON.stringify(exportCompatibilitySchemaSnapshot(viewSchema));
36+
fs.writeFileSync("PointSchema.json", encodedSchema);
37+
```
38+
39+
Next they create a regression test to ensure that the current view schema can read content written by the original view
40+
schema (`SchemaCompatibilityStatus.canUpgrade`). Initially `currentViewSchema === Point2D`:
41+
42+
```ts
43+
const encodedSchema = JSON.parse(fs.readFileSync("PointSchema.json", "utf8"));
44+
const oldViewSchema = importCompatibilitySchemaSnapshot(encodedSchema);
45+
46+
// Check to see if the document created by the historical view schema can be opened with the current view schema
47+
const compatibilityStatus = checkCompatibility(oldViewSchema, currentViewSchema);
48+
49+
// Check to see if the document created by the historical view schema can be opened with the current view schema
50+
const backwardsCompatibilityStatus = checkCompatibility(oldViewSchema, currentViewSchema);
51+
52+
// z is not present in Point2D, so the schema must be upgraded
53+
assert.equal(backwardsCompatibilityStatus.canView, false);
54+
55+
// The schema can be upgraded to add the new optional field
56+
assert.equal(backwardsCompatibilityStatus.canUpgrade, true);
57+
```
58+
59+
Additionally, they a regression test to ensure that older view schemas can read content written by the current view
60+
schema (`SchemaCompatibilityStatus.canView`):
61+
62+
```ts
63+
// Test what the old version of the application would do with a tree using the new schema:
64+
const forwardsCompatibilityStatus = checkCompatibility(currentViewSchema, oldViewSchema);
65+
66+
// If the old schema set allowUnknownOptionalFields, this would be true, but since it did not,
67+
// this assert will fail, detecting the forwards compatibility break:
68+
// this means these two versions of the application cannot collaborate on content using these schema.
69+
assert.equal(forwardsCompatibilityStatus.canView, true);
70+
```
71+
72+
Later in the application development cycle, the application author decides they want to change their Point2D to
73+
a Point3D, adding an extra field:
74+
75+
```ts
76+
// Build the current view schema
77+
const schemaFactory = new SchemaFactory("test");
78+
class Point3D extends schemaFactory.object("Point", {
79+
x: factory.number,
80+
y: factory.number,
81+
82+
// The current schema has a new optional field that was not present on Point2D
83+
z: factory.optional(factory.number),
84+
}) {}
85+
```
86+
87+
The test first compatibility test will pass as the Point2D schema is upgradeable to a Point3D schema.
88+
However, the second compatibility test fill fail as an application using the Point2D view schema cannot collaborate on
89+
content authored using the Point3D schema.

packages/dds/tree/api-report/tree.alpha.api.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,9 @@ export interface BranchableTree extends ViewableTree {
134134
rebase(branch: TreeBranchFork): void;
135135
}
136136

137+
// @alpha
138+
export function checkCompatibility(viewWhichCreatedStoredSchema: TreeViewConfiguration, view: TreeViewConfiguration): Omit<SchemaCompatibilityStatus, "canInitialize">;
139+
137140
// @alpha
138141
export function cloneWithReplacements(root: unknown, rootKey: string, replacer: (key: string, value: unknown) => {
139142
clone: boolean;
@@ -236,6 +239,9 @@ export function enumFromStrings<TScope extends string, const Members extends rea
236239
// @alpha
237240
export function evaluateLazySchema<T extends TreeNodeSchema>(value: LazyItem<T>): T;
238241

242+
// @alpha
243+
export function exportCompatibilitySchemaSnapshot(config: Pick<TreeViewConfiguration, "schema">): JsonCompatibleReadOnly;
244+
239245
// @public @system
240246
type ExtractItemType<Item extends LazyItem> = Item extends () => infer Result ? Result : Item;
241247

@@ -418,6 +424,9 @@ export type ImplicitAllowedTypes = AllowedTypes | TreeNodeSchema;
418424
// @public
419425
export type ImplicitFieldSchema = FieldSchema | ImplicitAllowedTypes;
420426

427+
// @alpha
428+
export function importCompatibilitySchemaSnapshot(config: JsonCompatibleReadOnly): TreeViewConfiguration;
429+
421430
// @alpha
422431
export function independentInitializedView<const TSchema extends ImplicitFieldSchema>(config: TreeViewConfiguration<TSchema>, options: ForestOptions & ICodecOptions, content: ViewContent): TreeViewAlpha<TSchema>;
423432

packages/dds/tree/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,9 @@ export {
285285
type SimpleAllowedTypeAttributes,
286286
encodeSimpleSchema,
287287
decodeSimpleSchema,
288+
exportCompatibilitySchemaSnapshot,
289+
importCompatibilitySchemaSnapshot,
290+
checkCompatibility,
288291
} from "./simple-tree/index.js";
289292
export {
290293
SharedTree,

packages/dds/tree/src/simple-tree/api/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,3 +171,8 @@ export {
171171
encodeSimpleSchema,
172172
decodeSimpleSchema,
173173
} from "./simpleSchemaCodec.js";
174+
export {
175+
exportCompatibilitySchemaSnapshot,
176+
importCompatibilitySchemaSnapshot,
177+
checkCompatibility,
178+
} from "./snapshotCompatibilityChecker.js";
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*!
2+
* Copyright (c) Microsoft Corporation and contributors. All rights reserved.
3+
* Licensed under the MIT License.
4+
*/
5+
6+
import type { JsonCompatibleReadOnly } from "../../util/index.js";
7+
import { toStoredSchema } from "../toStoredSchema.js";
8+
import { TreeViewConfigurationAlpha, TreeViewConfiguration } from "./configuration.js";
9+
import { SchemaCompatibilityTester } from "./schemaCompatibilityTester.js";
10+
import { generateSchemaFromSimpleSchema } from "./schemaFromSimple.js";
11+
import { decodeSimpleSchema, encodeSimpleSchema } from "./simpleSchemaCodec.js";
12+
import type { SchemaCompatibilityStatus } from "./tree.js";
13+
import { toSimpleTreeSchema } from "./viewSchemaToSimpleSchema.js";
14+
15+
/**
16+
* 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}.
17+
*
18+
* @remarks See {@link SchemaCompatibilityStatus} for details on the compatibility results.
19+
*
20+
* @example This example demonstrates checking the compatibility of a historical schema against a current schema.
21+
* In this case, the historical schema is a Point2D object with x and y fields, while the current schema is a Point3D object
22+
* that adds an optional z field.
23+
*
24+
* ```ts
25+
* // This snapshot is assumed to be the same as Point3D, except missing `z`.
26+
* const encodedSchema = JSON.parse(fs.readFileSync("PointSchema.json", "utf8"));
27+
* const oldViewSchema = importCompatibilitySchemaSnapshot(encodedSchema);
28+
*
29+
* // Build the current view schema
30+
* class Point3D extends factory.object("Point", {
31+
* x: factory.number,
32+
* y: factory.number,
33+
*
34+
* // The current schema has a new optional field that was not present on Point2D
35+
* z: factory.optional(factory.number),
36+
* }) {}
37+
* const currentViewSchema = new TreeViewConfiguration({ schema: Point3D });
38+
*
39+
* // Check to see if the document created by the historical view schema can be opened with the current view schema
40+
* const backwardsCompatibilityStatus = checkCompatibility(oldViewSchema, currentViewSchema);
41+
*
42+
* // z is not present in Point2D, so the schema must be upgraded
43+
* assert.equal(backwardsCompatibilityStatus.canView, false);
44+
*
45+
* // The schema can be upgraded to add the new optional field
46+
* assert.equal(backwardsCompatibilityStatus.canUpgrade, true);
47+
*
48+
* // Test what the old version of the application would do with a tree using the new schema:
49+
* const forwardsCompatibilityStatus = checkCompatibility(currentViewSchema, oldViewSchema);
50+
*
51+
* // If the old schema set allowUnknownOptionalFields, this would be true, but since it did not,
52+
* // this assert will fail, detecting the forwards compatibility break:
53+
* // this means these two versions of the application cannot collaborate on content using these schema.
54+
* assert.equal(forwardsCompatibilityStatus.canView, true);
55+
* ```
56+
*
57+
* @param viewWhichCreatedStoredSchema - From which to derive the stored schema, as if it initialized or upgraded a tree via {@link TreeView}.
58+
* @param view - The view being tested to see if it could view tree created or initialized using `viewWhichCreatedStoredSchema`.
59+
* @returns The compatibility status.
60+
*
61+
* @alpha
62+
*/
63+
export function checkCompatibility(
64+
viewWhichCreatedStoredSchema: TreeViewConfiguration,
65+
view: TreeViewConfiguration,
66+
): Omit<SchemaCompatibilityStatus, "canInitialize"> {
67+
const viewAsAlpha = new TreeViewConfigurationAlpha({ schema: view.schema });
68+
const stored = toStoredSchema(viewWhichCreatedStoredSchema.schema, {
69+
includeStaged: () => true,
70+
});
71+
const tester = new SchemaCompatibilityTester(viewAsAlpha);
72+
return tester.checkCompatibility(stored);
73+
}
74+
75+
/**
76+
* Returns a JSON compatible representation of the tree schema for snapshot compatibility checking.
77+
*
78+
* Snapshots can be loaded by the same or newer package versions, but not necessarily older versions.
79+
*
80+
* @see {@link importCompatibilitySchemaSnapshot} which loads these snapshots.
81+
*
82+
* @param config - The schema to snapshot. Only the schema field of the `TreeViewConfiguration` is used.
83+
* @returns The JSON representation of the schema.
84+
*
85+
* @example This example creates and persists a snapshot of a Point2D schema.
86+
*
87+
* ```ts
88+
* const schemaFactory = new SchemaFactory("test");
89+
* class Point2D extends schemaFactory.object("Point", {
90+
* x: factory.number,
91+
* y: factory.number,
92+
* }) {}
93+
* const viewSchema = new TreeViewConfiguration({ schema: Point2D });
94+
* const encodedSchema = JSON.stringify(exportCompatibilitySchemaSnapshot(viewSchema));
95+
* fs.writeFileSync("PointSchema.json", encodedSchema);
96+
* ```
97+
*
98+
* @alpha
99+
*/
100+
export function exportCompatibilitySchemaSnapshot(
101+
config: Pick<TreeViewConfiguration, "schema">,
102+
): JsonCompatibleReadOnly {
103+
const simpleSchema = toSimpleTreeSchema(config.schema, true);
104+
return encodeSimpleSchema(simpleSchema);
105+
}
106+
107+
/**
108+
* Parse the format exported by {@link exportCompatibilitySchemaSnapshot} into a schema.
109+
*
110+
* Can load snapshots created by the same or older package versions, but not necessarily newer versions.
111+
*
112+
* @see {@link exportCompatibilitySchemaSnapshot} which creates these snapshots.
113+
*
114+
* @param config - The JSON representation of the schema.
115+
* @returns The schema. Only the schema field of the {@link TreeViewConfiguration} is populated.
116+
* @throws Will throw a usage error if the encoded schema is not in the expected format.
117+
*
118+
* @example This example loads and parses a snapshot of a Point2D schema.
119+
*
120+
* ```ts;
121+
* const oldViewSchema = importCompatibilitySchemaSnapshot(fs.readFileSync("PointSchema.json", "utf8"));
122+
* ```
123+
*
124+
* @alpha
125+
*/
126+
export function importCompatibilitySchemaSnapshot(
127+
config: JsonCompatibleReadOnly,
128+
): TreeViewConfiguration {
129+
const simpleSchema = decodeSimpleSchema(config);
130+
const viewSchema = generateSchemaFromSimpleSchema(simpleSchema);
131+
132+
// We construct a TreeViewConfiguration here with the default parameters. The default set of validation parameters are fine for
133+
// a schema produced by `generateSchemaFromSimpleSchema`.
134+
return new TreeViewConfiguration({ schema: viewSchema.root });
135+
}

packages/dds/tree/src/simple-tree/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,4 +283,7 @@ export type { LeafSchema } from "./leafNodeSchema.js";
283283
export {
284284
encodeSimpleSchema,
285285
decodeSimpleSchema,
286+
exportCompatibilitySchemaSnapshot,
287+
importCompatibilitySchemaSnapshot,
288+
checkCompatibility,
286289
} from "./api/index.js";
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/*!
2+
* Copyright (c) Microsoft Corporation and contributors. All rights reserved.
3+
* Licensed under the MIT License.
4+
*/
5+
6+
import {
7+
checkCompatibility,
8+
normalizeFieldSchema,
9+
importCompatibilitySchemaSnapshot,
10+
SchemaFactory,
11+
exportCompatibilitySchemaSnapshot,
12+
TreeViewConfiguration,
13+
type SchemaCompatibilityStatus,
14+
} from "../../../simple-tree/index.js";
15+
import { strict as assert } from "node:assert";
16+
17+
describe("snapshotCompatibilityChecker", () => {
18+
it("parse and snapshot can roundtrip schema", () => {
19+
const factory = new SchemaFactory("test");
20+
const Schema = factory.optional(factory.string, {});
21+
22+
const view = new TreeViewConfiguration({ schema: Schema });
23+
const snapshot = exportCompatibilitySchemaSnapshot(view);
24+
const parsedView = importCompatibilitySchemaSnapshot(snapshot);
25+
26+
const normalizedView = normalizeFieldSchema(view.schema);
27+
28+
assert.equal(normalizedView.allowedTypeSet.size, 1);
29+
assert.equal(
30+
normalizedView.allowedTypesIdentifiers.has("com.fluidframework.leaf.string"),
31+
true,
32+
);
33+
});
34+
35+
it("checkCompatibility detects incompatible schemas", () => {
36+
const factory = new SchemaFactory("test");
37+
class Point2D extends factory.object("Point", {
38+
x: factory.number,
39+
y: factory.number,
40+
}) {}
41+
class Point3D extends factory.object("Point", {
42+
x: factory.number,
43+
y: factory.number,
44+
z: factory.optional(factory.number),
45+
}) {}
46+
47+
const storedAsView = new TreeViewConfiguration({ schema: Point2D });
48+
const view = new TreeViewConfiguration({ schema: Point3D });
49+
const compatibility = checkCompatibility(storedAsView, view);
50+
51+
const expected: Omit<SchemaCompatibilityStatus, "canInitialize"> = {
52+
canView: false,
53+
canUpgrade: true,
54+
isEquivalent: false,
55+
};
56+
assert.deepEqual(compatibility, expected);
57+
});
58+
59+
it("checkCompatibility detects compatible schemas", () => {
60+
const factory = new SchemaFactory("test");
61+
62+
// The past view schema, for the purposes of illustration. This wouldn't normally appear as a concrete schema in the test
63+
// checking compatibility, but rather would be loaded from a snapshot.
64+
class Point2D extends factory.object("Point", {
65+
x: factory.number,
66+
y: factory.number,
67+
}) {}
68+
const viewSchema = new TreeViewConfiguration({ schema: Point2D });
69+
const encodedSchema = JSON.stringify(exportCompatibilitySchemaSnapshot(viewSchema));
70+
71+
// Load the past view schema from the snapshot (in-memory for the purposes of this test)
72+
// This snapshot is assumed to be the same as Point3D, except missing `z`.
73+
const oldViewSchema = importCompatibilitySchemaSnapshot(JSON.parse(encodedSchema));
74+
75+
// Build the current view schema
76+
class Point3D extends factory.object("Point", {
77+
x: factory.number,
78+
y: factory.number,
79+
80+
// The current schema has a new optional field that was not present on Point2D
81+
z: factory.optional(factory.number),
82+
}) {}
83+
const currentViewSchema = new TreeViewConfiguration({ schema: Point3D });
84+
85+
// Check to see if the document created by the historical view schema can be opened with the current view schema
86+
const backwardsCompatibilityStatus = checkCompatibility(oldViewSchema, currentViewSchema);
87+
88+
// z is not present in Point2D, so the schema must be upgraded
89+
assert.equal(backwardsCompatibilityStatus.canView, false);
90+
91+
// The schema can be upgraded to add the new optional field
92+
assert.equal(backwardsCompatibilityStatus.canUpgrade, true);
93+
94+
// Test what the old version of the application would do with a tree using the new schema:
95+
const forwardsCompatibilityStatus = checkCompatibility(currentViewSchema, oldViewSchema);
96+
97+
// If the old schema set allowUnknownOptionalFields, this would be true, but since it did not,
98+
// we assert that there is forwards compatibility break:
99+
// this means these two versions of the application cannot collaborate on content using these schema.
100+
assert.equal(forwardsCompatibilityStatus.canView, false);
101+
});
102+
});

0 commit comments

Comments
 (0)