Skip to content

Commit e3bdbc3

Browse files
committed
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 Signed-off-by: Karl Pietrzak <karl@medplum.com>
1 parent 6c3a775 commit e3bdbc3

17 files changed

Lines changed: 824 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.12.24"
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.0.0",
5972
"@vitest/coverage-v8": "4.1.9",
6073
"eslint": "9.39.4",

scripts/s3tables-smoke.mjs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { icebergRead, restCatalogListNamespaces, restCatalogListTables } from '../src/index.js'
2+
import { loadS3TablesTable, s3TablesCatalogConnectFromEnv } from '../src/aws/s3tables.js'
3+
4+
const region = process.env.AWS_REGION ?? 'us-east-1'
5+
const tableBucketArn = process.env.S3TABLES_BUCKET_ARN
6+
const namespace = process.env.S3TABLES_NAMESPACE ?? 'default'
7+
const table = process.env.S3TABLES_TABLE ?? 'orders'
8+
9+
if (!tableBucketArn) {
10+
console.error('Set S3TABLES_BUCKET_ARN=arn:aws:s3tables:...')
11+
process.exit(1)
12+
}
13+
14+
console.log(`region=${region} bucket=${tableBucketArn}`)
15+
console.log(`namespace=${namespace} table=${table}\n`)
16+
17+
const catalog = await s3TablesCatalogConnectFromEnv({ region, tableBucketArn })
18+
console.log('connected. prefix:', catalog.prefix)
19+
20+
const namespaces = await restCatalogListNamespaces(catalog)
21+
const tables = await restCatalogListTables(catalog, { namespace })
22+
console.log('namespaces:', JSON.stringify(namespaces))
23+
console.log(`tables in "${namespace}": ${tables.length}`)
24+
25+
const { metadata, tableUrl, resolver } = await loadS3TablesTable({ catalog, namespace, table })
26+
console.log('metadata.location:', metadata.location)
27+
console.log('schema fields:', metadata.schemas?.[metadata['current-schema-id'] ?? 0]?.fields?.map(f => f.name))
28+
29+
const rows = await icebergRead({ tableUrl, metadata, resolver, rowEnd: 10 })
30+
31+
console.log(`\nfirst ${rows.length} row(s) of ${namespace}.${table}:\n`)
32+
const preview = rows.map(row => {
33+
/** @type {Record<string, unknown>} */
34+
const out = {}
35+
for (const [key, value] of Object.entries(row)) {
36+
if (value instanceof Date) out[key] = value.toISOString()
37+
else if (typeof value === 'string' && value.length > 80) out[key] = `${value.slice(0, 77)}...`
38+
else out[key] = value
39+
}
40+
return out
41+
})
42+
console.table(preview)

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: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* @typedef {object} ResolvedAwsCredentials
3+
* @property {string} accessKeyId
4+
* @property {string} secretAccessKey
5+
* @property {string} [sessionToken]
6+
* @property {string} region
7+
*/
8+
9+
/**
10+
* Resolve AWS credentials from explicit keys or the default Node provider chain.
11+
*
12+
* The optional peer dependency `@aws-sdk/credential-providers` is imported
13+
* lazily and only when falling back to the chain, so passing explicit keys
14+
* keeps this module free of the AWS SDK (and browser-compatible).
15+
*
16+
* @param {object} options
17+
* @param {string} options.region
18+
* @param {string} [options.accessKeyId]
19+
* @param {string} [options.secretAccessKey]
20+
* @param {string} [options.sessionToken]
21+
* @returns {Promise<ResolvedAwsCredentials>}
22+
*/
23+
export async function resolveAwsCredentials({
24+
region, accessKeyId, secretAccessKey, sessionToken,
25+
}) {
26+
if (accessKeyId && secretAccessKey) {
27+
return { accessKeyId, secretAccessKey, sessionToken, region }
28+
}
29+
const { fromNodeProviderChain } = await import('@aws-sdk/credential-providers')
30+
const provider = fromNodeProviderChain({ clientConfig: { region } })
31+
const creds = await provider()
32+
return {
33+
accessKeyId: creds.accessKeyId,
34+
secretAccessKey: creds.secretAccessKey,
35+
sessionToken: creds.sessionToken,
36+
region,
37+
}
38+
}

src/aws/s3tables.js

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

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)