Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rename .doc to .syncValue and return undefined when not ready #125

Merged
merged 4 commits into from
Aug 4, 2023
Merged
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
15 changes: 9 additions & 6 deletions packages/automerge-repo-react-hooks/src/useDocument.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Doc, ChangeFn, ChangeOptions } from "@automerge/automerge"
import { DocumentId, DocHandlePatchPayload } from "@automerge/automerge-repo"
import { DocumentId, DocHandleChangePayload } from "@automerge/automerge-repo"
import { useEffect, useState } from "react"
import { useRepo } from "./useRepo"

Expand All @@ -12,18 +12,21 @@ export function useDocument<T>(documentId?: DocumentId) {
useEffect(() => {
if (!handle) return

handle.value().then(v => setDoc(v))
handle.doc().then(v => setDoc(v))

const onPatch = (h: DocHandlePatchPayload<T>) => setDoc(h.patchInfo.after)
handle.on("patch", onPatch)
const onChange = (h: DocHandleChangePayload<T>) => setDoc(h.doc)
handle.on("change", onChange)
const cleanup = () => {
handle.removeListener("patch", onPatch)
handle.removeListener("change", onChange)
}

return cleanup
}, [handle])

const changeDoc = (changeFn: ChangeFn<T>, options?: ChangeOptions<T> | undefined) => {
const changeDoc = (
changeFn: ChangeFn<T>,
options?: ChangeOptions<T> | undefined
) => {
if (!handle) return
handle.change(changeFn, options)
}
Expand Down
8 changes: 4 additions & 4 deletions packages/automerge-repo-svelte-store/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { writable } from "svelte/store"
import {
Repo,
DocumentId,
DocHandlePatchPayload,
DocHandleChangePayload,
} from "@automerge/automerge-repo"

const ContextRepoKey = Symbol("svelte-context-automerge-repo")
Expand All @@ -21,9 +21,9 @@ export function document<T>(documentId: DocumentId) {
const repo = getContextRepo()
const handle = repo.find<T>(documentId)
const { set, subscribe } = writable<Doc<T>>(null, () => {
const onPatch = (h: DocHandlePatchPayload<T>) => set(h.patchInfo.after)
handle.addListener("patch", onPatch)
return () => handle.removeListener("patch", onPatch)
const onChange = (h: DocHandleChangePayload<T>) => set(h.doc)
handle.addListener("change", onChange)
return () => handle.removeListener("change", onChange)
})

return {
Expand Down
27 changes: 6 additions & 21 deletions packages/automerge-repo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,34 +53,19 @@ A `Repo` exposes these methods:
A `DocHandle` is a wrapper around an `Automerge.Doc`. Its primary function is to dispatch changes to
the document.

- `handle.doc()` or `handle.docSync()`
Returns a `Promise<Doc<T>>` that will contain the current value of the document.
it waits until the document has finished loading and/or synchronizing over the network before
returning a value.
- `handle.change((doc: T) => void)`
Calls the provided callback with an instrumented mutable object
representing the document. Any changes made to the document will be recorded and distributed to
other nodes.
- `handle.value()`
Returns a `Promise<Doc<T>>` that will contain the current value of the document.
it waits until the document has finished loading and/or synchronizing over the network before
returning a value.

When required, you can also access the underlying document directly, but only after the handle is ready:

```ts
if (handle.ready()) {
doc = handle.doc
} else {
handle.value().then(d => {
doc = d
})
}
```

A `DocHandle` also emits these events:

- `change({handle: DocHandle, doc: Doc<T>})`
Called any time changes are created or received on the document. Request the `value()` from the
handle.
- `patch({handle: DocHandle, patches: Patch[], patchInfo: PatchInfo})`
Useful for manual increment maintenance of a video, most notably for text editors.
- `change({handle: DocHandle, patches: Patch[], patchInfo: PatchInfo})`
Called whenever the document changes, the handle's .doc
- `delete`
Called when the document is deleted locally.

Expand Down
102 changes: 72 additions & 30 deletions packages/automerge-repo/src/DocHandle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,7 @@ export class DocHandle<T> //
this.#log = debug(`automerge-repo:dochandle:${documentId.slice(0, 5)}`)

// initial doc
const doc = A.init<T>({
patchCallback: (patches, patchInfo) =>
this.emit("patch", { handle: this, patches, patchInfo }),
})
const doc = A.init<T>()

/**
* Internally we use a state machine to orchestrate document loading and/or syncing, in order to
Expand Down Expand Up @@ -133,33 +130,36 @@ export class DocHandle<T> //
const oldDoc = history?.context?.doc
const newDoc = context.doc

this.#log(`${event} → ${state}`, newDoc)

const docChanged = newDoc && oldDoc && !headsAreSame(newDoc, oldDoc)
if (docChanged) {
this.emit("change", { handle: this, doc: newDoc })
this.emit("heads-changed", { handle: this, doc: newDoc })

const patches = A.diff(newDoc, A.getHeads(oldDoc), A.getHeads(newDoc))
if (patches.length > 0) {
const source = "change" // TODO: pass along the source (load/change/network)
this.emit("change", {
handle: this,
doc: newDoc,
patches,
patchInfo: { before: oldDoc, after: newDoc, source },
})
}

if (!this.isReady()) {
this.#machine.send(REQUEST_COMPLETE)
}
}
this.#log(`${event} → ${state}`, this.#doc)
})
.start()

this.#machine.send(isNew ? CREATE : FIND)
}

get doc() {
if (!this.isReady()) {
throw new Error(
`DocHandle#${this.documentId} is not ready. Check \`handle.isReady()\` before accessing the document.`
)
}

return this.#doc
}

// PRIVATE

/** Returns the current document */
/** Returns the current document, regardless of state */
get #doc() {
return this.#machine?.getSnapshot().context.doc
}
Expand All @@ -183,15 +183,40 @@ export class DocHandle<T> //

// PUBLIC

isReady = () => this.#state === READY
isReadyOrRequesting = () =>
this.#state === READY || this.#state === REQUESTING
isDeleted = () => this.#state === DELETED
/**
* Checks if the document is ready for accessing or changes.
* Note that for documents already stored locally this occurs before synchronization
* with any peers. We do not currently have an equivalent `whenSynced()`.
*/
isReady = () => this.inState([HandleState.READY])
/**
* Checks if this document has been marked as deleted.
* Deleted documents are removed from local storage and the sync process.
* It's not currently possible at runtime to undelete a document.
* @returns true if the document has been marked as deleted
*/
isDeleted = () => this.inState([HandleState.DELETED])
inState = (awaitStates: HandleState[]) =>
awaitStates.some(state => this.#machine?.getSnapshot().matches(state))

/**
* Use this to block until the document handle has finished loading.
* The async equivalent to checking `inState()`.
* @param awaitStates = [READY]
* @returns
*/
async whenReady(awaitStates: HandleState[] = [READY]): Promise<void> {
await withTimeout(this.#statePromise(awaitStates), this.#timeoutDelay)
}

/**
* Returns the current document, waiting for the handle to be ready if necessary.
* Returns the current state of the Automerge document this handle manages.
* Note that this waits for the handle to be ready if necessary, and currently, if
* loading (or synchronization) fails, will never resolve.
*
* @param {awaitStates=[READY]} optional states to wait for, such as "LOADING". mostly for internal use.
*/
async value(awaitStates: HandleState[] = [READY]) {
async doc(awaitStates: HandleState[] = [READY]): Promise<A.Doc<T>> {
await pause() // yield one tick because reasons
try {
// wait for the document to enter one of the desired states
Expand All @@ -205,8 +230,22 @@ export class DocHandle<T> //
return this.#doc
}

async loadAttemptedValue() {
return this.value([READY, REQUESTING])
/**
* Returns the current state of the Automerge document this handle manages, or undefined.
* Useful in a synchronous context. Consider using `await handle.doc()` instead, check `isReady()`,
* or use `whenReady()` if you want to make sure loading is complete first.
*
* Do not confuse this with the SyncState of the document, which describes the state of the synchronization process.
*
* Note that `undefined` is not a valid Automerge document so the return from this function is unambigous.
* @returns the current document, or undefined if the document is not ready
*/
docSync(): A.Doc<T> | undefined {
if (!this.isReady()) {
return undefined
}

return this.#doc
}

/** `load` is called by the repo when the document is found in storage */
Expand All @@ -218,7 +257,9 @@ export class DocHandle<T> //

/** `update` is called by the repo when we receive changes from the network */
update(callback: (doc: A.Doc<T>) => A.Doc<T>) {
this.#machine.send(UPDATE, { payload: { callback } })
this.#machine.send(UPDATE, {
payload: { callback },
})
}

/** `change` is called by the repo when the document is changed locally */
Expand Down Expand Up @@ -280,7 +321,7 @@ export interface DocHandleMessagePayload {
data: Uint8Array
}

export interface DocHandleChangePayload<T> {
export interface DocHandleEncodedChangePayload<T> {
handle: DocHandle<T>
doc: A.Doc<T>
}
Expand All @@ -289,15 +330,16 @@ export interface DocHandleDeletePayload<T> {
handle: DocHandle<T>
}

export interface DocHandlePatchPayload<T> {
export interface DocHandleChangePayload<T> {
handle: DocHandle<T>
doc: A.Doc<T>
patches: A.Patch[]
patchInfo: A.PatchInfo<T>
}

export interface DocHandleEvents<T> {
"heads-changed": (payload: DocHandleEncodedChangePayload<T>) => void
change: (payload: DocHandleChangePayload<T>) => void
patch: (payload: DocHandlePatchPayload<T>) => void
delete: (payload: DocHandleDeletePayload<T>) => void
}

Expand Down Expand Up @@ -383,7 +425,7 @@ type DocHandleXstateMachine<T> = Interpreter<

// CONSTANTS

const { IDLE, LOADING, REQUESTING, READY, ERROR, DELETED } = HandleState
export const { IDLE, LOADING, REQUESTING, READY, ERROR, DELETED } = HandleState
const {
CREATE,
LOAD,
Expand Down
4 changes: 2 additions & 2 deletions packages/automerge-repo/src/Repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { CollectionSynchronizer } from "./synchronizer/CollectionSynchronizer.js
import { ChannelId, DocumentId, PeerId } from "./types.js"

import debug from "debug"
import { waitFor } from "xstate/lib/waitFor.js"

const SYNC_CHANNEL = "sync_channel" as ChannelId

Expand All @@ -31,8 +32,7 @@ export class Repo extends DocCollection {
this.on("document", async ({ handle }) => {
if (storageSubsystem) {
// Save when the document changes
handle.on("change", async ({ handle }) => {
const doc = await handle.value()
handle.on("heads-changed", async ({ handle, doc }) => {
storageSubsystem.save(handle.documentId, doc)
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export function runAdapterTests(_setup: SetupFn, title?: string): void {

// Bob receives the change
await eventPromise(bobHandle, "change")
assert.equal((await bobHandle.value()).foo, "bar")
assert.equal((await bobHandle.doc()).foo, "bar")

// Bob changes the document
bobHandle.change(d => {
Expand All @@ -55,7 +55,7 @@ export function runAdapterTests(_setup: SetupFn, title?: string): void {

// Alice receives the change
await eventPromise(aliceHandle, "change")
assert.equal((await aliceHandle.value()).foo, "baz")
assert.equal((await aliceHandle.doc()).foo, "baz")
}

// Run the test in both directions, in case they're different types of adapters
Expand Down Expand Up @@ -97,8 +97,8 @@ export function runAdapterTests(_setup: SetupFn, title?: string): void {

// Bob and Charlie receive the change
await eventPromises([bobHandle, charlieHandle], "change")
assert.equal((await bobHandle.value()).foo, "bar")
assert.equal((await charlieHandle.value()).foo, "bar")
assert.equal((await bobHandle.doc()).foo, "bar")
assert.equal((await charlieHandle.doc()).foo, "bar")

// Charlie changes the document
charlieHandle.change(d => {
Expand All @@ -107,8 +107,8 @@ export function runAdapterTests(_setup: SetupFn, title?: string): void {

// Alice and Bob receive the change
await eventPromises([aliceHandle, bobHandle], "change")
assert.equal((await bobHandle.value()).foo, "baz")
assert.equal((await charlieHandle.value()).foo, "baz")
assert.equal((await bobHandle.doc()).foo, "baz")
assert.equal((await charlieHandle.doc()).foo, "baz")

teardown()
})
Expand Down
5 changes: 1 addition & 4 deletions packages/automerge-repo/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
export { DocCollection } from "./DocCollection.js"
export { DocHandle, HandleState } from "./DocHandle.js"
export type {
DocHandleChangePayload,
DocHandlePatchPayload,
} from "./DocHandle.js"
export type { DocHandleChangePayload } from "./DocHandle.js"
export { NetworkAdapter } from "./network/NetworkAdapter.js"
export type {
InboundMessagePayload,
Expand Down
10 changes: 5 additions & 5 deletions packages/automerge-repo/src/synchronizer/DocSynchronizer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as A from "@automerge/automerge"
import { DocHandle } from "../DocHandle.js"
import { DocHandle, READY, REQUESTING } from "../DocHandle.js"
import { ChannelId, PeerId } from "../types.js"
import { Synchronizer } from "./Synchronizer.js"

Expand Down Expand Up @@ -33,7 +33,7 @@ export class DocSynchronizer extends Synchronizer {

// Process pending sync messages immediately after the handle becomes ready.
void (async () => {
await handle.loadAttemptedValue()
await handle.doc([READY, REQUESTING])
this.#processAllPendingSyncMessages()
})()
}
Expand All @@ -46,7 +46,7 @@ export class DocSynchronizer extends Synchronizer {

async #syncWithPeers() {
this.#log(`syncWithPeers`)
const doc = await this.handle.value()
const doc = await this.handle.doc()
this.#peers.forEach(peerId => this.#sendSyncMessage(peerId, doc))
}

Expand Down Expand Up @@ -120,7 +120,7 @@ export class DocSynchronizer extends Synchronizer {

// At this point if we don't have anything in our storage, we need to use an empty doc to sync
// with; but we don't want to surface that state to the front end
void this.handle.loadAttemptedValue().then(doc => {
void this.handle.doc([READY, REQUESTING]).then(doc => {
// HACK: if we have a sync state already, we round-trip it through the encoding system to make
// sure state is preserved. This prevents an infinite loop caused by failed attempts to send
// messages during disconnection.
Expand All @@ -147,7 +147,7 @@ export class DocSynchronizer extends Synchronizer {
throw new Error(`channelId doesn't match documentId`)

// We need to block receiving the syncMessages until we've checked local storage
if (!this.handle.isReadyOrRequesting()) {
if (!this.handle.inState([READY, REQUESTING])) {
this.#pendingSyncMessages.push({ peerId, message })
return
}
Expand Down
Loading
Loading