@gscdump/engine 0.33.10 → 0.35.2

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.
@@ -2,7 +2,7 @@ import { SCHEMAS, drizzleSchema } from "./schema.mjs";
2
2
  import { enumeratePartitions } from "./compaction.mjs";
3
3
  import { escapeLike } from "../sql-fragments.mjs";
4
4
  import "../planner.mjs";
5
- import "./schema2.mjs";
5
+ import { DEFAULT_PARTITION_KEY_ENCODING } from "./schema2.mjs";
6
6
  import { UnresolvableDatasetError, buildLogicalComparisonPlan, buildLogicalPlan, inferDataset as inferLogicalDataset, isDatasetResolvable } from "gscdump/query/plan";
7
7
  import { PgDialect, pgTable, varchar } from "drizzle-orm/pg-core";
8
8
  import { normalizeUrl } from "gscdump/normalize";
@@ -492,7 +492,7 @@ function createR2SqlResolverAdapter(options = {}) {
492
492
  tableRef: (tk) => sql.raw(`"${tk}"`),
493
493
  queryDimTableRef: () => sql.raw("\"query_dim\"")
494
494
  });
495
- if ((options.partitionKeyEncoding ?? "int") === "int") return adapter;
495
+ if ((options.partitionKeyEncoding ?? DEFAULT_PARTITION_KEY_ENCODING) === "int") return adapter;
496
496
  return {
497
497
  ...adapter,
498
498
  siteIdColRef: (tk) => sql`CONCAT(${adapter.siteIdColRef(tk)}, '')`,
@@ -1,4 +1,14 @@
1
- import { SCHEMAS } from "./schema.mjs";
1
+ import { SCHEMAS, TABLE_METADATA } from "./schema.mjs";
2
+ import { DEFAULT_PARTITION_KEY_ENCODING, DEFAULT_PARTITION_KEY_ENCODING as DEFAULT_PARTITION_KEY_ENCODING$1, defineIcebergDataset } from "@gscdump/lakehouse";
3
+ function mapColumnType(t) {
4
+ switch (t) {
5
+ case "VARCHAR": return "STRING";
6
+ case "INTEGER": return "INT";
7
+ case "BIGINT": return "LONG";
8
+ case "DOUBLE": return "DOUBLE";
9
+ case "DATE": return "DATE";
10
+ }
11
+ }
2
12
  const ICEBERG_TABLES = [
3
13
  "pages",
4
14
  "queries",
@@ -10,7 +20,6 @@ const ICEBERG_TABLES = [
10
20
  "search_appearance_queries",
11
21
  "search_appearance_page_queries"
12
22
  ];
13
- const DEFAULT_PARTITION_KEY_ENCODING = "int";
14
23
  const SEARCH_TYPE_INT = {
15
24
  web: 1,
16
25
  image: 2,
@@ -31,7 +40,7 @@ const ICEBERG_PARTITION_COLUMNS = [{
31
40
  required: true,
32
41
  fieldId: 2
33
42
  }];
34
- function icebergPartitionColumns(encoding = "int") {
43
+ function icebergPartitionColumns(encoding = DEFAULT_PARTITION_KEY_ENCODING) {
35
44
  if (encoding === "string") return ICEBERG_PARTITION_COLUMNS;
36
45
  return [{
37
46
  name: "site_id",
@@ -63,38 +72,47 @@ const ICEBERG_PARTITION_SPEC = [
63
72
  name: "date_month"
64
73
  }
65
74
  ];
66
- function mapColumnType(t) {
67
- switch (t) {
68
- case "VARCHAR": return "STRING";
69
- case "INTEGER": return "INT";
70
- case "BIGINT": return "LONG";
71
- case "DOUBLE": return "DOUBLE";
72
- case "DATE": return "DATE";
73
- }
74
- }
75
- function icebergTableSpec(table, encoding = "int") {
76
- const base = SCHEMAS[table];
77
- const dataColumns = base.columns.map((col, i) => ({
75
+ function dataColumnsFor(table) {
76
+ return SCHEMAS[table].columns.map((col) => ({
78
77
  name: col.name,
79
78
  type: mapColumnType(col.type),
80
- required: !col.nullable,
81
- fieldId: 3 + i
79
+ required: !col.nullable
82
80
  }));
81
+ }
82
+ function searchTypeDim(encoding) {
83
+ return {
84
+ toPartitionValue: (v) => encoding === "int" ? SEARCH_TYPE_INT[v] : v,
85
+ boundEncoding: encoding === "int" ? "int32" : "string"
86
+ };
87
+ }
88
+ function gscDatasetDef(table, encoding) {
89
+ const meta = TABLE_METADATA[table];
83
90
  return {
91
+ namespace: "gsc",
84
92
  table,
85
- columns: [...icebergPartitionColumns(encoding), ...dataColumns],
86
- partitionSpec: ICEBERG_PARTITION_SPEC,
87
- identityColumns: [
88
- "site_id",
89
- "search_type",
90
- ...base.sortKey
91
- ]
93
+ identity: {
94
+ kind: "site-int",
95
+ encoding
96
+ },
97
+ dims: { search_type: searchTypeDim(encoding) },
98
+ columns: dataColumnsFor(table),
99
+ partition: ICEBERG_PARTITION_SPEC,
100
+ naturalKey: meta.sortKey,
101
+ clusterKey: meta.clusterKey
92
102
  };
93
103
  }
104
+ const GSC_DATASETS_STRING = Object.fromEntries(ICEBERG_TABLES.map((t) => [t, defineIcebergDataset(gscDatasetDef(t, "string"))]));
105
+ const GSC_DATASETS_INT = Object.fromEntries(ICEBERG_TABLES.map((t) => [t, defineIcebergDataset(gscDatasetDef(t, "int"))]));
106
+ function gscDataset(table, encoding = DEFAULT_PARTITION_KEY_ENCODING) {
107
+ return (encoding === "int" ? GSC_DATASETS_INT : GSC_DATASETS_STRING)[table];
108
+ }
109
+ function icebergTableSpec(table, encoding = DEFAULT_PARTITION_KEY_ENCODING) {
110
+ return gscDataset(table, encoding).tableSpec;
111
+ }
94
112
  const ICEBERG_SCHEMAS_STRING = Object.fromEntries(ICEBERG_TABLES.map((t) => [t, icebergTableSpec(t, "string")]));
95
113
  const ICEBERG_SCHEMAS_INT = Object.fromEntries(ICEBERG_TABLES.map((t) => [t, icebergTableSpec(t, "int")]));
96
114
  const ICEBERG_SCHEMAS = ICEBERG_SCHEMAS_INT;
97
- function icebergSchemasFor(encoding = "int") {
115
+ function icebergSchemasFor(encoding = DEFAULT_PARTITION_KEY_ENCODING) {
98
116
  return encoding === "int" ? ICEBERG_SCHEMAS_INT : ICEBERG_SCHEMAS_STRING;
99
117
  }
100
118
  const ICEBERG_TABLE_SET = new Set(ICEBERG_TABLES);
@@ -105,4 +123,4 @@ function assertIcebergTable(table) {
105
123
  if (!isIcebergTable(table)) throw new Error(`Unknown Iceberg table '${table}'. Expected one of: ${ICEBERG_TABLES.join(", ")}`);
106
124
  return table;
107
125
  }
108
- export { DEFAULT_PARTITION_KEY_ENCODING, ICEBERG_FIELD_ID_BASE, ICEBERG_PARTITION_COLUMNS, ICEBERG_PARTITION_SPEC, ICEBERG_SCHEMAS, ICEBERG_SCHEMAS_INT, ICEBERG_SCHEMAS_STRING, ICEBERG_TABLES, INT_SEARCH_TYPE, SEARCH_TYPE_INT, assertIcebergTable, icebergPartitionColumns, icebergSchemasFor, icebergTableSpec, isIcebergTable };
126
+ export { DEFAULT_PARTITION_KEY_ENCODING$1 as DEFAULT_PARTITION_KEY_ENCODING, ICEBERG_FIELD_ID_BASE, ICEBERG_PARTITION_COLUMNS, ICEBERG_PARTITION_SPEC, ICEBERG_SCHEMAS, ICEBERG_SCHEMAS_INT, ICEBERG_SCHEMAS_STRING, ICEBERG_TABLES, INT_SEARCH_TYPE, SEARCH_TYPE_INT, assertIcebergTable, gscDataset, icebergPartitionColumns, icebergSchemasFor, icebergTableSpec, isIcebergTable };
@@ -1,77 +1,13 @@
1
- import { QueryProfiler, Row as Row$1, SearchType, TenantCtx as TenantCtx$1 } from "./storage.mjs";
1
+ import { Row as Row$1, SearchType, TenantCtx as TenantCtx$1 } from "./storage.mjs";
2
2
  import { EngineError } from "./errors.mjs";
3
- import { Storage } from "./libs/unstorage.mjs";
4
- import { cachingResolver, icebergAppend, restCatalogConnect } from "./libs/icebird.mjs";
5
3
  import { Result } from "gscdump/result";
4
+ import { CatalogCache, CommitRetryOptions, ConnectIcebergOptions, DEFAULT_PARTITION_KEY_ENCODING as DEFAULT_PARTITION_KEY_ENCODING$1, IcebergCatalogConfig, IcebergColumn, IcebergColumn as IcebergColumn$1, IcebergColumnType, IcebergConnection, IcebergConnection as IcebergConnection$1, IcebergListedDataFile, IcebergPartitionField, IcebergPartitionField as IcebergPartitionField$1, IcebergPartitionSpec, IcebergPartitionSpecField, IcebergPartitionTransform, IcebergPrimitiveType, IcebergS3Config, IcebergSchema, IcebergSchemaField, IcebergSortOrder, IcebergSortOrderField, IcebergTableSpec, IcebergTableSpec as IcebergTableSpec$1, PartitionKeyEncoding, PartitionKeyEncoding as PartitionKeyEncoding$1, QueryProfiler, connectIcebergCatalog, ensureIcebergNamespace, listIcebergTables } from "@gscdump/lakehouse";
5
+ import { icebergAppendRetrying, isCommitRateLimited } from "@gscdump/lakehouse/unsafe-raw";
6
6
  import { TableName } from "@gscdump/contracts";
7
- /** Injected catalog cache: an unstorage `Storage` plus an optional defer hook. */
8
- interface CatalogCache {
9
- /** unstorage storage instance — the driver is the caller's choice. */
10
- storage: Storage;
11
- /**
12
- * Optional hook to run a cache WRITE off the response critical path, e.g.
13
- * Cloudflare's `ctx.waitUntil`. When omitted the writer awaits the put
14
- * inline so it is never cut off when the response returns.
15
- */
16
- defer?: (write: Promise<unknown>) => void;
17
- }
18
- /**
19
- * S3-compatible credentials for the Iceberg warehouse object store (R2 in prod,
20
- * MinIO in the POC). The single definition shared by every catalog/writer/sink
21
- * that signs warehouse object access — keep this contract in one place so the
22
- * credential shape cannot drift between the icebird and PyIceberg paths.
23
- */
24
- interface IcebergS3Config {
25
- /** S3 endpoint host (POC MinIO: `localhost:9100`; prod: the R2 S3 endpoint). */
26
- endpoint: string;
27
- accessKeyId: string;
28
- secretAccessKey: string;
29
- /** Defaults to `'auto'` (R2's region). */
30
- region?: string;
31
- }
32
7
  /** The 9 fact tables that exist as global Iceberg tables. */
33
8
  type IcebergTableName = Extract<TableName, 'pages' | 'queries' | 'countries' | 'page_queries' | 'dates' | 'search_appearance' | 'search_appearance_pages' | 'search_appearance_queries' | 'search_appearance_page_queries'>;
34
9
  /** The 9 Iceberg table names, in canonical order. */
35
10
  declare const ICEBERG_TABLES: readonly IcebergTableName[];
36
- /**
37
- * Iceberg-native column type. Superset-mapped from the engine `ColumnType`;
38
- * `LONG` is Iceberg's name for 64-bit integers, `STRING` for varchar.
39
- */
40
- type IcebergColumnType = 'STRING' | 'INT' | 'LONG' | 'DOUBLE' | 'DATE';
41
- interface IcebergColumn {
42
- /** Column name as written into the Iceberg table (snake_case). */
43
- name: string;
44
- type: IcebergColumnType;
45
- /** Iceberg field nullability. Partition identity columns are never null. */
46
- required: boolean;
47
- /**
48
- * Stable Iceberg field id. Field ids — not names — are the schema-evolution
49
- * identity in Iceberg; never reuse or renumber an id once a table is live.
50
- */
51
- fieldId: number;
52
- }
53
- /**
54
- * Partition-key encoding for the two identity columns (`site_id`, `search_type`).
55
- *
56
- * - `'string'` (legacy): both columns are STRING. Correct, but R2 SQL's
57
- * string min/max statistics are truncated in predicate pushdown, so a bare
58
- * `WHERE site_id='<uuid>'` UNDERCOUNTS — callers must CONCAT(col,'') to stay
59
- * correct, which defeats partition pruning.
60
- * - `'int'`: BOTH `site_id` and `search_type` are INT. Integer statistics are
61
- * fixed-width and never truncated, so `WHERE site_id=<n>` is both correct AND
62
- * prunes (empirically confirmed 2026-06-19, gscdump.com probe-int64-partition;
63
- * INT equality proven via the search_type column in the engine e2e canary). A
64
- * small INT site_id is ample (≪ 2.1B sites) — no LONG/BigInt needed. The caller
65
- * maps the UUID `site_id` ↔ int (app-owned, per-tenant serial) and uses
66
- * {@link SEARCH_TYPE_INT} for `search_type` (engine-owned, fixed enum).
67
- *
68
- * New per-team catalogs are provisioned `'int'`; existing catalogs stay
69
- * `'string'`. Callers that read or write legacy catalogs must pass
70
- * `encoding: 'string'` explicitly.
71
- */
72
- type PartitionKeyEncoding = 'string' | 'int';
73
- /** Default for new Iceberg/R2 Data Catalog tables. */
74
- declare const DEFAULT_PARTITION_KEY_ENCODING: PartitionKeyEncoding;
75
11
  /**
76
12
  * Stable `search_type` enum → int map for `'int'`-encoded catalogs. Engine-owned
77
13
  * and FROZEN: never renumber or reuse an id (it's the on-disk partition value).
@@ -79,31 +15,6 @@ declare const DEFAULT_PARTITION_KEY_ENCODING: PartitionKeyEncoding;
79
15
  declare const SEARCH_TYPE_INT: Record<SearchType, number>;
80
16
  /** Reverse of {@link SEARCH_TYPE_INT} — int → `search_type`, for read-result mapping. */
81
17
  declare const INT_SEARCH_TYPE: Record<number, SearchType>;
82
- /** Iceberg partition transform applied to a source column. */
83
- type IcebergPartitionTransform = 'identity' | 'month';
84
- interface IcebergPartitionField {
85
- /** Source column the transform reads. */
86
- sourceColumn: 'site_id' | 'search_type' | 'date';
87
- transform: IcebergPartitionTransform;
88
- /** Partition field name as it appears in Iceberg metadata. */
89
- name: string;
90
- }
91
- interface IcebergTableSpec {
92
- table: IcebergTableName;
93
- columns: readonly IcebergColumn[];
94
- /**
95
- * Partition spec — shared by every table: identity(site_id),
96
- * identity(search_type), month(date).
97
- */
98
- partitionSpec: readonly IcebergPartitionField[];
99
- /**
100
- * Natural-key columns: a row is uniquely identified by this tuple within
101
- * its partition. Drives partition-overwrite revision correctness and
102
- * dedup. Mirrors `TABLE_METADATA[table].sortKey` plus `site_id` +
103
- * `search_type`.
104
- */
105
- identityColumns: readonly string[];
106
- }
107
18
  /**
108
19
  * The two partition-identity columns Iceberg rows carry that the legacy
109
20
  * parquet path encoded in the object-key prefix instead. Field ids 1–2 are
@@ -123,22 +34,18 @@ declare function icebergPartitionColumns(encoding?: PartitionKeyEncoding): reado
123
34
  /**
124
35
  * First field id used for per-table (non-partition) columns — immediately
125
36
  * after the two partition-identity columns (`site_id`=1, `search_type`=2).
126
- *
127
- * ADVISORY ONLY. The icebird spike (2026-05-22) established that R2 Data
128
- * Catalog's `createTable` endpoint re-assigns field ids sequentially and does
129
- * NOT preserve caller-supplied ids. The contiguous numbering here matches what
130
- * the catalog produces (`site_id`=1, `search_type`=2, data columns 3, 4, …) so
131
- * the contract describes reality, but ids are authoritatively assigned by the
132
- * catalog. Iceberg still guarantees ids are stable once a table exists.
37
+ * No longer read by `defineDataset` below (field ids are now assigned by
38
+ * `defineIcebergDataset` itself, identity-then-dims-then-columns) kept as a
39
+ * documented, byte-identity-tested constant for existing external readers.
133
40
  */
134
41
  declare const ICEBERG_FIELD_ID_BASE = 3;
135
42
  /** Shared partition spec — identical across every table. */
136
43
  declare const ICEBERG_PARTITION_SPEC: readonly IcebergPartitionField[];
137
44
  /**
138
- * Derive the full Iceberg table spec for a table from the engine `SCHEMAS`
139
- * (drizzle-derived column set) plus the shared partition-identity columns.
140
- * Field ids are assigned deterministically from `ICEBERG_FIELD_ID_BASE` in
141
- * column declaration order so the same schema always yields the same ids.
45
+ * Derive the full Iceberg table spec for a table + encoding. Thin wrapper
46
+ * over {@link gscDataset}'s `tableSpec` (ADR-0021 R2-FIXES C5) kept as a
47
+ * named export so existing `@gscdump/engine/iceberg` consumers see no change
48
+ * to their import surface.
142
49
  *
143
50
  * CONTRACT NOTE: implementation agents must treat the RETURNED VALUE as the
144
51
  * source of truth — do not hand-list columns elsewhere.
@@ -162,286 +69,91 @@ declare function isIcebergTable(table: string): table is IcebergTableName;
162
69
  * Iceberg job instead of failing loudly.
163
70
  */
164
71
  declare function assertIcebergTable(table: string): IcebergTableName;
165
- /** icebird's lowercase Iceberg primitive types (subset we use). */
166
- type IcebergPrimitiveType = 'string' | 'int' | 'long' | 'double' | 'date';
167
- /** A field in an icebird table `Schema`. */
168
- interface IcebergSchemaField {
169
- id: number;
170
- name: string;
171
- required: boolean;
172
- type: IcebergPrimitiveType;
173
- }
174
- /** An icebird table `Schema` (Iceberg `struct`). */
175
- interface IcebergSchema {
176
- 'type': 'struct';
177
- 'schema-id': number;
178
- 'fields': IcebergSchemaField[];
179
- }
180
- /** A field in an icebird `PartitionSpec`. */
181
- interface IcebergPartitionSpecField {
182
- 'source-id': number;
183
- 'field-id': number;
184
- 'name': string;
185
- 'transform': 'identity' | 'month';
186
- }
187
- /** An icebird `PartitionSpec`. */
188
- interface IcebergPartitionSpec {
189
- 'spec-id': number;
190
- 'fields': IcebergPartitionSpecField[];
191
- }
192
- /** A field in an icebird `SortOrder`. */
193
- interface IcebergSortOrderField {
194
- 'source-id': number;
195
- 'transform': 'identity';
196
- 'direction': 'asc' | 'desc';
197
- 'null-order': 'nulls-first' | 'nulls-last';
198
- }
199
- /** An icebird `SortOrder` (Iceberg write-order). */
200
- interface IcebergSortOrder {
201
- 'order-id': number;
202
- 'fields': IcebergSortOrderField[];
203
- }
204
- /** Everything needed to talk to the R2 Data Catalog. */
205
- interface IcebergCatalogConfig {
206
- /** REST catalog URI, e.g. `https://catalog.cloudflarestorage.com/<acct>/<warehouse>`. */
207
- catalogUri: string;
208
- /** Warehouse identifier, e.g. `<acct>_gscdump-analytics`. */
209
- warehouse: string;
210
- /** Catalog namespace the 5 fact tables live under (e.g. `gsc`). */
211
- namespace: string;
212
- /** Bearer token for the REST catalog (`R2_CATALOG_TOKEN`). */
213
- catalogToken: string;
214
- /** R2 S3 credentials for the warehouse objects. */
215
- s3: IcebergS3Config;
216
- }
217
- /** The connected catalog context + a signed S3 resolver — the icebird call inputs. */
218
- interface IcebergConnection {
219
- /** icebird REST catalog context, passed as `{ catalog }` to icebird write fns. */
220
- catalog: Awaited<ReturnType<typeof restCatalogConnect>>;
221
- /** icebird S3 resolver (caching-wrapped), passed as `{ resolver }` to icebird fns. */
222
- resolver: ReturnType<typeof cachingResolver>;
223
- /** The namespace the fact tables live under. */
224
- namespace: string;
225
- }
226
72
  /**
227
- * Build the icebird `Schema` for one of the 5 fact tables from the frozen
73
+ * Build the icebird `Schema` for one of the 9 fact tables from the frozen
228
74
  * `ICEBERG_SCHEMAS` contract. Field ids are advisory — R2 Data Catalog
229
- * re-assigns them on `createTable` (see `ICEBERG_FIELD_ID_BASE`).
75
+ * re-assigns them on `createTable`.
230
76
  */
231
- declare function icebergSchemaFor(table: IcebergTableName, encoding?: PartitionKeyEncoding): IcebergSchema;
77
+ declare function icebergSchemaFor(table: IcebergTableName, encoding?: PartitionKeyEncoding$1): {
78
+ type: "struct";
79
+ 'schema-id': number;
80
+ fields: {
81
+ id: number;
82
+ name: string;
83
+ required: boolean;
84
+ type: import("@gscdump/lakehouse").IcebergPrimitiveType;
85
+ }[];
86
+ };
232
87
  /**
233
- * Build the icebird `PartitionSpec` for one of the 5 fact tables: the locked
234
- * spec `identity(site_id) + identity(search_type) + month(date)`. Each
235
- * partition field's `source-id` is resolved to the real column field id from
236
- * {@link icebergSchemaFor}.
88
+ * Build the icebird `PartitionSpec` for one of the 9 fact tables: the locked
89
+ * spec `identity(site_id) + identity(search_type) + month(date)`.
237
90
  */
238
- declare function icebergPartitionSpecFor(table: IcebergTableName, encoding?: PartitionKeyEncoding): IcebergPartitionSpec;
91
+ declare function icebergPartitionSpecFor(table: IcebergTableName, encoding?: PartitionKeyEncoding$1): {
92
+ 'spec-id': number;
93
+ fields: {
94
+ 'source-id': number;
95
+ 'field-id': number;
96
+ name: string;
97
+ transform: import("@gscdump/lakehouse").IcebergPartitionTransform;
98
+ }[];
99
+ };
239
100
  /**
240
101
  * Build the icebird `SortOrder` for a fact table from its `clusterKey`
241
- * (dimension-first, then `date`) — e.g. `pages` → sort by `url`, then `date`.
242
- *
243
- * Declared so any sort-aware compaction (a self-run `icebergRewrite`, or R2
244
- * managed compaction if/when it honors sort order) re-clusters merged files the
245
- * same way the append path already orders them ({@link sortByClusterKey} in
246
- * `append-sink.ts`). R2's managed compaction currently only bin-packs small
247
- * files without re-sorting, so this is forward-looking: it costs nothing today
248
- * (the table simply carries the metadata) and means a future sort-aware pass
249
- * produces globally clustered files for free, maximizing row-group skipping on
250
- * the DuckDB-over-R2 read path. clusterKey columns are all non-null, so the
251
- * null ordering is moot; `identity`/`asc` mirrors the physical write order.
102
+ * (dimension-first, then `date`).
252
103
  */
253
- declare function icebergSortOrderFor(table: IcebergTableName, encoding?: PartitionKeyEncoding): IcebergSortOrder;
254
- /** Options for {@link connectIcebergCatalog}. */
255
- interface ConnectIcebergOptions {
256
- /**
257
- * Optional cross-isolate cache (any unstorage driver). When supplied, the
258
- * `/v1/config` REST probe is served from cache on a warm catalog, removing
259
- * one serial network hop from cold-isolate connects. The bearer token is
260
- * NEVER cached — only the warehouse-static routing config (`url`, `prefix`,
261
- * `defaults`, `overrides`) is; `requestInit` is rebuilt from `config`.
262
- */
263
- cache?: CatalogCache;
264
- /** Injectable clock for the cache TTL. Defaults to `Date.now`. */
265
- clock?: () => number;
266
- }
267
- /**
268
- * Connect to the R2 Data Catalog: a REST catalog context + a signed S3
269
- * resolver. Runs in Node and in `workerd` — SigV4 is Web Crypto, I/O is
270
- * `fetch`, no node builtins.
271
- *
272
- * With a `cache`, the `/v1/config` probe is skipped on a warm catalog and the
273
- * context is rebuilt from the cached routing config plus the freshly-derived
274
- * bearer `requestInit`. icebird reads only `url`/`prefix`/`requestInit` from
275
- * the context downstream, so this is a faithful, secret-free reconstruction.
276
- */
277
- declare function connectIcebergCatalog(config: IcebergCatalogConfig, opts?: ConnectIcebergOptions): Promise<IcebergConnection>;
278
- /** Tunable retry policy for {@link icebergAppendRetrying}. */
279
- interface CommitRetryOptions {
280
- /** Total attempts, including the first. Default 6. */
281
- maxAttempts?: number;
282
- /** Base (ms) for the exponential back-off ceiling. Default 1000. */
283
- baseDelayMs?: number;
284
- /** Hard cap (ms) on the back-off ceiling. Default 20_000. */
285
- maxDelayMs?: number;
286
- /** Injectable sleep — tests pass a synchronous no-op. */
287
- sleep?: (ms: number) => Promise<void>;
288
- /** Injectable RNG for the jitter — tests pass a deterministic value. */
289
- random?: () => number;
290
- /**
291
- * Idempotency token stamped into the appended snapshot's summary
292
- * (`gscdump.append-id`) and matched by the landed-check. When omitted it is
293
- * DERIVED from the records' content (a SHA-256 over each row's full
294
- * key/value, order-independent — see {@link deriveAppendId}), making the
295
- * token STABLE across PROCESSES, not just within one call's retry loop.
296
- *
297
- * Why content-derived (superseding the old "random per call" choice): the
298
- * double we actually hit is a CROSS-RUN re-append — a job commits the
299
- * Iceberg snapshot, then dies/evicts before its D1 ledger write, so a queue
300
- * RETRY (or operator re-resync) re-runs from scratch, re-emits the identical
301
- * buffer, and appends a second copy. A random per-call id can't catch that
302
- * (the retry is a new process → new id). A content hash CAN: the retry
303
- * re-derives the same id, the pre-append landed-check finds it, and skips.
304
- *
305
- * The old "content hash risks a false skip / data loss" fear does NOT apply
306
- * to this fact model: a date is appended exactly once when finalized and
307
- * never revised (revisions go through the OVERWRITE path), and pagination
308
- * pages carry DIFFERENT rows → a different content hash → never falsely
309
- * skipped. Two appends collide only if their full row-sets are byte-identical
310
- * — which is precisely a duplicate that SHOULD be skipped. Injectable for
311
- * deterministic tests.
312
- */
313
- appendId?: string;
314
- }
315
- /**
316
- * True when `err` is an R2 Data Catalog commit rate-limit response
317
- * (`429 too many commits to this table`). Matches a numeric `status` of 429
318
- * or the message text, so it holds whether icebird surfaces the raw HTTP
319
- * error or a wrapped `Error`.
320
- */
321
- declare function isCommitRateLimited(err: unknown): boolean;
322
- /**
323
- * `icebergAppend` wrapped with retry on R2 Data Catalog 429 commit
324
- * rate-limits, using full-jitter exponential back-off. icebird already
325
- * retries 412/409 internally; 429 is the gap this closes. Non-429 errors
326
- * (and 429s that survive every attempt) propagate unchanged.
327
- *
328
- * IDEMPOTENT RE-RUN. A 429 that escapes icebird's internal loop is retried HERE
329
- * by re-running the whole `icebergAppend`, which re-prepares + re-uploads the
330
- * data files. That is safe ONLY if the prior attempt's commit did NOT land — but
331
- * R2 Data Catalog can apply a commit and STILL return 429 (rate-limit on the
332
- * response path), and a lost ack looks identical. A blind re-run then appends a
333
- * SECOND copy of the same rows → silent double-count (observed: a re-resynced
334
- * site read 2× its true totals). To close this, every attempt stamps a per-call
335
- * `gscdump.append-id` into the snapshot summary, and BEFORE retrying or giving
336
- * up we reload the table and check whether that id already landed
337
- * ({@link appendAlreadyLanded}); if so the append succeeded and we return without
338
- * re-appending. A non-landed 429 still re-runs (the previous attempt's parquet is
339
- * an orphan, reclaimed by R2 cleanup) — same as before, no double.
340
- */
341
- declare function icebergAppendRetrying(args: Parameters<typeof icebergAppend>[0], options?: CommitRetryOptions): Promise<void>;
104
+ declare function icebergSortOrderFor(table: IcebergTableName, encoding?: PartitionKeyEncoding$1): {
105
+ 'order-id': number;
106
+ fields: {
107
+ 'source-id': number;
108
+ transform: "identity";
109
+ direction: "asc";
110
+ 'null-order': "nulls-last";
111
+ }[];
112
+ };
342
113
  /**
343
114
  * Outcome of a single table create/drop: the table name plus a `Result` —
344
115
  * `Ok(void)` on success, `Err(iceberg-table-op-failed)` carrying the failure
345
- * message (and original `cause`) when the catalog rejects the op (e.g. "table
346
- * already exists", a 5xx). Per-table so a partial provisioning run is fully
347
- * observable; the human-readable string lives on `error.message`.
116
+ * message when the catalog rejects the op. Per-table so a partial
117
+ * provisioning run is fully observable.
348
118
  */
349
119
  interface IcebergTableOpResult {
350
120
  table: string;
351
121
  outcome: Result<void, EngineError>;
352
122
  }
353
123
  /**
354
- * Ensure the catalog namespace exists. Idempotent an "already exists"
355
- * response from the REST catalog is swallowed.
124
+ * Create the global Iceberg fact tables with the locked partition spec and
125
+ * the schema derived from {@link ICEBERG_SCHEMAS}. Per-table errors are
126
+ * captured rather than thrown so a partial run is observable.
356
127
  */
357
- declare function ensureIcebergNamespace(conn: IcebergConnection): Promise<void>;
128
+ declare function createIcebergTables(conn: IcebergConnection, tables?: readonly IcebergTableName[], encoding?: PartitionKeyEncoding$1): Promise<IcebergTableOpResult[]>;
358
129
  /**
359
- * Create the global Iceberg fact tables with the locked partition spec
360
- * (`identity(site_id) + identity(search_type) + month(date)`) and the schema
361
- * derived from {@link ICEBERG_SCHEMAS}. Per-table errors are captured rather
362
- * than thrown so a partial run is observable; "table already exists" surfaces
363
- * as a failed result. Used by the app's one-off provisioning script.
364
- */
365
- declare function createIcebergTables(conn: IcebergConnection, tables?: readonly IcebergTableName[], encoding?: PartitionKeyEncoding): Promise<IcebergTableOpResult[]>;
366
- /**
367
- * List the table names currently in the catalog namespace.
368
- *
369
- * A genuinely-empty namespace resolves to `[]`. A LIST *failure* (catalog
370
- * unreachable, 401/403, 5xx) propagates rather than being masked as an empty
371
- * list — callers must be able to tell "no tables" from "couldn't ask".
130
+ * Drop tables from the catalog namespace, purging their data objects.
131
+ * Defaults to every table currently in the namespace. Delegates the walk to
132
+ * `@gscdump/lakehouse`'s generic `dropIcebergTables`, mapping its simpler
133
+ * `{table, ok, error?}` result back onto the engine's original `Result`-typed
134
+ * {@link IcebergTableOpResult} contract.
372
135
  */
373
- declare function listIcebergTables(conn: IcebergConnection): Promise<string[]>;
374
- /** A data file in the current snapshot's manifest, scoped to one partition. */
375
- interface IcebergListedDataFile {
376
- /** Raw Iceberg `data_file.file_path` (e.g. `s3://gscdump-analytics/.../x.parquet`). */
377
- filePath: string;
378
- /** Object key relative to the warehouse bucket (the part after `s3://<bucket>/`). */
379
- objectKey: string;
380
- bytes: number;
381
- rowCount: number;
382
- }
136
+ declare function dropIcebergTables$1(conn: IcebergConnection, tables?: readonly string[]): Promise<IcebergTableOpResult[]>;
383
137
  interface ListIcebergDataFilesOptions {
384
138
  table: IcebergTableName;
385
139
  /** Partition identity column. `number` for `'int'`-encoded catalogs. */
386
140
  siteId: string | number;
387
141
  /** Partition identity column. `number` (int code) for `'int'`-encoded catalogs. */
388
142
  searchType: string | number;
389
- /**
390
- * Partition-key encoding of the catalog. `'int'` changes how manifest-summary
391
- * bounds are decoded (int bytes vs UTF-8) and how the per-file partition value
392
- * is compared. Defaults to `'int'` for new catalogs; pass `'string'` for
393
- * legacy catalogs.
394
- */
395
- encoding?: PartitionKeyEncoding;
396
- /**
397
- * Inclusive date range. Every month touched by `[start, end]` is scanned;
398
- * `month(date)` is the third partition transform.
399
- */
143
+ encoding?: PartitionKeyEncoding$1;
400
144
  range: {
401
145
  start: string;
402
146
  end: string;
403
147
  };
404
- /**
405
- * Optional cross-isolate cache (any unstorage driver). When supplied, the
406
- * snapshot pointer is cached short (so a warm catalog skips `loadTable`) and
407
- * the resolved file list is cached long, content-addressed by snapshot id
408
- * (so it skips the manifest walk). Omit it to read straight from the catalog.
409
- */
410
- cache?: CatalogCache;
411
- /** Injectable clock for the cache TTLs. Defaults to `Date.now`. */
148
+ cache?: import('@gscdump/lakehouse').CatalogCache;
412
149
  clock?: () => number;
413
- /**
414
- * Optional read-path profiler. Emits `iceberg.snapshot` (snapshot-pointer
415
- * load), `iceberg.cache` (resolved-files lookup + hit/miss), and
416
- * `iceberg.walk` (manifest fetch + entry scan, with manifest/file counts) —
417
- * the catalog cold-start breakdown a hosted reader wants in `Server-Timing`.
418
- */
419
150
  profiler?: QueryProfiler;
420
151
  }
421
152
  /**
422
- * List the parquet data files in the current snapshot of `table`, filtered to a
423
- * single partition slice `(siteId, searchType, month(date) ∈ range)`.
424
- *
425
- * The shared `gsc.<table>` tables are multi-tenant, so a naive walk is O(all
426
- * tenants). This prunes the manifest LIST by partition summaries before
427
- * fetching any manifest's entries (see {@link buildPartitionFilter}), making
428
- * the fetch count independent of tenant count, and — when an unstorage `cache`
429
- * is supplied — skips the `loadTable` round-trip on a warm snapshot pointer and
430
- * the manifest walk entirely on a resolved-files hit. The final entry-level
431
- * partition filter is the authoritative correctness check; pruning only avoids
432
- * reading manifests that cannot match.
433
- *
434
- * Skips deleted entries (status=2) and non-data file types (delete files).
435
- * Returns object keys + bytes + rowCount so the caller can build presigned
436
- * URLs without re-walking the catalog.
153
+ * List the parquet data files in the current snapshot of `table`, filtered to
154
+ * a single partition slice `(siteId, searchType, month(date) ∈ range)`.
437
155
  */
438
- declare function listIcebergDataFiles(conn: IcebergConnection, opts: ListIcebergDataFilesOptions): Promise<IcebergListedDataFile[]>;
439
- /**
440
- * Drop tables from the catalog namespace, purging their data objects.
441
- * Defaults to every table currently in the namespace — used to clear the
442
- * wrong-spec Pipelines-provisioned `gsc.*` tables before re-creating them.
443
- */
444
- declare function dropIcebergTables(conn: IcebergConnection, tables?: readonly string[]): Promise<IcebergTableOpResult[]>;
156
+ declare function listIcebergDataFiles(conn: IcebergConnection, opts: ListIcebergDataFilesOptions): Promise<import("@gscdump/lakehouse").IcebergListedDataFile[]>;
445
157
  /**
446
158
  * Identifies one fact slice — the atomic unit a sink emits.
447
159
  * `(table, site, searchType, date)`. `userId` rides along on `ctx` for
@@ -554,15 +266,6 @@ interface IcebergAppendSinkOptions extends SinkOptions {
554
266
  * INT is ample (≪ 2.1B sites), so no LONG/BigInt is involved. See
555
267
  * {@link import('./iceberg/schema').PartitionKeyEncoding}.
556
268
  */
557
- encoding?: PartitionKeyEncoding;
558
- }
559
- /** `LocalIcebergSink` options — points at the local Iceberg REST catalog. */
560
- interface LocalIcebergSinkOptions extends SinkOptions {
561
- /** Iceberg REST catalog URI (POC: `apache/iceberg-rest-fixture`). */
562
- catalogUri: string;
563
- /** Catalog namespace the 5 tables live under. */
564
- namespace: string;
565
- /** S3-compatible warehouse location (POC: MinIO). */
566
- warehouse: string;
269
+ encoding?: PartitionKeyEncoding$1;
567
270
  }
568
- export { CatalogCache, CommitRetryOptions, ConnectIcebergOptions, DEFAULT_PARTITION_KEY_ENCODING, ICEBERG_FIELD_ID_BASE, ICEBERG_PARTITION_COLUMNS, ICEBERG_PARTITION_SPEC, ICEBERG_SCHEMAS, ICEBERG_SCHEMAS_INT, ICEBERG_SCHEMAS_STRING, ICEBERG_TABLES, INT_SEARCH_TYPE, IcebergAppendSinkOptions, IcebergCatalogConfig, IcebergColumn, IcebergColumnType, IcebergConnection, IcebergListedDataFile, IcebergPartitionField, IcebergPartitionSpec, IcebergPartitionSpecField, IcebergPartitionTransform, IcebergPrimitiveType, IcebergS3Config, IcebergSchema, IcebergSchemaField, IcebergSortOrder, IcebergSortOrderField, IcebergTableName, IcebergTableOpResult, IcebergTableSpec, ListIcebergDataFilesOptions, LocalIcebergSinkOptions, PartitionKeyEncoding, SEARCH_TYPE_INT, Sink, SinkCapabilities, SinkCloseResult, SinkOptions, SinkSlice, SinkWriteResult, SliceOverwriteWriter, assertIcebergTable, connectIcebergCatalog, createIcebergTables, dropIcebergTables, ensureIcebergNamespace, icebergAppendRetrying, icebergPartitionColumns, icebergPartitionSpecFor, icebergSchemaFor, icebergSchemasFor, icebergSortOrderFor, icebergTableSpec, isCommitRateLimited, isIcebergTable, listIcebergDataFiles, listIcebergTables };
271
+ export { type CatalogCache, type CommitRetryOptions, type ConnectIcebergOptions, DEFAULT_PARTITION_KEY_ENCODING$1 as DEFAULT_PARTITION_KEY_ENCODING, ICEBERG_FIELD_ID_BASE, ICEBERG_PARTITION_COLUMNS, ICEBERG_PARTITION_SPEC, ICEBERG_SCHEMAS, ICEBERG_SCHEMAS_INT, ICEBERG_SCHEMAS_STRING, ICEBERG_TABLES, INT_SEARCH_TYPE, IcebergAppendSinkOptions, type IcebergCatalogConfig, type IcebergColumn$1 as IcebergColumn, type IcebergColumnType, type IcebergConnection$1 as IcebergConnection, type IcebergListedDataFile, type IcebergPartitionField$1 as IcebergPartitionField, type IcebergPartitionSpec, type IcebergPartitionSpecField, type IcebergPartitionTransform, type IcebergPrimitiveType, type IcebergS3Config, type IcebergSchema, type IcebergSchemaField, type IcebergSortOrder, type IcebergSortOrderField, IcebergTableName, IcebergTableOpResult, type IcebergTableSpec$1 as IcebergTableSpec, ListIcebergDataFilesOptions, type PartitionKeyEncoding$1 as PartitionKeyEncoding, SEARCH_TYPE_INT, Sink, SinkCapabilities, SinkCloseResult, SinkOptions, SinkSlice, SinkWriteResult, SliceOverwriteWriter, assertIcebergTable, connectIcebergCatalog, createIcebergTables, dropIcebergTables$1 as dropIcebergTables, ensureIcebergNamespace, icebergAppendRetrying, icebergPartitionColumns, icebergPartitionSpecFor, icebergSchemaFor, icebergSchemasFor, icebergSortOrderFor, icebergTableSpec, isCommitRateLimited, isIcebergTable, listIcebergDataFiles, listIcebergTables };
@@ -1,10 +1,10 @@
1
1
  import { arrowToRows } from "../arrow-utils.mjs";
2
2
  import { createRequire } from "node:module";
3
- import { join } from "node:path";
4
- import { fileURLToPath } from "node:url";
5
- import process from "node:process";
6
3
  import { unlinkSync } from "node:fs";
7
4
  import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import process from "node:process";
7
+ import { fileURLToPath } from "node:url";
8
8
  import { ConsoleLogger, NODE_RUNTIME, VoidLogger, createDuckDB } from "@duckdb/duckdb-wasm/dist/duckdb-node-blocking.cjs";
9
9
  const require_ = createRequire(typeof __filename !== "undefined" ? __filename : typeof import.meta !== "undefined" ? fileURLToPath(import.meta.url) : process.cwd());
10
10
  let singleton = null;