diff --git a/packages/automerge-repo/src/DocHandle.ts b/packages/automerge-repo/src/DocHandle.ts index 5757835ac..e7d6beeb9 100644 --- a/packages/automerge-repo/src/DocHandle.ts +++ b/packages/automerge-repo/src/DocHandle.ts @@ -41,6 +41,7 @@ export class DocHandle // #machine: DocHandleXstateMachine #timeoutDelay: number #remoteHeads: Record = {} + #changeMetadata: ChangeMetadataFunction /** The URL of this document * @@ -54,18 +55,31 @@ export class DocHandle // /** @hidden */ constructor( public documentId: DocumentId, - { isNew = false, timeoutDelay = 60_000 }: DocHandleOptions = {} + { + timeoutDelay = 60_000, + changeMetadata: changeMetadataFunction = () => undefined, + init = false, + }: DocHandleOptions = {} ) { super() this.#timeoutDelay = timeoutDelay + this.#changeMetadata = changeMetadataFunction this.#log = debug(`automerge-repo:dochandle:${this.documentId.slice(0, 5)}`) // initial doc let doc = A.init() // Make an empty change so that we have something to save to disk - if (isNew) { - doc = A.emptyChange(doc, {}) + if (init) { + const options = init === true ? {} : init + + doc = A.emptyChange( + doc, + optionsWithGlobalMetadata( + options, + this.#changeMetadata(this.documentId) ?? {} + ) + ) } /** @@ -217,7 +231,7 @@ export class DocHandle // }) .start() - this.#machine.send(isNew ? CREATE : FIND) + this.#machine.send(init ? CREATE : FIND) } // PRIVATE @@ -340,7 +354,7 @@ export class DocHandle // } /** `change` is called by the repo when the document is changed locally */ - change(callback: A.ChangeFn, options: A.ChangeOptions = {}) { + change(callback: A.ChangeFn, options: DocHandleChangeOptions = {}) { if (!this.isReady()) { throw new Error( `DocHandle#${this.documentId} is not ready. Check \`handle.isReady()\` before accessing the document.` @@ -349,7 +363,14 @@ export class DocHandle // this.#machine.send(UPDATE, { payload: { callback: (doc: A.Doc) => { - return A.change(doc, options, callback) + return A.change( + doc, + optionsWithGlobalMetadata( + options, + this.#changeMetadata(this.documentId) ?? {} + ), + callback + ) }, }, }) @@ -362,7 +383,7 @@ export class DocHandle // changeAt( heads: A.Heads, callback: A.ChangeFn, - options: A.ChangeOptions = {} + options: DocHandleChangeOptions = {} ): string[] | undefined { if (!this.isReady()) { throw new Error( @@ -373,7 +394,15 @@ export class DocHandle // this.#machine.send(UPDATE, { payload: { callback: (doc: A.Doc) => { - const result = A.changeAt(doc, heads, options, callback) + const result = A.changeAt( + doc, + heads, + optionsWithGlobalMetadata( + options, + this.#changeMetadata(this.documentId) ?? {} + ), + callback + ) resultHeads = result.newHeads return result.newDoc }, @@ -448,14 +477,88 @@ export class DocHandle // } } +function optionsWithGlobalMetadata( + options: DocHandleChangeOptions, + globalMetadata: ChangeMetadata +): A.ChangeOptions { + const mergedMetadata: MergedMetadata = { metadata: {} } + + mergeMetadata(mergedMetadata, globalMetadata) + + if (options.metadata) { + mergeMetadata(mergedMetadata, options.metadata) + } + + const { metadata, time } = mergedMetadata + + return { + time, + message: + Object.values(metadata).length > 0 ? JSON.stringify(metadata) : undefined, + patchCallback: options.patchCallback, + } +} + +function mergeMetadata(target: MergedMetadata, metadata: ChangeMetadata) { + for (const [key, value] of Object.entries(metadata)) { + const type = typeof value + + // remove time from metadata, because it can be stored more effiently as a time delta + // this will be no longer necessary once we have proper metadata support + if (key === "time" && type === "number") { + target.time = value as number + continue + } + + if (value === undefined) { + delete target.metadata[key] + continue + } + + if (type !== "number" && type !== "string" && type !== "boolean") { + throw new Error( + `Only primive values "number", "string" and "boolean" are allowed in metadata` + ) + } + + target.metadata[key] = value + } +} + +interface MergedMetadata { + metadata: ChangeMetadata + time?: number +} + // WRAPPER CLASS TYPES /** @hidden */ -export interface DocHandleOptions { - isNew?: boolean +export interface DocHandleOptions { timeoutDelay?: number + changeMetadata?: ChangeMetadataFunction + // set init to true or pass in initialization options to create a new empty document + init?: boolean | DocHandleChangeOptions } +// todo: remove this type once we have real metadata on changes in automerge +// as an interim solution we use the message attribute to store the metadata as a JSON string +export interface DocHandleChangeOptions { + metadata?: ChangeMetadata + patchCallback?: A.PatchCallback +} + +export type ChangeMetadata = Record + +/** A function that defines default meta data for each change on the handle + * + * @remarks + * This function can be defined globally on the {@link Repo} and is passed down to all {@link DocHandle}. + * The metadata can be override by explicitly passing metadata in {@link DocHandle.change} or {@link DocHandle.changeAt}. + * */ +export type ChangeMetadataFunction = ( + documentId: DocumentId +) => ChangeMetadata | undefined + export interface DocHandleMessagePayload { destinationId: PeerId documentId: DocumentId diff --git a/packages/automerge-repo/src/Repo.ts b/packages/automerge-repo/src/Repo.ts index 8d90dce7c..22d4916eb 100644 --- a/packages/automerge-repo/src/Repo.ts +++ b/packages/automerge-repo/src/Repo.ts @@ -6,7 +6,12 @@ import { interpretAsDocumentId, parseAutomergeUrl, } from "./AutomergeUrl.js" -import { DocHandle, DocHandleEncodedChangePayload } from "./DocHandle.js" +import { + ChangeMetadataFunction, + DocHandle, + DocHandleEncodedChangePayload, + DocHandleChangeOptions, +} from "./DocHandle.js" import { RemoteHeadsSubscriptions } from "./RemoteHeadsSubscriptions.js" import { headsAreSame } from "./helpers/headsAreSame.js" import { throttle } from "./helpers/throttle.js" @@ -55,6 +60,8 @@ export class Repo extends EventEmitter { #remoteHeadsSubscriptions = new RemoteHeadsSubscriptions() #remoteHeadsGossipingEnabled = false + #changeMetadata: ChangeMetadataFunction + constructor({ storage, network, @@ -62,11 +69,13 @@ export class Repo extends EventEmitter { sharePolicy, isEphemeral = storage === undefined, enableRemoteHeadsGossiping = false, + changeMetadata = () => undefined, }: RepoConfig) { super() this.#remoteHeadsGossipingEnabled = enableRemoteHeadsGossiping this.#log = debug(`automerge-repo:repo`) this.sharePolicy = sharePolicy ?? this.sharePolicy + this.#changeMetadata = changeMetadata // DOC COLLECTION @@ -323,15 +332,18 @@ export class Repo extends EventEmitter { /** The documentId of the handle to look up or create */ documentId: DocumentId, - /** If we know we're creating a new document, specify this so we can have access to it immediately */ - isNew: boolean + /** When creating a handle for a new doc set init to true or pass in a init options object */ + init?: DocHandleChangeOptions ) { // If we have the handle cached, return it if (this.#handleCache[documentId]) return this.#handleCache[documentId] // If not, create a new handle, cache it, and return it if (!documentId) throw new Error(`Invalid documentId ${documentId}`) - const handle = new DocHandle(documentId, { isNew }) + const handle = new DocHandle(documentId, { + changeMetadata: this.#changeMetadata, + init, + }) this.#handleCache[documentId] = handle return handle } @@ -355,7 +367,7 @@ export class Repo extends EventEmitter { * an empty object `{}`. Its documentId is generated by the system. we emit a `document` event * to advertise interest in the document. */ - create(): DocHandle { + create(options: DocHandleChangeOptions = {}): DocHandle { // TODO: // either // - pass an initial value and do something like this to ensure that you get a valid initial value @@ -376,7 +388,7 @@ export class Repo extends EventEmitter { // Generate a new UUID and store it in the buffer const { documentId } = parseAutomergeUrl(generateAutomergeUrl()) - const handle = this.#getHandle(documentId, true) as DocHandle + const handle = this.#getHandle(documentId, options) as DocHandle this.emit("document", { handle, isNew: true }) return handle } @@ -442,7 +454,7 @@ export class Repo extends EventEmitter { return this.#handleCache[documentId] } - const handle = this.#getHandle(documentId, false) as DocHandle + const handle = this.#getHandle(documentId) as DocHandle this.emit("document", { handle, isNew: false }) return handle } @@ -453,7 +465,7 @@ export class Repo extends EventEmitter { ) { const documentId = interpretAsDocumentId(id) - const handle = this.#getHandle(documentId, false) + const handle = this.#getHandle(documentId) handle.delete() delete this.#handleCache[documentId] @@ -470,7 +482,7 @@ export class Repo extends EventEmitter { async export(id: AnyDocumentId): Promise { const documentId = interpretAsDocumentId(id) - const handle = this.#getHandle(documentId, false) + const handle = this.#getHandle(documentId) const doc = await handle.doc() if (!doc) return undefined return Automerge.save(doc) @@ -532,6 +544,12 @@ export interface RepoConfig { */ sharePolicy?: SharePolicy + /** + * Define default meta data that is added to each change made through the repo. + * This function is called inside of {@link DocHandle} on each change. + */ + changeMetadata?: ChangeMetadataFunction + /** * Whether to enable the experimental remote heads gossiping feature */ diff --git a/packages/automerge-repo/test/DocHandle.test.ts b/packages/automerge-repo/test/DocHandle.test.ts index 11bed7fff..0840f372e 100644 --- a/packages/automerge-repo/test/DocHandle.test.ts +++ b/packages/automerge-repo/test/DocHandle.test.ts @@ -109,7 +109,7 @@ describe("DocHandle", () => { }) it("should emit a change message when changes happen", async () => { - const handle = new DocHandle(TEST_ID, { isNew: true }) + const handle = new DocHandle(TEST_ID, { init: true }) const p = new Promise>(resolve => handle.once("change", d => resolve(d)) @@ -129,7 +129,7 @@ describe("DocHandle", () => { it("should not emit a change message if no change happens via update", () => new Promise((done, reject) => { - const handle = new DocHandle(TEST_ID, { isNew: true }) + const handle = new DocHandle(TEST_ID, { init: true }) handle.once("change", () => { reject(new Error("shouldn't have changed")) }) @@ -140,7 +140,7 @@ describe("DocHandle", () => { })) it("should update the internal doc prior to emitting the change message", async () => { - const handle = new DocHandle(TEST_ID, { isNew: true }) + const handle = new DocHandle(TEST_ID, { init: true }) const p = new Promise(resolve => handle.once("change", ({ handle, doc }) => { @@ -158,7 +158,7 @@ describe("DocHandle", () => { }) it("should emit distinct change messages when consecutive changes happen", async () => { - const handle = new DocHandle(TEST_ID, { isNew: true }) + const handle = new DocHandle(TEST_ID, { init: true }) let calls = 0 const p = new Promise(resolve => @@ -188,7 +188,7 @@ describe("DocHandle", () => { }) it("should emit a change message when changes happen", async () => { - const handle = new DocHandle(TEST_ID, { isNew: true }) + const handle = new DocHandle(TEST_ID, { init: true }) const p = new Promise(resolve => handle.once("change", d => resolve(d))) handle.change(doc => { @@ -202,7 +202,7 @@ describe("DocHandle", () => { it("should not emit a patch message if no change happens", () => new Promise((done, reject) => { - const handle = new DocHandle(TEST_ID, { isNew: true }) + const handle = new DocHandle(TEST_ID, { init: true }) handle.on("change", () => { reject(new Error("shouldn't have changed")) }) @@ -269,7 +269,7 @@ describe("DocHandle", () => { }) it("should emit a delete event when deleted", async () => { - const handle = new DocHandle(TEST_ID, { isNew: true }) + const handle = new DocHandle(TEST_ID, { init: true }) const p = new Promise(resolve => handle.once("delete", () => resolve()) @@ -281,7 +281,7 @@ describe("DocHandle", () => { }) it("should allow changing at old heads", async () => { - const handle = new DocHandle(TEST_ID, { isNew: true }) + const handle = new DocHandle(TEST_ID, { init: true }) handle.change(doc => { doc.foo = "bar" @@ -303,9 +303,160 @@ describe("DocHandle", () => { assert(wasBar, "foo should have been bar as we changed at the old heads") }) + describe("metadata on changes", () => { + it("should allow to pass in metadata function", () => { + const documentIds = [] + + let time = 1 + + const handle = new DocHandle(TEST_ID, { + init: true, + changeMetadata: documentId => { + documentIds.push(documentId) + + return { + time: time++, + author: "bob", + } + }, + }) + + const doc1 = handle.docSync() + + // ... with change + handle.change(doc => { + doc.foo = "bar" + }) + + // ... with change at + handle.changeAt(A.getHeads(doc1), doc => { + doc.foo = "baz" + }) + + // changeMetadata was called with the right documentId + assert.equal(documentIds.length, 3) + assert(documentIds.every(documentId => documentId === handle.documentId)) + + // changes all have bob as author metadata + const changes = A.getAllChanges(handle.docSync()).map(A.decodeChange) + assert.equal(changes.length, 3) + assert.equal(changes[0].message, JSON.stringify({ author: "bob" })) + assert.equal(changes[0].time, 1) + assert.equal(changes[1].message, JSON.stringify({ author: "bob" })) + assert.equal(changes[1].time, 2) + assert.equal(changes[2].message, JSON.stringify({ author: "bob" })) + assert.equal(changes[2].time, 3) + }) + + it("should allow to provide additional metadata when applying change", () => { + const documentIds = [] + + const handle = new DocHandle(TEST_ID, { + init: { metadata: { time: 0 } }, + changeMetadata: documentId => { + documentIds.push(documentId) + + return { + author: "bob", + } + }, + }) + + const doc1 = handle.docSync() + + // ... with change + handle.change( + doc => { + doc.foo = "bar" + }, + { metadata: { message: "with change", time: 1 } } + ) + + // ... with change at + handle.changeAt( + A.getHeads(doc1), + doc => { + doc.foo = "baz" + }, + { metadata: { message: "with changeAt", time: 2 } } + ) + + // changeMetadata was called with the right documentId + assert.equal(documentIds.length, 3) + assert(documentIds.every(documentId => documentId === handle.documentId)) + + // changes have bob as author and the locally set message and time + const changes = A.getAllChanges(handle.docSync()).map(A.decodeChange) + assert.equal(changes.length, 3) + assert.equal(changes[0].message, JSON.stringify({ author: "bob" })) + assert.equal(changes[0].time, 0) + assert.equal( + changes[1].message, + JSON.stringify({ author: "bob", message: "with change" }) + ) + assert.equal(changes[1].time, 1) + assert.equal( + changes[2].message, + JSON.stringify({ author: "bob", message: "with changeAt" }) + ) + assert.equal(changes[2].time, 2) + }) + + it("should allow to override global meta data when applying change", () => { + const documentIds = [] + + const handle = new DocHandle(TEST_ID, { + init: { + metadata: { author: "alex", time: 1, location: undefined }, + }, + changeMetadata: documentId => { + documentIds.push(documentId) + + return { + author: "bob", + location: "home", + } + }, + }) + + const doc1 = handle.docSync() + + // ... with change + handle.change( + doc => { + doc.foo = "bar" + }, + { metadata: { author: "sandra", time: 2, location: undefined } } + ) + + // ... with change at + handle.changeAt( + A.getHeads(doc1), + doc => { + doc.foo = "baz" + }, + { metadata: { author: "frank", time: 3, location: undefined } } + ) + + // changeMetadata was called with the right documentId + assert.equal(documentIds.length, 3) + assert(documentIds.every(documentId => documentId === handle.documentId)) + + // changes have the locally overriden author and the timestamp, location is removed + const changes = A.getAllChanges(handle.docSync()).map(A.decodeChange) + assert.equal(changes.length, 3) + assert.equal(changes[0].message, JSON.stringify({ author: "alex" })) + assert.equal(changes[0].time, 1) + assert.equal(changes[1].message, JSON.stringify({ author: "sandra" })) + assert.equal(changes[1].time, 2) + assert.equal(changes[2].message, JSON.stringify({ author: "frank" })) + assert.equal(changes[2].time, 3) + }) + }) + describe("ephemeral messaging", () => { it("can broadcast a message for the network to send out", async () => { - const handle = new DocHandle(TEST_ID, { isNew: true }) + const handle = new DocHandle(TEST_ID, { init: true }) const message = { foo: "bar" } const promise = eventPromise(handle, "ephemeral-message-outbound") diff --git a/packages/automerge-repo/test/DocSynchronizer.test.ts b/packages/automerge-repo/test/DocSynchronizer.test.ts index b86fc93a3..d9573146b 100644 --- a/packages/automerge-repo/test/DocSynchronizer.test.ts +++ b/packages/automerge-repo/test/DocSynchronizer.test.ts @@ -22,7 +22,7 @@ describe("DocSynchronizer", () => { const setup = () => { const docId = parseAutomergeUrl(generateAutomergeUrl()).documentId - handle = new DocHandle(docId, { isNew: true }) + handle = new DocHandle(docId, { init: true }) docSynchronizer = new DocSynchronizer({ handle: handle as DocHandle, }) @@ -104,7 +104,7 @@ describe("DocSynchronizer", () => { it("emits a requestMessage if the local handle is being requested", async () => { const docId = parseAutomergeUrl(generateAutomergeUrl()).documentId - const handle = new DocHandle(docId, { isNew: false }) + const handle = new DocHandle(docId, { init: false }) docSynchronizer = new DocSynchronizer({ handle: handle as DocHandle, }) @@ -118,7 +118,7 @@ describe("DocSynchronizer", () => { it("emits the correct sequence of messages when a document is not found then not available", async () => { const docId = parseAutomergeUrl(generateAutomergeUrl()).documentId - const bobHandle = new DocHandle(docId, { isNew: false }) + const bobHandle = new DocHandle(docId, { init: false }) const bobDocSynchronizer = new DocSynchronizer({ handle: bobHandle as DocHandle, }) @@ -126,7 +126,7 @@ describe("DocSynchronizer", () => { bobHandle.request() const message = await eventPromise(bobDocSynchronizer, "message") - const aliceHandle = new DocHandle(docId, { isNew: false }) + const aliceHandle = new DocHandle(docId, { init: false }) const aliceDocSynchronizer = new DocSynchronizer({ handle: aliceHandle as DocHandle, }) diff --git a/packages/automerge-repo/test/Repo.test.ts b/packages/automerge-repo/test/Repo.test.ts index f39548707..99c23c9ae 100644 --- a/packages/automerge-repo/test/Repo.test.ts +++ b/packages/automerge-repo/test/Repo.test.ts @@ -5,6 +5,7 @@ import * as Uuid from "uuid" import { describe, expect, it } from "vitest" import { READY } from "../src/DocHandle.js" import { parseAutomergeUrl } from "../src/AutomergeUrl.js" + import { generateAutomergeUrl, stringifyAutomergeUrl, @@ -30,6 +31,7 @@ import { import { getRandomItem } from "./helpers/getRandomItem.js" import { TestDoc } from "./types.js" import { StorageId } from "../src/storage/types.js" +import { ChangeMetadataFunction } from "../src/DocHandle.js" describe("Repo", () => { describe("constructor", () => { @@ -42,13 +44,20 @@ describe("Repo", () => { }) describe("local only", () => { - const setup = ({ startReady = true } = {}) => { + const setup = ({ + startReady = true, + changeMetadata, + }: { + startReady?: boolean + changeMetadata?: ChangeMetadataFunction + } = {}) => { const storageAdapter = new DummyStorageAdapter() const networkAdapter = new DummyNetworkAdapter({ startReady }) const repo = new Repo({ storage: storageAdapter, network: [networkAdapter], + changeMetadata, }) return { repo, storageAdapter, networkAdapter } } @@ -67,6 +76,18 @@ describe("Repo", () => { assert.equal(handle.isReady(), true) }) + it("can create a document with metadata", () => { + const { repo } = setup() + const handle = repo.create({ metadata: { author: "bob", time: 1 } }) + assert.notEqual(handle.documentId, null) + assert.equal(handle.isReady(), true) + + const changes = A.getAllChanges(handle.docSync()).map(A.decodeChange) + assert.equal(changes.length, 1) + assert.equal(changes[0].message, JSON.stringify({ author: "bob" })) + assert.equal(changes[0].time, 1) + }) + it("can find a document by url", () => { const { repo } = setup() const handle = repo.create() @@ -451,6 +472,33 @@ describe("Repo", () => { repo.import(A.init as unknown as Uint8Array) }).toThrow() }) + + it("can set change metadata function", () => { + let documentIds = [] + const { repo } = setup({ + changeMetadata: documentId => { + documentIds.push(documentId) + return { author: "bob" } + }, + }) + + const handle = repo.create() + + handle.change(doc => { + doc.foo = "bar" + }) + + const changes = A.getAllChanges(handle.docSync()).map(A.decodeChange) + + // changeMetadata was called with the right documentId + expect(documentIds.length).toEqual(2) + expect(documentIds.every(documentId => documentId === handle.documentId)) + + // changes all have bob as author metadata + expect(changes.length).toEqual(2) + expect(changes[0].message).toEqual(JSON.stringify({ author: "bob" })) + expect(changes[1].message).toEqual(JSON.stringify({ author: "bob" })) + }) }) describe("with peers (linear network)", async () => {