"use strict";
/*!
 * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
 * Licensed under the MIT License.
 */
Object.defineProperty(exports, "__esModule", { value: true });
exports.runSynchronous = exports.TreeCheckout = exports.createTreeCheckout = void 0;
const internal_1 = require("@fluidframework/core-utils/internal");
const internal_2 = require("@fluidframework/telemetry-utils/internal");
const index_js_1 = require("../codec/index.js");
const index_js_2 = require("../core/index.js");
const index_js_3 = require("../events/index.js");
const index_js_4 = require("../feature-libraries/index.js");
const index_js_5 = require("../shared-tree-core/index.js");
const index_js_6 = require("../util/index.js");
const sharedTreeChangeFamily_js_1 = require("./sharedTreeChangeFamily.js");
/**
 * Creates a {@link TreeCheckout}.
 * @param args - an object containing optional components that will be used to build the view.
 * Any components not provided will be created by default.
 * @remarks This does not create a {@link SharedTree}, but rather a view with the minimal state
 * and functionality required to implement {@link ITreeCheckout}.
 */
function createTreeCheckout(idCompressor, mintRevisionTag, revisionTagCodec, args) {
    const forest = args?.forest ?? (0, index_js_4.buildForest)();
    const schema = args?.schema ?? new index_js_2.TreeStoredSchemaRepository();
    const defaultCodecOptions = { jsonValidator: index_js_1.noopValidator };
    const defaultFieldBatchVersion = 1;
    const changeFamily = args?.changeFamily ??
        new sharedTreeChangeFamily_js_1.SharedTreeChangeFamily(revisionTagCodec, args?.fieldBatchCodec ??
            (0, index_js_4.makeFieldBatchCodec)(defaultCodecOptions, defaultFieldBatchVersion), { jsonValidator: index_js_1.noopValidator }, args?.chunkCompressionStrategy);
    const branch = args?.branch ??
        new index_js_5.SharedTreeBranch({
            change: changeFamily.rebaser.compose([]),
            revision: "root",
        }, changeFamily, () => idCompressor.generateCompressedId());
    const events = args?.events ?? (0, index_js_3.createEmitter)();
    const transaction = new Transaction(branch);
    return new TreeCheckout(transaction, branch, changeFamily, schema, forest, events, mintRevisionTag, revisionTagCodec, idCompressor, args?.removedRoots, args?.logger);
}
exports.createTreeCheckout = createTreeCheckout;
class Transaction {
    constructor(branch) {
        this.branch = branch;
    }
    start() {
        this.branch.startTransaction();
        this.branch.editor.enterTransaction();
    }
    commit() {
        this.branch.commitTransaction();
        this.branch.editor.exitTransaction();
        return index_js_6.TransactionResult.Commit;
    }
    abort() {
        this.branch.abortTransaction();
        this.branch.editor.exitTransaction();
        return index_js_6.TransactionResult.Abort;
    }
    inProgress() {
        return this.branch.isTransacting();
    }
}
/**
 * An implementation of {@link ITreeCheckoutFork}.
 */
class TreeCheckout {
    constructor(transaction, branch, changeFamily, storedSchema, forest, events, mintRevisionTag, revisionTagCodec, idCompressor, removedRoots = (0, index_js_2.makeDetachedFieldIndex)("repair", revisionTagCodec, idCompressor), 
    /** Optional logger for telemetry. */
    logger) {
        this.transaction = transaction;
        this.branch = branch;
        this.changeFamily = changeFamily;
        this.storedSchema = storedSchema;
        this.forest = forest;
        this.events = events;
        this.mintRevisionTag = mintRevisionTag;
        this.revisionTagCodec = revisionTagCodec;
        this.idCompressor = idCompressor;
        this.removedRoots = removedRoots;
        this.logger = logger;
        this.isDisposed = false;
        /**
         * Set of revertibles maintained for automatic disposal
         */
        this.revertibles = new Set();
        /**
         * Each branch's head commit corresponds to a revertible commit.
         * Maintaining a whole branch ensures the commit graph is not pruned in a way that would prevent the commit from
         * being reverted.
         */
        this.revertibleCommitBranches = new Map();
        /**
         * copies of the removed roots used as snapshots for reverting to previous state when transactions are aborted
         */
        this.removedRootsSnapshots = [];
        // when a transaction is started, take a snapshot of the current state of removed roots
        branch.on("transactionStarted", () => {
            this.removedRootsSnapshots.push(this.removedRoots.clone());
        });
        // when a transaction is committed, the latest snapshot of removed roots can be discarded
        branch.on("transactionCommitted", () => {
            this.removedRootsSnapshots.pop();
        });
        // after a transaction is rolled back, revert removed roots back to the latest snapshot
        branch.on("transactionRolledBack", () => {
            const snapshot = this.removedRootsSnapshots.pop();
            (0, internal_1.assert)(snapshot !== undefined, 0x9ae /* a snapshot for removed roots does not exist */);
            this.removedRoots = snapshot;
        });
        // We subscribe to `beforeChange` rather than `afterChange` here because it's possible that the change is invalid WRT our forest.
        // For example, a bug in the editor might produce a malformed change object and thus applying the change to the forest will throw an error.
        // In such a case we will crash here, preventing the change from being added to the commit graph, and preventing `afterChange` from firing.
        // One important consequence of this is that we will not submit the op containing the invalid change, since op submissions happens in response to `afterChange`.
        branch.on("beforeChange", (event) => {
            if (event.change !== undefined) {
                const revision = event.type === "replace"
                    ? event.newCommits[event.newCommits.length - 1].revision
                    : event.change.revision;
                // Conflicts due to schema will be empty and thus are not applied.
                for (const change of event.change.change.changes) {
                    if (change.type === "data") {
                        const delta = (0, index_js_4.intoDelta)((0, index_js_2.tagChange)(change.innerChange, revision));
                        this.withCombinedVisitor((visitor) => {
                            (0, index_js_2.visitDelta)(delta, visitor, this.removedRoots, revision);
                        });
                    }
                    else if (change.type === "schema") {
                        // Schema changes from a current to a new schema are expected to be backwards compatible.
                        // This guarantees that all data in the forest (which is valid before the schema change)
                        // is also valid under the new schema.
                        // Note however, that such schema changes may in some cases be rolled back:
                        // Case 1: A transaction with a schema change may be aborted.
                        // The transaction may have made some data changes that would render some trees invalid
                        // under the old schema, but these changes will also be rolled back, thereby putting the forest
                        // back in the state before the transaction, which is valid under the original (reinstated) schema.
                        // Case 2: A branch with a schema change may be rebased such that the schema change (because
                        // of a constraint) is no longer applied.
                        // Such a branch may contain data changes that would render some trees invalid under the
                        // original schema. These data changes may not necessarily be rolled back.
                        // They will however be rebased over the rollback of the schema change. This rebasing will
                        // ensure that these data changes are muted if they would render some trees invalid under the
                        // original (reinstated) schema.
                        storedSchema.apply(change.innerChange.schema.new);
                    }
                    else {
                        (0, index_js_6.fail)("Unknown Shared Tree change type.");
                    }
                }
                this.events.emit("afterBatch");
            }
            if (event.type === "replace" && (0, index_js_5.getChangeReplaceType)(event) === "transactionCommit") {
                const transactionRevision = event.newCommits[0].revision;
                for (const transactionStep of event.removedCommits) {
                    this.removedRoots.updateMajor(transactionStep.revision, transactionRevision);
                }
            }
        });
        branch.on("commitApplied", (data) => {
            const commit = branch.getHead();
            const { change, revision } = commit;
            let withinEventContext = true;
            const getRevertible = (0, sharedTreeChangeFamily_js_1.hasSchemaChange)(change)
                ? undefined
                : (onRevertibleDisposed) => {
                    if (!withinEventContext) {
                        throw new internal_2.UsageError("Cannot get a revertible outside of the context of a commitApplied event.");
                    }
                    if (this.revertibleCommitBranches.get(revision) !== undefined) {
                        throw new internal_2.UsageError("Cannot generate the same revertible more than once. Note that this can happen when multiple commitApplied event listeners are registered.");
                    }
                    const revertibleCommits = this.revertibleCommitBranches;
                    const revertible = {
                        get status() {
                            const revertibleCommit = revertibleCommits.get(revision);
                            return revertibleCommit === undefined
                                ? index_js_2.RevertibleStatus.Disposed
                                : index_js_2.RevertibleStatus.Valid;
                        },
                        revert: (release = true) => {
                            if (revertible.status === index_js_2.RevertibleStatus.Disposed) {
                                throw new internal_2.UsageError("Unable to revert a revertible that has been disposed.");
                            }
                            const revertMetrics = this.revertRevertible(revision, data.kind);
                            this.logger?.sendTelemetryEvent({
                                eventName: TreeCheckout.revertTelemetryEventName,
                                ...revertMetrics,
                            });
                            if (release) {
                                revertible.dispose();
                            }
                        },
                        dispose: () => {
                            if (revertible.status === index_js_2.RevertibleStatus.Disposed) {
                                throw new internal_2.UsageError("Unable to dispose a revertible that has already been disposed.");
                            }
                            this.disposeRevertible(revertible, revision);
                            onRevertibleDisposed?.(revertible);
                        },
                    };
                    this.revertibleCommitBranches.set(revision, branch.fork());
                    this.revertibles.add(revertible);
                    return revertible;
                };
            this.events.emit("commitApplied", data, getRevertible);
            withinEventContext = false;
        });
        // When the branch is trimmed, we can garbage collect any repair data whose latest relevant revision is one of the
        // trimmed revisions.
        branch.on("ancestryTrimmed", (revisions) => {
            this.withCombinedVisitor((visitor) => {
                revisions.forEach((revision) => {
                    // get all the roots last created or used by the revision
                    const roots = this.removedRoots.getRootsLastTouchedByRevision(revision);
                    // get the detached field for the root and delete it from the removed roots
                    for (const root of roots) {
                        visitor.destroy(this.removedRoots.toFieldKey(root), 1);
                    }
                    this.removedRoots.deleteRootsLastTouchedByRevision(revision);
                });
            });
        });
    }
    withCombinedVisitor(fn) {
        const anchorVisitor = this.forest.anchors.acquireVisitor();
        const combinedVisitor = (0, index_js_2.combineVisitors)([this.forest.acquireVisitor(), anchorVisitor], [anchorVisitor]);
        fn(combinedVisitor);
        combinedVisitor.free();
    }
    checkNotDisposed() {
        (0, internal_1.assert)(!this.isDisposed, 0x911 /* Invalid operation on a disposed TreeCheckout */);
    }
    get rootEvents() {
        return this.forest.anchors;
    }
    get editor() {
        this.checkNotDisposed();
        return this.branch.editor;
    }
    locate(anchor) {
        this.checkNotDisposed();
        return this.forest.anchors.locate(anchor);
    }
    fork() {
        this.checkNotDisposed();
        const anchors = new index_js_2.AnchorSet();
        const branch = this.branch.fork();
        const storedSchema = this.storedSchema.clone();
        const forest = this.forest.clone(storedSchema, anchors);
        const transaction = new Transaction(branch);
        return new TreeCheckout(transaction, branch, this.changeFamily, storedSchema, forest, (0, index_js_3.createEmitter)(), this.mintRevisionTag, this.revisionTagCodec, this.idCompressor, this.removedRoots.clone(), this.logger);
    }
    rebase(view) {
        this.checkNotDisposed();
        (0, internal_1.assert)(!view.transaction.inProgress(), 0x9af /* A view cannot be rebased while it has a pending transaction */);
        view.branch.rebaseOnto(this.branch);
    }
    rebaseOnto(view) {
        this.checkNotDisposed();
        view.rebase(this);
    }
    merge(view, disposeView = true) {
        this.checkNotDisposed();
        (0, internal_1.assert)(!this.transaction.inProgress(), 0x9b0 /* Views cannot be merged into a view while it has a pending transaction */);
        while (view.transaction.inProgress()) {
            view.transaction.commit();
        }
        this.branch.merge(view.branch);
        if (disposeView) {
            view[index_js_6.disposeSymbol]();
        }
    }
    updateSchema(newSchema) {
        this.checkNotDisposed();
        this.editor.schema.setStoredSchema(this.storedSchema.clone(), newSchema);
    }
    [index_js_6.disposeSymbol]() {
        this.checkNotDisposed();
        this.isDisposed = true;
        this.purgeRevertibles();
        this.branch.dispose();
    }
    getRemovedRoots() {
        const trees = [];
        const cursor = this.forest.allocateCursor("getRemovedRoots");
        for (const { id, root } of this.removedRoots.entries()) {
            const parentField = this.removedRoots.toFieldKey(root);
            this.forest.moveCursorToPath({ parent: undefined, parentField, parentIndex: 0 }, cursor);
            const tree = (0, index_js_4.jsonableTreeFromCursor)(cursor);
            // This method is used for tree consistency comparison.
            const { major, minor } = id;
            const finalizedMajor = major !== undefined ? this.revisionTagCodec.encode(major) : major;
            trees.push([finalizedMajor, minor, tree]);
        }
        cursor.free();
        return trees;
    }
    /**
     * This sets the tip revision as the latest relevant revision for any removed roots that are loaded from a summary.
     * This needs to be called right after loading {@link this.removedRoots} from a summary to allow loaded data to be garbage collected.
     */
    setTipRevisionForLoadedData(revision) {
        this.removedRoots.setRevisionsForLoadedData(revision);
    }
    purgeRevertibles() {
        for (const revertible of this.revertibles) {
            revertible.dispose();
        }
    }
    disposeRevertible(revertible, revision) {
        this.revertibleCommitBranches.get(revision)?.dispose();
        this.revertibleCommitBranches.delete(revision);
        this.revertibles.delete(revertible);
    }
    revertRevertible(revision, kind) {
        if (this.branch.isTransacting()) {
            throw new internal_2.UsageError("Undo is not yet supported during transactions.");
        }
        const revertibleBranch = this.revertibleCommitBranches.get(revision);
        (0, internal_1.assert)(revertibleBranch !== undefined, 0x7cc /* expected to find a revertible commit */);
        const commitToRevert = revertibleBranch.getHead();
        let change = (0, index_js_2.makeAnonChange)(this.changeFamily.rebaser.invert((0, index_js_2.tagChange)(commitToRevert.change, revision), false));
        const headCommit = this.branch.getHead();
        // Rebase the inverted change onto any commits that occurred after the undoable commits.
        if (commitToRevert !== headCommit) {
            change = (0, index_js_2.makeAnonChange)((0, index_js_2.rebaseChange)(this.changeFamily.rebaser, change, commitToRevert, headCommit, this.mintRevisionTag).change);
        }
        this.branch.apply(change.change, this.mintRevisionTag(), kind === index_js_2.CommitKind.Default || kind === index_js_2.CommitKind.Redo
            ? index_js_2.CommitKind.Undo
            : index_js_2.CommitKind.Redo);
        // Derive some stats about the reversion to return to the caller.
        let revertAge = 0;
        let currentCommit = headCommit;
        while (commitToRevert.revision !== currentCommit.revision) {
            revertAge++;
            const parentCommit = currentCommit.parent;
            (0, internal_1.assert)(parentCommit !== undefined, 0x9a9 /* expected to find a parent commit */);
            currentCommit = parentCommit;
        }
        return { age: revertAge };
    }
}
exports.TreeCheckout = TreeCheckout;
/**
 * The name of the telemetry event logged for calls to {@link TreeCheckout.revertRevertible}.
 * @privateRemarks Exposed for testing purposes.
 */
TreeCheckout.revertTelemetryEventName = "RevertRevertible";
/**
 * Run a synchronous transaction on the given shared tree view.
 * This is a convenience helper around the {@link SharedTreeFork#transaction} APIs.
 * @param view - the view on which to run the transaction
 * @param transaction - the transaction function. This will be executed immediately. It is passed `view` as an argument for convenience.
 * If this function returns an `Abort` result then the transaction will be aborted. Otherwise, it will be committed.
 * @returns whether or not the transaction was committed or aborted
 * @internal
 */
function runSynchronous(view, transaction) {
    view.transaction.start();
    const result = transaction(view);
    return result === index_js_6.TransactionResult.Abort
        ? view.transaction.abort()
        : view.transaction.commit();
}
exports.runSynchronous = runSynchronous;
//# sourceMappingURL=treeCheckout.js.map