Skip to content
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ const { metadata } = await restCatalogLoadTable(ctx, { namespace: 'analytics', t
const data = await icebergRead({ tableUrl: metadata.location, metadata })
```

For Amazon S3 Tables, use the optional [`icebird/s3tables`](#amazon-s3-tables) subpath (read-only in this release).

## SQL

Icebird ships a SQL engine on top of [squirreling](https://github.com/hyparam/squirreling). `icebergQuery` runs a SQL query across one or more iceberg tables. Rows are streamed lazily. Multi-segment namespaces in the SQL `FROM` clause must be dot-separated and quoted: `FROM "analytics.orders"` resolves to namespace `analytics`, table `orders`.
Expand All @@ -131,6 +133,53 @@ const result = await icebergQuery({
const rows = await collect(result)
```

## Amazon S3 Tables

Read-only support for [Amazon S3 Tables](https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-tables-tables.html) lives in the optional `icebird/s3tables` subpath. The main `icebird` export has **no AWS dependency**, and requests are signed with icebird's own SigV4 implementation. The only optional dependency is `@aws-sdk/credential-providers`, used to resolve the default AWS credential chain — and even that is imported lazily, so passing explicit credentials needs no AWS SDK at all.

Install the peer dependency only if you rely on the default credential chain:

```bash
npm install icebird @aws-sdk/credential-providers
```

Connect with the default AWS credential chain (env vars, shared config, IAM role on Lambda/EC2):

```javascript
import { icebergRead } from 'icebird'
import { loadS3TablesTable, s3TablesCatalogConnectFromEnv } from 'icebird/s3tables'

const catalog = await s3TablesCatalogConnectFromEnv({
region: 'us-east-1',
tableBucketArn: 'arn:aws:s3tables:us-east-1:111122223333:bucket/my-bucket',
})
const { metadata, tableUrl, resolver } = await loadS3TablesTable({
catalog, namespace: 'analytics', table: 'orders',
})
const rows = await icebergRead({ tableUrl, metadata, resolver })
```

Or pass explicit credentials:

```javascript
import { icebergRead, restCatalogLoadTable } from 'icebird'
import { loadS3TablesTable, s3TablesCatalogConnect, s3TablesResolver } from 'icebird/s3tables'

const creds = { region: 'us-east-1', accessKeyId, secretAccessKey }
const catalog = await s3TablesCatalogConnect({
...creds,
tableBucketArn: 'arn:aws:s3tables:us-east-1:111122223333:bucket/my-bucket',
})

const resolver = await s3TablesResolver(creds)
const { metadata } = await restCatalogLoadTable(catalog, { namespace: 'analytics', table: 'orders' })
const rows = await icebergRead({ tableUrl: metadata.location, metadata, resolver })
```

**IAM (read-only):** grant `s3tables:GetTableBucket`, `s3tables:ListNamespaces`, `s3tables:GetNamespace`, `s3tables:ListTables`, `s3tables:GetTable`, `s3tables:GetTableMetadataLocation`, and `s3tables:GetTableData` on your table bucket and tables.

**Limitations:** S3 Tables namespaces are single-level only. `s3Lister` does not work on table-bucket warehouse paths (use the REST catalog to load metadata). Writes, Glue REST endpoint, and OAuth are not supported via this subpath yet.

## Writing

Icebird has experimental write support for Iceberg v2 (and v3 deletion vectors). All write functions take a `Catalog` and dispatch internally — the same call works against `fileCatalog({ resolver })` or a REST catalog context returned by `restCatalogConnect`.
Expand Down
13 changes: 13 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@
"types": "./types/avro/index.d.ts",
"import": "./src/avro/index.js"
},
"./s3tables": {
"types": "./types/aws/s3tables.d.ts",
"import": "./src/aws/s3tables.js"
},
"./src/*.js": {
"types": "./types/*.d.ts",
"import": "./src/*.js"
Expand All @@ -54,7 +58,16 @@
"hyparquet-writer": "0.16.1",
"squirreling": "0.15.0"
},
"peerDependencies": {
"@aws-sdk/credential-providers": "^3.0.0"
},
"peerDependenciesMeta": {
"@aws-sdk/credential-providers": {
"optional": true
}
},
"devDependencies": {
"@aws-sdk/credential-providers": "3.1079.0",
"@types/node": "26.1.1",
"@vitest/coverage-v8": "4.1.10",
"eslint": "9.39.4",
Expand Down
27 changes: 24 additions & 3 deletions src/avro/avro.read.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,26 @@ function readType(reader, type) {
}
}
return arr
} else if (typeof type === 'object' && type.logicalType) {
} else if (typeof type === 'object' && type.type === 'map') {
// Avro map: repeated blocks of (count, [string key, value]...) ending with 0.
/** @type {Record<string, any>} */
const map = {}
while (true) {
let count = readZigZag(reader)
if (count === 0) break
if (count < 0) {
count = -count
readZigZag(reader) // block size in bytes
}
for (let i = 0; i < count; i++) {
const key = readType(reader, 'string')
map[key] = readType(reader, type.values)
}
}
return map
} else if (typeof type === 'object' && type.type === 'enum') {
return type.symbols[readZigZag(reader)]
} else if (typeof type === 'object' && 'logicalType' in type && type.logicalType) {
if (type.logicalType === 'date' && type.type === 'int') {
const value = readZigZag(reader)
return new Date(value * 86400000)
Expand Down Expand Up @@ -159,9 +178,11 @@ function readType(reader, type) {
const text = new TextDecoder().decode(bytes)
reader.offset += length
return text
} else if (typeof type === 'object' && typeof type.type === 'string') {
// Boxed primitive/named type, e.g. { "type": "string" } or { "type": "long" }.
return readType(reader, type.type)
} else {
// enum, fixed, null, map
throw new Error(`unsupported type: ${type}`)
throw new Error(`unsupported type: ${JSON.stringify(type)}`)
}
}

Expand Down
3 changes: 2 additions & 1 deletion src/avro/avro.write.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ function writeType(writer, schema, value) {
// annotation (e.g. an Iceberg array with logicalType=map is still an array).
const tag = typeof s === 'string' ? s
: s.type === 'record' || s.type === 'array' || s.type === 'fixed' ? s.type
: s.logicalType
: 'logicalType' in s && s.logicalType ? s.logicalType
: s.type

if (value == null) return tag === 'null'
if (tag === 'boolean') return typeof value === 'boolean'
Expand Down
46 changes: 46 additions & 0 deletions src/aws/credentials.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* @import {ResolvedAwsCredentials} from '../../src/aws/types.js'
*/

/**
* Resolve AWS credentials from explicit keys or the default Node provider chain.
*
* The optional peer dependency `@aws-sdk/credential-providers` is imported
* lazily and only when falling back to the chain, so passing explicit keys
* keeps this module free of the AWS SDK (and browser-compatible).
*
* @param {object} options
* @param {string} options.region
* @param {string} [options.accessKeyId]
* @param {string} [options.secretAccessKey]
* @param {string} [options.sessionToken]
* @returns {Promise<ResolvedAwsCredentials>}
*/
export async function resolveAwsCredentials({
region, accessKeyId, secretAccessKey, sessionToken,
}) {
if (accessKeyId && secretAccessKey) {
return { accessKeyId, secretAccessKey, sessionToken, region }
}
let fromNodeProviderChain
try {
;({ fromNodeProviderChain } = await import('@aws-sdk/credential-providers'))
} catch (err) {
const { code } = /** @type {NodeJS.ErrnoException} */ (err)
if (code === 'ERR_MODULE_NOT_FOUND') {
throw new Error(
'Cannot find module \'@aws-sdk/credential-providers\'. '
+ 'Install the optional peer dependency: npm install @aws-sdk/credential-providers'
)
}
throw err
}
const provider = fromNodeProviderChain({ clientConfig: { region } })
const creds = await provider()
return {
accessKeyId: creds.accessKeyId,
secretAccessKey: creds.secretAccessKey,
sessionToken: creds.sessionToken,
region,
}
}
91 changes: 91 additions & 0 deletions src/aws/s3tables.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { loadTable } from '../catalog/loadTable.js'
import { restCatalogConnect } from '../catalog/rest.js'
import { s3SignedResolver } from '../s3.js'
import { createSigV4SignRequest } from '../sigv4.js'
import { resolveAwsCredentials } from './credentials.js'

/**
* @import {S3TablesCatalogContext, S3TablesConnectOptions, S3TablesCredentialsOptions} from '../../src/aws/types.js'
* @import {Resolver} from '../../src/types.js'
*/

/**
* Iceberg REST endpoint URL for Amazon S3 Tables in a region.
*
* @param {string} region
* @returns {string}
*/
export function s3TablesEndpoint(region) {
return `https://s3tables.${region}.amazonaws.com/iceberg`
}

/**
* Connect to the Amazon S3 Tables Iceberg REST catalog for a table bucket.
*
* When credentials are resolved from the default chain, the optional peer
* dependency `@aws-sdk/credential-providers` is required (pass explicit keys to
* avoid it). Catalog requests are SigV4-signed with service name `s3tables`.
* Use {@link s3TablesResolver} with the same credentials to read table data
* files (SigV4 with service name `s3`).
*
* @param {S3TablesConnectOptions} options
* @returns {Promise<S3TablesCatalogContext>}
*/
export async function s3TablesCatalogConnect({
region, tableBucketArn, accessKeyId, secretAccessKey, sessionToken,
}) {
const creds = await resolveAwsCredentials({ region, accessKeyId, secretAccessKey, sessionToken })
const ctx = await restCatalogConnect({
url: s3TablesEndpoint(region),
warehouse: tableBucketArn,
signRequest: createSigV4SignRequest({
accessKeyId: creds.accessKeyId,
secretAccessKey: creds.secretAccessKey,
sessionToken: creds.sessionToken,
region,
service: 's3tables',
}),
})
return Object.freeze({ ...ctx, s3TablesCreds: creds })
}

/**
* Connect using the default AWS credential chain (env vars, shared config, IAM role).
*
* @param {object} options
* @param {string} options.region
* @param {string} options.tableBucketArn
* @returns {Promise<S3TablesCatalogContext>}
*/
export function s3TablesCatalogConnectFromEnv({ region, tableBucketArn }) {
return s3TablesCatalogConnect({ region, tableBucketArn })
}

/**
* Build a SigV4 `Resolver` for reading S3 Tables data files (`s3://…--table-s3/…`).
*
* @param {S3TablesCredentialsOptions} options
* @returns {Promise<Resolver>}
*/
export async function s3TablesResolver({ region, accessKeyId, secretAccessKey, sessionToken }) {
const creds = await resolveAwsCredentials({ region, accessKeyId, secretAccessKey, sessionToken })
return s3SignedResolver(creds)
}

/**
* Load a table from an S3 Tables catalog context, wiring a resolver from stored
* credentials when none is supplied.
*
* @param {object} options
* @param {S3TablesCatalogContext} options.catalog
* @param {string | string[]} options.namespace
* @param {string} options.table
* @param {Resolver} [options.resolver]
* @returns {ReturnType<typeof loadTable>}
*/
export function loadS3TablesTable({ catalog, namespace, table, resolver }) {
const eff = resolver ?? (catalog.s3TablesCreds
? s3SignedResolver(catalog.s3TablesCreds)
: undefined)
return loadTable({ catalog, namespace, table, resolver: eff })
}
26 changes: 26 additions & 0 deletions src/aws/types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { RestCatalogContext } from '../types.js'

export interface ResolvedAwsCredentials {
accessKeyId: string
secretAccessKey: string
sessionToken?: string
region: string
}

export interface S3TablesCatalogContext extends RestCatalogContext {
s3TablesCreds?: ResolvedAwsCredentials
}

export interface S3TablesCredentialsOptions {
/** AWS region, e.g. `us-east-1` */
region: string
/** Omit to use the default AWS credential chain */
accessKeyId?: string
secretAccessKey?: string
sessionToken?: string
}

export interface S3TablesConnectOptions extends S3TablesCredentialsOptions {
/** e.g. `arn:aws:s3tables:us-east-1:111122223333:bucket/my-bucket` */
tableBucketArn: string
}
17 changes: 12 additions & 5 deletions src/catalog/rest.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,17 @@ import { parseIcebergJson } from '../json.js'
* @param {string} options.url - catalog base URL, with or without trailing slash
* @param {string} [options.warehouse] - optional warehouse query param sent to /v1/config
* @param {RequestInit} [options.requestInit] - fetch options (e.g. Authorization header)
* @param {(url: string, init?: RequestInit) => Promise<RequestInit>} [options.signRequest] - per-request auth hook
* @returns {Promise<RestCatalogContext>}
*/
export async function restCatalogConnect({ url, warehouse, requestInit }) {
export async function restCatalogConnect({ url, warehouse, requestInit, signRequest }) {
const base = url.replace(/\/$/, '')
const configUrl = warehouse
? `${base}/v1/config?warehouse=${encodeURIComponent(warehouse)}`
: `${base}/v1/config`
const res = await fetch(configUrl, requestInit)
let init = requestInit
if (signRequest) init = await signRequest(configUrl, init)
const res = await fetch(configUrl, init)
if (!res.ok) await throwRestError(res)
const body = parseIcebergJson(await res.text())
const defaults = body.defaults ?? {}
Expand All @@ -36,14 +39,17 @@ export async function restCatalogConnect({ url, warehouse, requestInit }) {
// config — overrides wins over defaults. Cloudflare R2 Data Catalog returns
// it via `overrides.prefix`.
const prefix = overrides.prefix ?? defaults.prefix ?? ''
return Object.freeze({
/** @type {RestCatalogContext} */
const ctx = {
type: 'rest',
url: base,
prefix: typeof prefix === 'string' ? prefix : '',
defaults,
overrides,
requestInit,
})
}
if (signRequest) ctx.signRequest = signRequest
return Object.freeze(ctx)
}

/**
Expand Down Expand Up @@ -339,7 +345,8 @@ function encodeNamespace(namespace) {
async function restFetch(ctx, path, init) {
const prefixSegment = ctx.prefix ? `${ctx.prefix.replace(/^\/|\/$/g, '')}/` : ''
const fullUrl = `${ctx.url}/v1/${prefixSegment}${path}`
const merged = mergeRequestInit(ctx.requestInit, init)
let merged = mergeRequestInit(ctx.requestInit, init)
if (ctx.signRequest) merged = await ctx.signRequest(fullUrl, merged)
const res = await fetch(fullUrl, merged)
if (!res.ok) await throwRestError(res)
return res
Expand Down
Loading