Skip to content

🚩 Add strict flag - #42

Closed
alecgibson wants to merge 1 commit into
ottypes:masterfrom
reedsy:strict-flag
Closed

🚩 Add strict flag#42
alecgibson wants to merge 1 commit into
ottypes:masterfrom
reedsy:strict-flag

Conversation

@alecgibson

@alecgibson alecgibson commented Jul 12, 2021

Copy link
Copy Markdown
Contributor

Fixes #41

This change adds a strict flag to control whether we have the stricter
type checking introduced in #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:

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.

@alecgibson

Copy link
Copy Markdown
Contributor Author

@josephg / @nornagon can you please check you're happy with this API? I've nested the flag inside an options object, because it feels like it would be useful for future options, and potentially for clients trying to negotiate options with the server in ShareDB.

The one thing I'm unsure about in this PR is making text0 share the same options as json0. It's definitely simplest for the consumer to do it like this, but might be slightly confusing? (In theory a consumer could independently set text0 options by overriding the whole options object: text0.options = {strict: true})

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 nornagon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm... I don't love this, for a couple of reasons.

  1. 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.
  2. "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?

@alecgibson

Copy link
Copy Markdown
Contributor Author

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 ?

@josephg

josephg commented Jul 13, 2021

Copy link
Copy Markdown
Member

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.

@alecgibson

Copy link
Copy Markdown
Contributor Author

@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 http://sharejs.org/types/JSONv0?v=1.1.0)

@alecgibson

Copy link
Copy Markdown
Contributor Author

Closing this as per above discussion.

@alecgibson alecgibson closed this Jul 13, 2021
@alecgibson
alecgibson deleted the strict-flag branch July 13, 2021 07:40
@alecgibson

Copy link
Copy Markdown
Contributor Author

@josephg / @nornagon I just discussed with @ericyhwang , and we think that it's best to just leave the URI the same, because:

  • in theory, if all of your ops are well formed, the new version of json0 is non-breaking
  • we're going to add a shim to handle historic ops
  • we want to let people smoothly transition to the new version of json0, and changing the URI type would force people to delete/recreate (or fudge their own create ops in the DB)

alecgibson added a commit to share/sharedb that referenced this pull request Sep 7, 2026
`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>
alecgibson added a commit to share/sharedb that referenced this pull request Sep 7, 2026
`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>
alecgibson added a commit to share/sharedb that referenced this pull request Sep 7, 2026
`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>
alecgibson added a commit to share/sharedb that referenced this pull request Sep 7, 2026
`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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Strict mode flag

3 participants