mirror of
https://github.com/larvit/adf-codec.git
synced 2026-11-12 11:48:29 +01:00
Return the new code engine-limit where the engine runs short of memory or passes its longest array, so unsupported-node-shape stays deterministic
This commit is contained in:
+5
-1
@@ -9,6 +9,9 @@
|
||||
code. `MIGRATION.md` says which call to take.
|
||||
- **Breaking:** the markdown readers refuse a value that is not a string with the new code
|
||||
`not-a-string`; `MIGRATION.md` lists what each value did before.
|
||||
- **Breaking:** every call returns the new code `engine-limit` where the JavaScript engine runs
|
||||
short of memory or passes its longest array, and, on JavaScriptCore, its longest string. A retry
|
||||
may succeed. Every other refusal stays deterministic.
|
||||
- **Breaking:** an attribute value that is not a plain object, array or JSON primitive, such as a
|
||||
`Date`, `Map`, typed array or class instance, makes `isAdfDocument` return `false` and the
|
||||
converters refuse it as `not-an-adf-document`. Before, the converters wrote it as `{}` or its own
|
||||
@@ -90,7 +93,8 @@
|
||||
`losslessMarkdownToAdf` refuses it as `unsupported-node-shape`, where a few megabytes of nested
|
||||
list lines ran the call out of memory.
|
||||
- Fix the markdown readers past the longest string or array the JavaScript engine builds: they
|
||||
return `unsupported-node-shape` where they threw.
|
||||
return `unsupported-node-shape` past V8's or SpiderMonkey's longest string, and `engine-limit`
|
||||
past the rest, where they threw.
|
||||
- Fix a paragraph of tens of millions of characters: `adfToLosslessMarkdown` and
|
||||
`adfToPortableMarkdown` write it in a few bytes of memory a character beyond the document and its
|
||||
markdown, where they took about 50, so 80 million characters ran Node's default heap out of memory
|
||||
|
||||
+3
-2
@@ -88,8 +88,8 @@ Markdown the spelling table leaves alone, which `0.2.0` reads as a different doc
|
||||
### Error codes
|
||||
|
||||
`unmappable-image`, `unspellable-character`, `unspellable-line-start` and `unspellable-link` leave
|
||||
`ConvertErrorCode` and `not-a-string` joins it: a `switch` naming one of the four stops compiling,
|
||||
and one naming every code needs the new case. A document `adfToMarkdown` refused with one of the
|
||||
`ConvertErrorCode` and `engine-limit` and `not-a-string` join it: a `switch` naming one of the four
|
||||
stops compiling, and one naming every code needs the new cases. A document `adfToMarkdown` refused with one of the
|
||||
three `unspellable-` codes now converts in `adfToLosslessMarkdown`; markdown the parser refused
|
||||
with one of the four now reads, or refuses with the code the table gives.
|
||||
|
||||
@@ -114,3 +114,4 @@ with one of the four now reads, or refuses with the code the table gives.
|
||||
| `null`, `undefined` or a non-empty array, on parse | throws a `TypeError` | `not-a-string` |
|
||||
| a `String` object, on parse | the document its text spells | `not-a-string`; pass `String(value)` |
|
||||
| any other value that is not a string, such as a number or `{}`, on parse | an empty document | `not-a-string` |
|
||||
| a conversion that runs the JavaScript engine short of memory or past its longest array | throws a `RangeError`, or the engine ends the process | `engine-limit`; retry it once |
|
||||
|
||||
@@ -210,11 +210,11 @@ Each call's name says which markdown it reads or writes:
|
||||
|
||||
| Call | Markdown | Refuses with |
|
||||
| --- | --- | --- |
|
||||
| `adfToLosslessMarkdown` | writes the lossless flavour: CommonMark plus GFM's pipe tables and `~~`, with `!adf:` directives and `adf:` fences for everything else | `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`, `unsupported-node-shape` |
|
||||
| `adfToPortableMarkdown` | writes markdown GitHub, GitLab and Obsidian render; drops what they cannot show, and writes no directive | `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`, `unsupported-node-shape` |
|
||||
| `commonMarkToAdf` | reads strict CommonMark as its spec says: a directive, a pipe table, `~~`, `==` and an `adf:` fence read as text and code | `not-a-string`, `unmappable-html`, `unsupported-nesting-depth`, `unsupported-node-shape` |
|
||||
| `adfToLosslessMarkdown` | writes the lossless flavour: CommonMark plus GFM's pipe tables and `~~`, with `!adf:` directives and `adf:` fences for everything else | `engine-limit`, `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`, `unsupported-node-shape` |
|
||||
| `adfToPortableMarkdown` | writes markdown GitHub, GitLab and Obsidian render; drops what they cannot show, and writes no directive | `engine-limit`, `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`, `unsupported-node-shape` |
|
||||
| `commonMarkToAdf` | reads strict CommonMark as its spec says: a directive, a pipe table, `~~`, `==` and an `adf:` fence read as text and code | `engine-limit`, `not-a-string`, `unmappable-html`, `unsupported-nesting-depth`, `unsupported-node-shape` |
|
||||
| `losslessMarkdownToAdf` | reads the lossless flavour, markdown `adfToLosslessMarkdown` wrote, edited or not | every code in [The errors](#the-errors) except `not-an-adf-document` and `unsupported-document-version` |
|
||||
| `portableMarkdownToAdf` | reads CommonMark, GFM's pipe tables and `~~`, alerts and task markers, Obsidian's callouts and `==`; a directive and an `adf:` fence read as text and code | `malformed-pipe-table`, `not-a-string`, `unmappable-html`, `unsupported-nesting-depth`, `unsupported-node-shape` |
|
||||
| `portableMarkdownToAdf` | reads CommonMark, GFM's pipe tables and `~~`, alerts and task markers, Obsidian's callouts and `==`; a directive and an `adf:` fence read as text and code | `engine-limit`, `malformed-pipe-table`, `not-a-string`, `unmappable-html`, `unsupported-nesting-depth`, `unsupported-node-shape` |
|
||||
|
||||
`isAdfDocument` is the type guard for the boundary you receive JSON at. It checks the shape the
|
||||
converters require: `type: 'doc'`, a finite `version` (the converters take only 1), nodes holding
|
||||
@@ -410,9 +410,8 @@ typed on `ConvertError` takes both.
|
||||
|
||||
A call reports the first refusal in document order and stops, so a document with several surfaces
|
||||
them one per call. Every refusal is deterministic — there is no I/O anywhere — so a retry returns
|
||||
the identical error: fix the input, or set the document aside. The exception is memory running
|
||||
short, which returns `unsupported-node-shape` at the empty path, the code and path of markdown past
|
||||
the length bound. Retry once any call that returns that; a repeat is a lasting refusal.
|
||||
the identical error: fix the input, or set the document aside. The exception is `engine-limit`,
|
||||
whose causes include memory running short: retry it once, and treat a repeat as lasting.
|
||||
|
||||
### Parsing
|
||||
|
||||
@@ -444,8 +443,9 @@ The emitter raises these, and so does a parse, which asks the emitter how to spe
|
||||
|
||||
| Code | Fires when | What you can do |
|
||||
| --- | --- | --- |
|
||||
| `engine-limit` | in every call: a conversion that passes the longest array the JavaScript engine builds or runs it short of memory, and on JavaScriptCore (Bun, Safari) one that passes its longest string, 2^31 − 1 characters | retry once; if it repeats, writing: set the document aside, keeping the ADF and passing it over or showing it read-only; reading: shorten the markdown, or nest its lists and quotes less deeply |
|
||||
| `unsupported-nesting-depth` | blocks, marks, an attribute's JSON or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it |
|
||||
| `unsupported-node-shape` | parsing, in `losslessMarkdownToAdf`: markdown spells a node with an attribute, value, argument or body its type does not take, or without one it needs, wraps a directive mark around nothing or only hard breaks, writes as a directive a node or mark the lossless flavour spells as CommonMark, or one that, written out as plain CommonMark, would be longer than 536870888 characters, or puts a reserved directive out of place: `!adf:textBreak{}` parting nothing, `!adf:doc` anywhere but as the whole document. Emitting, in `adfToLosslessMarkdown`: a node not riding the carry that carries one mark type twice, a block or a non-text inline node holding `text`, or a leaf block or a non-text inline node holding `content`. Emitting, in `adfToPortableMarkdown`: a line or task list the portable writer cannot spell, a bug in this library. Emitting, in both writers: a document whose markdown would be longer than 536870888 characters, V8's longest string, on every engine; in `adfToPortableMarkdown`, also a document whose text alone is that long, edge whitespace included. In every call: a conversion that passes the longest string or array the JavaScript engine builds, or runs it short of memory | fix what the message names; [`spec/flavour.md`](https://github.com/larvit/adf-codec/blob/main/spec/flavour.md) lists every type's attributes and body. For a portable line or task list, report it as a bug. Past 536870888 characters or the engine's limit, retry once; if it repeats, writing: set the document aside, keeping the ADF and passing it over or showing it read-only; reading: shorten the markdown, or nest its lists and quotes less deeply |
|
||||
| `unsupported-node-shape` | parsing, in `losslessMarkdownToAdf`: markdown spells a node with an attribute, value, argument or body its type does not take, or without one it needs, wraps a directive mark around nothing or only hard breaks, writes as a directive a node or mark the lossless flavour spells as CommonMark, or one that, written out as plain CommonMark, would be longer than 536870888 characters, or puts a reserved directive out of place: `!adf:textBreak{}` parting nothing, `!adf:doc` anywhere but as the whole document. Emitting, in `adfToLosslessMarkdown`: a node not riding the carry that carries one mark type twice, a block or a non-text inline node holding `text`, or a leaf block or a non-text inline node holding `content`. Emitting, in `adfToPortableMarkdown`: a line or task list the portable writer cannot spell, a bug in this library. Emitting, in both writers: a document whose markdown would be longer than 536870888 characters, V8's longest string, on every engine; in `adfToPortableMarkdown`, also a document whose text alone is that long, edge whitespace included. In every call: a conversion that passes the longest string the JavaScript engine builds | fix what the message names; [`spec/flavour.md`](https://github.com/larvit/adf-codec/blob/main/spec/flavour.md) lists every type's attributes and body. For a portable line or task list, report it as a bug. Past 536870888 characters or the engine's longest string, writing: set the document aside, keeping the ADF and passing it over or showing it read-only; reading: shorten the markdown, or nest its lists and quotes less deeply |
|
||||
|
||||
## The guarantees
|
||||
|
||||
|
||||
+7
-1
@@ -467,6 +467,11 @@ handles one cause alike whichever node, attribute or direction raised it.
|
||||
causes the bound (§The code list). A carried node passes it on about 1 MB of deeply nested JSON,
|
||||
since the block carry indents two spaces a level; `todo.md` item 107 respells the carry in a
|
||||
length linear in its JSON (2026-10-11).
|
||||
- A conversion the engine stops, by running short of memory or passing its longest array, is
|
||||
`engine-limit`, at the empty path. A retry may pass it, so `unsupported-node-shape`, which never
|
||||
does, cannot hold it. JavaScriptCore throws the same `Out of memory` past its longest string,
|
||||
which takes `engine-limit` too: telling the two apart would mean reading the engine's message
|
||||
(2026-10-13, the maintainer).
|
||||
- The markdown readers and `htmlToAdf` refuse a value that is not a string as `not-a-string`, at
|
||||
position `{ line: 1, offset: 0 }` with an empty path: no existing code reads true of it, and
|
||||
`not-an-adf-document` names the emitters' input (2026-10-05).
|
||||
@@ -682,7 +687,8 @@ carry's two-space JSON, which indents every value its depth over, stops at the b
|
||||
|
||||
JavaScriptCore builds a joined block up to its own longest string, 2^31 − 1, before the check. Past
|
||||
that, JavaScriptCore throws `Out of memory`, as it does when its heap runs short. That throw, and
|
||||
any engine's throw past its longest array, is refused with the engine's message.
|
||||
any engine's throw past its longest array or out of memory, is `engine-limit` (§Which code a cause
|
||||
takes).
|
||||
|
||||
## Nothing recurses unbounded
|
||||
|
||||
|
||||
@@ -23,15 +23,19 @@ test('passes a text within the bound', () => {
|
||||
assert.deepEqual(withinLongest('x'), success('x'))
|
||||
})
|
||||
|
||||
test('refuses a string past the bound as too long on every engine, and names any other engine limit', () => {
|
||||
test('refuses a string past the bound as too long on every engine, and any other engine limit as engine-limit', () => {
|
||||
assert.deepEqual(withinEngineLimits(thrown('RangeError', 'Invalid string length')), tooLong())
|
||||
assert.deepEqual(withinEngineLimits(thrown('InternalError', 'allocation size overflow')), tooLong())
|
||||
assert.deepEqual(withinEngineLimits(thrown('RangeError', 'Out of memory')), {
|
||||
error: { code: 'unsupported-node-shape', message: 'the conversion passed this JavaScript engine\'s longest string or array, or ran it out of memory (Out of memory)', path: [] },
|
||||
error: { code: 'engine-limit', message: 'the conversion passed this JavaScript engine\'s longest string or array, or ran it out of memory (Out of memory)', path: [] },
|
||||
ok: false,
|
||||
})
|
||||
assert.equal(withinEngineLimits(thrown('RangeError', 'Invalid array length')).ok, false)
|
||||
assert.equal(withinEngineLimits(thrown('RangeError', 'Array buffer allocation failed')).ok, false)
|
||||
const code = (message: string): string | undefined => {
|
||||
const refused = withinEngineLimits(thrown('RangeError', message))
|
||||
return refused.ok ? undefined : refused.error.code
|
||||
}
|
||||
assert.equal(code('Invalid array length'), 'engine-limit')
|
||||
assert.equal(code('Array buffer allocation failed'), 'engine-limit')
|
||||
assert.deepEqual(
|
||||
withinEngineLimits(() => success('x')),
|
||||
{ ok: true, value: 'x' },
|
||||
|
||||
@@ -36,6 +36,6 @@ export function withinEngineLimits<T>(convert: () => Result<T>): Result<T> {
|
||||
if (pastLongestString.includes(thrown)) return tooLong()
|
||||
// A stack overflow is a bug (docs/decisions.md §Nothing recurses unbounded), so it stays a throw.
|
||||
if (!pastEngineLimit.includes(thrown)) throw error
|
||||
return failure('unsupported-node-shape', `the conversion passed this JavaScript engine's longest string or array, or ran it out of memory (${error.message})`, [])
|
||||
return failure('engine-limit', `the conversion passed this JavaScript engine's longest string or array, or ran it out of memory (${error.message})`, [])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export type ConvertErrorCode =
|
||||
| 'engine-limit'
|
||||
| 'malformed-directive'
|
||||
| 'malformed-pipe-table'
|
||||
| 'not-a-string'
|
||||
|
||||
Reference in New Issue
Block a user