diff --git a/CHANGELOG.md b/CHANGELOG.md index 712fdb7..d6b36d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/MIGRATION.md b/MIGRATION.md index 345b1e2..370702a 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -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 | diff --git a/README.md b/README.md index f5729df..bee7538 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/decisions.md b/docs/decisions.md index 25b43f2..bed4ec9 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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 diff --git a/src/markdown/length-bound.test.ts b/src/markdown/length-bound.test.ts index f754d45..7730563 100644 --- a/src/markdown/length-bound.test.ts +++ b/src/markdown/length-bound.test.ts @@ -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' }, diff --git a/src/markdown/length-bound.ts b/src/markdown/length-bound.ts index be4f752..55bd612 100644 --- a/src/markdown/length-bound.ts +++ b/src/markdown/length-bound.ts @@ -36,6 +36,6 @@ export function withinEngineLimits(convert: () => Result): Result { 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})`, []) } } diff --git a/src/result.ts b/src/result.ts index 90166f7..283f1ea 100644 --- a/src/result.ts +++ b/src/result.ts @@ -1,4 +1,5 @@ export type ConvertErrorCode = + | 'engine-limit' | 'malformed-directive' | 'malformed-pipe-table' | 'not-a-string'