UNPKG

@sanity/migrate

Version:

Tooling for running data migrations on Sanity.io projects

273 lines (222 loc) • 9.02 kB
import {stat} from 'node:fs/promises' import path from 'node:path' import {fileURLToPath} from 'node:url' import {describe, expect, test} from 'vitest' import {firstValueFrom} from '../../it-utils/firstValueFrom.js' import {decodeText, parse} from '../../it-utils/index.js' import {lastValueFrom} from '../../it-utils/lastValueFrom.js' import {asyncIterableToStream} from '../../utils/asyncIterableToStream.js' import {streamToAsyncIterator} from '../../utils/streamToAsyncIterator.js' import {bufferThroughFile} from '../bufferThroughFile.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) let id = 0 const getTestBufferFileName = () => path.join(__dirname, '.tmp', `buffer-${id++}.ndjson`) describe('using primary stream', () => { test('stops buffering when the consumer is done', async () => { const encoder = new TextEncoder() async function* gen() { for (let n = 0; n < 100; n++) { yield encoder.encode(`{"foo": ${n},`) // simulate a bit of delay in the producer (which is often the case) // oxlint-disable-next-line no-await-in-loop await sleep(1) yield encoder.encode(`"bar": ${n}, "baz": ${n}}`) yield encoder.encode('\n') } } const bufferFile = getTestBufferFileName() const abortController = new AbortController() const createReader = bufferThroughFile(asyncIterableToStream(gen()), bufferFile, { keepFile: true, signal: abortController.signal, }) const fileBufferStream = createReader() const lines = [] for await (const chunk of parse(decodeText(streamToAsyncIterator(fileBufferStream)))) { lines.push(chunk) if (lines.length === 3) { // we only pick 3 lines and break out of the iteration. This should stop the buffering break } // simulate a slow consumer // (the bufferThroughFile stream should still continue to write to the file as fast as possible) await sleep(10) } expect(lines).toEqual([ {bar: 0, baz: 0, foo: 0}, {bar: 1, baz: 1, foo: 1}, {bar: 2, baz: 2, foo: 2}, ]) // Note: the stream needs to be explicitly aborted, otherwise the source stream will run to completion // would be nice if there was a way to "unref()" the file handle to prevent it from blocking the process, // but I don't think there is abortController.abort() // This asserts that buffer file contains more bytes than the 3 lines above represents const bufferFileSize = (await stat(bufferFile)).size expect(bufferFileSize).toBeGreaterThan(90) // but not the full 100 lines expect(bufferFileSize).toBeLessThan(3270) }) test('it runs to completion if consumer needs it', async () => { const encoder = new TextEncoder() async function* gen() { for (let n = 0; n < 100; n++) { yield encoder.encode(`{"foo": ${n},`) // simulate a bit of delay in the producer (which is often the case) await sleep(1) yield encoder.encode(`"bar": ${n}, "baz": ${n}}`) yield encoder.encode('\n') } } const bufferFile = getTestBufferFileName() const controller = new AbortController() const createReader = bufferThroughFile(asyncIterableToStream(gen()), bufferFile, { keepFile: true, signal: controller.signal, }) const fileBufferStream = createReader() const lines = [] for await (const chunk of parse(decodeText(streamToAsyncIterator(fileBufferStream)))) { if (lines.length < 3) { // in contrast to the test above, we don't break out of the iteration early, but let it run to completion lines.push(chunk) } } expect(lines).toEqual([ {bar: 0, baz: 0, foo: 0}, {bar: 1, baz: 1, foo: 1}, {bar: 2, baz: 2, foo: 2}, ]) // This asserts that buffer file contains all the yielded lines expect((await stat(bufferFile)).size).toBe(3270) }) }) describe('using secondary stream', () => { test('stops buffering when the consumer is done', async () => { const encoder = new TextEncoder() async function* gen() { for (let n = 0; n < 100; n++) { yield encoder.encode(`{"foo": ${n},`) // simulate a bit of delay in the producer (which is often the case) yield encoder.encode(`"bar": ${n}, "baz": ${n}}`) yield encoder.encode('\n') } } const bufferFile = getTestBufferFileName() const abortController = new AbortController() const createReader = bufferThroughFile(asyncIterableToStream(gen()), bufferFile, { keepFile: true, signal: abortController.signal, }) const fileBufferStream = createReader() const lines = [] for await (const chunk of parse(decodeText(streamToAsyncIterator(fileBufferStream)))) { lines.push( chunk, await lastValueFrom(parse(decodeText(streamToAsyncIterator(createReader())))), ) if (lines.length === 6) { break } } abortController.abort() expect(lines).toEqual([ {bar: 0, baz: 0, foo: 0}, {bar: 99, baz: 99, foo: 99}, {bar: 1, baz: 1, foo: 1}, {bar: 99, baz: 99, foo: 99}, {bar: 2, baz: 2, foo: 2}, {bar: 99, baz: 99, foo: 99}, ]) }) test('ends when the primary stream completes', async () => { const encoder = new TextEncoder() async function* gen() { for (let n = 0; n < 100; n++) { yield encoder.encode(`{"foo": ${n},`) yield encoder.encode(`"bar": ${n}, "baz": ${n}}`) yield encoder.encode('\n') } } const bufferFile = getTestBufferFileName() const createReader = bufferThroughFile(asyncIterableToStream(gen()), bufferFile) const primary = createReader() const first = firstValueFrom(parse(decodeText(streamToAsyncIterator(primary)))) const last = lastValueFrom(parse(decodeText(streamToAsyncIterator(createReader())))) expect(await first).toEqual({bar: 0, baz: 0, foo: 0}) expect(await last).toEqual({bar: 99, baz: 99, foo: 99}) }) test('throws if a new stream is created after abortion', async () => { const encoder = new TextEncoder() async function* gen() { for (let n = 0; n < 100; n++) { yield encoder.encode(`{"foo": ${n},`) yield encoder.encode(`"bar": ${n}, "baz": ${n}}`) yield encoder.encode('\n') } } const bufferFile = getTestBufferFileName() const controller = new AbortController() const createReader = bufferThroughFile(asyncIterableToStream(gen()), bufferFile, { keepFile: true, signal: controller.signal, }) const primary = createReader() const first = await firstValueFrom(parse(decodeText(streamToAsyncIterator(primary)))) expect(first).toEqual({bar: 0, baz: 0, foo: 0}) await primary.cancel() controller.abort() await expect(() => lastValueFrom(parse(decodeText(streamToAsyncIterator(createReader())))), ).rejects.toThrowErrorMatchingInlineSnapshot( '[Error: Cannot create new buffered readers on aborted stream]', ) }) }) describe('cleanup', () => { test('cleans up the file after cancel', async () => { const encoder = new TextEncoder() async function* gen() { for (let n = 0; n < 100; n++) { yield encoder.encode(`{"foo": ${n},`) yield encoder.encode(`"bar": ${n}, "baz": ${n}}`) yield encoder.encode('\n') } } const bufferFile = getTestBufferFileName() const controller = new AbortController() const createReader = bufferThroughFile(asyncIterableToStream(gen()), bufferFile, { signal: controller.signal, }) const reader = createReader() const first = await firstValueFrom(parse(decodeText(streamToAsyncIterator(reader)))) expect(first).toEqual({bar: 0, baz: 0, foo: 0}) await reader.cancel() await sleep(10) await expect(stat(bufferFile)).rejects.toThrow('ENOENT') }) test('cleans up after the abortController aborts', async () => { const encoder = new TextEncoder() async function* gen() { for (let n = 0; n < 100; n++) { yield encoder.encode(`{"foo": ${n},`) yield encoder.encode(`"bar": ${n}, "baz": ${n}}`) yield encoder.encode('\n') } } const bufferFile = getTestBufferFileName() const controller = new AbortController() const createReader = bufferThroughFile(asyncIterableToStream(gen()), bufferFile, { signal: controller.signal, }) const firstReader = createReader() const first = await firstValueFrom(parse(decodeText(streamToAsyncIterator(firstReader)))) expect(first).toEqual({bar: 0, baz: 0, foo: 0}) const second = await lastValueFrom(parse(decodeText(streamToAsyncIterator(firstReader)))) expect(second).toEqual({bar: 99, baz: 99, foo: 99}) controller.abort() await sleep(10) await expect(stat(bufferFile)).rejects.toThrow('ENOENT') }) })