@sanity/migrate
Version:
Tooling for running data migrations on Sanity.io projects
268 lines (218 loc) • 8.88 kB
text/typescript
import {stat} from 'node:fs/promises'
import path from 'node:path'
import {describe, expect, test} from 'vitest'
import {decodeText, parse} from '../../it-utils'
import {firstValueFrom} from '../../it-utils/firstValueFrom'
import {lastValueFrom} from '../../it-utils/lastValueFrom'
import {asyncIterableToStream} from '../../utils/asyncIterableToStream'
import {streamToAsyncIterator} from '../../utils/streamToAsyncIterator'
import {bufferThroughFile} from '../bufferThroughFile'
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, {
signal: abortController.signal,
keepFile: true,
})
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, {
signal: controller.signal,
keepFile: true,
})
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, {
signal: abortController.signal,
keepFile: true,
})
const fileBufferStream = createReader()
const lines = []
for await (const chunk of parse(decodeText(streamToAsyncIterator(fileBufferStream)))) {
lines.push(chunk)
lines.push(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, {
signal: controller.signal,
keepFile: true,
})
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')
})
})