Skip to content

Commit

Permalink
Tree: Add Schema export and compare APIs (#22733)
Browse files Browse the repository at this point in the history
## Description

See changeset for details.

A realistic detailed usage example can be found in
https://github.com/microsoft/FluidFramework/pull/22566/files#diff-b893cfcd2daa74fb9dd1a8b2ce47006faaaefcb7b83671f1413f63faf65257af
showing how an actual app may use these APIs. Assuming this lands before
the next release, the changeset could get a proper link to that code on
main added as an example.
  • Loading branch information
CraigMacomber authored Oct 4, 2024
1 parent 0689b0e commit 920a65f
Show file tree
Hide file tree
Showing 28 changed files with 553 additions and 36 deletions.
14 changes: 14 additions & 0 deletions .changeset/common-jokes-press.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"fluid-framework": minor
"@fluidframework/tree": minor
---
---
"section": tree
---

Add alpha API for snapshotting Schema

`extractPersistedSchema` can now be used to extra a JSON compatible representation of the subset of a schema that gets stored in documents.
This can be used write tests which snapshot an applications schema.
Such tests can be used to detect schema changes which could would impact document compatibility,
and can be combined with the new `comparePersistedSchema` to measure what kind of compatibility impact the schema change has.
14 changes: 14 additions & 0 deletions packages/dds/tree/api-report/tree.alpha.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ export interface CommitMetadata {
readonly kind: CommitKind;
}

// @alpha
export function comparePersistedSchema(persisted: JsonCompatible, view: JsonCompatible, options: ICodecOptions, canInitialize: boolean): SchemaCompatibilityStatus;

// @public @sealed
interface DefaultProvider extends ErasedType<"@fluidframework/tree.FieldProvider"> {
}
Expand All @@ -52,6 +55,9 @@ export function enumFromStrings<TScope extends string, const Members extends str
// @public
type ExtractItemType<Item extends LazyItem> = Item extends () => infer Result ? Result : Item;

// @alpha
export function extractPersistedSchema(schema: ImplicitFieldSchema): JsonCompatible;

// @public
type FieldHasDefault<T extends ImplicitFieldSchema> = T extends FieldSchema<FieldKind.Optional | FieldKind.Identifier> ? true : false;

Expand Down Expand Up @@ -250,6 +256,14 @@ export interface JsonArrayNodeSchema extends JsonNodeSchemaBase<NodeKind.Array,
readonly items: JsonFieldSchema;
}

// @alpha
export type JsonCompatible = string | number | boolean | null | JsonCompatible[] | JsonCompatibleObject;

// @alpha
export type JsonCompatibleObject = {
[P in string]?: JsonCompatible;
};

// @alpha @sealed
export type JsonFieldSchema = {
readonly description?: string | undefined;
Expand Down
4 changes: 4 additions & 0 deletions packages/dds/tree/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ export {
// Beta APIs
TreeBeta,
type TreeChangeEventsBeta,
extractPersistedSchema,
comparePersistedSchema,
// Back to normal types
type JsonTreeSchema,
type JsonSchemaId,
Expand Down Expand Up @@ -184,3 +186,5 @@ export {
// These would be put in `internalTypes` except doing so tents to cause errors like:
// The inferred type of 'NodeMap' cannot be named without a reference to '../../node_modules/@fluidframework/tree/lib/internalTypes.js'. This is likely not portable. A type annotation is necessary.
export type { MapNodeInsertableData } from "./simple-tree/index.js";

export type { JsonCompatible, JsonCompatibleObject } from "./util/index.js";
23 changes: 7 additions & 16 deletions packages/dds/tree/src/shared-tree/schematizingTreeView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
type TreeViewConfiguration,
mapTreeFromNodeData,
prepareContentForHydration,
comparePersistedSchemaInternal,
toStoredSchema,
} from "../simple-tree/index.js";
import { Breakable, breakingClass, disposeSymbol, type WithBreakable } from "../util/index.js";
Expand Down Expand Up @@ -225,22 +226,12 @@ export class SchematizingSimpleTreeView<in out TRootSchema extends ImplicitField
private update(): void {
this.disposeView();

const result = this.viewSchema.checkCompatibility(this.checkout.storedSchema);

// TODO: AB#8121: Weaken this check to support viewing under additional circumstances.
// In the near term, this should support viewing documents with additional optional fields in their schema on object types.
// Longer-term (as demand arises), we could also add APIs to constructing view schema to allow for more flexibility
// (e.g. out-of-schema content handlers could allow support for viewing docs which have extra allowed types in a particular field)
const canView =
result.write === Compatibility.Compatible && result.read === Compatibility.Compatible;
const canUpgrade = result.read === Compatibility.Compatible;
const isEquivalent = canView && canUpgrade;
const compatibility: SchemaCompatibilityStatus = {
canView,
canUpgrade,
isEquivalent,
canInitialize: canInitialize(this.checkout),
};
const compatibility = comparePersistedSchemaInternal(
this.checkout.storedSchema,
this.viewSchema,
canInitialize(this.checkout),
);

let lastRoot =
this.compatibility.canView && this.view !== undefined ? this.root : undefined;
this.currentCompatibility = compatibility;
Expand Down
4 changes: 2 additions & 2 deletions packages/dds/tree/src/simple-tree/api/customTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@ import {
import { isObjectNodeSchema } from "../objectNodeTypes.js";

/**
* Options for how to interpret a `CustomTree<TCustom>` without relying on schema.
* Options for how to encode a tree.
*/
export interface EncodeOptions<TCustom> {
/**
* Fixup custom input formats.
* How to encode any {@link @fluidframework/core-interfaces#IFluidHandle|IFluidHandles} in the tree.
* @remarks
* See note on {@link ParseOptions.valueConverter}.
*/
Expand Down
6 changes: 6 additions & 0 deletions packages/dds/tree/src/simple-tree/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ export type { ConciseTree } from "./conciseTree.js";

export { TreeBeta, type NodeChangedData, type TreeChangeEventsBeta } from "./treeApiBeta.js";

export {
extractPersistedSchema,
comparePersistedSchemaInternal,
comparePersistedSchema,
} from "./storedSchema.js";

// Exporting the schema (RecursiveObject) to test that recursive types are working correctly.
// These are `@internal` so they can't be included in the `InternalClassTreeTypes` due to https://github.com/microsoft/rushstack/issues/3639
export {
Expand Down
126 changes: 126 additions & 0 deletions packages/dds/tree/src/simple-tree/api/storedSchema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/*!
* Copyright (c) Microsoft Corporation and contributors. All rights reserved.
* Licensed under the MIT License.
*/

import type { ICodecOptions } from "../../codec/index.js";
import { Compatibility, type TreeStoredSchema } from "../../core/index.js";
import {
defaultSchemaPolicy,
encodeTreeSchema,
makeSchemaCodec,
} from "../../feature-libraries/index.js";
// eslint-disable-next-line import/no-internal-modules
import type { Format } from "../../feature-libraries/schema-index/index.js";
import type { JsonCompatible } from "../../util/index.js";
import type { ImplicitFieldSchema } from "../schemaTypes.js";
import { toStoredSchema } from "../toFlexSchema.js";
import type { SchemaCompatibilityStatus } from "./tree.js";
import { ViewSchema } from "./view.js";

/**
* Dumps the "persisted" schema subset of the provided `schema` into a deterministic JSON-compatible, semi-human-readable, but unspecified format.
*
* @remarks
* This can be used to help inspect schema for debugging, and to save a snapshot of schema to help detect and review changes to an applications schema.
*
* This format may change across major versions of this package: such changes are considered breaking.
* Beyond that, no compatibility guarantee is provided for this format: it should never be relied upon to load data, it should only be used for comparing outputs from this function.
*
* This only includes the "persisted" subset of schema information, which means the portion which gets included in documents.
* It thus uses "persisted" keys, see {@link FieldProps.key}.
*
* If two schema have identical "persisted" schema, then they are considered {@link SchemaCompatibilityStatus.isEquivalent|equivalent}.
*
* See also {@link comparePersistedSchema}.
*
* @example
* An application could use this API to generate a `schema.json` file when it first releases,
* then test that the schema is sill compatible with documents from that version with a test like :
* ```typescript
* assert.deepEqual(extractPersistedSchema(MySchema), require("./schema.json"));
* ```
*
* @privateRemarks
* This currently uses the schema summary format, but that could be changed to something more human readable (particularly if the encoded format becomes less human readable).
* This intentionally does not leak the format types in the API.
*
* Public API surface uses "persisted" terminology while internally we use "stored".
* @alpha
*/
export function extractPersistedSchema(schema: ImplicitFieldSchema): JsonCompatible {
const stored = toStoredSchema(schema);
return encodeTreeSchema(stored);
}

/**
* Compares two schema extracted using {@link extractPersistedSchema}.
* Reports the same compatibility that {@link TreeView.compatibility} would report if
* opening a document that used the `persisted` schema and provided `view` to {@link ViewableTree.viewWith}.
*
* @param persisted - Schema persisted for a document. Typically persisted alongside the data and assumed to describe that data.
* @param view - Schema which would be used to view persisted content. Use {@link extractPersistedSchema} to convert the view schema into this format.
* @param options - {@link ICodecOptions} used when parsing the provided schema.
* @param canInitialize - Passed through to the return value unchanged and otherwise unused.
* @returns The {@link SchemaCompatibilityStatus} a {@link TreeView} would report for this combination of schema.
*
* @remarks
* This uses the persisted formats for schema, meaning it only includes data which impacts compatibility.
* It also uses the persisted format so that this API can be used in tests to compare against saved schema from previous versions of the application.
*
* @example
* An application could use {@link extractPersistedSchema} to generate a `schema.json` file for various versions of the app,
* then test that documents using those schema can be upgraded to work with the current schema using a test like:
* ```typescript
* assert(
* comparePersistedSchema(
* require("./schema.json"),
* extractPersistedSchema(MySchema),
* { jsonValidator: typeboxValidator },
* false,
* ).canUpgrade,
* );
* ```
* @alpha
*/
export function comparePersistedSchema(
persisted: JsonCompatible,
view: JsonCompatible,
options: ICodecOptions,
canInitialize: boolean,
): SchemaCompatibilityStatus {
const schemaCodec = makeSchemaCodec(options);
const stored = schemaCodec.decode(persisted as Format);
const viewParsed = schemaCodec.decode(view as Format);
const viewSchema = new ViewSchema(defaultSchemaPolicy, {}, viewParsed);
return comparePersistedSchemaInternal(stored, viewSchema, canInitialize);
}

/**
* Compute compatibility for viewing a document with `stored` schema using `viewSchema`.
* `canInitialize` is passed through to the return value unchanged and otherwise unused.
*/
export function comparePersistedSchemaInternal(
stored: TreeStoredSchema,
viewSchema: ViewSchema,
canInitialize: boolean,
): SchemaCompatibilityStatus {
const result = viewSchema.checkCompatibility(stored);

// TODO: AB#8121: Weaken this check to support viewing under additional circumstances.
// In the near term, this should support viewing documents with additional optional fields in their schema on object types.
// Longer-term (as demand arises), we could also add APIs to constructing view schema to allow for more flexibility
// (e.g. out-of-schema content handlers could allow support for viewing docs which have extra allowed types in a particular field)
const canView =
result.write === Compatibility.Compatible && result.read === Compatibility.Compatible;
const canUpgrade = result.read === Compatibility.Compatible;
const isEquivalent = canView && canUpgrade;
const compatibility: SchemaCompatibilityStatus = {
canView,
canUpgrade,
isEquivalent,
canInitialize,
};

return compatibility;
}
16 changes: 6 additions & 10 deletions packages/dds/tree/src/simple-tree/api/verboseTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ import {
stringSchema,
} from "../leafNodeSchema.js";
import { isObjectNodeSchema } from "../objectNodeTypes.js";
import { walkFieldSchema } from "../walkFieldSchema.js";
import {
customFromCursorInner,
type CustomTreeNode,
Expand All @@ -47,12 +46,13 @@ import {
import { getUnhydratedContext } from "../createContext.js";

/**
* Verbose encoding of a {@link TreeNode} or {@link TreeValue}.
* Verbose encoding of a {@link TreeNode} or {@link TreeLeafValue}.
* @remarks
* This is verbose meaning that every {@link TreeNode} is a {@link VerboseTreeNode}.
* Any IFluidHandle values have been replaced by `THandle`.
* @privateRemarks
* This can store all possible simple trees, but it can not store all possible trees representable by our internal representations like FlexTree and JsonableTree.
* This can store all possible simple trees,
* but it can not store all possible trees representable by our internal representations like FlexTree and JsonableTree.
*/
export type VerboseTree<THandle = IFluidHandle> =
| VerboseTreeNode<THandle>
Expand Down Expand Up @@ -80,7 +80,8 @@ export type VerboseTree<THandle = IFluidHandle> =
* This format allows for all simple-tree compatible trees to be represented.
*
* Unlike `JsonableTree`, leaf nodes are not boxed into node objects, and instead have their schema inferred from the value.
* Additionally, sequence fields can only occur on a node that has a single sequence field (with the empty key) replicating the behavior of simple-tree ArrayNodes.
* Additionally, sequence fields can only occur on a node that has a single sequence field (with the empty key)
* replicating the behavior of simple-tree ArrayNodes.
*/
export interface VerboseTreeNode<THandle = IFluidHandle> {
/**
Expand Down Expand Up @@ -330,12 +331,7 @@ export function verboseFromCursor<TCustom>(
...options,
};

const schemaMap = new Map<string, TreeNodeSchema>();
walkFieldSchema(rootSchema, {
node(schema) {
schemaMap.set(schema.identifier, schema);
},
});
const schemaMap = getUnhydratedContext(rootSchema).schema;

return verboseFromCursorInner(reader, config, schemaMap);
}
Expand Down
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 @@ -71,7 +71,10 @@ export {
type EncodeOptions,
type ParseOptions,
type VerboseTree,
extractPersistedSchema,
comparePersistedSchema,
type ConciseTree,
comparePersistedSchemaInternal,
ViewSchema,
type Unenforced,
type FieldHasDefaultUnsafe,
Expand Down
48 changes: 48 additions & 0 deletions packages/dds/tree/src/test/simple-tree/api/storedSchema.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*!
* Copyright (c) Microsoft Corporation and contributors. All rights reserved.
* Licensed under the MIT License.
*/

import { strict as assert } from "node:assert";

import {
comparePersistedSchema,
extractPersistedSchema,
// eslint-disable-next-line import/no-internal-modules
} from "../../../simple-tree/api/storedSchema.js";
import { testSimpleTrees } from "../../testTrees.js";
import { takeJsonSnapshot, useSnapshotDirectory } from "../../snapshots/index.js";
import { typeboxValidator } from "../../../external-utilities/index.js";

describe("simple-tree storedSchema", () => {
describe("test-schema", () => {
useSnapshotDirectory("simple-tree-storedSchema");
for (const test of testSimpleTrees) {
it(test.name, () => {
const persisted = extractPersistedSchema(test.schema);
takeJsonSnapshot(persisted);
});

// comparePersistedSchema is a trivial wrapper around functionality that is tested elsewhere,
// but might as will give it a simple smoke test for the various test schema.
it(`comparePersistedSchema to self ${test.name}`, () => {
const persistedA = extractPersistedSchema(test.schema);
const persistedB = extractPersistedSchema(test.schema);
const status = comparePersistedSchema(
persistedA,
persistedB,
{
jsonValidator: typeboxValidator,
},
false,
);
assert.deepEqual(status, {
isEquivalent: true,
canView: true,
canUpgrade: true,
canInitialize: false,
});
});
}
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"version": 1,
"nodes": {},
"root": {
"kind": "Optional",
"types": []
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"version": 1,
"nodes": {
"com.fluidframework.leaf.boolean": {
"leaf": 2
}
},
"root": {
"kind": "Value",
"types": [
"com.fluidframework.leaf.boolean"
]
}
}
Loading

0 comments on commit 920a65f

Please sign in to comment.