Skip to content

Commit ec237ef

Browse files
authored
Add icebergUpdateSchema for schema evolution (#31)
Expose the existing add-schema / set-current-schema commit logic through a stable public entry point so consumers can add nullable columns (or rename columns, promote types) without recreating the table. - icebergStageUpdateSchema: pure metadata-only stage with CAS requirements (assert-current-schema-id, assert-last-assigned-field-id), validated eagerly via applyUpdates - icebergUpdateSchema: top-level commit through commitWithRetry, works for file and REST catalogs - StagedCommit supertype with optional snapshot for metadata-only commits
1 parent 22cbb92 commit ec237ef

7 files changed

Lines changed: 296 additions & 18 deletions

File tree

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ import {
144144
icebergExpireSnapshots,
145145
icebergRewrite,
146146
icebergSetRef,
147+
icebergUpdateSchema,
147148
} from 'icebird'
148149

149150
// `urlResolver()` ships with a `writer` (HTTP PUT) and `deleter` (HTTP DELETE);
@@ -173,6 +174,21 @@ await icebergDelete({
173174
// snapshot management
174175
await icebergSetRef({ catalog, tableUrl, ref: 'main', snapshotId })
175176
await icebergExpireSnapshots({ catalog, tableUrl, snapshotIds: [oldSnapshotId] })
177+
178+
// schema evolution — pass the complete evolved schema; existing columns keep
179+
// their field ids, new columns use ids above the table's `last-column-id`.
180+
// Metadata-only: existing data files read the new column as `null`.
181+
await icebergUpdateSchema({
182+
catalog, tableUrl,
183+
schema: {
184+
type: 'struct',
185+
'schema-id': 0, // ignored; the next schema id is assigned at commit
186+
fields: [
187+
...schema.fields,
188+
{ id: 3, name: 'score', required: false, type: 'double' },
189+
],
190+
},
191+
})
176192
```
177193

178194
If the table is created with a `sortOrder`, `icebergAppend` orders the rows in each written file by that order (tightening per-file column bounds for scan pruning). `icebergRewrite` compacts the current snapshot — reading every live row (deletes applied), sorting globally, and rewriting into consolidated, non-overlapping files via a `replace` snapshot (v2 tables):

src/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export { IcebergTransactionConflictError, icebergAppend, icebergCreateTable, icebergDelete, icebergDropTable, icebergExpireSnapshots, icebergRewrite, icebergSetRef, icebergTransaction } from './write/write.js'
1+
export { IcebergTransactionConflictError, icebergAppend, icebergCreateTable, icebergDelete, icebergDropTable, icebergExpireSnapshots, icebergRewrite, icebergSetRef, icebergTransaction, icebergUpdateSchema } from './write/write.js'
22
export { icebergCreate } from './create.js'
33
export { fileCatalog } from './catalog/file.js'
44
export { restCatalogConnect, restCatalogCreateNamespace, restCatalogDropNamespace, restCatalogListNamespaces, restCatalogListTables, restCatalogLoadCredentials, restCatalogLoadTable, restCatalogRegisterTable, restCatalogRenameTable } from './catalog/rest.js'

src/types.d.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -318,17 +318,27 @@ export interface IcebergTransaction {
318318
}
319319

320320
/**
321-
* Output of an `icebergStage*` call: the snapshot just produced, the CAS
322-
* preconditions and updates a catalog must apply, and the data/manifest files
323-
* already written to storage (useful for cleanup on commit failure).
321+
* Input to a commit function (`fileCatalogCommit`, `restCatalogUpdateTable`):
322+
* the CAS preconditions and updates a catalog must apply, and the
323+
* data/manifest files already written to storage (useful for cleanup on
324+
* commit failure). `snapshot` is absent for metadata-only updates like a
325+
* schema change.
324326
*/
325-
export interface StagedUpdate {
326-
snapshot: Snapshot
327+
export interface StagedCommit {
328+
snapshot?: Snapshot
327329
requirements: TableRequirement[]
328330
updates: TableUpdate[]
329331
writtenFiles: string[]
330332
}
331333

334+
/**
335+
* Output of an `icebergStage*` call that produces a snapshot (append, delete,
336+
* rewrite, setRef, expireSnapshots).
337+
*/
338+
export interface StagedUpdate extends StagedCommit {
339+
snapshot: Snapshot
340+
}
341+
332342
/**
333343
* Output of `prepareAppend`. Captures everything that does NOT depend on the
334344
* eventually-committed snapshot's sequence number or parent: the data files,

src/write/commit.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ import { parseDecimalType } from './conversions.js'
44
import { validatePartitionSpecForWrite } from './partition.js'
55

66
/**
7-
* @import {Field, IcebergType, PartitionSpec, Resolver, Schema, SnapshotRef, SortOrder, StagedUpdate, TableMetadata, TableRequirement, TableUpdate} from '../../src/types.js'
7+
* @import {Field, IcebergType, PartitionSpec, Resolver, Schema, SnapshotRef, SortOrder, StagedCommit, TableMetadata, TableRequirement, TableUpdate} from '../../src/types.js'
88
*/
99

1010
/**
11-
* Commit a `StagedUpdate` against a file-based catalog: verify requirements
11+
* Commit a `StagedCommit` against a file-based catalog: verify requirements
1212
* against the current metadata, apply updates, and write the next
1313
* `vN.metadata.json` and `version-hint.text`.
1414
*
@@ -30,7 +30,7 @@ import { validatePartitionSpecForWrite } from './partition.js'
3030
* prior version so rollback / log walks land on a real file even when the
3131
* prior writer used `NNNNN-<uuid>.metadata.json` instead of `vN.metadata.json`.
3232
* @param {number} [options.currentVersion] - If known, the on-disk version of `metadata`. Bypasses deriving from `metadata-log`, which can be empty/stale on foreign-written tables.
33-
* @param {StagedUpdate} options.staged
33+
* @param {StagedCommit} options.staged
3434
* @param {Resolver} options.resolver
3535
* @param {boolean} [options.conditionalCommits] - When true, write the metadata file with `ifNoneMatch: '*'`.
3636
* @returns {Promise<TableMetadata>} The new metadata, already persisted.

src/write/stage.js

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { validateSchemaForVersion } from '../schema.js'
22
import { uuid4 } from '../utils.js'
3+
import { applyUpdates } from './commit.js'
34
import { writeParquet } from './parquet.js'
45
import { writeDataManifest } from './manifest.js'
56
import { groupByPartition } from './partition.js'
@@ -13,7 +14,7 @@ import { computeColumnStats } from './stats.js'
1314

1415
/**
1516
* @import {CompressionCodec} from 'hyparquet'
16-
* @import {FieldSummary, Manifest, PreparedAppend, Resolver, Snapshot, StagedUpdate, TableMetadata, TableRequirement, TableUpdate} from '../../src/types.js'
17+
* @import {FieldSummary, Manifest, PreparedAppend, Resolver, Schema, Snapshot, StagedCommit, StagedUpdate, TableMetadata, TableRequirement, TableUpdate} from '../../src/types.js'
1718
*/
1819

1920
/**
@@ -367,10 +368,8 @@ export function icebergStageExpireSnapshots({ metadata, snapshotIds }) {
367368
/** @type {TableUpdate} */
368369
const update = { action: 'remove-snapshots', 'snapshot-ids': snapshotIds }
369370

370-
// The snapshot field on StagedUpdate is non-optional; surface the current
371-
// snapshot so callers reading `staged.snapshot` after an expire still see
372-
// the live tip. Synthesize a minimal placeholder for tables with no
373-
// snapshots left after the operation.
371+
// Surface the current snapshot so callers reading `staged.snapshot` after
372+
// an expire (e.g. `icebergTransaction`) still see the live tip.
374373
const tip = currentSnapshot(metadata) ?? snapshots[0]
375374
if (!tip) throw new Error('cannot expire snapshots from a table with no snapshots')
376375

@@ -382,6 +381,56 @@ export function icebergStageExpireSnapshots({ metadata, snapshotIds }) {
382381
}
383382
}
384383

384+
/**
385+
* Stage a schema update: add `schema` as a new schema and make it current.
386+
* This is the schema-evolution primitive — add a column, rename a column,
387+
* promote a type — expressed as the full evolved schema.
388+
*
389+
* The caller supplies the complete new schema with field ids assigned:
390+
* existing columns keep their ids, new columns use ids above
391+
* `last-column-id`. The `schema-id` is assigned at commit time via the spec
392+
* sentinel `-1`, so any `schema-id` on the input is ignored. Evolution rules
393+
* (no field-id reuse, valid type promotions, immutable `initial-default`,
394+
* required new fields need defaults) are validated here against the loaded
395+
* metadata and again at commit.
396+
*
397+
* Pure: produces a metadata-only `StagedCommit` (no snapshot, no files) to
398+
* pass into a commit function (`fileCatalogCommit`, `restCatalogUpdateTable`).
399+
*
400+
* @param {object} options
401+
* @param {TableMetadata} options.metadata - Current table metadata.
402+
* @param {Schema} options.schema - The complete evolved schema.
403+
* @returns {StagedCommit}
404+
*/
405+
export function icebergStageUpdateSchema({ metadata, schema }) {
406+
if (!schema || schema.type !== 'struct' || !Array.isArray(schema.fields)) {
407+
throw new Error('schema must be a struct with a fields array')
408+
}
409+
410+
/** @type {TableRequirement[]} */
411+
const requirements = [
412+
{ type: 'assert-table-uuid', uuid: metadata['table-uuid'] },
413+
{ type: 'assert-current-schema-id', 'current-schema-id': metadata['current-schema-id'] },
414+
{ type: 'assert-last-assigned-field-id', 'last-assigned-field-id': metadata['last-column-id'] },
415+
]
416+
417+
/** @type {TableUpdate[]} */
418+
const updates = [
419+
{ action: 'add-schema', schema: { ...schema, 'schema-id': -1 } },
420+
{ action: 'set-current-schema', 'schema-id': -1 },
421+
]
422+
423+
// Fail fast at stage time: applyUpdates enforces the schema-evolution rules
424+
// that would otherwise only surface at commit (server-side for REST).
425+
applyUpdates(metadata, updates)
426+
427+
return {
428+
requirements,
429+
updates,
430+
writtenFiles: [],
431+
}
432+
}
433+
385434
/**
386435
* Reject `write.format.default` values other than parquet. Iceberg also
387436
* defines `avro` and `orc`, but Icebird only writes parquet today.

src/write/write.js

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ import { loadLatestFileCatalogMetadata } from '../metadata.js'
55
import { applyUpdates, fileCatalogCommit } from './commit.js'
66
import { icebergStageDeletionVector } from './stage-deletion-vector.js'
77
import { icebergStagePositionDelete } from './stage-position-delete.js'
8-
import { icebergStageAppend, icebergStageExpireSnapshots, icebergStageSetRef, prepareAppend, stageSnapshotForAppend } from './stage.js'
8+
import { icebergStageAppend, icebergStageExpireSnapshots, icebergStageSetRef, icebergStageUpdateSchema, prepareAppend, stageSnapshotForAppend } from './stage.js'
99
import { icebergStageRewrite } from './rewrite.js'
1010

1111
/**
12-
* @import {Catalog, IcebergTransaction, Lister, PartitionSpec, Resolver, Schema, Snapshot, SortOrder, StagedUpdate, TableMetadata, TableRequirement, TableUpdate} from '../../src/types.js'
12+
* @import {Catalog, IcebergTransaction, Lister, PartitionSpec, Resolver, Schema, Snapshot, SortOrder, StagedCommit, StagedUpdate, TableMetadata, TableRequirement, TableUpdate} from '../../src/types.js'
1313
*/
1414

1515
const DEFAULT_RETRY = Object.freeze({
@@ -177,6 +177,33 @@ export async function icebergSetRef({
177177
})
178178
}
179179

180+
/**
181+
* Evolve the table schema: add `schema` as a new schema version and make it
182+
* current. Use it to add columns, rename columns, or promote types — pass the
183+
* complete evolved schema with existing field ids preserved and new columns
184+
* using ids above the table's `last-column-id`.
185+
*
186+
* Metadata-only: no data files are rewritten. Existing data files read the
187+
* new columns as `null` (or their `initial-default`), and subsequent appends
188+
* write with the evolved schema.
189+
*
190+
* @param {object} options
191+
* @param {Catalog} options.catalog
192+
* @param {string | string[]} [options.namespace] - REST catalog only.
193+
* @param {string} [options.table] - REST catalog only.
194+
* @param {string} [options.tableUrl] - File catalog only.
195+
* @param {Resolver} [options.resolver]
196+
* @param {Schema} options.schema - The complete evolved schema.
197+
* @returns {Promise<TableMetadata>}
198+
*/
199+
export async function icebergUpdateSchema({ catalog, namespace, table, tableUrl, resolver, schema }) {
200+
const ctx = await loadTable({ catalog, namespace, table, tableUrl, resolver })
201+
return await commitWithRetry({
202+
catalog, target: { namespace, table }, ctx,
203+
stage: workingCtx => icebergStageUpdateSchema({ metadata: workingCtx.metadata, schema }),
204+
})
205+
}
206+
180207
/**
181208
* Expire one or more snapshots from a table. Data files are not removed from
182209
* storage; that is a separate maintenance pass.
@@ -499,7 +526,7 @@ function requireResolver(resolver, caller) {
499526
* @param {Catalog} catalog
500527
* @param {{namespace?: string | string[], table?: string}} target
501528
* @param {{metadata: TableMetadata, metadataFileName: string | undefined, version?: number, tableUrl: string, resolver: Resolver | undefined}} ctx
502-
* @param {StagedUpdate} staged
529+
* @param {StagedCommit} staged
503530
* @returns {Promise<TableMetadata>}
504531
*/
505532
async function commitStaged(catalog, target, ctx, staged) {
@@ -552,7 +579,7 @@ async function commitStaged(catalog, target, ctx, staged) {
552579
* @param {Catalog} options.catalog
553580
* @param {{namespace?: string | string[], table?: string}} options.target
554581
* @param {{metadata: TableMetadata, metadataFileName: string | undefined, version?: number, tableUrl: string, resolver: Resolver | undefined}} options.ctx - The initial loaded ctx; refreshed on retry.
555-
* @param {(workingCtx: {metadata: TableMetadata, metadataFileName: string | undefined, version?: number, tableUrl: string, resolver: Resolver | undefined}) => Promise<StagedUpdate> | StagedUpdate} options.stage
582+
* @param {(workingCtx: {metadata: TableMetadata, metadataFileName: string | undefined, version?: number, tableUrl: string, resolver: Resolver | undefined}) => Promise<StagedCommit> | StagedCommit} options.stage
556583
* @returns {Promise<TableMetadata>}
557584
*/
558585
async function commitWithRetry({ catalog, target, ctx, stage }) {

0 commit comments

Comments
 (0)