@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.
package/dist/index.mjs ADDED
@@ -0,0 +1,356 @@
1
+ import { icebergCreateTable } from "./_chunks/libs/icebird.mjs";
2
+ import { ICEBERG_TYPE_MAP, buildManifestPartitionFilter, cacheGet, cachePut, connectIcebergCatalog, dropIcebergTables, ensureIcebergNamespace, icebergAppendRetrying, listIcebergTables, resolveIcebergDataFiles } from "./_chunks/catalog.mjs";
3
+ import process from "node:process";
4
+ const INT32_MIN = -2147483648;
5
+ const INT32_MAX = 2147483647;
6
+ const DAY_MILLIS = 864e5;
7
+ function identityColumnNames(identity) {
8
+ return identity.kind === "site-int" ? ["site_id"] : identity.columns.map((c) => c.name);
9
+ }
10
+ function identityEncodings(identity) {
11
+ const map = /* @__PURE__ */ new Map();
12
+ if (identity.kind === "site-int") map.set("site_id", (identity.encoding ?? "int") === "int" ? "int32" : "string");
13
+ else for (const c of identity.columns) map.set(c.name, c.encoding);
14
+ return map;
15
+ }
16
+ function identityColumnType(encoding) {
17
+ return encoding === "int32" ? "INT" : "STRING";
18
+ }
19
+ function deriveTableSpec(def) {
20
+ const encodings = identityEncodings(def.identity);
21
+ const idNames = identityColumnNames(def.identity);
22
+ const dimNames = def.dims ? Object.keys(def.dims) : [];
23
+ let fieldId = 1;
24
+ const columns = [];
25
+ for (const name of idNames) columns.push({
26
+ name,
27
+ type: identityColumnType(encodings.get(name)),
28
+ required: true,
29
+ fieldId: fieldId++
30
+ });
31
+ for (const name of dimNames) {
32
+ const enc = def.dims[name].boundEncoding;
33
+ columns.push({
34
+ name,
35
+ type: identityColumnType(enc),
36
+ required: true,
37
+ fieldId: fieldId++
38
+ });
39
+ }
40
+ for (const col of def.columns) columns.push({
41
+ name: col.name,
42
+ type: col.type,
43
+ required: col.required,
44
+ fieldId: fieldId++
45
+ });
46
+ return {
47
+ namespace: def.namespace,
48
+ table: def.table,
49
+ columns,
50
+ partitionSpec: def.partition,
51
+ naturalKey: def.naturalKey,
52
+ identityColumns: [
53
+ ...idNames,
54
+ ...dimNames,
55
+ ...def.naturalKey
56
+ ],
57
+ clusterKey: def.clusterKey
58
+ };
59
+ }
60
+ function icebergSchemaFromSpec(spec) {
61
+ return {
62
+ "type": "struct",
63
+ "schema-id": 0,
64
+ "fields": spec.columns.map((col) => ({
65
+ id: col.fieldId,
66
+ name: col.name,
67
+ required: col.required,
68
+ type: ICEBERG_TYPE_MAP[col.type]
69
+ }))
70
+ };
71
+ }
72
+ function icebergPartitionSpecFromSpec(spec) {
73
+ const fieldId = (name) => {
74
+ const col = spec.columns.find((c) => c.name === name);
75
+ if (!col) throw new Error(`lakehouse: table '${spec.table}' has no '${name}' column`);
76
+ return col.fieldId;
77
+ };
78
+ return {
79
+ "spec-id": 0,
80
+ "fields": spec.partitionSpec.map((p, i) => ({
81
+ "source-id": fieldId(p.sourceColumn),
82
+ "field-id": 1e3 + i,
83
+ "name": p.name,
84
+ "transform": p.transform
85
+ }))
86
+ };
87
+ }
88
+ function icebergSortOrderFromSpec(spec) {
89
+ if (!spec.clusterKey || spec.clusterKey.length === 0) return void 0;
90
+ const fieldId = (name) => {
91
+ const col = spec.columns.find((c) => c.name === name);
92
+ if (!col) throw new Error(`lakehouse: table '${spec.table}' has no '${name}' column`);
93
+ return col.fieldId;
94
+ };
95
+ return {
96
+ "order-id": 1,
97
+ "fields": spec.clusterKey.map((col) => ({
98
+ "source-id": fieldId(col),
99
+ "transform": "identity",
100
+ "direction": "asc",
101
+ "null-order": "nulls-last"
102
+ }))
103
+ };
104
+ }
105
+ function toIcebergDayCount(value) {
106
+ if (typeof value === "number") return value;
107
+ if (value instanceof Date) {
108
+ const ms = value.getTime();
109
+ if (Number.isNaN(ms)) throw new TypeError("toIcebergDayCount: invalid Date (NaN)");
110
+ return Math.floor(ms / DAY_MILLIS);
111
+ }
112
+ const ms = Date.parse(`${value}T00:00:00Z`);
113
+ if (Number.isNaN(ms)) throw new TypeError(`toIcebergDayCount: invalid date string '${value}'`);
114
+ return Math.floor(ms / DAY_MILLIS);
115
+ }
116
+ function coerceJsonSafe(value) {
117
+ return typeof value === "bigint" ? Number(value) : value;
118
+ }
119
+ function asInt32(value) {
120
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "bigint") return null;
121
+ const n = Number(value);
122
+ if (!Number.isSafeInteger(n) || n < INT32_MIN || n > INT32_MAX) return null;
123
+ return n;
124
+ }
125
+ function buildRowProcessor(def, tableSpec) {
126
+ const idNames = identityColumnNames(def.identity);
127
+ const idEncodings = identityEncodings(def.identity);
128
+ const dimNames = def.dims ? Object.keys(def.dims) : [];
129
+ function guard(row) {
130
+ const out = {};
131
+ for (const name of idNames) {
132
+ const enc = idEncodings.get(name);
133
+ const raw = row[name];
134
+ if (enc === "int32") {
135
+ const n = asInt32(raw);
136
+ if (n === null) return null;
137
+ out[name] = n;
138
+ } else {
139
+ if (raw == null || raw === "") return null;
140
+ out[name] = String(raw);
141
+ }
142
+ }
143
+ for (const name of dimNames) out[name] = def.dims[name].toPartitionValue(String(row[name]));
144
+ for (const col of def.columns) out[col.name] = coerceJsonSafe(row[col.name]);
145
+ return out;
146
+ }
147
+ function dedupe(rows) {
148
+ if (rows.length < 2) return rows;
149
+ const keyCols = tableSpec.identityColumns;
150
+ const seen = /* @__PURE__ */ new Map();
151
+ for (const rec of rows) {
152
+ const k = keyCols.map((c) => `${rec[c] ?? ""}`).join("\0");
153
+ seen.set(k, rec);
154
+ }
155
+ return seen.size === rows.length ? rows : [...seen.values()];
156
+ }
157
+ function sort(rows) {
158
+ const cols = def.clusterKey;
159
+ if (!cols || cols.length === 0 || rows.length < 2) return rows;
160
+ return rows.slice().sort((a, b) => {
161
+ for (const col of cols) {
162
+ const av = a[col];
163
+ const bv = b[col];
164
+ if (av === bv) continue;
165
+ if (av == null) return -1;
166
+ if (bv == null) return 1;
167
+ if (typeof av === "number" && typeof bv === "number") return av - bv;
168
+ const as = String(av);
169
+ const bs = String(bv);
170
+ if (as !== bs) return as < bs ? -1 : 1;
171
+ }
172
+ return 0;
173
+ });
174
+ }
175
+ return {
176
+ guard,
177
+ dedupe,
178
+ sort
179
+ };
180
+ }
181
+ function defineIcebergDataset(def) {
182
+ const tableSpec = deriveTableSpec(def);
183
+ const schema = icebergSchemaFromSpec(tableSpec);
184
+ const partitionSpecIcebird = icebergPartitionSpecFromSpec(tableSpec);
185
+ const sortOrder = icebergSortOrderFromSpec(tableSpec);
186
+ const { guard, dedupe, sort } = buildRowProcessor(def, tableSpec);
187
+ async function createTable(conn) {
188
+ const results = [];
189
+ await icebergCreateTable({
190
+ catalog: conn.catalog,
191
+ namespace: conn.namespace,
192
+ table: def.table,
193
+ schema,
194
+ partitionSpec: partitionSpecIcebird,
195
+ ...sortOrder ? { sortOrder } : {}
196
+ }).then(() => results.push({
197
+ table: def.table,
198
+ ok: true
199
+ }), (e) => results.push({
200
+ table: def.table,
201
+ ok: false,
202
+ error: e instanceof Error ? e.message : String(e)
203
+ }));
204
+ return results;
205
+ }
206
+ function process(rows) {
207
+ const guarded = [];
208
+ let skipped = 0;
209
+ for (const row of rows) {
210
+ const rec = guard(row);
211
+ if (rec === null) skipped++;
212
+ else guarded.push(rec);
213
+ }
214
+ return {
215
+ records: sort(dedupe(guarded)),
216
+ skipped
217
+ };
218
+ }
219
+ async function appendRows(conn, rows, opts = {}) {
220
+ if (rows.length === 0) return {
221
+ accepted: 0,
222
+ skipped: 0
223
+ };
224
+ const { records, skipped } = process(rows);
225
+ if (records.length === 0) return {
226
+ accepted: 0,
227
+ skipped
228
+ };
229
+ await icebergAppendRetrying({
230
+ catalog: conn.catalog,
231
+ namespace: conn.namespace,
232
+ table: def.table,
233
+ resolver: conn.resolver,
234
+ records
235
+ }, opts.commitRetry);
236
+ return {
237
+ accepted: records.length,
238
+ skipped
239
+ };
240
+ }
241
+ function appendSink(opts) {
242
+ let buffer = [];
243
+ let connection;
244
+ function connect() {
245
+ connection ??= connectIcebergCatalog(opts.catalog, opts.connect).then(async (conn) => {
246
+ await ensureIcebergNamespace(conn);
247
+ return conn;
248
+ });
249
+ return connection;
250
+ }
251
+ return {
252
+ emit(rows) {
253
+ for (const r of rows) buffer.push(r);
254
+ },
255
+ async close() {
256
+ if (buffer.length === 0) return {
257
+ flushed: false,
258
+ accepted: 0,
259
+ skipped: 0
260
+ };
261
+ const rows = buffer;
262
+ buffer = [];
263
+ try {
264
+ const result = await appendRows(await connect(), rows, { commitRetry: opts.commitRetry });
265
+ if (result.accepted > 0 && opts.ledger) await opts.ledger.record();
266
+ return {
267
+ flushed: result.accepted > 0,
268
+ ...result
269
+ };
270
+ } catch (error) {
271
+ return {
272
+ flushed: false,
273
+ accepted: 0,
274
+ skipped: 0,
275
+ error
276
+ };
277
+ }
278
+ }
279
+ };
280
+ }
281
+ function readerPredicate(identity, dims) {
282
+ const idNames = identityColumnNames(def.identity);
283
+ const idEncodings = identityEncodings(def.identity);
284
+ const matches = idNames.map((name) => ({
285
+ field: name,
286
+ value: idEncodings.get(name) === "int32" ? Number(identity) : identity,
287
+ encoding: idEncodings.get(name) === "int32" ? "int32" : "string"
288
+ }));
289
+ if (def.dims && dims) {
290
+ for (const [name, dim] of Object.entries(def.dims)) if (name in dims) matches.push({
291
+ field: name,
292
+ value: dim.toPartitionValue(dims[name]),
293
+ encoding: dim.boundEncoding
294
+ });
295
+ }
296
+ return matches;
297
+ }
298
+ function partitionBoundFilter(identity, months, dims) {
299
+ return buildManifestPartitionFilter(def.partition, readerPredicate(identity, dims), months);
300
+ }
301
+ async function resolveDataFiles(conn, identity, range, dims, opts = {}) {
302
+ return resolveIcebergDataFiles(conn, {
303
+ namespace: def.namespace,
304
+ table: def.table,
305
+ partitionSpec: def.partition,
306
+ matches: readerPredicate(identity, dims),
307
+ range,
308
+ cache: opts.cache,
309
+ clock: opts.clock,
310
+ profiler: opts.profiler
311
+ });
312
+ }
313
+ return {
314
+ def,
315
+ tableSpec,
316
+ icebergSchema: () => schema,
317
+ icebergPartitionSpec: () => partitionSpecIcebird,
318
+ icebergSortOrder: () => sortOrder,
319
+ createTable,
320
+ appendRows,
321
+ prepareRows: process,
322
+ appendSink,
323
+ readerPredicate,
324
+ partitionBoundFilter,
325
+ resolveDataFiles
326
+ };
327
+ }
328
+ function resolvePyIcebergPython(override) {
329
+ return override ?? process.env["GSCDUMP_ICEBERG_PYTHON"] ?? "python3";
330
+ }
331
+ async function runPyIcebergWriter(options) {
332
+ const { execFile } = await import("node:child_process");
333
+ return new Promise((resolve, reject) => {
334
+ execFile(options.python, [options.script], { maxBuffer: 64 * 1024 * 1024 }, (err, stdout, stderr) => {
335
+ let parsed;
336
+ if (stdout.trim()) try {
337
+ parsed = JSON.parse(stdout);
338
+ } catch {}
339
+ if (parsed && !(err && options.rejectOnProcessError)) {
340
+ resolve(parsed);
341
+ return;
342
+ }
343
+ if (err) {
344
+ if (options.processErrorAsParseFailure) {
345
+ reject(/* @__PURE__ */ new Error(`${options.label} produced no parseable output (${err.message})${stderr ? `: ${stderr}` : ""}`));
346
+ return;
347
+ }
348
+ reject(/* @__PURE__ */ new Error(`${options.label} process failed (${err.message})${stderr ? `: ${stderr}` : ""}`));
349
+ return;
350
+ }
351
+ reject(/* @__PURE__ */ new Error(`${options.label} produced no parseable output: ${stdout || stderr}`));
352
+ }).stdin?.end(JSON.stringify(options.job));
353
+ });
354
+ }
355
+ const DEFAULT_PARTITION_KEY_ENCODING = "int";
356
+ export { DEFAULT_PARTITION_KEY_ENCODING, ICEBERG_TYPE_MAP, buildManifestPartitionFilter, cacheGet, cachePut, connectIcebergCatalog, defineIcebergDataset, deriveTableSpec, dropIcebergTables, ensureIcebergNamespace, listIcebergTables, resolveIcebergDataFiles, resolvePyIcebergPython, runPyIcebergWriter, toIcebergDayCount };
@@ -0,0 +1,124 @@
1
+ import { PartitionKeyEncoding } from "../_chunks/schema.mjs";
2
+ /**
3
+ * Team-scoped Catalog Site Id allocator (ADR-0021 amendment 11).
4
+ *
5
+ * A "Catalog Site Id" is the small INT32 join key every dataset's `'site-int'`
6
+ * identity partitions on (nuxtseo ADR-0091's `gscdumpSiteIntId` / gscdump's
7
+ * `user_sites.int_id`). One allocator, owned here, so no consumer hand-rolls
8
+ * its own next-int sequence (the exact copy-paste class ADR-0021 exists to
9
+ * close). Pure — the caller persists the result; this module has no DB.
10
+ *
11
+ * Two entry points into a team's id space:
12
+ * - ADOPT a `supplied` id (e.g. gscdump's existing `int_id` for a site that
13
+ * predates the allocator) — validated for TEAM-SCOPE uniqueness, never
14
+ * silently reassigned.
15
+ * - ALLOCATE fresh — next-int strictly above every existing id in the team
16
+ * (adopted ids included), so a later allocation can never collide with an
17
+ * earlier adoption.
18
+ */
19
+ type AllocateCatalogSiteIdResult = {
20
+ _tag: 'allocated';
21
+ id: number;
22
+ } | {
23
+ _tag: 'adopted';
24
+ id: number;
25
+ } | {
26
+ _tag: 'collision';
27
+ id: number;
28
+ };
29
+ interface AllocateCatalogSiteIdOptions {
30
+ /** Every Catalog Site Id already in use for this team (adopted + previously allocated). */
31
+ existingIds: Iterable<number>;
32
+ /**
33
+ * Adopt this id instead of allocating a fresh one (e.g. gscdump's existing
34
+ * `int_id`). Validated against `existingIds` for team-scope uniqueness —
35
+ * a collision is returned as a value, never silently resolved.
36
+ */
37
+ supplied?: number;
38
+ }
39
+ /**
40
+ * Adopt `supplied`, or allocate the next unused int strictly above every id
41
+ * already in `existingIds`. Never mutates the caller's set; never reuses an
42
+ * id already present.
43
+ */
44
+ declare function allocateCatalogSiteId(opts: AllocateCatalogSiteIdOptions): AllocateCatalogSiteIdResult;
45
+ interface CloudflareProvisionCreds {
46
+ accountId: string;
47
+ /** Account-scoped management API token (bucket CRUD). */
48
+ apiToken: string;
49
+ /** R2-Data-Catalog-scoped token (enable / maintenance-configs / credential). */
50
+ catalogToken: string;
51
+ /**
52
+ * A dedicated R2 *Object Read & Write* token the catalog stores for its OWN
53
+ * maintenance runs. Compaction is configured but INERT without this — a
54
+ * catalog with `credential_status:absent` reports `enabled` but never
55
+ * compacts. Optional (best-effort): omitted, maintenance stays configured
56
+ * but dormant.
57
+ */
58
+ compactionCredentialToken?: string;
59
+ }
60
+ interface ProvisionedCatalogRef {
61
+ catalogUri: string;
62
+ warehouse: string;
63
+ bucket: string;
64
+ namespace: string;
65
+ }
66
+ interface SelfProvisionOptions {
67
+ creds: CloudflareProvisionCreds;
68
+ bucket: string;
69
+ namespace: string;
70
+ /** Browser-read CORS origins for direct S3 presigned reads (DuckDB-WASM). */
71
+ corsOrigins: readonly string[];
72
+ /** Injectable fetch — defaults to the global. */
73
+ fetch?: typeof fetch;
74
+ /** Injectable warn sink for best-effort maintenance failures. Defaults to `console.warn`. */
75
+ onWarn?: (message: string) => void;
76
+ }
77
+ /**
78
+ * Self-provision (idempotently) a team's R2 bucket + Data Catalog: create the
79
+ * bucket, set browser-read CORS, enable the catalog, set the maintenance
80
+ * credential, then enable compaction + snapshot expiry. Every step treats
81
+ * "already exists/enabled" as success. Table creation is NOT this routine's
82
+ * job — callers create their own dataset tables via `dataset.createTable`
83
+ * once the namespace exists.
84
+ */
85
+ declare function selfProvisionCatalog(opts: SelfProvisionOptions): Promise<ProvisionedCatalogRef>;
86
+ /** A remote catalog ref as reported by a partner's catalog API (gscdump's `/teams/:id/catalog`, e.g.). */
87
+ interface RemoteCatalogRef {
88
+ catalogUri: string | null;
89
+ warehouse: string | null;
90
+ bucket: string | null;
91
+ namespace?: string | null;
92
+ keyEncoding?: PartitionKeyEncoding | null;
93
+ provisioningState?: string | null;
94
+ }
95
+ type AdoptCatalogResult = {
96
+ _tag: 'adopted';
97
+ ref: ProvisionedCatalogRef;
98
+ } | {
99
+ _tag: 'no-catalog';
100
+ } | {
101
+ _tag: 'rejected';
102
+ reason: 'not-ready' | 'key-encoding';
103
+ detail: string;
104
+ };
105
+ interface AdoptCatalogOptions {
106
+ /** Identity partitions this consumer writes require this encoding (default `'int'`). */
107
+ requireEncoding?: PartitionKeyEncoding;
108
+ }
109
+ /**
110
+ * ADOPT mode: validate a partner-reported catalog ref rather than provisioning
111
+ * a second one. Rejections are deliberate hard gates, not fallbacks:
112
+ * - `key-encoding` — writers key identity unconditionally in one encoding;
113
+ * adopting a mismatched catalog would poison it (partition type mismatch).
114
+ * - `not-ready` — the partner owns that catalog's lifecycle; provisioning a
115
+ * parallel one would fork the team's lake.
116
+ */
117
+ declare function adoptCatalog(remote: RemoteCatalogRef | null, opts?: AdoptCatalogOptions): AdoptCatalogResult;
118
+ /**
119
+ * SUPPLIED-CATALOG mode: an external caller hands in R2 details directly
120
+ * ("provision a gscdump account with the R2 details and it just works") — a
121
+ * pure validation/passthrough, no CF calls, no persistence.
122
+ */
123
+ declare function suppliedCatalog(ref: ProvisionedCatalogRef): ProvisionedCatalogRef;
124
+ export { AdoptCatalogOptions, AdoptCatalogResult, type AllocateCatalogSiteIdOptions, type AllocateCatalogSiteIdResult, CloudflareProvisionCreds, ProvisionedCatalogRef, RemoteCatalogRef, SelfProvisionOptions, adoptCatalog, allocateCatalogSiteId, selfProvisionCatalog, suppliedCatalog };
@@ -0,0 +1,141 @@
1
+ const INT32_MAX = 2147483647;
2
+ function allocateCatalogSiteId(opts) {
3
+ const existing = new Set(opts.existingIds);
4
+ if (opts.supplied != null) {
5
+ if (existing.has(opts.supplied)) return {
6
+ _tag: "collision",
7
+ id: opts.supplied
8
+ };
9
+ return {
10
+ _tag: "adopted",
11
+ id: opts.supplied
12
+ };
13
+ }
14
+ let max = 0;
15
+ for (const id of existing) if (id > max) max = id;
16
+ let next = max + 1;
17
+ while (existing.has(next)) next++;
18
+ if (next > INT32_MAX) throw new RangeError(`allocateCatalogSiteId: exhausted the INT32 id space (next=${next})`);
19
+ return {
20
+ _tag: "allocated",
21
+ id: next
22
+ };
23
+ }
24
+ const CF_API_BASE = "https://api.cloudflare.com/client/v4";
25
+ const COMPACTION_TARGET_SIZE_MB = "128";
26
+ const SNAPSHOT_MIN_SNAPSHOTS_TO_KEEP = 10;
27
+ const SNAPSHOT_MAX_AGE = "3d";
28
+ function isBenignConflict(status, body) {
29
+ if (status === 409) return true;
30
+ const lower = body.toLowerCase();
31
+ return lower.includes("already exist") || lower.includes("already enabled") || lower.includes("already in use");
32
+ }
33
+ async function cfPost(fetchImpl, accountId, token, path, body) {
34
+ const res = await fetchImpl(`${CF_API_BASE}/accounts/${accountId}/${path}`, {
35
+ method: "POST",
36
+ headers: {
37
+ "Authorization": `Bearer ${token}`,
38
+ "Content-Type": "application/json"
39
+ },
40
+ body: body === void 0 ? void 0 : JSON.stringify(body)
41
+ });
42
+ const text = await res.text();
43
+ if (res.ok) {
44
+ const json = text ? JSON.parse(text) : { success: true };
45
+ if (json.success === false && !isBenignConflict(res.status, text)) throw new Error(`CF POST ${path} failed: ${json.errors?.map((e) => e.message).join(", ") || text}`);
46
+ return;
47
+ }
48
+ if (isBenignConflict(res.status, text)) return;
49
+ throw new Error(`CF POST ${path} failed: ${res.status} ${text}`);
50
+ }
51
+ async function putBucketCors(fetchImpl, accountId, apiToken, bucket, origins) {
52
+ const res = await fetchImpl(`${CF_API_BASE}/accounts/${accountId}/r2/buckets/${bucket}/cors`, {
53
+ method: "PUT",
54
+ headers: {
55
+ "Authorization": `Bearer ${apiToken}`,
56
+ "Content-Type": "application/json"
57
+ },
58
+ body: JSON.stringify({ rules: [{
59
+ allowed: {
60
+ origins,
61
+ methods: ["GET", "HEAD"],
62
+ headers: ["range", "content-type"]
63
+ },
64
+ exposeHeaders: [
65
+ "content-range",
66
+ "content-length",
67
+ "accept-ranges",
68
+ "etag"
69
+ ],
70
+ maxAgeSeconds: 86400
71
+ }] })
72
+ });
73
+ if (res.ok) return;
74
+ const text = await res.text().catch(() => "");
75
+ if (isBenignConflict(res.status, text)) return;
76
+ throw new Error(`CF PUT /r2/buckets/${bucket}/cors failed: ${res.status} ${text}`);
77
+ }
78
+ async function enableMaintenance(fetchImpl, accountId, catalogToken, bucket, onWarn) {
79
+ await cfPost(fetchImpl, accountId, catalogToken, `r2-catalog/${bucket}/maintenance-configs`, {
80
+ compaction: {
81
+ state: "enabled",
82
+ target_size_mb: COMPACTION_TARGET_SIZE_MB
83
+ },
84
+ snapshot_expiration: {
85
+ state: "enabled",
86
+ min_snapshots_to_keep: SNAPSHOT_MIN_SNAPSHOTS_TO_KEEP,
87
+ max_snapshot_age: SNAPSHOT_MAX_AGE
88
+ }
89
+ }).catch((err) => {
90
+ onWarn(`[lakehouse-provisioning] maintenance-configs failed for ${bucket} — catalog usable but maintenance stays off: ${err instanceof Error ? err.message : String(err)}`);
91
+ });
92
+ }
93
+ async function ensureCredential(fetchImpl, accountId, catalogToken, credentialToken, bucket, onWarn) {
94
+ await cfPost(fetchImpl, accountId, catalogToken, `r2-catalog/${bucket}/credential`, { token: credentialToken }).catch((err) => {
95
+ onWarn(`[lakehouse-provisioning] credential-set failed for ${bucket} — compaction stays inert (credential absent): ${err instanceof Error ? err.message : String(err)}`);
96
+ });
97
+ }
98
+ async function selfProvisionCatalog(opts) {
99
+ const { creds, bucket, namespace, corsOrigins } = opts;
100
+ const fetchImpl = opts.fetch ?? fetch;
101
+ const onWarn = opts.onWarn ?? ((m) => console.warn(m));
102
+ await cfPost(fetchImpl, creds.accountId, creds.apiToken, "r2/buckets", { name: bucket });
103
+ await putBucketCors(fetchImpl, creds.accountId, creds.apiToken, bucket, corsOrigins);
104
+ await cfPost(fetchImpl, creds.accountId, creds.catalogToken, `r2-catalog/${bucket}/enable`, {});
105
+ if (creds.compactionCredentialToken) await ensureCredential(fetchImpl, creds.accountId, creds.catalogToken, creds.compactionCredentialToken, bucket, onWarn);
106
+ await enableMaintenance(fetchImpl, creds.accountId, creds.catalogToken, bucket, onWarn);
107
+ return {
108
+ catalogUri: `https://catalog.cloudflarestorage.com/${creds.accountId}/${bucket}`,
109
+ warehouse: `${creds.accountId}_${bucket}`,
110
+ bucket,
111
+ namespace
112
+ };
113
+ }
114
+ function adoptCatalog(remote, opts = {}) {
115
+ if (!remote || !remote.catalogUri || !remote.warehouse || !remote.bucket) return { _tag: "no-catalog" };
116
+ const requireEncoding = opts.requireEncoding ?? "int";
117
+ if (remote.keyEncoding && remote.keyEncoding !== requireEncoding) return {
118
+ _tag: "rejected",
119
+ reason: "key-encoding",
120
+ detail: `catalog is ${remote.keyEncoding}-encoded; writers require ${requireEncoding}`
121
+ };
122
+ if (remote.provisioningState && remote.provisioningState !== "ready") return {
123
+ _tag: "rejected",
124
+ reason: "not-ready",
125
+ detail: `provisioningState=${remote.provisioningState}`
126
+ };
127
+ return {
128
+ _tag: "adopted",
129
+ ref: {
130
+ catalogUri: remote.catalogUri,
131
+ warehouse: remote.warehouse,
132
+ bucket: remote.bucket,
133
+ namespace: remote.namespace ?? "default"
134
+ }
135
+ };
136
+ }
137
+ function suppliedCatalog(ref) {
138
+ if (!ref.catalogUri || !ref.warehouse || !ref.bucket) throw new Error("suppliedCatalog: catalogUri, warehouse and bucket are all required");
139
+ return ref;
140
+ }
141
+ export { adoptCatalog, allocateCatalogSiteId, selfProvisionCatalog, suppliedCatalog };
@@ -0,0 +1,3 @@
1
+ import { icebergAppendRetrying, isCommitRateLimited } from "./_chunks/catalog.mjs";
2
+ import { icebergAppend, icebergCreateTable, icebergDropTable, icebergManifests, restCatalogConnect, restCatalogCreateNamespace, restCatalogListTables, restCatalogLoadTable, s3SignedResolver } from "./_chunks/libs/icebird.mjs";
3
+ export { icebergAppend, icebergAppendRetrying, icebergCreateTable, icebergDropTable, icebergManifests, isCommitRateLimited, restCatalogConnect, restCatalogCreateNamespace, restCatalogListTables, restCatalogLoadTable, s3SignedResolver };
@@ -0,0 +1,3 @@
1
+ import { icebergAppend, icebergCreateTable, icebergDropTable, icebergManifests, restCatalogConnect, restCatalogCreateNamespace, restCatalogListTables, restCatalogLoadTable, s3SignedResolver } from "./_chunks/libs/icebird.mjs";
2
+ import { icebergAppendRetrying, isCommitRateLimited } from "./_chunks/catalog.mjs";
3
+ export { icebergAppend, icebergAppendRetrying, icebergCreateTable, icebergDropTable, icebergManifests, isCommitRateLimited, restCatalogConnect, restCatalogCreateNamespace, restCatalogListTables, restCatalogLoadTable, s3SignedResolver };
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Pure-JS drop-in replacement for `hysnappy`.
3
+ *
4
+ * `hysnappy`'s `snappyUncompressor()` eagerly compiles a WASM module
5
+ * (`new WebAssembly.Module(byteArray)`) — and `hyparquet-compressors`
6
+ * instantiates it at module top level (`SNAPPY: snappyUncompressor()`).
7
+ * Cloudflare's `workerd` forbids compiling WebAssembly from a runtime buffer
8
+ * (WASM must be a bundled module import), so any Worker bundle that imports
9
+ * `icebird` (→ `hyparquet-compressors`) fails to start with
10
+ * `CompileError: Wasm code generation disallowed by embedder`.
11
+ *
12
+ * `icebird`'s append path never actually decompresses a snappy data file — it
13
+ * only reads gzipped `metadata.json` and Avro manifests — so the snappy
14
+ * decompressor is instantiated but never invoked. This shim swaps the WASM
15
+ * codec for `hyparquet`'s pure-JS snappy decompressor (a vendored snappyjs),
16
+ * keeping the exact `hysnappy` API surface so `hyparquet-compressors` is
17
+ * unaware of the swap. Wired in via a build-time `hysnappy` alias.
18
+ *
19
+ * See `docs/plans/2026-05-22-icebird-ingest-writer-spike.md` (section e).
20
+ */
21
+ /**
22
+ * Pure-JS stand-in for `hysnappy`'s `snappyUncompressor()`. Returns the
23
+ * decompressor immediately — no WASM compilation, so it is safe to call at
24
+ * module top level inside `workerd`.
25
+ */
26
+ declare function snappyUncompressor(): (input: Uint8Array, outputLength: number) => Uint8Array;
27
+ /** Pure-JS stand-in for `hysnappy`'s `snappyUncompress(input, outputLength)`. */
28
+ declare function snappyUncompress(input: Uint8Array, outputLength: number): Uint8Array;
29
+ export { snappyUncompress, snappyUncompressor };
@@ -0,0 +1,2 @@
1
+ import { snappyUncompress, snappyUncompressor } from "../_chunks/libs/hyparquet-compressors.mjs";
2
+ export { snappyUncompress, snappyUncompressor };