Skip to content

Commit a07d494

Browse files
feature(s3tables): read-only Amazon S3 Tables support via icebird/s3tables subpath (#29)
* Add read-only Amazon S3 Tables support via icebird/s3tables subpath Adds an optional `icebird/s3tables` subpath that connects to the S3 Tables Iceberg REST catalog and reads table data. The core `icebird` package stays AWS-free: requests are signed with icebird's own SigV4 implementation, and the only optional peer dependency is `@aws-sdk/credential-providers` (imported lazily, and only when resolving the default credential chain — explicit credentials need no AWS SDK at all). - New `icebird/s3tables` subpath export and `src/aws/*` modules. - Generic `signRequest` hook on the REST catalog (`restCatalogConnect` / `restFetch`) so signing stays cloud-agnostic. - Extract the hand-rolled SigV4 primitives into `src/sigv4.js` and reuse them for both S3 data reads (service `s3`) and the S3 Tables catalog (`s3tables`). Fixes found while reading a live S3 Tables bucket: - double-URI-encode the canonical path for non-`s3` services (S3 Tables 403'd on catalog paths containing the URL-encoded warehouse prefix) - use the regional S3 endpoint so non us-east-1 buckets don't 301 redirect - decode boxed primitives, maps, and enums in the Avro reader, which S3 Tables manifests use
1 parent ec237ef commit a07d494

18 files changed

Lines changed: 825 additions & 135 deletions

README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ const { metadata } = await restCatalogLoadTable(ctx, { namespace: 'analytics', t
116116
const data = await icebergRead({ tableUrl: metadata.location, metadata })
117117
```
118118

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

121123
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`.
@@ -131,6 +133,53 @@ const result = await icebergQuery({
131133
const rows = await collect(result)
132134
```
133135

136+
## Amazon S3 Tables
137+
138+
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.
139+
140+
Install the peer dependency only if you rely on the default credential chain:
141+
142+
```bash
143+
npm install icebird @aws-sdk/credential-providers
144+
```
145+
146+
Connect with the default AWS credential chain (env vars, shared config, IAM role on Lambda/EC2):
147+
148+
```javascript
149+
import { icebergRead } from 'icebird'
150+
import { loadS3TablesTable, s3TablesCatalogConnectFromEnv } from 'icebird/s3tables'
151+
152+
const catalog = await s3TablesCatalogConnectFromEnv({
153+
region: 'us-east-1',
154+
tableBucketArn: 'arn:aws:s3tables:us-east-1:111122223333:bucket/my-bucket',
155+
})
156+
const { metadata, tableUrl, resolver } = await loadS3TablesTable({
157+
catalog, namespace: 'analytics', table: 'orders',
158+
})
159+
const rows = await icebergRead({ tableUrl, metadata, resolver })
160+
```
161+
162+
Or pass explicit credentials:
163+
164+
```javascript
165+
import { icebergRead, restCatalogLoadTable } from 'icebird'
166+
import { loadS3TablesTable, s3TablesCatalogConnect, s3TablesResolver } from 'icebird/s3tables'
167+
168+
const creds = { region: 'us-east-1', accessKeyId, secretAccessKey }
169+
const catalog = await s3TablesCatalogConnect({
170+
...creds,
171+
tableBucketArn: 'arn:aws:s3tables:us-east-1:111122223333:bucket/my-bucket',
172+
})
173+
174+
const resolver = await s3TablesResolver(creds)
175+
const { metadata } = await restCatalogLoadTable(catalog, { namespace: 'analytics', table: 'orders' })
176+
const rows = await icebergRead({ tableUrl: metadata.location, metadata, resolver })
177+
```
178+
179+
**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.
180+
181+
**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.
182+
134183
## Writing
135184

136185
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`.

package.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@
3434
"types": "./types/avro/index.d.ts",
3535
"import": "./src/avro/index.js"
3636
},
37+
"./s3tables": {
38+
"types": "./types/aws/s3tables.d.ts",
39+
"import": "./src/aws/s3tables.js"
40+
},
3741
"./src/*.js": {
3842
"types": "./types/*.d.ts",
3943
"import": "./src/*.js"
@@ -54,7 +58,16 @@
5458
"hyparquet-writer": "0.16.1",
5559
"squirreling": "0.15.0"
5660
},
61+
"peerDependencies": {
62+
"@aws-sdk/credential-providers": "^3.0.0"
63+
},
64+
"peerDependenciesMeta": {
65+
"@aws-sdk/credential-providers": {
66+
"optional": true
67+
}
68+
},
5769
"devDependencies": {
70+
"@aws-sdk/credential-providers": "3.1079.0",
5871
"@types/node": "26.1.1",
5972
"@vitest/coverage-v8": "4.1.10",
6073
"eslint": "9.39.4",

src/avro/avro.read.js

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,26 @@ function readType(reader, type) {
9696
}
9797
}
9898
return arr
99-
} else if (typeof type === 'object' && type.logicalType) {
99+
} else if (typeof type === 'object' && type.type === 'map') {
100+
// Avro map: repeated blocks of (count, [string key, value]...) ending with 0.
101+
/** @type {Record<string, any>} */
102+
const map = {}
103+
while (true) {
104+
let count = readZigZag(reader)
105+
if (count === 0) break
106+
if (count < 0) {
107+
count = -count
108+
readZigZag(reader) // block size in bytes
109+
}
110+
for (let i = 0; i < count; i++) {
111+
const key = readType(reader, 'string')
112+
map[key] = readType(reader, type.values)
113+
}
114+
}
115+
return map
116+
} else if (typeof type === 'object' && type.type === 'enum') {
117+
return type.symbols[readZigZag(reader)]
118+
} else if (typeof type === 'object' && 'logicalType' in type && type.logicalType) {
100119
if (type.logicalType === 'date' && type.type === 'int') {
101120
const value = readZigZag(reader)
102121
return new Date(value * 86400000)
@@ -159,9 +178,11 @@ function readType(reader, type) {
159178
const text = new TextDecoder().decode(bytes)
160179
reader.offset += length
161180
return text
181+
} else if (typeof type === 'object' && typeof type.type === 'string') {
182+
// Boxed primitive/named type, e.g. { "type": "string" } or { "type": "long" }.
183+
return readType(reader, type.type)
162184
} else {
163-
// enum, fixed, null, map
164-
throw new Error(`unsupported type: ${type}`)
185+
throw new Error(`unsupported type: ${JSON.stringify(type)}`)
165186
}
166187
}
167188

src/avro/avro.write.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ function writeType(writer, schema, value) {
6767
// annotation (e.g. an Iceberg array with logicalType=map is still an array).
6868
const tag = typeof s === 'string' ? s
6969
: s.type === 'record' || s.type === 'array' || s.type === 'fixed' ? s.type
70-
: s.logicalType
70+
: 'logicalType' in s && s.logicalType ? s.logicalType
71+
: s.type
7172

7273
if (value == null) return tag === 'null'
7374
if (tag === 'boolean') return typeof value === 'boolean'

src/aws/credentials.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* @import {ResolvedAwsCredentials} from '../../src/aws/types.js'
3+
*/
4+
5+
/**
6+
* Resolve AWS credentials from explicit keys or the default Node provider chain.
7+
*
8+
* The optional peer dependency `@aws-sdk/credential-providers` is imported
9+
* lazily and only when falling back to the chain, so passing explicit keys
10+
* keeps this module free of the AWS SDK (and browser-compatible).
11+
*
12+
* @param {object} options
13+
* @param {string} options.region
14+
* @param {string} [options.accessKeyId]
15+
* @param {string} [options.secretAccessKey]
16+
* @param {string} [options.sessionToken]
17+
* @returns {Promise<ResolvedAwsCredentials>}
18+
*/
19+
export async function resolveAwsCredentials({
20+
region, accessKeyId, secretAccessKey, sessionToken,
21+
}) {
22+
if (accessKeyId && secretAccessKey) {
23+
return { accessKeyId, secretAccessKey, sessionToken, region }
24+
}
25+
let fromNodeProviderChain
26+
try {
27+
;({ fromNodeProviderChain } = await import('@aws-sdk/credential-providers'))
28+
} catch (err) {
29+
const { code } = /** @type {NodeJS.ErrnoException} */ (err)
30+
if (code === 'ERR_MODULE_NOT_FOUND') {
31+
throw new Error(
32+
'Cannot find module \'@aws-sdk/credential-providers\'. '
33+
+ 'Install the optional peer dependency: npm install @aws-sdk/credential-providers'
34+
)
35+
}
36+
throw err
37+
}
38+
const provider = fromNodeProviderChain({ clientConfig: { region } })
39+
const creds = await provider()
40+
return {
41+
accessKeyId: creds.accessKeyId,
42+
secretAccessKey: creds.secretAccessKey,
43+
sessionToken: creds.sessionToken,
44+
region,
45+
}
46+
}

src/aws/s3tables.js

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { loadTable } from '../catalog/loadTable.js'
2+
import { restCatalogConnect } from '../catalog/rest.js'
3+
import { s3SignedResolver } from '../s3.js'
4+
import { createSigV4SignRequest } from '../sigv4.js'
5+
import { resolveAwsCredentials } from './credentials.js'
6+
7+
/**
8+
* @import {S3TablesCatalogContext, S3TablesConnectOptions, S3TablesCredentialsOptions} from '../../src/aws/types.js'
9+
* @import {Resolver} from '../../src/types.js'
10+
*/
11+
12+
/**
13+
* Iceberg REST endpoint URL for Amazon S3 Tables in a region.
14+
*
15+
* @param {string} region
16+
* @returns {string}
17+
*/
18+
export function s3TablesEndpoint(region) {
19+
return `https://s3tables.${region}.amazonaws.com/iceberg`
20+
}
21+
22+
/**
23+
* Connect to the Amazon S3 Tables Iceberg REST catalog for a table bucket.
24+
*
25+
* When credentials are resolved from the default chain, the optional peer
26+
* dependency `@aws-sdk/credential-providers` is required (pass explicit keys to
27+
* avoid it). Catalog requests are SigV4-signed with service name `s3tables`.
28+
* Use {@link s3TablesResolver} with the same credentials to read table data
29+
* files (SigV4 with service name `s3`).
30+
*
31+
* @param {S3TablesConnectOptions} options
32+
* @returns {Promise<S3TablesCatalogContext>}
33+
*/
34+
export async function s3TablesCatalogConnect({
35+
region, tableBucketArn, accessKeyId, secretAccessKey, sessionToken,
36+
}) {
37+
const creds = await resolveAwsCredentials({ region, accessKeyId, secretAccessKey, sessionToken })
38+
const ctx = await restCatalogConnect({
39+
url: s3TablesEndpoint(region),
40+
warehouse: tableBucketArn,
41+
signRequest: createSigV4SignRequest({
42+
accessKeyId: creds.accessKeyId,
43+
secretAccessKey: creds.secretAccessKey,
44+
sessionToken: creds.sessionToken,
45+
region,
46+
service: 's3tables',
47+
}),
48+
})
49+
return Object.freeze({ ...ctx, s3TablesCreds: creds })
50+
}
51+
52+
/**
53+
* Connect using the default AWS credential chain (env vars, shared config, IAM role).
54+
*
55+
* @param {object} options
56+
* @param {string} options.region
57+
* @param {string} options.tableBucketArn
58+
* @returns {Promise<S3TablesCatalogContext>}
59+
*/
60+
export function s3TablesCatalogConnectFromEnv({ region, tableBucketArn }) {
61+
return s3TablesCatalogConnect({ region, tableBucketArn })
62+
}
63+
64+
/**
65+
* Build a SigV4 `Resolver` for reading S3 Tables data files (`s3://…--table-s3/…`).
66+
*
67+
* @param {S3TablesCredentialsOptions} options
68+
* @returns {Promise<Resolver>}
69+
*/
70+
export async function s3TablesResolver({ region, accessKeyId, secretAccessKey, sessionToken }) {
71+
const creds = await resolveAwsCredentials({ region, accessKeyId, secretAccessKey, sessionToken })
72+
return s3SignedResolver(creds)
73+
}
74+
75+
/**
76+
* Load a table from an S3 Tables catalog context, wiring a resolver from stored
77+
* credentials when none is supplied.
78+
*
79+
* @param {object} options
80+
* @param {S3TablesCatalogContext} options.catalog
81+
* @param {string | string[]} options.namespace
82+
* @param {string} options.table
83+
* @param {Resolver} [options.resolver]
84+
* @returns {ReturnType<typeof loadTable>}
85+
*/
86+
export function loadS3TablesTable({ catalog, namespace, table, resolver }) {
87+
const eff = resolver ?? (catalog.s3TablesCreds
88+
? s3SignedResolver(catalog.s3TablesCreds)
89+
: undefined)
90+
return loadTable({ catalog, namespace, table, resolver: eff })
91+
}

src/aws/types.d.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { RestCatalogContext } from '../types.js'
2+
3+
export interface ResolvedAwsCredentials {
4+
accessKeyId: string
5+
secretAccessKey: string
6+
sessionToken?: string
7+
region: string
8+
}
9+
10+
export interface S3TablesCatalogContext extends RestCatalogContext {
11+
s3TablesCreds?: ResolvedAwsCredentials
12+
}
13+
14+
export interface S3TablesCredentialsOptions {
15+
/** AWS region, e.g. `us-east-1` */
16+
region: string
17+
/** Omit to use the default AWS credential chain */
18+
accessKeyId?: string
19+
secretAccessKey?: string
20+
sessionToken?: string
21+
}
22+
23+
export interface S3TablesConnectOptions extends S3TablesCredentialsOptions {
24+
/** e.g. `arn:aws:s3tables:us-east-1:111122223333:bucket/my-bucket` */
25+
tableBucketArn: string
26+
}

src/catalog/rest.js

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,17 @@ import { parseIcebergJson } from '../json.js'
2020
* @param {string} options.url - catalog base URL, with or without trailing slash
2121
* @param {string} [options.warehouse] - optional warehouse query param sent to /v1/config
2222
* @param {RequestInit} [options.requestInit] - fetch options (e.g. Authorization header)
23+
* @param {(url: string, init?: RequestInit) => Promise<RequestInit>} [options.signRequest] - per-request auth hook
2324
* @returns {Promise<RestCatalogContext>}
2425
*/
25-
export async function restCatalogConnect({ url, warehouse, requestInit }) {
26+
export async function restCatalogConnect({ url, warehouse, requestInit, signRequest }) {
2627
const base = url.replace(/\/$/, '')
2728
const configUrl = warehouse
2829
? `${base}/v1/config?warehouse=${encodeURIComponent(warehouse)}`
2930
: `${base}/v1/config`
30-
const res = await fetch(configUrl, requestInit)
31+
let init = requestInit
32+
if (signRequest) init = await signRequest(configUrl, init)
33+
const res = await fetch(configUrl, init)
3134
if (!res.ok) await throwRestError(res)
3235
const body = parseIcebergJson(await res.text())
3336
const defaults = body.defaults ?? {}
@@ -36,14 +39,17 @@ export async function restCatalogConnect({ url, warehouse, requestInit }) {
3639
// config — overrides wins over defaults. Cloudflare R2 Data Catalog returns
3740
// it via `overrides.prefix`.
3841
const prefix = overrides.prefix ?? defaults.prefix ?? ''
39-
return Object.freeze({
42+
/** @type {RestCatalogContext} */
43+
const ctx = {
4044
type: 'rest',
4145
url: base,
4246
prefix: typeof prefix === 'string' ? prefix : '',
4347
defaults,
4448
overrides,
4549
requestInit,
46-
})
50+
}
51+
if (signRequest) ctx.signRequest = signRequest
52+
return Object.freeze(ctx)
4753
}
4854

4955
/**
@@ -339,7 +345,8 @@ function encodeNamespace(namespace) {
339345
async function restFetch(ctx, path, init) {
340346
const prefixSegment = ctx.prefix ? `${ctx.prefix.replace(/^\/|\/$/g, '')}/` : ''
341347
const fullUrl = `${ctx.url}/v1/${prefixSegment}${path}`
342-
const merged = mergeRequestInit(ctx.requestInit, init)
348+
let merged = mergeRequestInit(ctx.requestInit, init)
349+
if (ctx.signRequest) merged = await ctx.signRequest(fullUrl, merged)
343350
const res = await fetch(fullUrl, merged)
344351
if (!res.ok) await throwRestError(res)
345352
return res

0 commit comments

Comments
 (0)