@sanity/migrate
Version:
Tooling for running data migrations on Sanity.io projects
273 lines (222 loc) • 9.02 kB
text/typescript
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')
})
})