@gscdump/engine 0.37.3 → 0.37.4

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.
@@ -1,9 +1,13 @@
1
1
  import { coerceBigIntToNumber } from "@gscdump/lakehouse";
2
2
  function coerceRow(row) {
3
3
  let mutated = null;
4
- for (const [k, v] of Object.entries(row)) if (typeof v === "bigint") {
5
- if (!mutated) mutated = { ...row };
6
- mutated[k] = coerceBigIntToNumber(v);
4
+ for (const k in row) {
5
+ if (!Object.hasOwn(row, k)) continue;
6
+ const v = row[k];
7
+ if (typeof v === "bigint") {
8
+ if (!mutated) mutated = { ...row };
9
+ mutated[k] = coerceBigIntToNumber(v);
10
+ }
7
11
  }
8
12
  return mutated ?? row;
9
13
  }
@@ -1,6 +1,6 @@
1
1
  import { dayPartition, inferSearchType, mondayOfWeek, monthPartition, objectKey, quarterOfMonth, quarterPartition, weekPartition } from "./layout.mjs";
2
2
  import { currentSchemaVersion } from "./schema.mjs";
3
- import { MS_PER_DAY } from "gscdump";
3
+ import { MS_PER_DAY } from "gscdump/dates";
4
4
  const DAILY_PARTITION_RE = /^daily\/(\d{4}-\d{2}-\d{2})$/;
5
5
  const WEEKLY_PARTITION_RE = /^weekly\/(\d{4}-\d{2}-\d{2})$/;
6
6
  const MONTHLY_PARTITION_RE = /^monthly\/(\d{4}-\d{2})$/;
@@ -414,8 +414,13 @@ function createStorageEngine(opts) {
414
414
  if (probed) bytes = probed.bytes;
415
415
  }
416
416
  if (bytes > 104857600) {
417
- await dataSource.delete([key]).catch(() => {});
418
- throw new Error(`writeDay payload ${bytes} bytes exceeds ${MAX_DAY_BYTES} hard ceiling (table=${ctx.table}, key=${key})`);
417
+ const sizeError = /* @__PURE__ */ new Error(`writeDay payload ${bytes} bytes exceeds ${MAX_DAY_BYTES} hard ceiling (table=${ctx.table}, key=${key})`);
418
+ try {
419
+ await dataSource.delete([key]);
420
+ } catch (deleteError) {
421
+ throw new AggregateError([sizeError, deleteError], `${sizeError.message}; failed to delete oversized object`);
422
+ }
423
+ throw sizeError;
419
424
  }
420
425
  const entry = {
421
426
  userId: ctx.userId,
@@ -482,8 +487,13 @@ function createStorageEngine(opts) {
482
487
  if (probed) bytes = probed.bytes;
483
488
  }
484
489
  if (bytes > 104857600) {
485
- await dataSource.delete([key]).catch(() => {});
486
- throw new Error(`writeHour payload ${bytes} bytes exceeds ${MAX_DAY_BYTES} hard ceiling (table=${ctx.table}, key=${key})`);
490
+ const sizeError = /* @__PURE__ */ new Error(`writeHour payload ${bytes} bytes exceeds ${MAX_DAY_BYTES} hard ceiling (table=${ctx.table}, key=${key})`);
491
+ try {
492
+ await dataSource.delete([key]);
493
+ } catch (deleteError) {
494
+ throw new AggregateError([sizeError, deleteError], `${sizeError.message}; failed to delete oversized object`);
495
+ }
496
+ throw sizeError;
487
497
  }
488
498
  const entry = {
489
499
  userId: ctx.userId,
@@ -569,7 +569,25 @@ function isoDate(ms) {
569
569
  return new Date(ms).toISOString().slice(0, 10);
570
570
  }
571
571
  function hashUrlList(urls) {
572
- return hashUrl(urls.map((u) => u.loc).sort().join("\n"));
572
+ return hashSortedUrlList(urls.map((u) => u.loc).sort());
573
+ }
574
+ function hashSortedUrlList(locs) {
575
+ let hi = 2166136261;
576
+ let lo = 3421674724;
577
+ for (let locIndex = 0; locIndex < locs.length; locIndex++) {
578
+ const loc = locs[locIndex];
579
+ const length = loc.length + (locIndex < locs.length - 1 ? 1 : 0);
580
+ for (let i = 0; i < length; i++) {
581
+ const c = i < loc.length ? loc.charCodeAt(i) : 10;
582
+ lo ^= c;
583
+ const loMul = Math.imul(lo, 435) >>> 0;
584
+ const carry = Math.floor(lo * 435 / 4294967296);
585
+ const hiMul = Math.imul(hi, 435) + Math.imul(lo, 1) + carry >>> 0;
586
+ lo = loMul;
587
+ hi = hiMul;
588
+ }
589
+ }
590
+ return (hi >>> 0).toString(16).padStart(8, "0") + (lo >>> 0).toString(16).padStart(8, "0");
573
591
  }
574
592
  function createSitemapStore(opts) {
575
593
  const ds = opts.dataSource;
@@ -621,7 +639,7 @@ function createSitemapStore(opts) {
621
639
  for await (const rec of this.loadUrls(ctx, feedpath, { includeRemoved: true })) priorByHash.set(rec.urlHash, rec);
622
640
  const livePrior = Array.from(priorByHash.values()).filter((r) => r.removedAt == null);
623
641
  if (livePrior.length > 0) {
624
- if (hashUrl(livePrior.map((r) => String(r.loc)).sort().join("\n")) === contentHash) return {
642
+ if (hashSortedUrlList(livePrior.map((r) => String(r.loc)).sort()) === contentHash) return {
625
643
  added: 0,
626
644
  removed: 0,
627
645
  kept: livePrior.length,
@@ -1,4 +1,4 @@
1
- import { MS_PER_DAY, toIsoDate } from "gscdump";
1
+ import { MS_PER_DAY, toIsoDate } from "gscdump/dates";
2
2
  function dayPartition(date) {
3
3
  return `daily/${date}`;
4
4
  }
@@ -1,9 +1,9 @@
1
1
  import { arrowToRows } from "../arrow-utils.mjs";
2
2
  import { createRequire } from "node:module";
3
+ import process from "node:process";
3
4
  import { unlinkSync } from "node:fs";
4
5
  import { tmpdir } from "node:os";
5
6
  import { join } from "node:path";
6
- import process from "node:process";
7
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());
@@ -64,10 +64,15 @@ function createNodeDuckDBHandle(opts = {}) {
64
64
  for (const name of names) {
65
65
  try {
66
66
  db.dropFile(name);
67
- } catch {}
67
+ } catch (error) {
68
+ const message = error instanceof Error ? error.message : String(error);
69
+ if (!/not found|does not exist|unknown file/i.test(message)) throw error;
70
+ }
68
71
  try {
69
72
  unlinkSync(name);
70
- } catch {}
73
+ } catch (error) {
74
+ if (error.code !== "ENOENT") throw error;
75
+ }
71
76
  }
72
77
  },
73
78
  makeTempPath(ext) {
@@ -95,7 +95,11 @@ function createFilesystemManifestStore(opts) {
95
95
  const tmp = `${manifestPath}.${randomBytes(6).toString("hex")}.tmp`;
96
96
  await writeFile(tmp, JSON.stringify(data), "utf8");
97
97
  await rename(tmp, manifestPath).catch(async (err) => {
98
- await unlink(tmp).catch(() => {});
98
+ try {
99
+ await unlink(tmp);
100
+ } catch (cleanupError) {
101
+ throw new AggregateError([err, cleanupError], `failed to replace manifest and remove temporary file ${tmp}`);
102
+ }
99
103
  throw err;
100
104
  });
101
105
  }
@@ -112,7 +116,7 @@ function createFilesystemManifestStore(opts) {
112
116
  async function drain() {
113
117
  if (running) return;
114
118
  running = true;
115
- while (queue.length > 0) await queue.shift()().catch(() => {});
119
+ while (queue.length > 0) await queue.shift()();
116
120
  running = false;
117
121
  }
118
122
  async function registerVersionsImpl(newEntries, superseding) {
package/dist/index.mjs CHANGED
@@ -41,6 +41,16 @@ function createNoopIngestAccumulator() {
41
41
  function createIngestAccumulator(opts) {
42
42
  const { engine, ctx, hooks, ...accOpts } = opts;
43
43
  const acc = createRowAccumulator(accOpts);
44
+ function reportHookFailure(hook, error) {
45
+ console.warn(`[gscdump/engine] ${hook} hook failed`, error);
46
+ }
47
+ async function notifyWriteError(info) {
48
+ try {
49
+ await hooks.onWriteError(info);
50
+ } catch (hookError) {
51
+ reportHookFailure("onWriteError", hookError);
52
+ }
53
+ }
44
54
  async function writeOne(table, date, rows) {
45
55
  const scope = scopeOf(ctx, table, date);
46
56
  return (ctx.grain === "hour" ? engine.writeHour ?? (() => Promise.reject(/* @__PURE__ */ new Error("ingest accumulator: grain=hour requires engine.writeHour"))) : engine.writeDay)(scope, rows).then(() => engine.setSyncState(scope, "done")).then(async () => {
@@ -54,23 +64,31 @@ function createIngestAccumulator(opts) {
54
64
  rows: rows.length
55
65
  };
56
66
  }).catch(async (err) => {
57
- await hooks.onWriteError({
67
+ await notifyWriteError({
58
68
  table,
59
69
  date,
60
70
  error: err
61
- }).catch(() => {});
71
+ });
62
72
  return { ok: false };
63
73
  });
64
74
  }
65
75
  async function recover(table, date) {
66
76
  const scope = scopeOf(ctx, table, date);
67
- await engine.setSyncState(scope, "failed", { error: "mid-continuation-skip" }).catch(() => {});
77
+ try {
78
+ await engine.setSyncState(scope, "failed", { error: "mid-continuation-skip" });
79
+ } catch (stateError) {
80
+ await notifyWriteError({
81
+ table,
82
+ date,
83
+ error: stateError
84
+ });
85
+ }
68
86
  return hooks.onRecover(table, date).catch(async (err) => {
69
- await hooks.onWriteError({
87
+ await notifyWriteError({
70
88
  table,
71
89
  date,
72
90
  error: err
73
- }).catch(() => {});
91
+ });
74
92
  return false;
75
93
  });
76
94
  }
@@ -86,18 +104,18 @@ function createIngestAccumulator(opts) {
86
104
  const tasks = [];
87
105
  for (const [table, byDate] of buckets) for (const date of byDate.keys()) tasks.push(recover(table, date));
88
106
  const results = await Promise.all(tasks).catch(async (err) => {
89
- await hooks.onWriteError({
107
+ await notifyWriteError({
90
108
  table: null,
91
109
  date: null,
92
110
  error: err
93
- }).catch(() => {});
111
+ });
94
112
  return [];
95
113
  });
96
- if (overflowed) await hooks.onWriteError({
114
+ if (overflowed) await notifyWriteError({
97
115
  table: null,
98
116
  date: null,
99
117
  error: /* @__PURE__ */ new Error(`ingest accumulator overflow at ${totalRows} rows; recovering via forced re-sync`)
100
- }).catch(() => {});
118
+ });
101
119
  return {
102
120
  flushed: 0,
103
121
  recovered: results.filter(Boolean).length,
@@ -115,10 +133,14 @@ function createIngestAccumulator(opts) {
115
133
  flushed++;
116
134
  rowsWritten += o.rows;
117
135
  } else failed++;
118
- if (flushed > 0) await hooks.onJobComplete?.({
119
- flushed,
120
- rowsWritten
121
- }).catch(() => {});
136
+ if (flushed > 0 && hooks.onJobComplete) try {
137
+ await hooks.onJobComplete({
138
+ flushed,
139
+ rowsWritten
140
+ });
141
+ } catch (hookError) {
142
+ reportHookFailure("onJobComplete", hookError);
143
+ }
122
144
  return {
123
145
  flushed,
124
146
  recovered: 0,
package/dist/ingest.mjs CHANGED
@@ -18,7 +18,37 @@ const TABLE_DIMS = {
18
18
  ],
19
19
  hourly_pages: ["hour", "page"]
20
20
  };
21
+ const COMMON_HTTP_AUTHORITY_RE = /^([\w.~-]+)(?::(\d{1,5}))?$/;
22
+ const IPV4_HOST_RE = /^\d+(?:\.\d+){3}$/;
23
+ function isCommonHttpAuthority(value) {
24
+ const match = COMMON_HTTP_AUTHORITY_RE.exec(value);
25
+ if (!match) return false;
26
+ if (match[2] !== void 0 && Number(match[2]) > 65535) return false;
27
+ const host = match[1];
28
+ if (IPV4_HOST_RE.test(host) && host.split(".").some((part) => Number(part) > 255)) return false;
29
+ return true;
30
+ }
21
31
  function toPath(gscUrl) {
32
+ const authorityStart = gscUrl.startsWith("https://") ? 8 : gscUrl.startsWith("http://") ? 7 : -1;
33
+ if (authorityStart > 0) {
34
+ const slash = gscUrl.indexOf("/", authorityStart);
35
+ const query = gscUrl.indexOf("?", authorityStart);
36
+ const hash = gscUrl.indexOf("#", authorityStart);
37
+ const backslash = gscUrl.indexOf("\\", authorityStart);
38
+ let firstDelimiter = gscUrl.length;
39
+ if (slash >= 0 && slash < firstDelimiter) firstDelimiter = slash;
40
+ if (query >= 0 && query < firstDelimiter) firstDelimiter = query;
41
+ if (hash >= 0 && hash < firstDelimiter) firstDelimiter = hash;
42
+ const authority = gscUrl.slice(authorityStart, firstDelimiter);
43
+ if (backslash < 0 && !/\s/.test(gscUrl) && firstDelimiter > authorityStart && isCommonHttpAuthority(authority)) {
44
+ if (firstDelimiter !== slash) return "/";
45
+ let pathEnd = gscUrl.length;
46
+ if (query > slash && query < pathEnd) pathEnd = query;
47
+ if (hash > slash && hash < pathEnd) pathEnd = hash;
48
+ const path = gscUrl.slice(slash, pathEnd);
49
+ if (!/[^\x20-\x7E]/.test(path) && !/(?:^|\/)(?:\.|%2e){1,2}(?:\/|$)/i.test(path)) return path;
50
+ }
51
+ }
22
52
  try {
23
53
  return new URL(gscUrl).pathname;
24
54
  } catch {
@@ -209,19 +239,6 @@ function createRowAccumulator(options = {}) {
209
239
  const latestDate = /* @__PURE__ */ new Map();
210
240
  let total = 0;
211
241
  let overflowed = false;
212
- function bucketFor(table, date) {
213
- let byDate = buckets.get(table);
214
- if (!byDate) {
215
- byDate = /* @__PURE__ */ new Map();
216
- buckets.set(table, byDate);
217
- }
218
- let rows = byDate.get(date);
219
- if (!rows) {
220
- rows = [];
221
- byDate.set(date, rows);
222
- }
223
- return rows;
224
- }
225
242
  return {
226
243
  get totalRows() {
227
244
  return total;
@@ -231,20 +248,30 @@ function createRowAccumulator(options = {}) {
231
248
  },
232
249
  push(table, rows) {
233
250
  if (overflowed) return false;
251
+ let byDate = buckets.get(table);
252
+ let newestDate = trackDateBoundary ? latestDate.get(table) : void 0;
234
253
  for (const r of rows) {
235
254
  const t = transformGscRow(table, r, options);
236
255
  if (!t || !t.date) continue;
237
- bucketFor(table, t.date).push(t.row);
238
- total++;
239
- if (trackDateBoundary) {
240
- const prev = latestDate.get(table);
241
- if (!prev || t.date > prev) latestDate.set(table, t.date);
256
+ if (!byDate) {
257
+ byDate = /* @__PURE__ */ new Map();
258
+ buckets.set(table, byDate);
242
259
  }
260
+ let dateRows = byDate.get(t.date);
261
+ if (!dateRows) {
262
+ dateRows = [];
263
+ byDate.set(t.date, dateRows);
264
+ }
265
+ dateRows.push(t.row);
266
+ total++;
267
+ if (trackDateBoundary && (!newestDate || t.date > newestDate)) newestDate = t.date;
243
268
  if (total > maxRows) {
269
+ if (newestDate) latestDate.set(table, newestDate);
244
270
  overflowed = true;
245
271
  return false;
246
272
  }
247
273
  }
274
+ if (newestDate) latestDate.set(table, newestDate);
248
275
  return true;
249
276
  },
250
277
  drain() {
@@ -1,9 +1,9 @@
1
- import { MS_PER_DAY, daysAgo, toIsoDate } from "gscdump";
1
+ import { MS_PER_DAY, daysAgoUtc, toIsoDate } from "gscdump/dates";
2
2
  function defaultEndDate() {
3
- return daysAgo(3);
3
+ return daysAgoUtc(3);
4
4
  }
5
5
  function defaultStartDate() {
6
- return daysAgo(31);
6
+ return daysAgoUtc(31);
7
7
  }
8
8
  function periodOf(params) {
9
9
  return {
@@ -332,26 +332,6 @@ declare const topKeywords28dRollup: RollupDef;
332
332
  * coexist during a migration.
333
333
  */
334
334
  declare const topKeywords28dParquetRollup: RollupDef;
335
- /**
336
- * Materialises canonical-query variant grouping so the read path
337
- * (`buildExtrasQueries` in `resolver/compile.ts`) becomes a passthrough scan
338
- * instead of two window passes (`ROW_NUMBER`/`COUNT` over `PARTITION BY
339
- * query_canonical`) plus a `GROUP_CONCAT` over the whole `queries` table on
340
- * every request — work that is single-threaded under DuckDB-WASM/Workers and
341
- * scales with table size. See ADR-0017.
342
- *
343
- * One row per `query_canonical` group, columns named 1:1 with the live query's
344
- * output (`joinKey`, `variantCount`, `canonicalName`, `variants`) so
345
- * `mergeExtras` consumes either source unchanged. `variants` packs the top-10
346
- * variants as `query:::clicks:::impressions:::position` joined by `||`,
347
- * identical to the live composer.
348
- *
349
- * Full history (`windowDays: null`), not a trailing window: grouping metadata
350
- * is global (which variant is canonical, how many variants exist) and stays
351
- * stable across requests rather than shifting with each query's date range.
352
- * Reflects the last sync/compaction, not the live tail — readers that need the
353
- * tail can layer a recent-overlay later (the envelope carries `builtAt`).
354
- */
355
335
  declare const queryCanonicalVariantsRollup: RollupDef;
356
336
  /**
357
337
  * Canonical-grained fact aggregate (ADR-0018 Gap 2): pre-sums the raw
package/dist/rollups.mjs CHANGED
@@ -3,7 +3,7 @@ import { engineErrors } from "./errors.mjs";
3
3
  import { encodeRowsToParquetFlex } from "./adapters/hyparquet.mjs";
4
4
  import { createIndexingMetadataStore, createQueryDimStore, createSitemapStore, inspectionParquetKey, sitemapUrlsIndexPrefix } from "./_chunks/entities.mjs";
5
5
  import { encodeJsonBigintSafe } from "@gscdump/lakehouse";
6
- import { MS_PER_DAY } from "gscdump";
6
+ import { MS_PER_DAY } from "gscdump/dates";
7
7
  function rollupPrefix(ctx, searchType) {
8
8
  const base = ctx.siteId ? `u_${ctx.userId}/${ctx.siteId}/rollups` : `u_${ctx.userId}/rollups`;
9
9
  return searchType !== void 0 && searchType !== "web" ? `${base}/${searchType}` : base;
@@ -569,6 +569,24 @@ const topKeywords28dParquetRollup = {
569
569
  }));
570
570
  }
571
571
  };
572
+ const CANONICAL_VARIANT_LIMIT = 10;
573
+ function retainCanonicalVariant(bucket, query, clicks, impressions, sumPos) {
574
+ bucket.count++;
575
+ let insertAt = 0;
576
+ while (insertAt < bucket.top.length) {
577
+ const existing = bucket.top[insertAt];
578
+ if (clicks > existing.clicks || clicks === existing.clicks && query.localeCompare(existing.query) < 0) break;
579
+ insertAt++;
580
+ }
581
+ if (insertAt >= CANONICAL_VARIANT_LIMIT) return;
582
+ bucket.top.splice(insertAt, 0, {
583
+ query,
584
+ clicks,
585
+ impressions,
586
+ sumPos
587
+ });
588
+ if (bucket.top.length > CANONICAL_VARIANT_LIMIT) bucket.top.pop();
589
+ }
572
590
  const queryCanonicalVariantsRollup = {
573
591
  id: "query_canonical_variants",
574
592
  windowDays: null,
@@ -641,28 +659,27 @@ const queryCanonicalVariantsRollup = {
641
659
  });
642
660
  for (const r of rows) {
643
661
  const joinKey = String(r.joinKey);
644
- const list = byCanonical.get(joinKey);
645
- const v = {
646
- query: String(r.query),
647
- clicks: Number(r.clicks),
648
- impressions: Number(r.impressions),
649
- sumPos: Number(r.sum_pos)
650
- };
651
- if (list) list.push(v);
652
- else byCanonical.set(joinKey, [v]);
662
+ let bucket = byCanonical.get(joinKey);
663
+ if (!bucket) {
664
+ bucket = {
665
+ count: 0,
666
+ top: []
667
+ };
668
+ byCanonical.set(joinKey, bucket);
669
+ }
670
+ retainCanonicalVariant(bucket, String(r.query), Number(r.clicks), Number(r.impressions), Number(r.sum_pos));
653
671
  }
654
672
  if (rows.length < 2e4) break;
655
673
  cursor = String(rows[rows.length - 1].query);
656
674
  }
657
675
  const out = [];
658
- for (const [joinKey, variants] of byCanonical) {
659
- variants.sort((a, b) => b.clicks - a.clicks || a.query.localeCompare(b.query));
660
- const canonicalName = variants[0]?.query ?? null;
661
- const top = variants.slice(0, 10).filter((v) => v.impressions > 0);
676
+ for (const [joinKey, bucket] of byCanonical) {
677
+ const canonicalName = bucket.top[0]?.query ?? null;
678
+ const top = bucket.top.filter((v) => v.impressions > 0);
662
679
  const variantsStr = top.length === 0 ? null : top.map((v) => `${v.query}:::${v.clicks}:::${v.impressions}:::${(v.sumPos / v.impressions + 1).toFixed(1)}`).join("||");
663
680
  out.push({
664
681
  joinKey,
665
- variantCount: BigInt(variants.length),
682
+ variantCount: BigInt(bucket.count),
666
683
  canonicalName,
667
684
  variants: variantsStr
668
685
  });
@@ -1083,6 +1100,40 @@ const sitemapHealthRollup = {
1083
1100
  };
1084
1101
  }
1085
1102
  };
1103
+ const RECENT_SITEMAP_CHANGE_LIMIT = 200;
1104
+ function isLessRecent(a, b) {
1105
+ return a.at < b.at || a.at === b.at && a.sequence > b.sequence;
1106
+ }
1107
+ function retainRecentSitemapChange(heap, change) {
1108
+ if (heap.length < RECENT_SITEMAP_CHANGE_LIMIT) {
1109
+ heap.push(change);
1110
+ let index = heap.length - 1;
1111
+ while (index > 0) {
1112
+ const parent = index - 1 >> 1;
1113
+ if (!isLessRecent(heap[index], heap[parent])) break;
1114
+ const parentValue = heap[parent];
1115
+ heap[parent] = heap[index];
1116
+ heap[index] = parentValue;
1117
+ index = parent;
1118
+ }
1119
+ return;
1120
+ }
1121
+ if (!isLessRecent(heap[0], change)) return;
1122
+ heap[0] = change;
1123
+ let index = 0;
1124
+ for (;;) {
1125
+ const left = index * 2 + 1;
1126
+ const right = left + 1;
1127
+ let leastRecent = index;
1128
+ if (left < heap.length && isLessRecent(heap[left], heap[leastRecent])) leastRecent = left;
1129
+ if (right < heap.length && isLessRecent(heap[right], heap[leastRecent])) leastRecent = right;
1130
+ if (leastRecent === index) return;
1131
+ const childValue = heap[leastRecent];
1132
+ heap[leastRecent] = heap[index];
1133
+ heap[index] = childValue;
1134
+ index = leastRecent;
1135
+ }
1136
+ }
1086
1137
  const sitemapChanges28dRollup = {
1087
1138
  id: "sitemap_changes_28d",
1088
1139
  windowDays: 28,
@@ -1094,6 +1145,7 @@ const sitemapChanges28dRollup = {
1094
1145
  const counts = /* @__PURE__ */ new Map();
1095
1146
  const addedTop = [];
1096
1147
  const removedTop = [];
1148
+ let sequence = 0;
1097
1149
  function key(k) {
1098
1150
  return `${k.day}\x00${k.feedpath}`;
1099
1151
  }
@@ -1112,20 +1164,18 @@ const sitemapChanges28dRollup = {
1112
1164
  added: 0,
1113
1165
  removed: 0
1114
1166
  };
1167
+ const change = {
1168
+ loc: d.loc,
1169
+ feedpath: d.feedpath,
1170
+ at: d.at,
1171
+ sequence: sequence++
1172
+ };
1115
1173
  if (d.op === "added") {
1116
1174
  cur.added += 1;
1117
- addedTop.push({
1118
- loc: d.loc,
1119
- feedpath: d.feedpath,
1120
- at: d.at
1121
- });
1175
+ retainRecentSitemapChange(addedTop, change);
1122
1176
  } else {
1123
1177
  cur.removed += 1;
1124
- removedTop.push({
1125
- loc: d.loc,
1126
- feedpath: d.feedpath,
1127
- at: d.at
1128
- });
1178
+ retainRecentSitemapChange(removedTop, change);
1129
1179
  }
1130
1180
  counts.set(k, cur);
1131
1181
  }
@@ -1133,12 +1183,15 @@ const sitemapChanges28dRollup = {
1133
1183
  if (a.day !== b.day) return a.day < b.day ? -1 : 1;
1134
1184
  return a.feedpath < b.feedpath ? -1 : 1;
1135
1185
  });
1136
- addedTop.sort((a, b) => b.at - a.at);
1137
- removedTop.sort((a, b) => b.at - a.at);
1186
+ const toRecentList = (heap) => heap.sort((a, b) => b.at - a.at || a.sequence - b.sequence).map(({ loc, feedpath, at }) => ({
1187
+ loc,
1188
+ feedpath,
1189
+ at
1190
+ }));
1138
1191
  return {
1139
1192
  days,
1140
- topAdded: addedTop.slice(0, 200),
1141
- topRemoved: removedTop.slice(0, 200)
1193
+ topAdded: toRecentList(addedTop),
1194
+ topRemoved: toRecentList(removedTop)
1142
1195
  };
1143
1196
  }
1144
1197
  };
@@ -1,5 +1,37 @@
1
1
  import { ICEBERG_SCHEMAS, assertIcebergTable } from "./_chunks/schema2.mjs";
2
- import { resolvePyIcebergPython, runPyIcebergWriter } from "@gscdump/lakehouse";
2
+ import process from "node:process";
3
+ const PYICEBERG_PYTHON_ENV = "GSCDUMP_ICEBERG_PYTHON";
4
+ const DEFAULT_PYICEBERG_PYTHON = "python3";
5
+ function resolvePyIcebergPython(override) {
6
+ return override ?? process.env[PYICEBERG_PYTHON_ENV] ?? DEFAULT_PYICEBERG_PYTHON;
7
+ }
8
+ async function runPyIcebergWriter(options) {
9
+ const { execFile } = await import("node:child_process");
10
+ return new Promise((resolve, reject) => {
11
+ execFile(options.python, [options.script], { maxBuffer: 64 * 1024 * 1024 }, (err, stdout, stderr) => {
12
+ let parsed;
13
+ let parseError;
14
+ if (stdout.trim()) try {
15
+ parsed = JSON.parse(stdout);
16
+ } catch (error) {
17
+ parseError = error;
18
+ }
19
+ if (parsed && !(err && options.rejectOnProcessError)) {
20
+ resolve(parsed);
21
+ return;
22
+ }
23
+ if (err) {
24
+ if (options.processErrorAsParseFailure) {
25
+ reject(/* @__PURE__ */ new Error(`${options.label} produced no parseable output (${err.message})${stderr ? `: ${stderr}` : ""}`));
26
+ return;
27
+ }
28
+ reject(/* @__PURE__ */ new Error(`${options.label} process failed (${err.message})${stderr ? `: ${stderr}` : ""}`));
29
+ return;
30
+ }
31
+ reject(new Error(`${options.label} produced no parseable output: ${stdout || stderr}`, { cause: parseError }));
32
+ }).stdin?.end(JSON.stringify(options.job));
33
+ });
34
+ }
3
35
  function createIcebergOverwriteWriter(opts) {
4
36
  const { catalog, backend } = opts;
5
37
  async function overwriteSlice(slice, rows) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gscdump/engine",
3
3
  "type": "module",
4
- "version": "0.37.3",
4
+ "version": "0.37.4",
5
5
  "description": "Append-only Parquet/DuckDB storage engine + planner + adapters for the gscdump pipeline. Node + edge runtimes; opt-in heavy peers.",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",
@@ -181,12 +181,13 @@
181
181
  "dependencies": {
182
182
  "drizzle-orm": "1.0.0-rc.3",
183
183
  "proper-lockfile": "^4.1.2",
184
- "@gscdump/lakehouse": "0.37.3",
185
- "@gscdump/contracts": "0.37.3",
186
- "gscdump": "0.37.3"
184
+ "gscdump": "0.37.4",
185
+ "@gscdump/contracts": "0.37.4",
186
+ "@gscdump/lakehouse": "0.37.4"
187
187
  },
188
188
  "devDependencies": {
189
189
  "@duckdb/duckdb-wasm": "^1.32.0",
190
+ "@types/node": "^26.1.0",
190
191
  "@types/proper-lockfile": "^4.1.4",
191
192
  "aws4fetch": "^1.0.20",
192
193
  "hyparquet": "^1.26.2",