-
Notifications
You must be signed in to change notification settings - Fork 451
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 Promise-based versions of methods in public ShareDB API #523
Comments
I personally find On the other hand, I think I wouldn't mind just breaking the API to get As far as the |
I'd favor the breaking change. That approach seems overall simpler in that it places fewer constraints on the ShareDB internals. Less complexity, fewer tests required, lower chance of bugs. I have written functions that wrap the ShareDB API in Promises so many times. It would be amazing if ShareDB supported Promises out of the box! That said, I wonder how much work it would be to upgrade Racer and Derby to use the new API. Then the onus would be on those packages provide backwards compatibility or not, for all the products built on them. |
If we did eventually want to make a breaking change, we can and should put the new behavior behind a feature flag first, to give interested consumers time to prep their code in advance, and provide some migration recommendations. The "error" event handlers are the big sticking point as far as backwards compatibility goes. That kind of error handling is not super unusual for Node APIs, especially for libraries that can be used in a more "fire-and-forget" manner, like using Redis for logging or event queueing, where you do synchronous-looking calls in application code and have the library handle errors centrally. ShareDB could be used like that, though I don't know how common that is. Difficulty of migration depends on how many fire-and-forget calls to fetch, subscribe, submitOp, etc. there are in your codebase. Tens of calls in one package is pretty easy. Hundreds of calls spread around different packages, where a single Backend gets passed into different packages... that's much harder, even with a feature flag. We could look into either implementing or recommending an unhandled rejection shim to help. On my end, updating Racer would not be difficult with either approach, since Racer doesn't directly expose the ShareDB API. Racer's calls into the ShareDB Doc are contained into a single Racer RemoteDoc class, so Racer can just always pass a callback for ShareDB calls and then do its own thing for error dispatch. Which is to say, either approach for promises, it's not much work to migrate "my" usages of ShareDB, but in general I do want to make sure we're considering difficulty of migration when talking about breaking changes. As far as API footprint goes: I will point out that either approach adds the same amount of API footprint, it's just whether or not the new API footprint is bundled into a method overload or broken out into a separate method. Writing out formal TS signatures makes that clear: // New method, existing method signatures unchanged
class Doc {
submitOp(op: Op, options: Options, callback?: Callback): void;
submitOp(op: Op, callback?: Callback): void;
submitOpPromised(op: Op, options?: Options): Promise<void>;
}
// New overload for existing method, existing signatures slightly change
class Doc {
// Callback becomes required in existing void-return signatures
submitOp(op: Op, options: Options, callback: Callback): void;
submitOp(op: Op, callback: Callback): void;
// New signature for overload with no callback and returned Promise
submitOp(op: Op, options?: Options): Promise<void>;
} You could argue that, fully expanding out the optional params, it's actually 5 total signatures for new-method vs 4 signatures for new-overload. The extra signature in the new-method approach is the existing |
I think I'm personally leaning towards the hybrid callback/Promise API. It's certainly not uncommon, and it's a bit "nicer" for consumers. I'm not all that concerned with the API footprint, just with the ickiness of the method name 😛 If we do this, then I think we should drop emitting error events, since the unhandled promise rejection fulfills the same role as the unhandled error event, and I can't really see a reason to have both. I agree that we should hide this behind a feature flag, which would just toggle you between "callbacks & error emission" vs "callbacks & promises". The main difficulty for people migrating is if you're listening out for a "generic" error event on eg your window.addEventListener('unhandledrejection', (event) => {
if (event.reason instanceof ShareDBDocError) {
// Move doc.on('error') code here
}
}); |
I was thinking of attaching the Doc or Connection to the Error, that way your global handler could check for the In the past, I've had Thoughts on whether the flag to enable promises should be "global" or specific to each Connection? |
Attaching non-serialisable objects to an If if (error.isShareDBError) {
switch (error.code) {
}
}
I can't immediately think of a good case where — in the same codebase — you'd want different code styles/error handling on a per-connection basis. I think we can just force people all-in. If they have some (weird) reason that this doesn't work, they can stay opted-out, and raise a use case with us? |
Promises would be super nice. I'm currently wrapping the API like this: // Fetches a ShareDB Doc.
// See https://share.github.io/sharedb/api/doc
const fetchDoc = (
collectionName: string,
id: string,
shareDBConnection: ShareDBConnection
): ShareDBDoc =>
new Promise((resolve, reject) => {
const shareDBDoc = shareDBConnection.get(collectionName, id);
shareDBDoc.fetch((error) => {
error ? reject(error) : resolve(shareDBDoc);
});
});
// Saves a fetched ShareDB doc (upsert).
const saveDoc = (doc: ShareDBDoc, data) =>
new Promise((resolve, reject) => {
const callback = (error) => (error ? reject(error) : resolve(null));
if (!doc.type) {
doc.create(data, callback);
} else {
doc.submitOp(diff(doc.data, data), callback);
}
});
// Deletes a fetched ShareDB doc.
const deleteDoc = (doc: ShareDBDoc, data) =>
new Promise((resolve, reject) => {
doc.del((error) => (error ? reject(error) : resolve(null)));
}); Invocations look like this: await saveDoc(await fetchShareDBDoc(COLLECTION, id, shareDBConnection), data);
...
await deleteDoc(await fetchShareDBDoc(COLLECTION, id, shareDBConnection)); |
When we (eventually) get around to doing this, it might also be nice to add support for the proposd Explicit Resource Management? TypeScript just added support for it in v5.2, and it would be super nice to write things like: using connection = backend.connect(...);
await using doc = connection.get(...);
// do stuff and don't worry about having to call doc.destroy() or conneciton.close() etc. Assuming we've switched to Doc.prototype[Symbol.asyncDispose] = Doc.prototype.destroy;
Connection.prototype[Symbol.dispose] = Connection.prototype.close; |
Revisited today:
|
The last time this was brought up was back in 2018, in #202, @nateps expressed said he wasn't really a fan on promises in ShareDB. However, I can't find his reasoning written down anywhere.
Nate's not really an active maintainer anymore ever since handing ShareDB off to @alecgibson and me a while back, so the two of us can decide to add promises if we want :)
I know Alec's a fan of promises in general, so let's try and get it done!
After thinking through it and doing some testing, I think we should:
.on('error', listener)
"default error handler" listenersThoughts, questions, comments, concerns? Anything I missed in my analysis below?
Adding new promisified versions of the methods
Example, using TypeScript syntax:
Advantages:
submitOp()
a bunch of times synchronously and then useDoc#on('error', listener)
to handle errors, they can still do so.submitOpPromised
, that's an explicit signal that they intend to use the returned promise, including correctly handling errors, so we don't have to worry about emitting it to the Doc error listener.Downside, we have double the number of such methods, and consumers have to change to calling the new method. However, even with the next auto-promise version, consumers would still have to add
then
/catch
orawait
to their call sites to actually take advantage of the promises, so I feel like it's not a big deal to have them call a separate method.Automatically returning Promise if no callback is given
Advantages:
then
/catch
orawait
at the call sites to actually use promises, so they'd be modifying those lines in their code anyways.Problem 1
As that PR description alludes to, this would be a breaking change for
on('error', listener)
, which acts as a default "unhandled error" handler at the Connection/Doc level when an explicit callback isn't provided to a method that accepts one. We could make this non-breaking with some work, see further down.There are tests exercising that the error event is emitted when no callback is given, such as this one:
sharedb/test/client/submit.js
Lines 875 to 883 in 083f8ac
If we start auto-returning a Promise:
doc.create({age: 3});
starts returning a Promise since no callback was given.doc.on('error', ...)
handler.There is a way to make this backwards-compatible with more work. In the callback-to-promise shim code, when calling
reject(errorFromCallback)
, also check ifconnectionOrDoc.listenerCount('error') > 0
. If so, then add a no-op catch to the original promise to not trigger the global unhandled exception handler, and emit the error on the Connection/Doc.Problem 2
With the backwards-compatible workaround, the Connection/Doc error listener will fire even for errors that get handled further on in a promise chain or higher up in the stack. It no longer solely acts as a Connection/Doc-level handler for otherwise unhandled errors, since it receives errors that did get handled.
Couple examples, where the error handler receives an error that gets handled:
There's not really a clean way around this, since the handling of the error isn't directly on the original promise. A process/window level unhandled rejection listener that forwards back to the Connection/Doc could mostly work. One issue, the listener for sharedb wouldn't be able to prevent the event from continuing on to other unhandled rejection listeners.
The text was updated successfully, but these errors were encountered: