Skip to content
Draft
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
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