@gscdump/lakehouse 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,236 @@
1
+ import { Storage } from "./libs/unstorage.mjs";
2
+ import { IcebergColumnType, IcebergPartitionField, IcebergS3Config } from "./schema.mjs";
3
+ import { cachingResolver, icebergAppend, restCatalogConnect } from "./libs/icebird.mjs";
4
+ /** Injected catalog cache: an unstorage `Storage` plus an optional defer hook. */
5
+ interface CatalogCache {
6
+ /** unstorage storage instance — the driver is the caller's choice. */
7
+ storage: Storage;
8
+ /**
9
+ * Optional hook to run a cache WRITE off the response critical path, e.g.
10
+ * Cloudflare's `ctx.waitUntil`. When omitted the writer awaits the put
11
+ * inline so it is never cut off when the response returns.
12
+ */
13
+ defer?: (write: Promise<unknown>) => void;
14
+ }
15
+ /**
16
+ * Read a cached value. Returns `undefined` on a miss, an expired entry, a
17
+ * malformed box, or any driver error (the cache is best-effort: a read failure
18
+ * degrades to a fresh load, never to an error).
19
+ */
20
+ declare function cacheGet<T>(cache: CatalogCache, key: string, now: number): Promise<T | undefined>;
21
+ /**
22
+ * Write a cached value with an embedded expiry and a forwarded driver TTL.
23
+ *
24
+ * Returns the write promise. With a `defer` hook the write is handed to the
25
+ * hook and a resolved promise is returned (the response is not blocked on it);
26
+ * without one the write promise is returned for the caller to await, so a
27
+ * fire-and-forget put is never silently dropped. Driver errors are swallowed —
28
+ * a failed cache write must not fail the read.
29
+ */
30
+ declare function cachePut<T>(cache: CatalogCache, key: string, value: T, ttlMs: number, now: number): Promise<void>;
31
+ /** Minimal shape of an icebird manifest-list `partitions` field-summary. */
32
+ interface IcebergFieldSummary {
33
+ contains_null: boolean;
34
+ contains_nan?: boolean | null;
35
+ lower_bound?: Uint8Array | null;
36
+ upper_bound?: Uint8Array | null;
37
+ }
38
+ /** Predicate handed to icebird's patched `icebergManifests({ partitionFilter })`. */
39
+ type ManifestPartitionFilter = (partitions: IcebergFieldSummary[] | undefined) => boolean;
40
+ /**
41
+ * One identity/dims value to prune an `identity`-transform partition field by.
42
+ *
43
+ * `'int32'`-encoded fields are deliberately NOT pruned here (mirrors the
44
+ * original engine behavior): an INT column's bound bytes are a 4-byte int, but
45
+ * per-team catalogs are single-tenant, so identity pruning on them saves ~nothing
46
+ * — the per-file partition check in the resolver remains the authoritative
47
+ * correctness filter. Only `'string'`-encoded identity fields are pruned by
48
+ * lexicographic UTF-8 bound comparison (the truncated-string-stats case R2 SQL
49
+ * needs the workaround for).
50
+ */
51
+ interface PartitionValueMatch {
52
+ /** Partition field name as declared in the partition spec (e.g. `'site_id'`). */
53
+ field: string;
54
+ value: string | number;
55
+ encoding: 'string' | 'int32';
56
+ }
57
+ /**
58
+ * Build the `partitionFilter` predicate for a slice of `matches` (identity/dims
59
+ * value equality) plus an optional `wantedMonths` set (for a `month`-transform
60
+ * field in `partitionSpec`). Returns `false` to skip a manifest, `true` to keep
61
+ * it. Keep-all when a manifest carries no `partitions` summaries.
62
+ */
63
+ declare function buildManifestPartitionFilter(partitionSpec: readonly IcebergPartitionField[], matches: readonly PartitionValueMatch[], wantedMonths?: ReadonlySet<number>): ManifestPartitionFilter;
64
+ /** icebird's lowercase Iceberg primitive types (subset we use). */
65
+ type IcebergPrimitiveType = 'string' | 'int' | 'long' | 'double' | 'date' | 'boolean';
66
+ /** A field in an icebird table `Schema`. */
67
+ interface IcebergSchemaField {
68
+ id: number;
69
+ name: string;
70
+ required: boolean;
71
+ type: IcebergPrimitiveType;
72
+ }
73
+ /** An icebird table `Schema` (Iceberg `struct`). */
74
+ interface IcebergSchema {
75
+ 'type': 'struct';
76
+ 'schema-id': number;
77
+ 'fields': IcebergSchemaField[];
78
+ }
79
+ /** A field in an icebird `PartitionSpec`. */
80
+ interface IcebergPartitionSpecField {
81
+ 'source-id': number;
82
+ 'field-id': number;
83
+ 'name': string;
84
+ 'transform': 'identity' | 'month';
85
+ }
86
+ /** An icebird `PartitionSpec`. */
87
+ interface IcebergPartitionSpec {
88
+ 'spec-id': number;
89
+ 'fields': IcebergPartitionSpecField[];
90
+ }
91
+ /** A field in an icebird `SortOrder`. */
92
+ interface IcebergSortOrderField {
93
+ 'source-id': number;
94
+ 'transform': 'identity';
95
+ 'direction': 'asc' | 'desc';
96
+ 'null-order': 'nulls-first' | 'nulls-last';
97
+ }
98
+ /** An icebird `SortOrder` (Iceberg write-order). */
99
+ interface IcebergSortOrder {
100
+ 'order-id': number;
101
+ 'fields': IcebergSortOrderField[];
102
+ }
103
+ /** Everything needed to talk to the R2 Data Catalog. */
104
+ interface IcebergCatalogConfig {
105
+ /** REST catalog URI, e.g. `https://catalog.cloudflarestorage.com/<acct>/<warehouse>`. */
106
+ catalogUri: string;
107
+ /** Warehouse identifier, e.g. `<acct>_<bucket>`. */
108
+ warehouse: string;
109
+ /** Catalog namespace the dataset's table lives under (e.g. `gsc`, `crawl`). */
110
+ namespace: string;
111
+ /** Bearer token for the REST catalog. */
112
+ catalogToken: string;
113
+ /** R2 S3 credentials for the warehouse objects. */
114
+ s3: IcebergS3Config;
115
+ }
116
+ /** The connected catalog context + a signed S3 resolver — the icebird call inputs. */
117
+ interface IcebergConnection {
118
+ /** icebird REST catalog context, passed as `{ catalog }` to icebird write fns. */
119
+ catalog: Awaited<ReturnType<typeof restCatalogConnect>>;
120
+ /** icebird S3 resolver (caching-wrapped), passed as `{ resolver }` to icebird fns. */
121
+ resolver: ReturnType<typeof cachingResolver>;
122
+ /** The namespace the dataset's table lives under. */
123
+ namespace: string;
124
+ }
125
+ declare const ICEBERG_TYPE_MAP: Record<IcebergColumnType, IcebergPrimitiveType>;
126
+ /** Options for {@link connectIcebergCatalog}. */
127
+ interface ConnectIcebergOptions {
128
+ /**
129
+ * Optional cross-isolate cache (any unstorage driver). When supplied, the
130
+ * `/v1/config` REST probe is served from cache on a warm catalog.
131
+ */
132
+ cache?: CatalogCache;
133
+ /** Injectable clock for the cache TTL. Defaults to `Date.now`. */
134
+ clock?: () => number;
135
+ }
136
+ /**
137
+ * Connect to the R2 Data Catalog: a REST catalog context + a signed S3
138
+ * resolver. Runs in Node and in `workerd` — SigV4 is Web Crypto, I/O is
139
+ * `fetch`, no node builtins.
140
+ */
141
+ declare function connectIcebergCatalog(config: IcebergCatalogConfig, opts?: ConnectIcebergOptions): Promise<IcebergConnection>;
142
+ /**
143
+ * Ensure the catalog namespace exists. Idempotent — an "already exists"
144
+ * response from the REST catalog is swallowed.
145
+ */
146
+ declare function ensureIcebergNamespace(conn: IcebergConnection): Promise<void>;
147
+ /**
148
+ * List the table names currently in the catalog namespace.
149
+ *
150
+ * A genuinely-empty namespace resolves to `[]`. A LIST *failure* (catalog
151
+ * unreachable, 401/403, 5xx) propagates rather than being masked as an empty
152
+ * list — callers must be able to tell "no tables" from "couldn't ask".
153
+ */
154
+ declare function listIcebergTables(conn: IcebergConnection): Promise<string[]>;
155
+ /** Outcome of a single table create/drop op. */
156
+ interface IcebergTableOpResult {
157
+ table: string;
158
+ ok: boolean;
159
+ error?: string;
160
+ }
161
+ /**
162
+ * Drop tables from the catalog namespace, purging their data objects.
163
+ * Defaults to every table currently in the namespace.
164
+ */
165
+ declare function dropIcebergTables(conn: IcebergConnection, tables?: readonly string[]): Promise<IcebergTableOpResult[]>;
166
+ /** Tunable retry policy for {@link icebergAppendRetrying}. */
167
+ interface CommitRetryOptions {
168
+ /** Total attempts, including the first. Default 6. */
169
+ maxAttempts?: number;
170
+ /** Base (ms) for the exponential back-off ceiling. Default 1000. */
171
+ baseDelayMs?: number;
172
+ /** Hard cap (ms) on the back-off ceiling. Default 20_000. */
173
+ maxDelayMs?: number;
174
+ /** Injectable sleep — tests pass a synchronous no-op. */
175
+ sleep?: (ms: number) => Promise<void>;
176
+ /** Injectable RNG for the jitter — tests pass a deterministic value. */
177
+ random?: () => number;
178
+ /**
179
+ * Idempotency token stamped into the appended snapshot's summary
180
+ * (`lakehouse.append-id`) and matched by the landed-check. When omitted it is
181
+ * DERIVED from the records' content, making the token STABLE across
182
+ * PROCESSES, not just within one call's retry loop.
183
+ */
184
+ appendId?: string;
185
+ }
186
+ /**
187
+ * True when `err` is an R2 Data Catalog commit rate-limit response
188
+ * (`429 too many commits to this table`).
189
+ */
190
+ declare function isCommitRateLimited(err: unknown): boolean;
191
+ /**
192
+ * `icebergAppend` wrapped with retry on R2 Data Catalog 429 commit
193
+ * rate-limits, using full-jitter exponential back-off, plus a landed-check
194
+ * idempotency guard so a 429 whose commit actually landed is never re-applied
195
+ * (see `deriveAppendId`/`appendAlreadyLanded`).
196
+ */
197
+ declare function icebergAppendRetrying(args: Parameters<typeof icebergAppend>[0], options?: CommitRetryOptions): Promise<void>;
198
+ /** A data file in the current snapshot's manifest, scoped to one partition. */
199
+ interface IcebergListedDataFile {
200
+ filePath: string;
201
+ objectKey: string;
202
+ bytes: number;
203
+ rowCount: number;
204
+ }
205
+ /** Minimal profiler contract — start a named span, get back an end callback. */
206
+ interface QueryProfiler {
207
+ start: (name: string) => ((meta?: Record<string, unknown>) => void) | undefined;
208
+ }
209
+ interface ResolveIcebergDataFilesOptions {
210
+ namespace: string;
211
+ table: string;
212
+ /** Partition spec for this table — used both for the manifest prune and the per-file check. */
213
+ partitionSpec: readonly {
214
+ sourceColumn: string;
215
+ transform: 'identity' | 'month';
216
+ name: string;
217
+ }[];
218
+ /** Identity/dims values to match. */
219
+ matches: readonly PartitionValueMatch[];
220
+ /** Inclusive date range. Every month touched by `[start, end]` is scanned. */
221
+ range: {
222
+ start: string;
223
+ end: string;
224
+ };
225
+ cache?: CatalogCache;
226
+ clock?: () => number;
227
+ profiler?: QueryProfiler;
228
+ }
229
+ /**
230
+ * List the parquet data files in the current snapshot of `table`, filtered to
231
+ * one partition slice (`matches` + `range`). Generic over the partition spec —
232
+ * the `IcebergDataset.resolveDataFiles` method is a thin wrapper that supplies
233
+ * the def's own spec + identity/dims values.
234
+ */
235
+ declare function resolveIcebergDataFiles(conn: IcebergConnection, opts: ResolveIcebergDataFilesOptions): Promise<IcebergListedDataFile[]>;
236
+ export { CatalogCache, CommitRetryOptions, ConnectIcebergOptions, ICEBERG_TYPE_MAP, IcebergCatalogConfig, IcebergConnection, IcebergFieldSummary, IcebergListedDataFile, IcebergPartitionSpec, IcebergPartitionSpecField, IcebergPrimitiveType, IcebergSchema, IcebergSchemaField, IcebergSortOrder, IcebergSortOrderField, IcebergTableOpResult, ManifestPartitionFilter, PartitionValueMatch, QueryProfiler, ResolveIcebergDataFilesOptions, buildManifestPartitionFilter, cacheGet, cachePut, connectIcebergCatalog, dropIcebergTables, ensureIcebergNamespace, icebergAppendRetrying, isCommitRateLimited, listIcebergTables, resolveIcebergDataFiles };
@@ -0,0 +1,331 @@
1
+ import { cachingResolver, icebergAppend, icebergDropTable, icebergManifests, restCatalogConnect, restCatalogCreateNamespace, restCatalogListTables, restCatalogLoadTable, s3SignedResolver } from "./libs/icebird.mjs";
2
+ async function cacheGet(cache, key, now) {
3
+ const boxed = await cache.storage.getItem(key).catch(() => null);
4
+ if (!boxed || typeof boxed.exp !== "number" || boxed.exp <= now) return void 0;
5
+ return boxed.v;
6
+ }
7
+ function cachePut(cache, key, value, ttlMs, now) {
8
+ const boxed = {
9
+ v: value,
10
+ exp: now + ttlMs
11
+ };
12
+ const write = cache.storage.setItem(key, boxed, { ttl: Math.ceil(ttlMs / 1e3) }).catch(() => {});
13
+ if (cache.defer) {
14
+ cache.defer(write);
15
+ return Promise.resolve();
16
+ }
17
+ return write;
18
+ }
19
+ function toUint8(bytes) {
20
+ if (bytes == null) return null;
21
+ return bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
22
+ }
23
+ function decodeString(bytes) {
24
+ const u = toUint8(bytes);
25
+ return u == null ? null : new TextDecoder().decode(u);
26
+ }
27
+ function decodeMonthInt(bytes) {
28
+ const u = toUint8(bytes);
29
+ if (u == null) return null;
30
+ return new DataView(u.buffer, u.byteOffset, u.byteLength).getInt32(0, true);
31
+ }
32
+ function buildManifestPartitionFilter(partitionSpec, matches, wantedMonths) {
33
+ const fieldIndex = (name) => partitionSpec.findIndex((f) => f.name === name || f.sourceColumn === name);
34
+ const monthFieldIndex = partitionSpec.findIndex((f) => f.transform === "month");
35
+ return (partitions) => {
36
+ if (!partitions || partitions.length === 0) return true;
37
+ for (const match of matches) {
38
+ if (match.encoding !== "string") continue;
39
+ const idx = fieldIndex(match.field);
40
+ if (idx < 0) continue;
41
+ const summary = partitions[idx];
42
+ if (!summary || summary.lower_bound == null && summary.upper_bound == null) continue;
43
+ const lo = decodeString(summary.lower_bound);
44
+ const hi = decodeString(summary.upper_bound);
45
+ const wantStr = String(match.value);
46
+ if (lo != null && hi != null && (wantStr < lo || wantStr > hi)) return false;
47
+ }
48
+ if (wantedMonths && wantedMonths.size > 0 && monthFieldIndex >= 0) {
49
+ const monthSummary = partitions[monthFieldIndex];
50
+ if (monthSummary && (monthSummary.lower_bound != null || monthSummary.upper_bound != null)) {
51
+ const lo = decodeMonthInt(monthSummary.lower_bound);
52
+ const hi = decodeMonthInt(monthSummary.upper_bound);
53
+ if (lo != null && hi != null) {
54
+ let anyInRange = false;
55
+ for (const wm of wantedMonths) if (wm >= lo && wm <= hi) {
56
+ anyInRange = true;
57
+ break;
58
+ }
59
+ if (!anyInRange) return false;
60
+ }
61
+ }
62
+ }
63
+ return true;
64
+ };
65
+ }
66
+ const ICEBERG_TYPE_MAP = {
67
+ STRING: "string",
68
+ INT: "int",
69
+ LONG: "long",
70
+ DOUBLE: "double",
71
+ DATE: "date",
72
+ BOOLEAN: "boolean"
73
+ };
74
+ const CATALOG_CONFIG_TTL_MS = 3600 * 1e3;
75
+ function catalogConfigKey(config) {
76
+ return `lakehouse-catalog-cfg\0${config.catalogUri}\0${config.warehouse}`;
77
+ }
78
+ async function connectIcebergCatalog(config, opts = {}) {
79
+ const now = (opts.clock ?? Date.now)();
80
+ const requestInit = { headers: { Authorization: `Bearer ${config.catalogToken}` } };
81
+ let catalog;
82
+ if (opts.cache) {
83
+ const cached = await cacheGet(opts.cache, catalogConfigKey(config), now);
84
+ if (cached) catalog = Object.freeze({
85
+ type: "rest",
86
+ url: cached.url,
87
+ prefix: cached.prefix,
88
+ defaults: cached.defaults,
89
+ overrides: cached.overrides,
90
+ requestInit
91
+ });
92
+ }
93
+ if (!catalog) {
94
+ catalog = await restCatalogConnect({
95
+ url: config.catalogUri,
96
+ warehouse: config.warehouse,
97
+ requestInit
98
+ });
99
+ if (opts.cache) {
100
+ const toCache = {
101
+ url: catalog.url,
102
+ prefix: catalog.prefix,
103
+ defaults: catalog.defaults,
104
+ overrides: catalog.overrides
105
+ };
106
+ await cachePut(opts.cache, catalogConfigKey(config), toCache, CATALOG_CONFIG_TTL_MS, now);
107
+ }
108
+ }
109
+ const resolver = cachingResolver(s3SignedResolver({
110
+ accessKeyId: config.s3.accessKeyId,
111
+ secretAccessKey: config.s3.secretAccessKey,
112
+ region: config.s3.region ?? "auto",
113
+ endpoint: config.s3.endpoint,
114
+ pathStyle: true
115
+ }));
116
+ return {
117
+ catalog,
118
+ resolver,
119
+ namespace: config.namespace
120
+ };
121
+ }
122
+ async function ensureIcebergNamespace(conn) {
123
+ await restCatalogCreateNamespace(conn.catalog, { namespace: conn.namespace }).catch(() => {});
124
+ }
125
+ async function listIcebergTables(conn) {
126
+ return (await restCatalogListTables(conn.catalog, { namespace: conn.namespace })).map((t) => t.name).sort();
127
+ }
128
+ async function dropIcebergTables(conn, tables) {
129
+ const targets = tables ?? (await restCatalogListTables(conn.catalog, { namespace: conn.namespace })).map((t) => t.name);
130
+ const results = [];
131
+ for (const table of targets) await icebergDropTable({
132
+ catalog: conn.catalog,
133
+ namespace: conn.namespace,
134
+ table,
135
+ purgeRequested: true
136
+ }).then(() => results.push({
137
+ table,
138
+ ok: true
139
+ }), (e) => results.push({
140
+ table,
141
+ ok: false,
142
+ error: e instanceof Error ? e.message : String(e)
143
+ }));
144
+ return results;
145
+ }
146
+ const APPEND_ID_SUMMARY_KEY = "lakehouse.append-id";
147
+ const APPEND_LANDED_SCAN_DEPTH = 25;
148
+ function isCommitRateLimited(err) {
149
+ if (err && typeof err === "object" && err.status === 429) return true;
150
+ const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
151
+ return msg.includes("429") || msg.includes("too many commits") || msg.includes("rate limit");
152
+ }
153
+ function defaultCommitSleep(ms) {
154
+ return new Promise((resolve) => setTimeout(resolve, ms));
155
+ }
156
+ async function icebergAppendRetrying(args, options = {}) {
157
+ const maxAttempts = options.maxAttempts ?? 6;
158
+ const baseDelayMs = options.baseDelayMs ?? 1e3;
159
+ const maxDelayMs = options.maxDelayMs ?? 2e4;
160
+ const sleep = options.sleep ?? defaultCommitSleep;
161
+ const random = options.random ?? Math.random;
162
+ const appendId = options.appendId ?? await deriveAppendId(args);
163
+ const stampedArgs = {
164
+ ...args,
165
+ snapshotProperties: {
166
+ ...args.snapshotProperties,
167
+ [APPEND_ID_SUMMARY_KEY]: appendId
168
+ }
169
+ };
170
+ if (await appendAlreadyLanded(args, appendId).catch(() => false)) return;
171
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
172
+ const err = await icebergAppend(stampedArgs).then(() => void 0, (e) => e);
173
+ if (err === void 0) return;
174
+ if (await appendAlreadyLanded(args, appendId).catch(() => false)) return;
175
+ if (!isCommitRateLimited(err) || attempt === maxAttempts - 1) throw err;
176
+ const ceiling = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
177
+ await sleep(Math.floor(random() * ceiling));
178
+ }
179
+ }
180
+ async function deriveAppendId(args) {
181
+ const records = args.records ?? [];
182
+ if (records.length === 0) return globalThis.crypto.randomUUID();
183
+ const rowSig = (r) => Object.keys(r).sort().map((k) => `${k}=${String(r[k])}`).join("");
184
+ const body = records.map(rowSig).sort().join("");
185
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(body));
186
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
187
+ }
188
+ async function appendAlreadyLanded(args, appendId) {
189
+ const a = args;
190
+ if (a.catalog?.type !== "rest" || a.namespace == null || a.table == null) return false;
191
+ const { metadata } = await restCatalogLoadTable(a.catalog, {
192
+ namespace: a.namespace,
193
+ table: a.table
194
+ });
195
+ const snapshots = metadata.snapshots ?? [];
196
+ const from = Math.max(0, snapshots.length - APPEND_LANDED_SCAN_DEPTH);
197
+ for (let i = snapshots.length - 1; i >= from; i--) if (snapshots[i]?.summary?.[APPEND_ID_SUMMARY_KEY] === appendId) return true;
198
+ return false;
199
+ }
200
+ const SNAPSHOT_REF_TTL_MS = 1800 * 1e3;
201
+ const RESOLVED_FILES_TTL_MS = 1440 * 60 * 1e3;
202
+ const METADATA_TTL_MS = 1440 * 60 * 1e3;
203
+ const MAX_CACHED_METADATA_BYTES = 2 * 1024 * 1024;
204
+ function snapshotRefKey(namespace, table) {
205
+ return `lh-snapref\0${namespace}\0${table}`;
206
+ }
207
+ function metadataRefKey(namespace, table, snapshotId) {
208
+ return `lh-snapmeta\0${namespace}\0${table}\0${snapshotId}`;
209
+ }
210
+ function resolvedFilesKey(namespace, table, snapshotId, matches, wantedMonths) {
211
+ return `lh-files\0${namespace}\0${table}\0${snapshotId}\0${[...matches].map((m) => `${m.field}=${m.value}`).sort().join(",")}\0${[...wantedMonths].sort((a, b) => a - b).join(",")}`;
212
+ }
213
+ function monthsInRange(range) {
214
+ const [sy, sm] = range.start.split("-").map(Number);
215
+ const [ey, em] = range.end.split("-").map(Number);
216
+ const out = [];
217
+ let y = sy;
218
+ let m = sm;
219
+ while (y < ey || y === ey && m <= em) {
220
+ out.push(`${y}-${String(m).padStart(2, "0")}`);
221
+ m++;
222
+ if (m > 12) {
223
+ m = 1;
224
+ y++;
225
+ }
226
+ }
227
+ return out;
228
+ }
229
+ function monthsSinceEpoch(ym) {
230
+ const [y, m] = ym.split("-").map(Number);
231
+ return (y - 1970) * 12 + (m - 1);
232
+ }
233
+ function stripBucket(filePath) {
234
+ if (!filePath.startsWith("s3://")) return filePath;
235
+ const rest = filePath.slice(5);
236
+ const slash = rest.indexOf("/");
237
+ return slash >= 0 ? rest.slice(slash + 1) : rest;
238
+ }
239
+ async function loadSnapshotId(conn, namespace, table, cache, now) {
240
+ if (cache) {
241
+ const cached = await cacheGet(cache, snapshotRefKey(namespace, table), now);
242
+ if (cached !== void 0) return {
243
+ snapshotId: cached,
244
+ metadata: null
245
+ };
246
+ }
247
+ const { metadata } = await restCatalogLoadTable(conn.catalog, {
248
+ namespace,
249
+ table
250
+ });
251
+ const raw = metadata["current-snapshot-id"];
252
+ const snapshotId = raw == null ? null : String(raw);
253
+ if (cache) {
254
+ await cachePut(cache, snapshotRefKey(namespace, table), snapshotId, SNAPSHOT_REF_TTL_MS, now);
255
+ if (snapshotId != null) {
256
+ if (JSON.stringify(metadata).length <= MAX_CACHED_METADATA_BYTES) await cachePut(cache, metadataRefKey(namespace, table, snapshotId), metadata, METADATA_TTL_MS, now);
257
+ }
258
+ }
259
+ return {
260
+ snapshotId,
261
+ metadata
262
+ };
263
+ }
264
+ async function resolveIcebergDataFiles(conn, opts) {
265
+ const { namespace, table } = opts;
266
+ const profiler = opts.profiler;
267
+ const now = (opts.clock ?? Date.now)();
268
+ const wantedMonths = new Set(monthsInRange(opts.range).map(monthsSinceEpoch));
269
+ const endSnapshot = profiler?.start("iceberg.snapshot");
270
+ let { snapshotId, metadata } = await loadSnapshotId(conn, namespace, table, opts.cache, now);
271
+ endSnapshot?.({ cached: metadata == null && snapshotId != null });
272
+ if (snapshotId == null) return [];
273
+ const filesKey = resolvedFilesKey(namespace, table, snapshotId, opts.matches, wantedMonths);
274
+ if (opts.cache) {
275
+ const endCache = profiler?.start("iceberg.cache");
276
+ const cached = await cacheGet(opts.cache, filesKey, now);
277
+ endCache?.({ hit: cached !== void 0 });
278
+ if (cached !== void 0) return cached;
279
+ }
280
+ if (!metadata && opts.cache) {
281
+ const cachedMeta = await cacheGet(opts.cache, metadataRefKey(namespace, table, snapshotId), now);
282
+ if (cachedMeta != null) metadata = cachedMeta;
283
+ }
284
+ if (!metadata) {
285
+ const reloaded = await loadSnapshotId(conn, namespace, table, void 0, now);
286
+ snapshotId = reloaded.snapshotId;
287
+ metadata = reloaded.metadata;
288
+ if (snapshotId == null || !metadata) return [];
289
+ }
290
+ const endWalk = profiler?.start("iceberg.walk");
291
+ const partitionFilter = buildManifestPartitionFilter(opts.partitionSpec, opts.matches, wantedMonths);
292
+ const manifests = await icebergManifests({
293
+ metadata,
294
+ resolver: conn.resolver,
295
+ partitionFilter
296
+ });
297
+ const monthFieldName = opts.partitionSpec.find((f) => f.transform === "month")?.name;
298
+ const out = [];
299
+ for (const m of manifests) for (const entry of m.entries) {
300
+ if (entry.status === 2) continue;
301
+ const df = entry.data_file;
302
+ if (df.content !== 0) continue;
303
+ const part = df.partition;
304
+ let matchesAll = true;
305
+ for (const match of opts.matches) if (String(part[match.field]) !== String(match.value)) {
306
+ matchesAll = false;
307
+ break;
308
+ }
309
+ if (!matchesAll) continue;
310
+ if (monthFieldName) {
311
+ const month = part[monthFieldName];
312
+ if (typeof month !== "number" || !wantedMonths.has(month)) continue;
313
+ }
314
+ out.push({
315
+ filePath: df.file_path,
316
+ objectKey: stripBucket(df.file_path),
317
+ bytes: Number(df.file_size_in_bytes),
318
+ rowCount: Number(df.record_count)
319
+ });
320
+ }
321
+ endWalk?.({
322
+ manifests: manifests.length,
323
+ files: out.length
324
+ });
325
+ if (opts.cache) {
326
+ const freshKey = resolvedFilesKey(namespace, table, snapshotId, opts.matches, wantedMonths);
327
+ await cachePut(opts.cache, freshKey, out, RESOLVED_FILES_TTL_MS, now);
328
+ }
329
+ return out;
330
+ }
331
+ export { ICEBERG_TYPE_MAP, buildManifestPartitionFilter, cacheGet, cachePut, connectIcebergCatalog, dropIcebergTables, ensureIcebergNamespace, icebergAppendRetrying, isCommitRateLimited, listIcebergTables, resolveIcebergDataFiles };
@@ -0,0 +1,6 @@
1
+ declare global {
2
+ var netlifyBlobsContext: unknown;
3
+ }
4
+ /**
5
+ * The context object that we expect in the environment.
6
+ */
@@ -0,0 +1 @@
1
+ export { };
@@ -0,0 +1 @@
1
+ export { };
@@ -0,0 +1 @@
1
+ export { };