-
Notifications
You must be signed in to change notification settings - Fork 55
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
Add metadata api #267
Add metadata api #267
Conversation
- metadata can be attached to changes - global metadata can be set on repo with `repo.setGlobalMetadata({...})` - global metadata is attached to each change that's made through the repo - metadata can be also set locally by passing a metadata object to `DocHandle.change` or `DocHandle.changeAt` - local metadata overrides global metadata
} | ||
|
||
return { | ||
time: options.time, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm assuming this is an epoch? I wouldn't use it unless there was some sort of indication if it's a client or server timestamp, a way to sign client time (trusted timestamp), and a spec for the format.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Being able to add timestamps to changes is not a new feature that already existed before.The timestamps are purely advisory and are not used internally for any algorithms.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok. Sorry that was a very convoluted way to explain myself.
I don't understand what it's for, should it be in the metadata instead?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree it would be more consistent not to make the timestamp special and instead just put it into the metadata. What do you think @alexjg?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah I think the API should be just to have the timestamp in the metadata and then internally pull it out and separately pass it to automerge.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, is it used internally somehow? I would actually feel better if it was separate from the metadata then, so we're not hiding a potential complexity.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Timestamps are not used by the CRDT logic – they are only informational, to enable things like history visualisation. But the current Automerge data format already has a place for storing timestamps; removing it and moving it into the generic metadata would be a breaking change to the data format.
@@ -42,6 +42,9 @@ export class DocHandle<T> // | |||
#timeoutDelay: number | |||
#remoteHeads: Record<StorageId, A.Heads> = {} | |||
|
|||
// Reference to global meta data that is set on the repo to be attached to each change | |||
#globalMetadataRef?: ChangeMetadataRef |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It might be worth allowing this to a be a function (DocumentId) -> Record<..>
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes I think that would be a better API
I have been pondering for a while how best to proceed with adding change metadata. This seems like a good compromise. However, I have 2 thoughts:
For my own part, I am really only looking for a way to identify which user has made a change. My current thought is to store a list of userIds and their corresponding actorIds within the document. This could either be the same doc or a separate one. |
I would use this to allow users to annotate their changes, and when debugging. I wouldn't have a use for globalMetadata. I have been using |
@acurrieclark you're correct that this will bloat the document a lot. This PR is a test to see if the API is useful. If it is useful then we'll store the metadata in a columnar encoded fashion so that it compressed well. |
Ah OK, so this is a precursor to adding it at he automerge level? |
patchCallback?: A.PatchCallback<T> | ||
} | ||
|
||
export type ChangeMetadata = Record<string, number | string | boolean> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looking ahead to how we compress this I am not sure this API will do everything we need. When compressing this metadata we don't store the names of the fields, but instead an integer column ID. This means that the application will need to provide some mapping from a column ID to the name of the field in the metadata object.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I’d suggest that for app-defined metadata columns we identify the columns by name+type, rather than by a numeric column ID. That would simplify the API and only cost a few bytes more space. The question is how the type should be identified in the API. With a non-null value we could check if the value is an integer, string, or byte array, and assign it to the appropriate typed column. With a null value we could just treat it as absent, and any metadata columns that exist because of non-null values on other changes will just be filled in with null anyway.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah that makes sense. This would imply storing the union of every metadata key of every change in the documen in a lookup table somewhere in the serialized document chunk right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah I think so. The first time a change has a non-null value in its metadata, we create a column identified by its metadata key and the type of the value. The serialised document will have to store every metadata column that exists on any of the changes. Changes that don't mention a particular metadata column just fill it in with null, as is the behaviour for the Automerge-internal columns.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This does mean that if a user never puts anything except null as the value for a metadata key then we would have to do something like not write it to the document at all right (because we don't know what column type to write). This means it would not be possible to distinguish between a null value and a not-present value. Maybe we should say that you can't write null
values to avoid ambiguity?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yup agree, let's disallow nulls.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've been thinking a little bit more about this. At some point we're going to want to have some kind of squash/rebase workflow I think. In such a workflow we would need to decide what to do with the metadata on each change. I think ideally we would just encode all the metadata into the squashed change. This suggests to me that we should actually treat the metadata as a multimap, somewhat like the query parameters in a URL. @ept @paulsonnentag what do you think?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What would a squash look like? Could a user still override the metadata to set it to something custom for the squashed change, or would it be purely mechanical that the metadata of the squashed changes would always be the union of the metadata of the individual changes?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@alexjg Good point. I'd think that a squash commit would need to bring in custom logic for compacting the metadata: for example, we might not want to keep every single timestamp, but only the minimum and maximum among the timestamps in the squashed range. For authors we might want to keep the set of distinct users who have contributed at least one change, and for signatures we might want to keep the most recent signature per branch per signing key. This suggests to me that we can keep the data model for metadata on a single change simple (a single value per entry in the map), and figure out how to represent changes on squash commits once we get to that point.
I just realised something: if we do author attribution using metadata on changes, it would probably not be possible to do attribution on a squash commit or shallow clone, because the per-change information is not available. On the other hand, if we do attribution by mapping actor IDs to user IDs, attribution should still be possible, because the squash should preserve opIds, and the actor-to-user mapping can be included in the squash. That would be an argument for using the actorIds for attribution.
I like this idea a lot. I think maybe the global/local terminology is going to be confusing - "local" suggests "device-level", when that's more like what you mean by "global". Rather than come up with alternative wording, I'd maybe just drop the word |
Just throwing out a use case in case it affects how we think about using/implementing this: I'm interested in trying to use this feature to tag changes with information about domain-level actions that created the change. Imagine a JSON object representing the inputs to a function like
+1 |
I've implemented the API @alexjg suggested. Instead of setting change metadata globally on the repo you can configure a changeMetadata function in the repo config: const repo = new Repo({
...
changeMetadata: (documentId) => ({ author: "bob" })
}) I've also added a way to set metadata when a document is initially created: const handle = repo.create({ metadata: {author: "bob"} }) |
} | ||
|
||
return { | ||
time: options.time, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah I think the API should be just to have the timestamp in the metadata and then internally pull it out and separately pass it to automerge.
metadata = {} | ||
} | ||
|
||
Object.assign(metadata, options.metadata) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Per https://github.com/automerge/automerge-repo/pull/267/files#r1449148679 I think we should check that the values here are only of the allowed types and throw if not.
- time is also passed in with the metadata object and extracted when storing it automerge to take advantage of the fact that automerge stores timestamps as deltas - throw an error if metadata contains values that are not primitive (number, string, boolean)
continue | ||
} | ||
|
||
if (type !== "number" && type !== "string" && type !== "boolean") { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can also allow Uint8Array
here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Although I guess we currently can't serialize that to JSON in a nice way so maybe we leave that for the future.
LGTM |
- undefined values are removed and don't show up in the metadata - If the changeMetadata function adds a value to a key but subsequently in the DocHandleChangeOptions the key is set to undefined the value is removed
I absolutely agree with the addition of metadata here, and see why (in the short term) it needs to override the |
So @paulsonnentag and I discussed this and I think we shouldn't merge this until/unless the lower level Automerge changes get made. The immediate problem that this patch solves for @paulsonnentag is that it's tricky to set a commit message on a commit made inside of the automerge-codemiror plugin (used for author attribution) but the implementation is expensive (something like 20+ characters per keystroke!) and the API is complicated in a way that solves the problem but I would like to avoid. I showed him a way of working around the problem by replacing the change function on the handle that gets passed into the editor and while that is a horrible hack it does at least solve the problem at a prototype quality level. He's also got a forked version of automerge-repo in use in his prototype that works for him for now. So -- closing this as WONTFIX for today but I think it's a meaningful problem and we should return to it in the future. On another topic, thinking about the problem above (passing in the full handle + path to automerge-codemirror) has got @paulsonnentag and me thinking about what a better API might look like... hopefully we'll have time to explore that some time soon. |
repo.setGlobalMetadata({...})
DocHandle.change
orDocHandle.changeAt
More details here:
https://tiny-essay-editor.netlify.app/#automerge:ZJSzpLWWLBWJz67TiNM1c4fb2sa