Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,24 @@ jobs:

- name: Test
run: |
result_bundle="$RUNNER_TEMP/docmostly-ios-tests.xcresult"
set +e
xcodebuild test \
-project docmostly.xcodeproj \
-scheme docmostly \
-destination '${{ steps.ios-simulator.outputs.destination }}' \
-resultBundlePath "$result_bundle" \
CODE_SIGNING_ALLOWED=NO
test_status=$?
set -e

if [ "$test_status" -ne 0 ]; then
xcrun xcresulttool get test-results summary \
--path "$result_bundle" \
--compact
fi

exit "$test_status"

ipad-build:
name: iPad build
Expand Down
20 changes: 19 additions & 1 deletion Tools/CRDTRuntime/src/docmostly-crdt-runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -237,11 +237,29 @@ class DocmostlyCRDTDocument {
return base64FromBytes(Y.encodeStateAsUpdate(this.ydoc, bytesFromBase64(stateVector)));
}

validateUpdate(update) {
const validationDocument = new Y.Doc();
try {
Y.applyUpdate(validationDocument, bytesFromBase64(update));
return true;
} finally {
validationDocument.destroy();
}
}

applyRemoteUpdate(update) {
Y.applyUpdate(this.ydoc, bytesFromBase64(update), this.remoteOrigin);
this.enqueueSnapshot();
}

currentSnapshot() {
return {
title: null,
document: yDocToProsemirrorJSON(this.ydoc, fragmentName),
updatedAt: null
};
}

integrateLocalChange(change) {
this.applyDocument(change.after.title, change.after.document, this.localOrigin);
}
Expand Down Expand Up @@ -324,7 +342,7 @@ class DocmostlyCRDTDocument {

enqueueSnapshot() {
this.snapshots.push({
title: this.title,
title: null,
document: yDocToProsemirrorJSON(this.ydoc, fragmentName),
updatedAt: null
});
Expand Down
35 changes: 32 additions & 3 deletions Tools/CRDTRuntime/test/docmostly-crdt-runtime.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ test("applies remote document update to empty native state", () => {
secondDocument.applyRemoteUpdate(update);

assert.deepEqual(secondDocument.drainDocumentSnapshots(), [{
title: "Page",
title: null,
document: paragraphDocument("Shared edit"),
updatedAt: null
}]);
Expand All @@ -82,7 +82,7 @@ test("does not duplicate content when syncing with server-converted ydoc", () =>
nativeDocument.applyRemoteUpdate(serverUpdate);

assert.deepEqual(nativeDocument.drainDocumentSnapshots(), [{
title: "Page",
title: null,
document: paragraphDocument("Seed"),
updatedAt: null
}]);
Expand All @@ -100,12 +100,41 @@ test("restores a cached full document update into an empty native document", ()
restoredDocument.applyRemoteUpdate(cachedState);

assert.deepEqual(restoredDocument.drainDocumentSnapshots(), [{
title: "Page",
title: null,
document: paragraphDocument("Cached offline base"),
updatedAt: null
}]);
});

test("validates an update without mutating the live document", () => {
const source = serverYDocFromJSON(paragraphDocument("Validated"));
const update = base64FromBytes(Y.encodeStateAsUpdate(source));
const document = globalThis.docmostlyCRDT.createDocument({
pageID: "page-1",
title: "Page",
document: paragraphDocument("Stale projection")
});

assert.equal(document.validateUpdate(update), true);
assert.deepEqual(document.currentSnapshot(), {
title: null,
document: { type: "doc", content: [] },
updatedAt: null
});
assert.deepEqual(document.drainDocumentSnapshots(), []);
});

test("rejects corrupt updates during validation", () => {
const document = globalThis.docmostlyCRDT.createDocument({
pageID: "page-1",
title: "Page",
document: paragraphDocument("Seed")
});

assert.throws(() => document.validateUpdate("AQIDBA=="));
assert.deepEqual(document.drainDocumentSnapshots(), []);
});

test("merges non-overlapping edits from two offline native documents", () => {
const baseDocument = paragraphsDocument("First", "Second");
const serverDocument = serverYDocFromJSON(baseDocument);
Expand Down
68 changes: 24 additions & 44 deletions docmostly/App/AppState+Collaboration.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Foundation

nonisolated struct NativeEditorPreparedCRDTDocumentEngine: Sendable {
let engine: any NativeEditorCRDTDocumentEngine
nonisolated struct NativeEditorPreparedDocumentSession: Sendable {
let session: DocumentSession
let restoredLocalState: Bool
}

Expand All @@ -24,52 +24,32 @@ extension AppState {
return try NativeEditorRealtimeEventEndpoint.webSocketURL(serverBaseURL: serverURL)
}

func makeCRDTDocumentEngine(
func makeDocumentSession(
pageID: String,
title: String,
document: NativeEditorDocument
) async throws -> NativeEditorPreparedCRDTDocumentEngine? {
guard let crdtDocumentEngineFactory else { return nil }

let engine = try await crdtDocumentEngineFactory.makeDocumentEngine(
pageID: pageID,
) async throws -> NativeEditorPreparedDocumentSession? {
guard let documentSessionRegistry else { return nil }
guard let cacheScope, let workspaceID = currentUser?.workspace.id else {
throw APIError.connectionFailed("Offline collaboration storage is unavailable until you sign in.")
}
let key = DocumentStoreKey(
serverBaseURL: cacheScope.serverBaseURL,
userID: cacheScope.userID,
workspaceID: workspaceID,
pageID: pageID
)
let session = try await documentSessionRegistry.session(
for: key,
title: title,
document: document
)
let restoredState = try await loadCachedCRDTStateUpdate(pageID: pageID)
if let restoredState {
try await engine.applyRemoteUpdate(restoredState)
}
return NativeEditorPreparedCRDTDocumentEngine(
engine: engine,
restoredLocalState: restoredState != nil
return NativeEditorPreparedDocumentSession(
session: session,
restoredLocalState: session.restoredLocalState
)
}

func persistCRDTStateUpdate(pageID: String, update: Data) async throws {
guard update.isEmpty == false else {
throw APIError.connectionFailed("The collaborative document returned an empty local state.")
}
let scope = try requireCacheScope(
message: "Offline collaboration storage is unavailable until you sign in."
)
if let cacheWriter {
try await cacheWriter.saveCRDTStateUpdate(pageId: pageID, update: update, scope: scope)
return
}
guard let cacheRepository else {
throw APIError.connectionFailed("Offline collaboration storage is unavailable on this device.")
}
try cacheRepository.saveCRDTStateUpdate(pageId: pageID, update: update, scope: scope)
}

private func loadCachedCRDTStateUpdate(pageID: String) async throws -> Data? {
guard let cacheScope else { return nil }
if let cacheReader {
return try await cacheReader.loadCRDTStateUpdate(pageId: pageID, scope: cacheScope)
}
return try cacheRepository?.loadCRDTStateUpdate(pageId: pageID, scope: cacheScope)
}
}

enum NativeEditorCRDTDocumentEngineAttachment {
Expand All @@ -84,7 +64,7 @@ enum NativeEditorCRDTDocumentEngineAttachment {

do {
try Task.checkCancellation()
guard let preparedEngine = try await appState.makeCRDTDocumentEngine(
guard let preparedSession = try await appState.makeDocumentSession(
pageID: pageID,
title: title,
document: document
Expand All @@ -94,11 +74,11 @@ enum NativeEditorCRDTDocumentEngineAttachment {
}

try Task.checkCancellation()
editorViewModel.configureCRDTDocumentEngine(
preparedEngine.engine,
restoredLocalState: preparedEngine.restoredLocalState
editorViewModel.configureDocumentSession(
preparedSession.session,
restoredLocalState: preparedSession.restoredLocalState
)
if appState.isOffline, preparedEngine.restoredLocalState == false {
if appState.isOffline, preparedSession.restoredLocalState == false {
editorViewModel.markCollaborationUnavailable(
"Open this page online once before editing it offline so its collaborative state can be cached."
)
Expand Down
44 changes: 9 additions & 35 deletions docmostly/App/AppState+CollaborativeDraftResolution.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,56 +29,30 @@ extension AppState {
}
}

// swiftlint:disable:next function_parameter_count
func keepPendingCollaborativeDraft(
pageId: String,
title: String,
document: ProseMirrorDocument,
remoteBaseTitle: String,
remoteBaseDocument: ProseMirrorDocument,
replacingThrough cutoff: Date
) async throws -> OfflinePageUpdateSupersessionResult {
await pauseOfflineReplayForCollaborativeResolution()
let scope = try requireCacheScope(
message: "The local draft cannot be secured until you sign in again."
)
let resolvedAt = Date.now
let result: OfflinePageUpdateSupersessionResult

if let offlineQueueRepository {
result = try await offlineQueueRepository.resolvePendingPageUpdateKeepingLocal(
pageId: pageId,
title: title,
document: document,
remoteBaseTitle: remoteBaseTitle,
remoteBaseDocument: remoteBaseDocument,
replacingThrough: cutoff,
resolvedAt: resolvedAt,
scope: scope
)
} else if let offlineQueue {
result = try offlineQueue.resolvePendingPageUpdateKeepingLocal(
pageId: pageId,
title: title,
document: document,
remoteBaseTitle: remoteBaseTitle,
remoteBaseDocument: remoteBaseDocument,
replacingThrough: cutoff,
resolvedAt: resolvedAt,
scope: scope
)
} else {
throw APIError.connectionFailed("Local draft storage is unavailable on this device.")
let acknowledgement = try await acknowledgeCollaborativeDraft(pageId: pageId, through: cutoff)
guard acknowledgement != .newerPendingUpdatePreserved else {
return .newerPendingUpdatePreserved
}

guard result != .newerPendingUpdatePreserved else { return result }
_ = try await queueOfflineMutation(.updatePageMetadata(
pageId: pageId,
title: title,
baseTitle: remoteBaseTitle
))
_ = try await saveLocalEditableDraft(
pageId: pageId,
title: title,
document: document
)
await refreshOfflineMutationCount()
return result
return .superseded
}

private func acknowledgeCollaborativeDraft(
Expand Down
Loading
Loading