🚩 Add strict flag - #42
Conversation
|
@josephg / @nornagon can you please check you're happy with this API? I've nested the flag inside an The one thing I'm unsure about in this PR is making |
This change adds a `strict` flag to control whether we have the stricter type checking introduced in ottypes#40 Strict mode will be off by default (to maintain compatibility with old `json0` versions). In order to add this flag, we also add a new `options` object to the `type`. Strict mode is enabled on the type by setting the flag: ```js type.options.strict = true ``` Note that `text0` will share the same options as `json0` (ie enabling strict mode for `json0` also enables it for `text0`). In this change we also tidy up some unused utility functions from a previous commit.
nornagon
left a comment
There was a problem hiding this comment.
Hm... I don't love this, for a couple of reasons.
- It's not obvious with this API how to mix strict-mode and not-strict-mode in the same process. Granted, that's possibly an odd requirement, but e.g. it would preclude migrating data one "area" at a time.
- "strict" mode ought to be the default.
Might it make sense, rather than having the notion of a "strict" mode, to simply push the stricter requirements with a semver/major bump?
|
Sure, I'm all for a v2 release. Was just trying to give the option of backwards compatibility if you wanted it. If we're breaking things, can we also look at merging #23 ? |
|
I agree with @nornagon's concerns. And yeah, I'd be happy to merge #23 as well if thats the road we're taking. I'm concerned about how the type should be named / registered. Is it "json0" still or "json0v2"? I think the former makes more sense, but I can imagine running into issues when loading old operations which don't conform to the new rules or have missing deleted data. |
|
@josephg I've already run into these issues in share/sharedb#494 — I'm hoping to discuss them later today at the ShareDB PR meeting. I'm personally leaning towards renaming the type when we make a breaking change, so that you can register both versions separately (or we need to come up some way of adding semver to the URI and parsing that — in the past I think we've discussed naming types like |
|
Closing this as per above discussion. |
|
@josephg / @nornagon I just discussed with @ericyhwang , and we think that it's best to just leave the URI the same, because:
|
`ot-json0`'s `apply()` quietly treats an op that isn't a real array as a
no-op: it walks the op with `op.length` and numeric indexing, so a bare
component or a plain number iterates zero times and the snapshot comes
back untouched. `backend.submit()` has always accepted those, so they
commit, bump the version and get published, having changed nothing.
Its `compose()` and `invert()` are not so relaxed, though. Both assume a
real array, and throw on anything else:
```
compose({p: ['x'], oi: 1}, fixup) -> reading 'p' of undefined
compose({0: {…}, length: 1}, fixup) -> dest.push is not a function
compose([null], fixup) -> reading 'p' of null
invert({p: ['x'], oi: 1}) -> op.slice is not a function
```
`$fixup()` composes, and it's called from `apply` middleware, where a
throw is uncaught. So every one of those shapes takes down any server
that fixes ops up, needing nothing more than a single socket frame:
```
{"a":"op","c":"docs","d":"doc","v":1,"src":"c1","seq":1,
"op":{"p":["colour"],"oi":"red"}}
```
No ShareDB client sends that, since `Doc._submit()` runs
`type.normalize()` first, but nothing stops a hand-written one.
The disagreement is really `ot-json0`'s, and it's now recorded upstream
in ottypes/json0#52. Upstream isn't going to
fix it for us, though. `apply()` was already tightened in
ottypes/json0#40, but that's still unreleased: the plan settled on in
ottypes/json0#42 was a breaking `json0` v2 with the stricter checks on
by default, keeping the type URI, and leaving ShareDB to shim historic
ops — which we did, in #494. That release hasn't happened.
Even when it does, it won't help here. `$fixup()` is called from the
`apply` middleware, which `submit-request` triggers *before* it calls
`ot.apply()`, so a badly formed op reaches `compose()` while a stricter
`apply()` is still waiting its turn. Nor can we just catch the throw:
middleware calls `$fixup()` synchronously, so catching would mean
wrapping every middleware call, swallowing everyone's errors to fix one
type's, and it still wouldn't stop the no-op commit.
So this change makes the strict/lenient split on our side, which is
where the two populations of ops can be told apart anyway. Newly
submitted ops are checked in `submit-request`: it has to happen before
the op reaches any type function, and it can't go in `checkOp()`, which
runs before we have the snapshot and so doesn't know the document's
type. Ops we've already committed are deliberately left alone, since
rejecting a historical no-op would make that document unreadable
through `fetchSnapshot()`.
`checkOpForType()` sits alongside the `checkOpPathsForType()` added for
GHSA-9rqw-j2q5-gg2g, and shares its traversal, so the two can't drift
apart on what counts as an op component. `_otApply()` keeps checking
only paths, for the same reason `applyOps()` does: it replays committed
ops, and a document whose history contains one of these has to stay
readable.
`Doc` needs no equivalent shape check, because `normalize()` already
turns everything it accepts into a real op before the paths are checked.
It does need the one thing it was missing: that call sat outside the
`try`/`catch`, so a type that throws while normalizing threw straight
out of `submitOp()` instead of calling back — `doc.submitOp([null])`
being the json0 case. Wrapping it costs nothing and isn't json0
specific.
This is an API change: a json0 op that isn't an array used to commit as
a silent no-op, and is now rejected with `ERR_OT_OP_BADLY_FORMED`. That
seems worth it, since the behaviour being removed is a crash for fixup
users and a no-op for everyone else — but it is why three of our own
tests in `test/backend.js` needed their ops wrapping, which is a fair
signal that the shape is easy to write by accident. Clients are
otherwise unaffected: everything `doc.submitOp()` used to accept, it
still accepts.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
`ot-json0`'s `apply()` quietly treats an op that isn't a real array as a
no-op: it walks the op with `op.length` and numeric indexing, so a bare
component or a plain number iterates zero times and the snapshot comes
back untouched. `backend.submit()` has always accepted those, so they
commit, bump the version and get published, having changed nothing.
Its `compose()` and `invert()` are not so relaxed, though. Both assume a
real array, and throw on anything else:
```
compose({p: ['x'], oi: 1}, fixup) -> reading 'p' of undefined
compose({0: {…}, length: 1}, fixup) -> dest.push is not a function
compose([null], fixup) -> reading 'p' of null
invert({p: ['x'], oi: 1}) -> op.slice is not a function
```
`$fixup()` composes, and it's called from `apply` middleware, where a
throw is uncaught. So every one of those shapes takes down any server
that fixes ops up, needing nothing more than a single socket frame:
```
{"a":"op","c":"docs","d":"doc","v":1,"src":"c1","seq":1,
"op":{"p":["colour"],"oi":"red"}}
```
No ShareDB client sends that, since `Doc._submit()` runs
`type.normalize()` first, but nothing stops a hand-written one.
The disagreement is really `ot-json0`'s, and it's now recorded upstream
in ottypes/json0#52. Upstream isn't going to
fix it for us, though. `apply()` was already tightened in
ottypes/json0#40, but that's still unreleased: the plan settled on in
ottypes/json0#42 was a breaking `json0` v2 with the stricter checks on
by default, keeping the type URI, and leaving ShareDB to shim historic
ops — which we did, in #494. That release hasn't happened.
Even when it does, it won't help here. `$fixup()` is called from the
`apply` middleware, which `submit-request` triggers *before* it calls
`ot.apply()`, so a badly formed op reaches `compose()` while a stricter
`apply()` is still waiting its turn. Nor can we just catch the throw:
middleware calls `$fixup()` synchronously, so catching would mean
wrapping every middleware call, swallowing everyone's errors to fix one
type's, and it still wouldn't stop the no-op commit.
So this change makes the strict/lenient split on our side, which is
where the two populations of ops can be told apart anyway. Newly
submitted ops are checked in `submit-request`: it has to happen before
the op reaches any type function, and it can't go in `checkOp()`, which
runs before we have the snapshot and so doesn't know the document's
type. Ops we've already committed are deliberately left alone, since
rejecting a historical no-op would make that document unreadable
through `fetchSnapshot()`.
`checkSubmittedOpForType()` sits alongside the `checkOpPathsForType()`
added for GHSA-9rqw-j2q5-gg2g, and shares its traversal, so the two
can't drift apart on what counts as an op component. It's named for the
ops it's valid on: it demands a real array, which is only fair on an op
that reaches the type exactly as it arrived. `_otApply()` keeps checking
only paths, for the same reason `applyOps()` does: it replays committed
ops, and a document whose history contains one of these has to stay
readable.
`Doc` needs no equivalent shape check, because `normalize()` already
turns everything it accepts into a real op before the paths are checked.
It does need the one thing it was missing: that call sat outside the
`try`/`catch`, so a type that throws while normalizing threw straight
out of `submitOp()` instead of calling back — `doc.submitOp([null])`
being the json0 case. Wrapping it costs nothing and isn't json0
specific.
This is an API change: a json0 op that isn't an array used to commit as
a silent no-op, and is now rejected with `ERR_OT_OP_BADLY_FORMED`. That
seems worth it, since the behaviour being removed is a crash for fixup
users and a no-op for everyone else — but it is why three of our own
tests in `test/backend.js` needed their ops wrapping, which is a fair
signal that the shape is easy to write by accident. Clients are
otherwise unaffected: everything `doc.submitOp()` used to accept, it
still accepts.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
`ot-json0`'s `apply()` quietly treats an op that isn't a real array as a
no-op: it walks the op with `op.length` and numeric indexing, so a bare
component or a plain number iterates zero times and the snapshot comes
back untouched. `backend.submit()` has always accepted those, so they
commit, bump the version and get published, having changed nothing.
Its `compose()` and `invert()` are not so relaxed, though. Both assume a
real array, and throw on anything else:
```
compose({p: ['x'], oi: 1}, fixup) -> reading 'p' of undefined
compose({0: {…}, length: 1}, fixup) -> dest.push is not a function
compose([null], fixup) -> reading 'p' of null
invert({p: ['x'], oi: 1}) -> op.slice is not a function
```
`$fixup()` composes, and it's called from `apply` middleware, where a
throw is uncaught. So every one of those shapes takes down any server
that fixes ops up, needing nothing more than a single socket frame:
```
{"a":"op","c":"docs","d":"doc","v":1,"src":"c1","seq":1,
"op":{"p":["colour"],"oi":"red"}}
```
No ShareDB client sends that, since `Doc._submit()` runs
`type.normalize()` first, but nothing stops a hand-written one.
The disagreement is really `ot-json0`'s, and it's now recorded upstream
in ottypes/json0#52. Upstream isn't going to
fix it for us, though. `apply()` was already tightened in
ottypes/json0#40, but that's still unreleased: the plan settled on in
ottypes/json0#42 was a breaking `json0` v2 with the stricter checks on
by default, keeping the type URI, and leaving ShareDB to shim historic
ops — which we did, in #494. That release hasn't happened.
Even when it does, it won't help here. `$fixup()` is called from the
`apply` middleware, which `submit-request` triggers *before* it calls
`ot.apply()`, so a badly formed op reaches `compose()` while a stricter
`apply()` is still waiting its turn. Nor can we just catch the throw:
middleware calls `$fixup()` synchronously, so catching would mean
wrapping every middleware call, swallowing everyone's errors to fix one
type's, and it still wouldn't stop the no-op commit.
So this change makes the strict/lenient split on our side, which is
where the two populations of ops can be told apart anyway. Newly
submitted ops are checked in `submit-request`: it has to happen before
the op reaches any type function, and it can't go in `checkOp()`, which
runs before we have the snapshot and so doesn't know the document's
type. Ops we've already committed are deliberately left alone, since
rejecting a historical no-op would make that document unreadable
through `fetchSnapshot()`.
`checkSubmittedOpForType()` sits alongside the `checkOpPathsForType()`
added for GHSA-9rqw-j2q5-gg2g, and shares its traversal, so the two
can't drift apart on what counts as an op component. It's named for the
ops it's valid on: it demands a real array, which is only fair on an op
that reaches the type exactly as it arrived. `_otApply()` keeps checking
only paths, for the same reason `applyOps()` does: it replays committed
ops, and a document whose history contains one of these has to stay
readable.
`Doc` needs no equivalent shape check, because `normalize()` already
turns everything it accepts into a real op before the paths are checked.
It does need the one thing it was missing: that call sat outside the
`try`/`catch`, so a type that throws while normalizing threw straight
out of `submitOp()` instead of calling back — `doc.submitOp([null])`
being the json0 case. Wrapping it costs nothing and isn't json0
specific.
This is an API change: a json0 op that isn't an array used to commit as
a silent no-op, and is now rejected with `ERR_OT_OP_BADLY_FORMED`. That
seems worth it, since the behaviour being removed is a crash for fixup
users and a no-op for everyone else — but it is why three of our own
tests in `test/backend.js` needed their ops wrapping, which is a fair
signal that the shape is easy to write by accident. Clients are
otherwise unaffected: everything `doc.submitOp()` used to accept, it
still accepts.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
`ot-json0`'s `apply()` quietly treats an op that isn't a real array as a
no-op: it walks the op with `op.length` and numeric indexing, so a bare
component or a plain number iterates zero times and the snapshot comes
back untouched. `backend.submit()` has always accepted those, so they
commit, bump the version and get published, having changed nothing.
Its `compose()` and `invert()` are not so relaxed, though. Both assume a
real array, and throw on anything else:
```
compose({p: ['x'], oi: 1}, fixup) -> reading 'p' of undefined
compose({0: {…}, length: 1}, fixup) -> dest.push is not a function
compose([null], fixup) -> reading 'p' of null
invert({p: ['x'], oi: 1}) -> op.slice is not a function
```
`$fixup()` composes, and it's called from `apply` middleware, where a
throw is uncaught. So every one of those shapes takes down any server
that fixes ops up, needing nothing more than a single socket frame:
```
{"a":"op","c":"docs","d":"doc","v":1,"src":"c1","seq":1,
"op":{"p":["colour"],"oi":"red"}}
```
No ShareDB client sends that, since `Doc._submit()` runs
`type.normalize()` first, but nothing stops a hand-written one.
The disagreement is really `ot-json0`'s, and it's now recorded upstream
in ottypes/json0#52. Upstream isn't going to
fix it for us, though. `apply()` was already tightened in
ottypes/json0#40, but that's still unreleased: the plan settled on in
ottypes/json0#42 was a breaking `json0` v2 with the stricter checks on
by default, keeping the type URI, and leaving ShareDB to shim historic
ops — which we did, in #494. That release hasn't happened.
Even when it does, it won't help here. `$fixup()` is called from the
`apply` middleware, which `submit-request` triggers *before* it calls
`ot.apply()`, so a badly formed op reaches `compose()` while a stricter
`apply()` is still waiting its turn. Nor can we just catch the throw:
middleware calls `$fixup()` synchronously, so catching would mean
wrapping every middleware call, swallowing everyone's errors to fix one
type's, and it still wouldn't stop the no-op commit.
So this change makes the strict/lenient split on our side, which is
where the two populations of ops can be told apart anyway. Newly
submitted ops are checked in `submit-request`: it has to happen before
the op reaches any type function, and it can't go in `checkOp()`, which
runs before we have the snapshot and so doesn't know the document's
type. Ops we've already committed are deliberately left alone, since
rejecting a historical no-op would make that document unreadable
through `fetchSnapshot()`.
`checkSubmittedOpForType()` sits alongside the `checkOpPathsForType()`
added for GHSA-9rqw-j2q5-gg2g, and shares its traversal, so the two
can't drift apart on what counts as an op component. It's named for the
ops it's valid on: it demands a real array, which is only fair on an op
that reaches the type exactly as it arrived. `_otApply()` keeps checking
only paths, for the same reason `applyOps()` does: it replays committed
ops, and a document whose history contains one of these has to stay
readable.
`Doc` needs no equivalent shape check, because `normalize()` already
turns everything it accepts into a real op before the paths are checked.
It does need the one thing it was missing: that call sat outside the
`try`/`catch`, so a type that throws while normalizing threw straight
out of `submitOp()` instead of calling back — `doc.submitOp([null])`
being the json0 case. Wrapping it costs nothing and isn't json0
specific.
What the catch does with what it caught matters too, since a type can
throw anything at all. `.message` is only there to read if the thrown
value is an object, and `null.message` would throw straight back out of
the catch we just added, so a non-object falls back to the value itself.
A `ShareDBError` is passed through as it is: it already carries a code,
and giving the error a code is the only reason to wrap it.
This is an API change: a json0 op that isn't an array used to commit as
a silent no-op, and is now rejected with `ERR_OT_OP_BADLY_FORMED`. That
seems worth it, since the behaviour being removed is a crash for fixup
users and a no-op for everyone else — but it is why three of our own
tests in `test/backend.js` needed their ops wrapping, which is a fair
signal that the shape is easy to write by accident. Clients are
otherwise unaffected: everything `doc.submitOp()` used to accept, it
still accepts.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Fixes #41
This change adds a
strictflag to control whether we have the strictertype checking introduced in #40
Strict mode will be off by default (to maintain compatibility with old
json0versions).In order to add this flag, we also add a new
optionsobject to thetype. Strict mode is enabled on the type by setting the flag:Note that
text0will share the same options asjson0(ie enablingstrict mode for
json0also enables it fortext0).In this change we also tidy up some unused utility functions from a
previous commit.