From 5b5d4b2b88b977e759a1e4dbdd4b0402530ecc9b Mon Sep 17 00:00:00 2001 From: ryo-rm <6457344+ryo-rm@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:34:46 +0900 Subject: [PATCH 1/4] Carry over manifest stat maps decoded by the reader The Avro reader decodes Iceberg stat maps as arrays of {key, value} records rather than plain objects; boundForField in prune.js already handles both shapes on the read side. encodeMap only handled the object form, so writeExistingDeleteManifest threw "expected bigint value" out of avroWrite whenever it carried over an entry that had come back from a manifest with stats on it. Real delete files carry those stats: the Spark and Java position delete files under test/files/hyperparam-iceberg/*/bunnies both have lower_bounds, and the Java equality delete file has value_counts as well. Icebird alone never reached this, since v3 refuses to write new position delete files and v2 refuses to write deletion vectors, so the two only coexist on an upgraded table or one another engine wrote. The added test covers that case. --- src/write/manifest.js | 8 ++- test/write/stage.deletion-vector.test.js | 84 ++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/src/write/manifest.js b/src/write/manifest.js index b494c1c..f27307c 100644 --- a/src/write/manifest.js +++ b/src/write/manifest.js @@ -124,14 +124,18 @@ function icebergSchemaJson(schema) { /** * Encode an Iceberg stat map as an Avro array of {key, value} records, - * or null if the input has no entries. + * or null if the input has no entries. Entries decoded by the reader already + * carry that array form (see `boundForField` in prune.js), so the carry-over + * writers pass one straight back through; hand-built entries use a plain + * `Record`. * * @template V - * @param {Record|undefined} m + * @param {Record|{key: number, value: V}[]|undefined} m * @returns {{key: number, value: V}[]|null} */ function encodeMap(m) { if (!m) return null + if (Array.isArray(m)) return m.length ? m : null const entries = Object.entries(m) if (!entries.length) return null return entries.map(([k, value]) => ({ key: Number(k), value })) diff --git a/test/write/stage.deletion-vector.test.js b/test/write/stage.deletion-vector.test.js index 8f80b3b..b373ef8 100644 --- a/test/write/stage.deletion-vector.test.js +++ b/test/write/stage.deletion-vector.test.js @@ -478,6 +478,90 @@ describe('icebergStageDeletionVector', () => { expect(ids).toEqual([2n, 3n]) }) + it('carries over a position delete entry decoded by the reader', async () => { + // A v2 table can accumulate position delete files across two partitions, + // putting two entries in one delete manifest, each carrying the stat maps + // stage-position-delete writes. Upgrading the table to v3 leaves those + // files in place, so the next deletion vector delete obsoletes one entry + // and has to carry the other over as EXISTING. The reader decodes Iceberg + // stat maps as {key, value} record arrays rather than plain objects, so + // that write has to accept the shape the reader produced. + const tableUrl = 'http://test/dv-carry-over-decoded' + const { resolver } = memResolver() + + /** @type {Schema} */ + const partitioned = { + type: 'struct', + 'schema-id': 0, + fields: [ + { id: 1, name: 'id', required: true, type: 'long' }, + { id: 2, name: 'part', required: true, type: 'string' }, + ], + } + const partitionSpec = { + 'spec-id': 0, + fields: [{ 'source-id': 2, 'field-id': 1000, name: 'part', transform: 'identity' }], + } + const created = await icebergCreate({ tableUrl, resolver, schema: partitioned, partitionSpec }) + + // One append per partition, so each partition's data file is named by its + // own staging result rather than looked up by partition value. + const appendX = await icebergStageAppend({ + tableUrl, metadata: created, resolver, + records: [{ id: 1n, part: 'x' }, { id: 2n, part: 'x' }], + }) + const afterX = await fileCatalogCommit({ tableUrl, metadata: created, staged: appendX, resolver }) + const appendY = await icebergStageAppend({ + tableUrl, metadata: afterX, resolver, + records: [{ id: 3n, part: 'y' }, { id: 4n, part: 'y' }], + }) + const afterAppend = await fileCatalogCommit({ tableUrl, metadata: afterX, staged: appendY, resolver }) + const fileX = appendX.writtenFiles[0] + const fileY = appendY.writtenFiles[0] + + // One position-delete op touching both partitions: two delete files, one + // manifest. A single-partition delete would leave nothing to carry over. + const deletes = await icebergStagePositionDelete({ + tableUrl, + metadata: afterAppend, + deletes: [{ file_path: fileX, pos: 0n }, { file_path: fileY, pos: 0n }], + resolver, + }) + const afterDeletes = await fileCatalogCommit({ tableUrl, metadata: afterAppend, staged: deletes, resolver }) + const deleteManifest = deletes.writtenFiles.find(f => f.endsWith('.avro') && !f.includes('snap-')) + if (!deleteManifest) throw new Error('expected a delete manifest') + expect(await fetchAvroRecords(deleteManifest, resolver)).toHaveLength(2) + + const upgraded = { ...afterDeletes, 'format-version': /** @type {3} */ (3), 'next-row-id': 0 } + + // Targets partition x only, so its position delete entry goes obsolete + // and partition y's entry is carried over. + const staged = await icebergStageDeletionVector({ + tableUrl, + metadata: upgraded, + deletes: [{ file_path: fileX, pos: 1n }], + resolver, + }) + + const afterDv = await fileCatalogCommit({ tableUrl, metadata: upgraded, staged, resolver }) + const carried = (await currentManifestEntries(afterDv, resolver)) + .filter(e => e.data_file.content === 1 && e.status === 0) + expect(carried).toHaveLength(1) + // The stats came through the carry-over intact rather than being dropped + // or re-encoded. The retained delete file covers position 0 of partition + // y's data file, so both `pos` bounds (reserved field id 2147483545) are + // the 8-byte little-endian encoding of 0. + expect(carried[0].data_file.lower_bounds).toContainEqual({ + key: 2147483545, value: new Uint8Array(8), + }) + expect(carried[0].data_file.upper_bounds).toContainEqual({ + key: 2147483545, value: new Uint8Array(8), + }) + + const read = await icebergRead({ tableUrl, metadata: afterDv, resolver }) + expect(read.map(r => r.id).sort((a, b) => Number(a - b))).toEqual([4n]) + }) + it('preserves v3 next-row-id and emits added-rows=0', async () => { vi.spyOn(Date, 'now').mockReturnValue(1700000000000) const tableUrl = 'http://test/dv-nextrow' From 9a606c159f8ef5f97133acec46fd95a0bb811df7 Mon Sep 17 00:00:00 2001 From: ryo-rm <6457344+ryo-rm@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:35:15 +0900 Subject: [PATCH 2/4] Add writeExistingDataManifest for manifest rewrites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewriting manifests without touching the data files they reference — merging many small fast-append manifests into one — needs data entries written with status EXISTING and explicit per-entry snapshot_id, sequence_number and file_sequence_number. writeDataManifest hard-codes status ADDED and leaves the sequence numbers null for inheritance, which would move carried-over files forward to the rewriting snapshot's sequence number and change which delete files apply to them. Mirrors writeExistingDeleteManifest, which already covers the delete side for deletion vector replacement. Exporting manifestEntrySchema instead would pin the Avro schema builder's signature, manifestContent argument included, as public API. --- src/write/manifest.js | 47 ++++++++++++ test/write/manifest.test.js | 145 +++++++++++++++++++++++++++++++++++- 2 files changed, 190 insertions(+), 2 deletions(-) diff --git a/src/write/manifest.js b/src/write/manifest.js index f27307c..fc247b3 100644 --- a/src/write/manifest.js +++ b/src/write/manifest.js @@ -216,6 +216,53 @@ export function writeDeleteManifest({ writer, schema, partitionSpec, snapshotId, }) } +/** + * Write a data manifest containing already-existing data entries. Used when + * manifests are rewritten without touching the data files they reference — + * merging many small manifests into one, for instance. Carried-over files + * must keep their original data and file sequence numbers rather than + * inheriting the rewriting snapshot's, which would misattribute their data + * sequence numbers and change which delete files apply to them. + * + * @param {object} options + * @param {Writer} options.writer + * @param {Schema} options.schema + * @param {PartitionSpec} options.partitionSpec + * @param {ManifestEntry[]} options.entries + * @param {2|3} [options.formatVersion] + * @returns {void | Promise} resolves when the writer's `finish()` lands + */ +export function writeExistingDataManifest({ writer, schema, partitionSpec, entries, formatVersion = 2 }) { + const records = entries.map(entry => { + const dataFile = entry.data_file + if (dataFile.content !== 0) { + throw new Error(`writeExistingDataManifest expects data files (content=0), got content=${dataFile.content}`) + } + const record = manifestEntryRecord(dataFile, schema, partitionSpec, 0n, formatVersion, 0) + record.status = 0 + record.snapshot_id = entry.snapshot_id ?? null + record.sequence_number = entry.sequence_number ?? null + record.file_sequence_number = entry.file_sequence_number ?? null + if (record.sequence_number == null || record.file_sequence_number == null) { + throw new Error('existing data manifest entry missing sequence numbers') + } + return record + }) + + return avroWrite({ + writer, + schema: manifestEntrySchema(schema, partitionSpec, formatVersion, 0), + records, + metadata: { + 'format-version': String(formatVersion), + content: 'data', + schema: icebergSchemaJson(schema), + 'partition-spec': partitionSpecJson(partitionSpec), + 'partition-spec-id': String(partitionSpec['spec-id']), + }, + }) +} + /** * Write a delete manifest containing already-existing delete entries. Used * when a v3 deletion vector replaces an older vector in a mixed manifest: the diff --git a/test/write/manifest.test.js b/test/write/manifest.test.js index fca6ee7..0e35e2b 100644 --- a/test/write/manifest.test.js +++ b/test/write/manifest.test.js @@ -1,11 +1,11 @@ import { describe, expect, it } from 'vitest' import { ByteWriter } from 'hyparquet-writer' -import { writeDataManifest } from '../../src/write/manifest.js' +import { writeDataManifest, writeExistingDataManifest } from '../../src/write/manifest.js' import { avroMetadata } from '../../src/avro/avro.metadata.js' import { avroRead } from '../../src/avro/avro.read.js' /** - * @import {DataFile, PartitionSpec, Schema} from '../../src/types.js' + * @import {DataFile, ManifestEntry, PartitionSpec, Schema} from '../../src/types.js' */ describe('writeDataManifest', () => { @@ -119,3 +119,144 @@ describe('writeDataManifest', () => { expect(records[0].data_file.first_row_id).toBe(1000n) }) }) + +describe('writeExistingDataManifest', () => { + /** @type {Schema} */ + const schema = { + type: 'struct', + 'schema-id': 0, + fields: [ + { id: 1, name: 'id', required: true, type: 'long' }, + { id: 2, name: 'name', required: false, type: 'string' }, + ], + } + + /** @type {PartitionSpec} */ + const unpartitioned = { 'spec-id': 0, fields: [] } + + /** @type {DataFile} */ + const dataFile = { + content: 0, + file_path: 's3://bucket/table/data/abc.parquet', + file_format: 'parquet', + partition: {}, + record_count: 3n, + file_size_in_bytes: 421n, + sort_order_id: 0, + } + + /** @type {ManifestEntry} */ + const entry = { + status: 1, + snapshot_id: 111n, + sequence_number: 7n, + file_sequence_number: 7n, + data_file: dataFile, + } + + it('writes EXISTING entries that keep their original snapshot and sequence numbers', async () => { + const writer = new ByteWriter() + writeExistingDataManifest({ writer, schema, partitionSpec: unpartitioned, entries: [entry] }) + const buffer = writer.getBuffer() + + const reader = { view: new DataView(buffer), offset: 0 } + const { metadata, syncMarker } = await avroMetadata(reader) + expect(metadata.content).toBe('data') + + const records = await avroRead({ reader, metadata, syncMarker }) + expect(records).toHaveLength(1) + // status 0 (EXISTING) with explicit numbers: an ADDED entry would leave + // these null and inherit the rewriting snapshot's sequence number, which + // would move the file forward in time and change which deletes apply. + expect(records[0]).toMatchObject({ + status: 0, + snapshot_id: 111n, + sequence_number: 7n, + file_sequence_number: 7n, + data_file: { file_path: 's3://bucket/table/data/abc.parquet' }, + }) + }) + + it('carries v3 first_row_id through', async () => { + const writer = new ByteWriter() + writeExistingDataManifest({ + writer, + schema, + partitionSpec: unpartitioned, + entries: [{ ...entry, data_file: { ...dataFile, first_row_id: 1000n } }], + formatVersion: 3, + }) + const buffer = writer.getBuffer() + + const reader = { view: new DataView(buffer), offset: 0 } + const { metadata, syncMarker } = await avroMetadata(reader) + const records = await avroRead({ reader, metadata, syncMarker }) + expect(records[0].data_file.first_row_id).toBe(1000n) + }) + + it('round-trips stat maps from a read-decoded manifest', async () => { + // The Avro reader hands Iceberg maps back as {key, value} record arrays + // rather than plain objects, and a manifest rewrite feeds exactly those + // decoded entries back in. Sequence numbers are supplied here the way + // `icebergManifests` materializes them from the manifest list. + const first = new ByteWriter() + writeDataManifest({ + writer: first, + schema, + partitionSpec: unpartitioned, + snapshotId: 111n, + dataFiles: [{ + ...dataFile, + value_counts: { 1: 3n, 2: 3n }, + lower_bounds: { 1: new Uint8Array([1, 0, 0, 0, 0, 0, 0, 0]) }, + }], + }) + const firstBuffer = first.getBuffer() + + const firstReader = { view: new DataView(firstBuffer), offset: 0 } + const firstMeta = await avroMetadata(firstReader) + const decoded = (await avroRead({ + reader: firstReader, + metadata: firstMeta.metadata, + syncMarker: firstMeta.syncMarker, + }))[0] + + const second = new ByteWriter() + writeExistingDataManifest({ + writer: second, + schema, + partitionSpec: unpartitioned, + entries: [/** @type {ManifestEntry} */ ({ ...decoded, sequence_number: 7n, file_sequence_number: 7n })], + }) + const secondBuffer = second.getBuffer() + + const secondReader = { view: new DataView(secondBuffer), offset: 0 } + const secondMeta = await avroMetadata(secondReader) + const records = await avroRead({ + reader: secondReader, + metadata: secondMeta.metadata, + syncMarker: secondMeta.syncMarker, + }) + expect(records[0].data_file.value_counts).toEqual(decoded.data_file.value_counts) + expect(records[0].data_file.lower_bounds).toEqual(decoded.data_file.lower_bounds) + expect(records[0].data_file.record_count).toBe(3n) + }) + + it('rejects delete files', () => { + expect(() => writeExistingDataManifest({ + writer: new ByteWriter(), + schema, + partitionSpec: unpartitioned, + entries: [{ ...entry, data_file: { ...dataFile, content: 1 } }], + })).toThrow('writeExistingDataManifest expects data files') + }) + + it('rejects entries without sequence numbers', () => { + expect(() => writeExistingDataManifest({ + writer: new ByteWriter(), + schema, + partitionSpec: unpartitioned, + entries: [{ ...entry, file_sequence_number: undefined }], + })).toThrow('existing data manifest entry missing sequence numbers') + }) +}) From b579b86488fea8a22e4f7104df6dddce26a66aab Mon Sep 17 00:00:00 2001 From: Kenny Daniel Date: Wed, 19 Aug 2026 22:00:19 -0700 Subject: [PATCH 3/4] Validate rewritten manifest entries Materialize inherited snapshot IDs before rewrites and require existing data entries to retain their original snapshot and partition spec metadata. --- src/manifest.js | 1 + src/write/manifest.js | 9 +++++++- test/manifest.test.js | 42 ++++++++++++++++++++++++++++++++++++- test/write/manifest.test.js | 26 ++++++++++++++++++++++- 4 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/manifest.js b/src/manifest.js index ba5a83e..629db76 100644 --- a/src/manifest.js +++ b/src/manifest.js @@ -63,6 +63,7 @@ async function fetchManifests(manifests, resolver) { // Inherit sequence number from manifest if not present in entry for (const entry of entries) { entry.partition_spec_id = manifest.partition_spec_id ?? 0 + if (entry.snapshot_id == null) entry.snapshot_id = manifest.added_snapshot_id if (entry.sequence_number === undefined) { // When reading v1 manifests with no sequence number column, diff --git a/src/write/manifest.js b/src/write/manifest.js index fc247b3..aa99e0b 100644 --- a/src/write/manifest.js +++ b/src/write/manifest.js @@ -222,7 +222,8 @@ export function writeDeleteManifest({ writer, schema, partitionSpec, snapshotId, * merging many small manifests into one, for instance. Carried-over files * must keep their original data and file sequence numbers rather than * inheriting the rewriting snapshot's, which would misattribute their data - * sequence numbers and change which delete files apply to them. + * sequence numbers and change which delete files apply to them. All entries + * must belong to the supplied partition spec. * * @param {object} options * @param {Writer} options.writer @@ -238,11 +239,17 @@ export function writeExistingDataManifest({ writer, schema, partitionSpec, entri if (dataFile.content !== 0) { throw new Error(`writeExistingDataManifest expects data files (content=0), got content=${dataFile.content}`) } + if (entry.partition_spec_id !== partitionSpec['spec-id']) { + throw new Error(`existing data entry partition spec ${entry.partition_spec_id} does not match ${partitionSpec['spec-id']}`) + } const record = manifestEntryRecord(dataFile, schema, partitionSpec, 0n, formatVersion, 0) record.status = 0 record.snapshot_id = entry.snapshot_id ?? null record.sequence_number = entry.sequence_number ?? null record.file_sequence_number = entry.file_sequence_number ?? null + if (record.snapshot_id == null) { + throw new Error('existing data manifest entry missing snapshot id') + } if (record.sequence_number == null || record.file_sequence_number == null) { throw new Error('existing data manifest entry missing sequence numbers') } diff --git a/test/manifest.test.js b/test/manifest.test.js index a381021..744c64a 100644 --- a/test/manifest.test.js +++ b/test/manifest.test.js @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest' import fs from 'fs' import { icebergManifests } from '../src/manifest.js' import { icebergMetadata } from '../src/metadata.js' -import { localResolver } from './helpers.js' +import { writeDataManifest } from '../src/write/manifest.js' +import { localResolver, memResolver } from './helpers.js' describe('Iceberg Manifests', () => { const tableUrl = 's3://hyperparam-iceberg/spark/bunnies' @@ -91,4 +92,43 @@ describe('Iceberg Manifests', () => { expect(manifests).toHaveLength(1) expect(calls).toEqual([{ url: manifestPath, byteLength: manifestLength }]) }) + + it('inherits a null entry snapshot id from the manifest list', async () => { + const { resolver: memory } = memResolver() + const manifestPath = 'http://test/inherited-snapshot-id.avro' + const writer = memory.writer?.(manifestPath) + if (!writer) throw new Error('expected resolver.writer') + await writeDataManifest({ + writer, + schema: { type: 'struct', 'schema-id': 0, fields: [] }, + partitionSpec: { 'spec-id': 0, fields: [] }, + snapshotId: /** @type {any} */ (null), + dataFiles: [{ + content: 0, + file_path: 'http://test/data.parquet', + file_format: 'parquet', + partition: {}, + record_count: 1n, + file_size_in_bytes: 1n, + }], + }) + + const metadata = /** @type {any} */ ({ + 'current-snapshot-id': 77, + snapshots: [{ + 'snapshot-id': 77, + manifests: [{ + manifest_path: manifestPath, + manifest_length: BigInt(writer.offset), + partition_spec_id: 0, + content: 0, + sequence_number: 5n, + added_snapshot_id: 77n, + }], + }], + }) + + const manifests = await icebergManifests({ metadata, resolver: memory }) + expect(manifests[0].entries[0].snapshot_id).toBe(77n) + }) }) diff --git a/test/write/manifest.test.js b/test/write/manifest.test.js index 0e35e2b..07a1685 100644 --- a/test/write/manifest.test.js +++ b/test/write/manifest.test.js @@ -151,6 +151,7 @@ describe('writeExistingDataManifest', () => { snapshot_id: 111n, sequence_number: 7n, file_sequence_number: 7n, + partition_spec_id: 0, data_file: dataFile, } @@ -226,7 +227,12 @@ describe('writeExistingDataManifest', () => { writer: second, schema, partitionSpec: unpartitioned, - entries: [/** @type {ManifestEntry} */ ({ ...decoded, sequence_number: 7n, file_sequence_number: 7n })], + entries: [/** @type {ManifestEntry} */ ({ + ...decoded, + sequence_number: 7n, + file_sequence_number: 7n, + partition_spec_id: 0, + })], }) const secondBuffer = second.getBuffer() @@ -259,4 +265,22 @@ describe('writeExistingDataManifest', () => { entries: [{ ...entry, file_sequence_number: undefined }], })).toThrow('existing data manifest entry missing sequence numbers') }) + + it('rejects entries without a materialized snapshot id', () => { + expect(() => writeExistingDataManifest({ + writer: new ByteWriter(), + schema, + partitionSpec: unpartitioned, + entries: [{ ...entry, snapshot_id: undefined }], + })).toThrow('existing data manifest entry missing snapshot id') + }) + + it('rejects entries from another partition spec', () => { + expect(() => writeExistingDataManifest({ + writer: new ByteWriter(), + schema, + partitionSpec: unpartitioned, + entries: [{ ...entry, partition_spec_id: 1 }], + })).toThrow('existing data entry partition spec 1 does not match 0') + }) }) From e1eda6ee211315e1f01e483ff1184ff33f9cdaea Mon Sep 17 00:00:00 2001 From: Kenny Daniel Date: Wed, 19 Aug 2026 22:11:31 -0700 Subject: [PATCH 4/4] Preserve rewritten manifest metadata --- src/write/manifest.js | 10 ++++++++++ test/write/manifest.test.js | 11 +++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/write/manifest.js b/src/write/manifest.js index aa99e0b..047b264 100644 --- a/src/write/manifest.js +++ b/src/write/manifest.js @@ -39,6 +39,12 @@ function manifestEntrySchema(schema, partitionSpec, formatVersion, manifestConte mapField('nan_value_counts', 137, 'k138_v139', 138, 139, 'long'), mapField('lower_bounds', 125, 'k126_v127', 126, 127, 'bytes'), mapField('upper_bounds', 128, 'k129_v130', 129, 130, 'bytes'), + { + name: 'split_offsets', + type: ['null', { type: 'array', items: 'long', 'element-id': 133 }], + default: null, + 'field-id': 132, + }, { name: 'sort_order_id', type: ['null', 'int'], default: null, 'field-id': 140 }, ] if (manifestContent === 1) { @@ -236,6 +242,9 @@ export function writeDeleteManifest({ writer, schema, partitionSpec, snapshotId, export function writeExistingDataManifest({ writer, schema, partitionSpec, entries, formatVersion = 2 }) { const records = entries.map(entry => { const dataFile = entry.data_file + if (entry.status === 2) { + throw new Error('writeExistingDataManifest cannot rewrite deleted entries as existing') + } if (dataFile.content !== 0) { throw new Error(`writeExistingDataManifest expects data files (content=0), got content=${dataFile.content}`) } @@ -346,6 +355,7 @@ function manifestEntryRecord(dataFile, schema, partitionSpec, snapshotId, format nan_value_counts: encodeMap(dataFile.nan_value_counts), lower_bounds: encodeMap(dataFile.lower_bounds), upper_bounds: encodeMap(dataFile.upper_bounds), + split_offsets: dataFile.split_offsets?.length ? dataFile.split_offsets : null, sort_order_id: dataFile.content === 1 ? null : dataFile.sort_order_id ?? 0, } if (manifestContent === 1) { diff --git a/test/write/manifest.test.js b/test/write/manifest.test.js index 07a1685..70a75b3 100644 --- a/test/write/manifest.test.js +++ b/test/write/manifest.test.js @@ -210,6 +210,7 @@ describe('writeExistingDataManifest', () => { ...dataFile, value_counts: { 1: 3n, 2: 3n }, lower_bounds: { 1: new Uint8Array([1, 0, 0, 0, 0, 0, 0, 0]) }, + split_offsets: [4n, 100n], }], }) const firstBuffer = first.getBuffer() @@ -245,9 +246,19 @@ describe('writeExistingDataManifest', () => { }) expect(records[0].data_file.value_counts).toEqual(decoded.data_file.value_counts) expect(records[0].data_file.lower_bounds).toEqual(decoded.data_file.lower_bounds) + expect(records[0].data_file.split_offsets).toEqual(decoded.data_file.split_offsets) expect(records[0].data_file.record_count).toBe(3n) }) + it('rejects deleted entries', () => { + expect(() => writeExistingDataManifest({ + writer: new ByteWriter(), + schema, + partitionSpec: unpartitioned, + entries: [{ ...entry, status: 2 }], + })).toThrow('writeExistingDataManifest cannot rewrite deleted entries as existing') + }) + it('rejects delete files', () => { expect(() => writeExistingDataManifest({ writer: new ByteWriter(),