Skip to content
37 changes: 19 additions & 18 deletions src/documentEnvelopeIfMatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,9 @@ import {
type DocumentEnvelopeLimits,
} from './documentEnvelope.js';
import {
createValidatedDocumentEnvelopeRevisionEvidence,
} from './documentRevisionEvidence.js';
import {
createValidatedDocumentEnvelopeRevision,
createValidatedDocumentEnvelopeRevisionWithResolvedProvider,
DocumentEnvelopeRevisionError,
resolveDocumentEnvelopeDigestProvider,
type CwlEditorDocumentRevision,
type DocumentEnvelopeDigestProvider,
} from './documentEnvelopeRevision.js';
Expand Down Expand Up @@ -112,11 +110,12 @@ export function restoreDocumentEnvelopeBytesIfMatch(
/**
* Execute one guarded restore using a caller-selected envelope preparation path.
*
* The function captures and hashes one current envelope, returns that same
* frozen envelope beside every non-null current revision, reconstructs the
* incoming source through the active schema, and hashes the exact normalized
* document that will be applied. It does not inspect the incoming source when
* the expected validator already conflicts.
* The function captures one digest capability before document serialization,
* hashes one current envelope with that capability, returns that same frozen
* envelope beside every non-null current revision, reconstructs the incoming
* source through the active schema, and reuses the captured capability for the
* exact normalized document that will be applied. It does not inspect the
* incoming source when the expected validator already conflicts.
*/
async function restoreIfMatch(
editor: Editor,
Expand All @@ -131,15 +130,17 @@ async function restoreIfMatch(
return createMovedDocumentConflict();
}

const resolvedProvider = resolveDocumentEnvelopeDigestProvider(digestProvider);
const capturedDocument = editor.state.doc;
const currentEnvelope = createDocumentEnvelope(
capturedDocument.toJSON(),
limits,
);
const currentRevision = await createValidatedDocumentEnvelopeRevision(
currentEnvelope,
digestProvider,
);
const currentRevision =
await createValidatedDocumentEnvelopeRevisionWithResolvedProvider(
currentEnvelope,
resolvedProvider,
);

if (hasEditorMoved(editor, capturedDocument)) {
return createMovedDocumentConflict();
Expand All @@ -161,10 +162,10 @@ async function restoreIfMatch(
prepared.documentNode.toJSON(),
limits,
);
const nextEvidence =
await createValidatedDocumentEnvelopeRevisionEvidence(
const nextRevision =
await createValidatedDocumentEnvelopeRevisionWithResolvedProvider(
appliedEnvelope,
digestProvider,
resolvedProvider,
);
if (hasEditorMoved(editor, capturedDocument)) {
return createMovedDocumentConflict();
Expand All @@ -175,8 +176,8 @@ async function restoreIfMatch(
status: 'restored',
previousRevision: currentRevision,
previousEnvelope: currentEnvelope,
revision: nextEvidence.revision,
envelope: nextEvidence.envelope,
revision: nextRevision,
envelope: appliedEnvelope,
});
}

Expand Down
128 changes: 128 additions & 0 deletions src/documentEnvelopeIfMatchDigestPreflight.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { Node as ProseMirrorNode } from '@tiptap/pm/model';
import { Editor } from '@tiptap/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createDocumentEnvelope } from './documentEnvelope.js';
import { encodeDocumentEnvelope } from './documentEnvelopeCanonical.js';
import {
DocumentEnvelopeRevisionError,
type DocumentEnvelopeDigestProvider,
} from './documentEnvelopeRevision.js';
import {
restoreDocumentEnvelopeBytesIfMatch,
restoreDocumentEnvelopeIfMatch,
} from './documentEnvelopeIfMatch.js';
import { buildExtensions } from './extensions/kit.js';

const ZERO_REVISION_TAG = `"sha256-${'00'.repeat(32)}"`;
const DIGEST_FAILURE = 'Document envelope SHA-256 digest could not be created';
const openEditors: Editor[] = [];

function makeEditor(): Editor {
const element = document.createElement('div');
document.body.appendChild(element);
const editor = new Editor({
element,
extensions: buildExtensions(),
content: '<p>Current guarded document</p>',
});
openEditors.push(editor);
return editor;
}

function incomingEnvelope() {
return createDocumentEnvelope({
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Replacement document' }],
},
],
});
}

afterEach(() => {
vi.restoreAllMocks();
for (const editor of openEditors.splice(0)) {
if (!editor.isDestroyed) editor.destroy();
}
document.body.replaceChildren();
});

describe('revision-guarded restore digest capability preflight', () => {
it('rejects an unusable provider before serializing the current document', async () => {
const editor = makeEditor();
const toJson = vi.spyOn(ProseMirrorNode.prototype, 'toJSON');
const provider = { digest: 7 } as unknown as DocumentEnvelopeDigestProvider;

await expect(
restoreDocumentEnvelopeIfMatch(
editor,
ZERO_REVISION_TAG,
incomingEnvelope(),
undefined,
provider,
),
).rejects.toThrow(new DocumentEnvelopeRevisionError(DIGEST_FAILURE));

expect(toJson).not.toHaveBeenCalled();
});

it('preflights the same capability before current-document work on the byte path', async () => {
const editor = makeEditor();
const sourceBytes = encodeDocumentEnvelope(incomingEnvelope());
const toJson = vi.spyOn(ProseMirrorNode.prototype, 'toJSON');
const provider = { digest: 7 } as unknown as DocumentEnvelopeDigestProvider;

await expect(
restoreDocumentEnvelopeBytesIfMatch(
editor,
ZERO_REVISION_TAG,
sourceBytes,
undefined,
provider,
),
).rejects.toThrow(new DocumentEnvelopeRevisionError(DIGEST_FAILURE));

expect(toJson).not.toHaveBeenCalled();
});

it('captures one accessor-backed callable for both guarded revisions', async () => {
const editor = makeEditor();
let digestReads = 0;
let digestCalls = 0;
const provider = {} as DocumentEnvelopeDigestProvider;
Object.defineProperty(provider, 'digest', {
get() {
digestReads += 1;
return function digest(
this: DocumentEnvelopeDigestProvider,
algorithm: 'SHA-256',
source: BufferSource,
): Promise<ArrayBuffer> {
expect(this).toBe(provider);
expect(algorithm).toBe('SHA-256');
expect(ArrayBuffer.isView(source)).toBe(true);
digestCalls += 1;
return Promise.resolve(new ArrayBuffer(32));
};
},
});

const result = await restoreDocumentEnvelopeIfMatch(
editor,
ZERO_REVISION_TAG,
incomingEnvelope(),
undefined,
provider,
);

expect(result).toMatchObject({
status: 'restored',
previousRevision: { strongEntityTag: ZERO_REVISION_TAG },
revision: { strongEntityTag: ZERO_REVISION_TAG },
});
expect(digestReads).toBe(1);
expect(digestCalls).toBe(2);
});
});
Loading