Skip to content

Commit

Permalink
feat(migrate): use safe JSON parser when streaming from Export HTTP A…
Browse files Browse the repository at this point in the history
…PI (#5542)

* refactor(util): create shared JSON parser for `@sanity/export` and `@sanity/migrate`

* feat(migrate): add safe JSON parser

* chore(util): update comment

* chore(export): update comment

* feat(migrate): use safe JSON parser when streaming from Export HTTP API

* feat(migrate): add JSON options and type parameter to NDJSON utility

* refactor(migrate): use type parameter

* fix(migrate): add missing export
  • Loading branch information
juice49 authored and bjoerge committed Jan 30, 2024
1 parent b730324 commit a1c0faf
Show file tree
Hide file tree
Showing 17 changed files with 116 additions and 72 deletions.
1 change: 1 addition & 0 deletions packages/@sanity/export/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"test": "jest"
},
"dependencies": {
"@sanity/util": "3.26.1",
"archiver": "^5.0.0",
"debug": "^3.2.7",
"get-it": "^8.4.4",
Expand Down
32 changes: 12 additions & 20 deletions packages/@sanity/export/src/tryParseJson.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,13 @@
module.exports = (line) => {
try {
return JSON.parse(line)
} catch (err) {
// Catch half-done lines with an error at the end
const errorPosition = line.lastIndexOf('{"error":')
if (errorPosition === -1) {
err.message = `${err.message} (${line})`
throw err
}
const {createSafeJsonParser} = require('@sanity/util/createSafeJsonParser')

const errorJson = line.slice(errorPosition)
const errorLine = JSON.parse(errorJson)
const error = errorLine && errorLine.error
if (error && error.description) {
throw new Error(`Error streaming dataset: ${error.description}\n\n${errorJson}\n`)
}

throw err
}
}
/**
* Safe JSON parser that is able to handle lines interrupted by an error object.
*
* This may occur when streaming NDJSON from the Export HTTP API.
*
* @internal
* @see {@link https://github.com/sanity-io/sanity/pull/1787 | Initial pull request}
*/
module.exports = createSafeJsonParser({
errorLabel: 'Error streaming dataset',
})
1 change: 1 addition & 0 deletions packages/@sanity/migrate/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
"@bjoerge/mutiny": "^0.5.1",
"@sanity/client": "^6.11.1",
"@sanity/types": "^3.23.4",
"@sanity/util": "3.26.1",
"arrify": "^2.0.1",
"fast-fifo": "^1.3.2"
},
Expand Down
1 change: 1 addition & 0 deletions packages/@sanity/migrate/src/_exports/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export {fromExportArchive} from '../sources/fromExportArchive'
export {fromExportEndpoint} from '../sources/fromExportEndpoint'
export {fromDocuments} from '../sources/fromDocuments'
export {safeJsonParser} from '../sources/fromExportEndpoint'
export * from '../types'
export * from '../defineMigration'
export * from '../it-utils'
Expand Down
24 changes: 8 additions & 16 deletions packages/@sanity/migrate/src/it-utils/__test__/json.test.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,31 @@
import {parseJSON} from '../json'
import {createSafeJsonParser} from '../createSafeJsonParser'

test('parse JSON', async () => {
const gen = async function* () {
yield '{"someString": "string"}'
yield '{"someNumber": 42}'
}

const it = parseJSON(gen(), {})
const it = parseJSON(gen())

expect(await it.next()).toEqual({value: {someString: 'string'}, done: false})
expect(await it.next()).toEqual({value: {someNumber: 42}, done: false})
expect(await it.next()).toEqual({value: undefined, done: true})
})

test('parse JSON with interrupting error', async () => {
test('parse JSON with a custom parser', async () => {
const gen = async function* () {
yield '{"someString": "string"}'
yield '{"someString": "str{"error":{"description":"Some error"}}'
yield '{"someNumber": 42}'
}

const it = parseJSON(gen(), {
parse: createSafeJsonParser({
errorLabel: 'Error parsing JSON',
parse: (line) => ({
parsed: JSON.parse(line),
}),
})

expect(await it.next()).toEqual({value: {someString: 'string'}, done: false})

await expect(async () => {
await it.next()
}).rejects.toThrowErrorMatchingInlineSnapshot(`
"Error parsing JSON: Some error
{\\"error\\":{\\"description\\":\\"Some error\\"}}
"
`)
expect(await it.next()).toEqual({value: {parsed: {someString: 'string'}}, done: false})
expect(await it.next()).toEqual({value: {parsed: {someNumber: 42}}, done: false})
expect(await it.next()).toEqual({value: undefined, done: true})
})
8 changes: 4 additions & 4 deletions packages/@sanity/migrate/src/it-utils/json.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
type Parser<Type> = (line: string) => Type
export type JSONParser<Type> = (line: string) => Type

interface Options<Type> {
parse?: Parser<Type>
export interface JSONOptions<Type> {
parse?: JSONParser<Type>
}

export async function* parseJSON<Type>(
it: AsyncIterableIterator<string>,
{parse = JSON.parse}: Options<Type> = {},
{parse = JSON.parse}: JSONOptions<Type> = {},
): AsyncIterableIterator<Type> {
for await (const chunk of it) {
yield parse(chunk)
Expand Down
12 changes: 9 additions & 3 deletions packages/@sanity/migrate/src/it-utils/ndjson.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import {split} from './split'
import {decodeText} from './decodeText'
import {parseJSON} from './json'
import {type JSONOptions, parseJSON} from './json'
import {filter} from './filter'

export function ndjson(it: AsyncIterableIterator<Uint8Array>) {
return parseJSON(filter(split(decodeText(it), '\n'), (line) => Boolean(line && line.trim())))
export function ndjson<Type>(
it: AsyncIterableIterator<Uint8Array>,
options?: JSONOptions<Type>,
): AsyncIterableIterator<Type> {
return parseJSON(
filter(split(decodeText(it), '\n'), (line) => Boolean(line && line.trim())),
options,
)
}
6 changes: 4 additions & 2 deletions packages/@sanity/migrate/src/runner/dryRun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {CompactFormatter} from '@bjoerge/mutiny'
import {SanityDocument} from '@sanity/types'
import {APIConfig, Migration} from '../types'
import {ndjson} from '../it-utils/ndjson'
import {fromExportEndpoint} from '../sources/fromExportEndpoint'
import {fromExportEndpoint, safeJsonParser} from '../sources/fromExportEndpoint'
import {collectMigrationMutations} from './collectMigrationMutations'

interface MigrationRunnerOptions {
Expand All @@ -12,7 +12,9 @@ interface MigrationRunnerOptions {
export async function* dryRun(config: MigrationRunnerOptions, migration: Migration) {
const mutations = collectMigrationMutations(
migration,
ndjson(await fromExportEndpoint(config.api)) as AsyncIterableIterator<SanityDocument>,
ndjson<SanityDocument>(await fromExportEndpoint(config.api), {
parse: safeJsonParser,
}),
)

for await (const mutation of mutations) {
Expand Down
6 changes: 4 additions & 2 deletions packages/@sanity/migrate/src/runner/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {SanityDocument} from '@sanity/types'
import {MultipleMutationResult} from '@sanity/client'
import {APIConfig, Migration} from '../types'
import {ndjson} from '../it-utils/ndjson'
import {fromExportEndpoint} from '../sources/fromExportEndpoint'
import {fromExportEndpoint, safeJsonParser} from '../sources/fromExportEndpoint'
import {toMutationEndpoint} from '../destinations/toMutationEndpoint'
import {collectMigrationMutations} from './collectMigrationMutations'

Expand All @@ -13,7 +13,9 @@ interface MigrationRunnerOptions {
export async function* run(config: MigrationRunnerOptions, migration: Migration) {
const mutations = collectMigrationMutations(
migration,
ndjson(await fromExportEndpoint(config.api)) as AsyncIterableIterator<SanityDocument>,
ndjson<SanityDocument>(await fromExportEndpoint(config.api), {
parse: safeJsonParser,
}),
)

for await (const result of toMutationEndpoint(config.api, mutations)) {
Expand Down
14 changes: 14 additions & 0 deletions packages/@sanity/migrate/src/sources/fromExportEndpoint.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import {createSafeJsonParser} from '@sanity/util/createSafeJsonParser'
import {fetchAsyncIterator} from '../fetch-utils/fetchStream'
import {toFetchOptions} from '../fetch-utils/sanityRequestOptions'
import {endpoints} from '../fetch-utils/endpoints'
import {APIConfig} from '../types'
import {SanityDocument} from '@sanity/types'

export function fromExportEndpoint(options: APIConfig) {
return fetchAsyncIterator(
Expand All @@ -14,3 +16,15 @@ export function fromExportEndpoint(options: APIConfig) {
}),
)
}

/**
* Safe JSON parser that is able to handle lines interrupted by an error object.
*
* This may occur when streaming NDJSON from the Export HTTP API.
*
* @internal
* @see {@link https://github.com/sanity-io/sanity/pull/1787 | Initial pull request}
*/
export const safeJsonParser = createSafeJsonParser<SanityDocument>({
errorLabel: 'Error streaming dataset',
})
1 change: 1 addition & 0 deletions packages/@sanity/util/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

# Exports
/content.js
/createSafeJsonParser.js
/fs.js
/legacyDateFormat.js
/paths.js
1 change: 1 addition & 0 deletions packages/@sanity/util/exports/createSafeJsonParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from '../src/createSafeJsonParser'
40 changes: 19 additions & 21 deletions packages/@sanity/util/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,7 @@
"name": "@sanity/util",
"version": "3.26.1",
"description": "Utilities shared across projects of Sanity",
"keywords": [
"sanity",
"cms",
"headless",
"realtime",
"content",
"util"
],
"keywords": ["sanity", "cms", "headless", "realtime", "content", "util"],
"homepage": "https://www.sanity.io/",
"bugs": {
"url": "https://github.com/sanity-io/sanity/issues"
Expand Down Expand Up @@ -55,6 +48,17 @@
"import": "./lib/content.esm.js",
"default": "./lib/content.esm.js"
},
"./createSafeJsonParser": {
"types": "./lib/exports/createSafeJsonParser.d.ts",
"source": "./exports/createSafeJsonParser.ts",
"require": "./lib/createSafeJsonParser.js",
"node": {
"module": "./lib/createSafeJsonParser.esm.js",
"import": "./lib/createSafeJsonParser.cjs.mjs"
},
"import": "./lib/createSafeJsonParser.esm.js",
"default": "./lib/createSafeJsonParser.esm.js"
},
"./legacyDateFormat": {
"types": "./lib/exports/legacyDateFormat.d.ts",
"source": "./exports/legacyDateFormat.ts",
Expand Down Expand Up @@ -85,22 +89,16 @@
"types": "./lib/exports/index.d.ts",
"typesVersions": {
"*": {
"fs": [
"./lib/exports/fs.d.ts"
],
"content": [
"./lib/exports/content.d.ts"
],
"legacyDateFormat": [
"./lib/exports/legacyDateFormat.d.ts"
],
"paths": [
"./lib/exports/paths.d.ts"
]
"fs": ["./lib/exports/fs.d.ts"],
"content": ["./lib/exports/content.d.ts"],
"createSafeJsonParser": ["./lib/exports/createSafeJsonParser.d.ts"],
"legacyDateFormat": ["./lib/exports/legacyDateFormat.d.ts"],
"paths": ["./lib/exports/paths.d.ts"]
}
},
"files": [
"content.js",
"createSafeJsonParser.js",
"fs.js",
"legacyDateFormat.js",
"lib",
Expand All @@ -112,7 +110,7 @@
"build": "pkg-utils build --tsconfig tsconfig.lib.json",
"postbuild": "run-s check:package",
"check:package": "pkg-utils --tsconfig tsconfig.lib.json",
"clean": "rimraf content.js fs.js legacyDateFormat.js lib paths.js",
"clean": "rimraf content.js createSafeJsonParser.js fs.js legacyDateFormat.js lib paths.js",
"test": "jest",
"watch": "pkg-utils watch --tsconfig tsconfig.lib.json"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ type Parser<Type> = (line: string) => Type
/**
* Create a safe JSON parser that is able to handle lines interrupted by an error object.
*
* TODO: Unify with the `tryParseJson` function that already exists at
* `packages/@sanity/export/src/tryParseJson.js`.
* This may occur when streaming NDJSON from the Export HTTP API.
*
* @internal
* @see {@link https://github.com/sanity-io/sanity/pull/1787 | Initial pull request}
*/
export function createSafeJsonParser<Type>({errorLabel}: Options): Parser<Type> {
return function safeJsonParser(line) {
Expand Down
20 changes: 20 additions & 0 deletions packages/@sanity/util/test/createSafeJsonParser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import {createSafeJsonParser} from '../src/createSafeJsonParser'

const parse = createSafeJsonParser({
errorLabel: 'Error parsing JSON',
})

test('parse JSON', () => {
expect(parse('{"someString": "string"}')).toEqual({someString: 'string'})
expect(parse('{"someNumber": 42}')).toEqual({someNumber: 42})
})

test('parse JSON with interrupting error', () => {
expect(() => parse('{"someString": "str{"error":{"description":"Some error"}}'))
.toThrowErrorMatchingInlineSnapshot(`
"Error parsing JSON: Some error
{\\"error\\":{\\"description\\":\\"Some error\\"}}
"
`)
})
10 changes: 9 additions & 1 deletion packages/@sanity/util/turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,15 @@
"extends": ["//"],
"pipeline": {
"build": {
"outputs": ["lib/**", "index.js", "content.js", "fs.js", "legacyDateFormat.js", "paths.js"]
"outputs": [
"lib/**",
"index.js",
"content.js",
"createSafeJsonParser.js",
"fs.js",
"legacyDateFormat.js",
"paths.js"
]
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
collectMigrationMutations,
fromExportArchive,
fromExportEndpoint,
safeJsonParser,
Migration,
ndjson,
run,
Expand Down Expand Up @@ -164,7 +165,9 @@ async function* dryRun(
) {
const mutations = collectMigrationMutations(
migration,
ndjson(await fromExportEndpoint(config.api)) as AsyncIterableIterator<SanityDocument>,
ndjson<SanityDocument>(await fromExportEndpoint(config.api), {
parse: safeJsonParser,
}),
)

for await (const mutation of mutations) {
Expand Down

0 comments on commit a1c0faf

Please sign in to comment.