@gscdump/analysis 0.40.1 → 1.0.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.
@@ -1,117 +0,0 @@
1
- import { AnalysisQuerySource, QueryRow } from "@gscdump/engine/source";
2
- type ContentGapDevice = 'webgpu' | 'wasm';
3
- type ContentGapEmbeddingRole = 'query' | 'passage';
4
- type ContentGapExtractor = (texts: string[], opts: {
5
- pooling: 'mean';
6
- normalize: boolean;
7
- }) => Promise<{
8
- data: Float32Array;
9
- dims: number[];
10
- }>;
11
- interface EmbeddedContentGapTexts {
12
- vectors: Float32Array[];
13
- hits: number;
14
- misses: number;
15
- }
16
- interface ContentGapEmbeddingCache {
17
- getMany: (role: ContentGapEmbeddingRole, texts: string[]) => Promise<Map<string, Float32Array>>;
18
- putMany: (role: ContentGapEmbeddingRole, entries: Array<[string, Float32Array]>) => Promise<void>;
19
- }
20
- declare function createMemoryContentGapCache(): ContentGapEmbeddingCache;
21
- interface ContentGapEmbeddingRuntime {
22
- modelId: string;
23
- queryPrefix: string;
24
- selectDevice: (requested?: ContentGapDevice) => Promise<ContentGapDevice>;
25
- loadExtractor: (device: ContentGapDevice) => Promise<ContentGapExtractor>;
26
- embed: (extractor: ContentGapExtractor, role: ContentGapEmbeddingRole, texts: string[], transform: (text: string) => string, onProgress: (done: number, total: number) => void) => Promise<EmbeddedContentGapTexts>;
27
- }
28
- interface ContentGapQueryCandidate {
29
- query: string;
30
- impressions: number;
31
- clicks: number;
32
- avgPosition: number;
33
- currentUrl: string;
34
- }
35
- interface ContentGapInputOptions {
36
- maxQueries: number;
37
- maxUrls: number;
38
- minImpressions: number;
39
- }
40
- type ExecuteSql = (sql: string, params?: unknown[]) => Promise<QueryRow[]>;
41
- type NormalizeUrl = (url: string) => string;
42
- declare function fetchContentGapInputs(executeSql: ExecuteSql, options: ContentGapInputOptions, normalizeUrl: NormalizeUrl, now?: () => number): Promise<{
43
- queries: ContentGapQueryCandidate[];
44
- urls: string[];
45
- sqlMs: number;
46
- }>;
47
- interface ContentGapResult {
48
- query: string;
49
- impressions: number;
50
- clicks: number;
51
- avgPosition: number;
52
- currentUrl: string;
53
- currentSimilarity: number;
54
- suggestedUrl: string;
55
- suggestedSimilarity: number;
56
- alternatives: Array<{
57
- url: string;
58
- similarity: number;
59
- }>;
60
- divergence: number;
61
- impact: number;
62
- }
63
- interface ContentGapProgress {
64
- phase: 'idle' | 'loading-model' | 'fetching-data' | 'embedding-queries' | 'embedding-urls' | 'computing-gaps' | 'done' | 'error';
65
- message: string;
66
- done?: number;
67
- total?: number;
68
- modelMs?: number;
69
- sqlMs?: number;
70
- embedMs?: number;
71
- computeMs?: number;
72
- }
73
- interface ContentGapOptions {
74
- maxQueries?: number;
75
- maxUrls?: number;
76
- minImpressions?: number;
77
- minDivergence?: number;
78
- device?: 'webgpu' | 'wasm';
79
- onProgress?: (progress: ContentGapProgress) => void;
80
- }
81
- /**
82
- * Content-gap requires a source with a raw-SQL escape hatch. The analyzer's
83
- * query shape (CTEs + window functions against `main.page_queries`) isn't
84
- * expressible as a {@link BuilderState}, so it bypasses `queryRows` and
85
- * goes directly through `source.executeSql`.
86
- */
87
- declare class ContentGapSourceUnsupportedError extends Error {
88
- constructor(kind: string);
89
- }
90
- interface ContentGapAnalysis {
91
- results: ContentGapResult[];
92
- meta: {
93
- modelMs: number;
94
- sqlMs: number;
95
- embedMs: number;
96
- computeMs: number;
97
- cacheHits: number;
98
- totalInputs: number;
99
- device: 'webgpu' | 'wasm';
100
- modelId: string;
101
- };
102
- }
103
- interface ContentGapAnalyzer {
104
- analyze: (source: AnalysisQuerySource, opts?: ContentGapOptions) => Promise<ContentGapAnalysis>;
105
- }
106
- interface CreateContentGapAnalyzerOptions {
107
- embeddings?: ContentGapEmbeddingRuntime;
108
- loadInputs?: typeof fetchContentGapInputs;
109
- now?: () => number;
110
- }
111
- declare function normalizeUrl(u: string): string;
112
- declare function deriveUrlText(url: string): string;
113
- declare function cosineNormalized(a: Float32Array, b: Float32Array): number;
114
- declare function rankContentGaps(queries: ContentGapQueryCandidate[], urls: string[], queryEmbeddings: Float32Array[], urlEmbeddings: Float32Array[], minDivergence: number): ContentGapResult[];
115
- declare function createContentGapAnalyzer(opts?: CreateContentGapAnalyzerOptions): ContentGapAnalyzer;
116
- declare function analyzeContentGap(source: AnalysisQuerySource, opts?: ContentGapOptions): Promise<ContentGapAnalysis>;
117
- export { type ContentGapAnalysis, type ContentGapAnalyzer, type ContentGapEmbeddingCache, type ContentGapEmbeddingRuntime, type ContentGapOptions, type ContentGapProgress, type ContentGapResult, ContentGapSourceUnsupportedError, type CreateContentGapAnalyzerOptions, analyzeContentGap, cosineNormalized, createContentGapAnalyzer, createMemoryContentGapCache, deriveUrlText, normalizeUrl, rankContentGaps };
@@ -1,444 +0,0 @@
1
- const CONTENT_GAP_MODEL_ID = "Xenova/bge-base-en-v1.5";
2
- const CONTENT_GAP_QUERY_PREFIX = "Represent this sentence for searching relevant passages: ";
3
- const DB_NAME = "content-gap-embeddings";
4
- const STORE = "vectors";
5
- function cacheKey(role, text) {
6
- return `${CONTENT_GAP_MODEL_ID}|${role}|${text}`;
7
- }
8
- function createIndexedDbContentGapCache() {
9
- let dbPromise = null;
10
- function openDb() {
11
- if (dbPromise != null) return dbPromise;
12
- dbPromise = new Promise((resolve, reject) => {
13
- const req = indexedDB.open(DB_NAME, 1);
14
- req.onupgradeneeded = () => {
15
- const db = req.result;
16
- if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE);
17
- };
18
- req.onsuccess = () => resolve(req.result);
19
- req.onerror = () => reject(req.error ?? /* @__PURE__ */ new Error("indexedDB open failed"));
20
- });
21
- return dbPromise;
22
- }
23
- return {
24
- async getMany(role, texts) {
25
- const db = await openDb().catch(() => null);
26
- if (db == null) return /* @__PURE__ */ new Map();
27
- return new Promise((resolve) => {
28
- const store = db.transaction(STORE, "readonly").objectStore(STORE);
29
- const out = /* @__PURE__ */ new Map();
30
- let pending = texts.length;
31
- if (pending === 0) {
32
- resolve(out);
33
- return;
34
- }
35
- for (const text of texts) {
36
- const req = store.get(cacheKey(role, text));
37
- req.onsuccess = () => {
38
- if (req.result instanceof Float32Array) out.set(text, req.result);
39
- pending -= 1;
40
- if (pending === 0) resolve(out);
41
- };
42
- req.onerror = () => {
43
- pending -= 1;
44
- if (pending === 0) resolve(out);
45
- };
46
- }
47
- });
48
- },
49
- async putMany(role, entries) {
50
- const db = await openDb().catch(() => null);
51
- if (db == null) return;
52
- await new Promise((resolve) => {
53
- const tx = db.transaction(STORE, "readwrite");
54
- for (const [text, vector] of entries) tx.objectStore(STORE).put(vector, cacheKey(role, text));
55
- tx.oncomplete = () => resolve();
56
- tx.onerror = () => resolve();
57
- tx.onabort = () => resolve();
58
- });
59
- }
60
- };
61
- }
62
- function createMemoryContentGapCache() {
63
- const values = /* @__PURE__ */ new Map();
64
- return {
65
- async getMany(role, texts) {
66
- const out = /* @__PURE__ */ new Map();
67
- for (const text of texts) {
68
- const value = values.get(cacheKey(role, text));
69
- if (value) out.set(text, value);
70
- }
71
- return out;
72
- },
73
- async putMany(role, entries) {
74
- for (const [text, vector] of entries) values.set(cacheKey(role, text), vector);
75
- }
76
- };
77
- }
78
- async function embedRawBatch(extractor, texts, onProgress, batchSize = 32) {
79
- const result = [];
80
- for (let i = 0; i < texts.length; i += batchSize) {
81
- const batch = texts.slice(i, i + batchSize);
82
- const out = await extractor(batch, {
83
- pooling: "mean",
84
- normalize: true
85
- });
86
- const dim = out.dims[out.dims.length - 1];
87
- for (let k = 0; k < batch.length; k++) {
88
- const start = k * dim;
89
- result.push(new Float32Array(out.data.buffer, out.data.byteOffset + start * 4, dim).slice());
90
- }
91
- onProgress(result.length);
92
- }
93
- return result;
94
- }
95
- async function selectContentGapDevice(requested) {
96
- let chosenDevice = "wasm";
97
- if (requested === "webgpu" || requested == null) {
98
- const gpu = globalThis.navigator?.gpu;
99
- if (gpu != null) {
100
- if (await gpu.requestAdapter().catch(() => null) != null) chosenDevice = "webgpu";
101
- }
102
- }
103
- return chosenDevice;
104
- }
105
- async function loadContentGapExtractor(device) {
106
- const { pipeline, env } = await import("@huggingface/transformers");
107
- env.useBrowserCache = true;
108
- return await pipeline("feature-extraction", CONTENT_GAP_MODEL_ID, {
109
- device,
110
- dtype: "fp32"
111
- });
112
- }
113
- async function embedContentGapTexts(extractor, role, texts, transform, onProgress, cache) {
114
- const cached = await cache.getMany(role, texts);
115
- const vectors = Array.from({ length: texts.length });
116
- const missIdx = [];
117
- const missTexts = [];
118
- for (let i = 0; i < texts.length; i++) {
119
- const hit = cached.get(texts[i]);
120
- if (hit != null) vectors[i] = hit;
121
- else {
122
- missIdx.push(i);
123
- missTexts.push(transform(texts[i]));
124
- }
125
- }
126
- const hits = cached.size;
127
- const misses = missTexts.length;
128
- onProgress(hits, texts.length);
129
- if (missTexts.length > 0) {
130
- const embedded = await embedRawBatch(extractor, missTexts, (done) => {
131
- onProgress(hits + done, texts.length);
132
- });
133
- const toPersist = [];
134
- for (let m = 0; m < embedded.length; m++) {
135
- const i = missIdx[m];
136
- vectors[i] = embedded[m];
137
- toPersist.push([texts[i], embedded[m]]);
138
- }
139
- await cache.putMany(role, toPersist);
140
- }
141
- return {
142
- vectors,
143
- hits,
144
- misses
145
- };
146
- }
147
- function createContentGapEmbeddingRuntime(cache = createIndexedDbContentGapCache()) {
148
- return {
149
- modelId: CONTENT_GAP_MODEL_ID,
150
- queryPrefix: CONTENT_GAP_QUERY_PREFIX,
151
- selectDevice: selectContentGapDevice,
152
- loadExtractor: loadContentGapExtractor,
153
- embed(extractor, role, texts, transform, onProgress) {
154
- return embedContentGapTexts(extractor, role, texts, transform, onProgress, cache);
155
- }
156
- };
157
- }
158
- async function fetchContentGapInputs(executeSql, options, normalizeUrl, now = () => performance.now()) {
159
- const t1 = now();
160
- const queryRows = await executeSql(`
161
- WITH query_totals AS (
162
- SELECT query,
163
- SUM(impressions)::BIGINT AS total_impressions,
164
- SUM(clicks)::BIGINT AS total_clicks,
165
- SUM(sum_position) / NULLIF(SUM(impressions), 0) + 1 AS avg_position
166
- FROM main.page_queries
167
- WHERE query IS NOT NULL AND query <> ''
168
- GROUP BY query
169
- HAVING SUM(impressions) >= ?
170
- ORDER BY total_impressions DESC
171
- LIMIT ?
172
- ),
173
- per_query_url AS (
174
- SELECT pk.query, pk.url,
175
- SUM(pk.impressions)::BIGINT AS url_impressions,
176
- SUM(pk.sum_position) / NULLIF(SUM(pk.impressions), 0) + 1 AS url_position,
177
- ROW_NUMBER() OVER (PARTITION BY pk.query ORDER BY SUM(pk.impressions) DESC) AS rnk
178
- FROM main.page_queries pk
179
- JOIN query_totals qt USING (query)
180
- WHERE pk.url IS NOT NULL AND pk.url <> ''
181
- GROUP BY pk.query, pk.url
182
- )
183
- SELECT q.query, q.total_impressions AS impressions, q.total_clicks AS clicks, q.avg_position,
184
- pu.url AS current_url, pu.url_position AS current_position
185
- FROM query_totals q
186
- JOIN per_query_url pu USING (query)
187
- WHERE pu.rnk = 1
188
- `, [Number(options.minImpressions), Number(options.maxQueries)]);
189
- const urlRows = await executeSql(`
190
- SELECT url, SUM(impressions)::BIGINT AS impressions
191
- FROM main.page_queries
192
- WHERE url IS NOT NULL AND url <> ''
193
- GROUP BY url
194
- ORDER BY impressions DESC
195
- LIMIT ?
196
- `, [Number(options.maxUrls)]);
197
- const sqlMs = now() - t1;
198
- const queries = queryRows.map((row) => ({
199
- query: String(row.query),
200
- impressions: Number(row.impressions),
201
- clicks: Number(row.clicks),
202
- avgPosition: Number(row.avg_position),
203
- currentUrl: normalizeUrl(String(row.current_url))
204
- }));
205
- const urlAgg = /* @__PURE__ */ new Map();
206
- for (const row of urlRows) {
207
- const norm = normalizeUrl(String(row.url));
208
- urlAgg.set(norm, (urlAgg.get(norm) ?? 0) + Number(row.impressions));
209
- }
210
- return {
211
- queries,
212
- urls: [...urlAgg.entries()].sort((a, b) => b[1] - a[1]).slice(0, Number(options.maxUrls)).map(([url]) => url),
213
- sqlMs
214
- };
215
- }
216
- var ContentGapSourceUnsupportedError = class extends Error {
217
- constructor(kind) {
218
- super(`content-gap requires a source with executeSql (got '${kind}'); use createBrowserQuerySource or createSqliteQuerySource`);
219
- this.name = "ContentGapSourceUnsupportedError";
220
- }
221
- };
222
- const ORIGIN_RE = /^https?:\/\/[^/]+/;
223
- const HTML_EXT_RE = /\.(html?|php|aspx?)$/i;
224
- const SEP_RE = /[-_]+/g;
225
- const DIGITS_ONLY_RE = /^\d+$/;
226
- const WWW_RE = /^www\./;
227
- const DOT_RE = /\./g;
228
- const HASH_RE = /#.*$/;
229
- const QUERY_RE = /\?.*$/;
230
- const TRAIL_SLASH_RE = /(?<=.)\/$/;
231
- function normalizeUrl(u) {
232
- try {
233
- const url = new URL(u);
234
- url.hash = "";
235
- url.search = "";
236
- let path = url.pathname;
237
- if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
238
- return `${url.origin}${path}`;
239
- } catch {
240
- return u.replace(HASH_RE, "").replace(QUERY_RE, "").replace(TRAIL_SLASH_RE, "");
241
- }
242
- }
243
- function deriveUrlText(url) {
244
- try {
245
- const u = new URL(url);
246
- const text = u.pathname.split("/").filter(Boolean).map((s) => decodeURIComponent(s).toLowerCase()).map((s) => s.replace(HTML_EXT_RE, "")).map((s) => s.replace(SEP_RE, " ")).filter((s) => s.length > 0 && !DIGITS_ONLY_RE.test(s)).join(" ");
247
- if (text.length > 0) return text;
248
- return u.hostname.replace(WWW_RE, "").replace(DOT_RE, " ");
249
- } catch {
250
- return url.replace(ORIGIN_RE, "").replace(SEP_RE, " ");
251
- }
252
- }
253
- function cosineNormalized(a, b) {
254
- let dot = 0;
255
- const n = a.length;
256
- for (let i = 0; i < n; i++) dot += a[i] * b[i];
257
- return dot;
258
- }
259
- function notify(onProgress, progress) {
260
- onProgress?.(progress);
261
- }
262
- function rankContentGaps(queries, urls, queryEmbeddings, urlEmbeddings, minDivergence) {
263
- const urlIndex = /* @__PURE__ */ new Map();
264
- for (let i = 0; i < urls.length; i++) urlIndex.set(urls[i], i);
265
- const gaps = [];
266
- for (let i = 0; i < queries.length; i++) {
267
- const qr = queries[i];
268
- const qEmb = queryEmbeddings[i];
269
- const currentIdx = urlIndex.get(qr.currentUrl);
270
- const top = [];
271
- let currentSimilarity = 0;
272
- for (let j = 0; j < urls.length; j++) {
273
- const similarity = cosineNormalized(qEmb, urlEmbeddings[j]);
274
- if (j === currentIdx) currentSimilarity = similarity;
275
- let insertAt = top.length;
276
- while (insertAt > 0 && similarity > top[insertAt - 1].similarity) insertAt--;
277
- if (insertAt < 4) {
278
- top.splice(insertAt, 0, {
279
- url: urls[j],
280
- similarity
281
- });
282
- if (top.length > 4) top.pop();
283
- }
284
- }
285
- const suggested = top[0];
286
- if (!suggested) continue;
287
- const suggestedUrl = suggested.url;
288
- const suggestedSimilarity = suggested.similarity;
289
- if (suggestedUrl === qr.currentUrl) continue;
290
- const divergence = suggestedSimilarity - currentSimilarity;
291
- if (divergence < minDivergence) continue;
292
- gaps.push({
293
- query: qr.query,
294
- impressions: qr.impressions,
295
- clicks: qr.clicks,
296
- avgPosition: qr.avgPosition,
297
- currentUrl: qr.currentUrl,
298
- currentSimilarity,
299
- suggestedUrl,
300
- suggestedSimilarity,
301
- alternatives: top.slice(1),
302
- divergence,
303
- impact: qr.impressions * divergence
304
- });
305
- }
306
- gaps.sort((a, b) => b.impact - a.impact);
307
- return gaps;
308
- }
309
- async function runContentGapAnalysis(source, opts = {}, runtime) {
310
- if (!source.executeSql) throw new ContentGapSourceUnsupportedError(source.name ?? "unknown");
311
- const executeSql = source.executeSql.bind(source);
312
- const { maxQueries = 1500, maxUrls = 400, minImpressions = 50, minDivergence = .12, device, onProgress } = opts;
313
- notify(onProgress, {
314
- phase: "loading-model",
315
- message: "Checking device..."
316
- });
317
- const t0 = runtime.now();
318
- const chosenDevice = await runtime.embeddings.selectDevice(device);
319
- notify(onProgress, {
320
- phase: "loading-model",
321
- message: `Loading ${runtime.embeddings.modelId} on ${chosenDevice} (~110MB, cached after first run)...`
322
- });
323
- const extractor = await runtime.embeddings.loadExtractor(chosenDevice);
324
- const modelMs = runtime.now() - t0;
325
- notify(onProgress, {
326
- phase: "fetching-data",
327
- message: `Running SQL (device: ${chosenDevice})...`,
328
- modelMs
329
- });
330
- const { queries, urls, sqlMs } = await runtime.loadInputs(executeSql, {
331
- maxQueries,
332
- maxUrls,
333
- minImpressions
334
- }, normalizeUrl, runtime.now);
335
- if (queries.length === 0 || urls.length === 0) {
336
- notify(onProgress, {
337
- phase: "done",
338
- message: "Not enough data to analyze.",
339
- modelMs,
340
- sqlMs
341
- });
342
- return {
343
- results: [],
344
- meta: {
345
- modelMs,
346
- sqlMs,
347
- embedMs: 0,
348
- computeMs: 0,
349
- cacheHits: 0,
350
- totalInputs: 0,
351
- device: chosenDevice,
352
- modelId: runtime.embeddings.modelId
353
- }
354
- };
355
- }
356
- const queryTexts = queries.map((q) => q.query);
357
- const urlTexts = urls.map(deriveUrlText);
358
- notify(onProgress, {
359
- phase: "embedding-queries",
360
- message: `Embedding ${queryTexts.length} queries on ${chosenDevice}...`,
361
- total: queryTexts.length,
362
- done: 0,
363
- modelMs,
364
- sqlMs
365
- });
366
- const t2 = runtime.now();
367
- const queryEmbed = await runtime.embeddings.embed(extractor, "query", queryTexts, (t) => runtime.embeddings.queryPrefix + t, (done, total) => {
368
- notify(onProgress, {
369
- phase: "embedding-queries",
370
- message: `Embedding ${queryTexts.length} queries on ${chosenDevice}...`,
371
- done,
372
- total,
373
- modelMs,
374
- sqlMs
375
- });
376
- });
377
- notify(onProgress, {
378
- phase: "embedding-urls",
379
- message: `Embedding ${urls.length} URLs...`,
380
- total: urls.length,
381
- done: 0,
382
- modelMs,
383
- sqlMs
384
- });
385
- const urlEmbed = await runtime.embeddings.embed(extractor, "passage", urlTexts, (t) => t, (done, total) => {
386
- notify(onProgress, {
387
- phase: "embedding-urls",
388
- message: `Embedding ${urls.length} URLs...`,
389
- done,
390
- total,
391
- modelMs,
392
- sqlMs
393
- });
394
- });
395
- const embedMs = runtime.now() - t2;
396
- notify(onProgress, {
397
- phase: "computing-gaps",
398
- message: "Computing semantic similarities...",
399
- modelMs,
400
- sqlMs,
401
- embedMs
402
- });
403
- const t3 = runtime.now();
404
- const gaps = rankContentGaps(queries, urls, queryEmbed.vectors, urlEmbed.vectors, minDivergence);
405
- const computeMs = runtime.now() - t3;
406
- const totalHits = queryEmbed.hits + urlEmbed.hits;
407
- const totalInputs = queryTexts.length + urls.length;
408
- const cacheNote = totalHits > 0 ? ` · ${totalHits}/${totalInputs} cache hits` : "";
409
- notify(onProgress, {
410
- phase: "done",
411
- message: `Found ${gaps.length} content gaps across ${queries.length} queries${cacheNote}`,
412
- modelMs,
413
- sqlMs,
414
- embedMs,
415
- computeMs
416
- });
417
- return {
418
- results: gaps.slice(0, 150),
419
- meta: {
420
- modelMs,
421
- sqlMs,
422
- embedMs,
423
- computeMs,
424
- cacheHits: totalHits,
425
- totalInputs,
426
- device: chosenDevice,
427
- modelId: runtime.embeddings.modelId
428
- }
429
- };
430
- }
431
- function createContentGapAnalyzer(opts = {}) {
432
- const runtime = {
433
- embeddings: opts.embeddings ?? createContentGapEmbeddingRuntime(),
434
- loadInputs: opts.loadInputs ?? fetchContentGapInputs,
435
- now: opts.now ?? (() => performance.now())
436
- };
437
- return { analyze(source, options) {
438
- return runContentGapAnalysis(source, options, runtime);
439
- } };
440
- }
441
- function analyzeContentGap(source, opts = {}) {
442
- return createContentGapAnalyzer().analyze(source, opts);
443
- }
444
- export { ContentGapSourceUnsupportedError, analyzeContentGap, cosineNormalized, createContentGapAnalyzer, createMemoryContentGapCache, deriveUrlText, normalizeUrl, rankContentGaps };
@@ -1,37 +0,0 @@
1
- import { BuilderState } from "gscdump/query";
2
- import { PlannerCapabilities } from "gscdump/query/plan";
3
- import { AnalysisQuerySource, QueryRow } from "@gscdump/engine/source";
4
- interface SyncedRange {
5
- oldestDateSynced: string | null;
6
- newestDateSynced: string | null;
7
- /**
8
- * Optional sorted list of `[start, end]` daily-key spans (`YYYY-MM-DD`,
9
- * both inclusive) that the engine actually has partitions for. When set,
10
- * `shouldRouteToLive` returns true for any requested range that overlaps
11
- * a day NOT inside one of these spans — even when the request sits inside
12
- * `oldestDateSynced..newestDateSynced`. Lets the composite catch *internal*
13
- * manifest gaps (e.g. a missing monthly tier) that the outer envelope
14
- * doesn't reveal. Spans must be sorted by `start` and non-overlapping.
15
- */
16
- coveredSpans?: ReadonlyArray<{
17
- start: string;
18
- end: string;
19
- }>;
20
- }
21
- interface CompositeSourceOptions {
22
- engine: AnalysisQuerySource;
23
- live: AnalysisQuerySource;
24
- site: SyncedRange;
25
- }
26
- declare function createCompositeSource(opts: CompositeSourceOptions): AnalysisQuerySource;
27
- /**
28
- * Permissive defaults: in-memory sources are usually test doubles, so they
29
- * advertise every capability unless the test explicitly narrows them.
30
- */
31
- declare const IN_MEMORY_DEFAULT_CAPABILITIES: PlannerCapabilities;
32
- interface InMemoryQuerySourceOptions {
33
- queryRows: (state: BuilderState) => Promise<QueryRow[]> | QueryRow[];
34
- capabilities?: PlannerCapabilities;
35
- }
36
- declare function createInMemoryQuerySource(options: InMemoryQuerySourceOptions): AnalysisQuerySource;
37
- export { type CompositeSourceOptions, IN_MEMORY_DEFAULT_CAPABILITIES, type InMemoryQuerySourceOptions, type SyncedRange, createCompositeSource, createInMemoryQuerySource };
@@ -1,60 +0,0 @@
1
- import { canProxyToGsc } from "@gscdump/engine-gsc-api";
2
- import { extractDateRange } from "gscdump/query";
3
- import { isStateResolvable } from "gscdump/query/plan";
4
- function hasGapInCoveredSpans(start, end, coveredSpans) {
5
- let cursor = start;
6
- for (const span of coveredSpans) {
7
- if (span.end < cursor) continue;
8
- if (span.start > cursor) return true;
9
- if (span.end >= end) return false;
10
- cursor = nextDay(span.end);
11
- if (cursor > end) return false;
12
- }
13
- return cursor <= end;
14
- }
15
- function nextDay(day) {
16
- const t = Date.parse(`${day}T00:00:00Z`) + 864e5;
17
- return new Date(t).toISOString().slice(0, 10);
18
- }
19
- function shouldRouteToLive(state, site) {
20
- if (!canProxyToGsc(state)) return false;
21
- if (!isStateResolvable(state)) return true;
22
- const { startDate, endDate } = extractDateRange(state.filter);
23
- if (!startDate || !endDate) return false;
24
- if (!site.oldestDateSynced || !site.newestDateSynced) return true;
25
- if (startDate < site.oldestDateSynced || endDate > site.newestDateSynced) return true;
26
- if (site.coveredSpans && site.coveredSpans.length > 0) return hasGapInCoveredSpans(startDate, endDate, site.coveredSpans);
27
- return false;
28
- }
29
- function createCompositeSource(opts) {
30
- const { engine, live, site } = opts;
31
- const engineExecuteSql = engine.executeSql;
32
- return {
33
- name: "composite-engine-live",
34
- kind: "composite",
35
- capabilities: engine.capabilities,
36
- adapter: engine.adapter,
37
- siteId: engine.siteId,
38
- async queryRows(state) {
39
- return shouldRouteToLive(state, site) ? live.queryRows(state) : engine.queryRows(state);
40
- },
41
- executeSql: engineExecuteSql ? (sql, params, execOpts) => engineExecuteSql(sql, params, execOpts) : void 0
42
- };
43
- }
44
- const IN_MEMORY_DEFAULT_CAPABILITIES = {
45
- regex: true,
46
- multiDataset: true,
47
- comparisonJoin: true,
48
- windowTotals: true
49
- };
50
- function createInMemoryQuerySource(options) {
51
- return {
52
- name: "memory",
53
- kind: "in-memory",
54
- capabilities: options.capabilities ?? IN_MEMORY_DEFAULT_CAPABILITIES,
55
- async queryRows(state) {
56
- return await options.queryRows(state);
57
- }
58
- };
59
- }
60
- export { IN_MEMORY_DEFAULT_CAPABILITIES, createCompositeSource, createInMemoryQuerySource };