@gscdump/nuxt 0.22.1 → 1.5.1

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.
Files changed (65) hide show
  1. package/README.md +10 -125
  2. package/dist/module.d.mts +22 -0
  3. package/dist/module.mjs +30 -0
  4. package/dist/runtime-config.d.mts +12 -0
  5. package/dist/runtime-config.mjs +27 -0
  6. package/package.json +25 -44
  7. package/app/assets/css/main.css +0 -2
  8. package/app/components/GscAnalyzerPanel.vue +0 -94
  9. package/app/components/GscAnonymizationBanner.vue +0 -46
  10. package/app/components/GscBootProgress.vue +0 -297
  11. package/app/components/GscCommandPalette.vue +0 -77
  12. package/app/components/GscDashboardPage.vue +0 -26
  13. package/app/components/GscEngineBadge.vue +0 -28
  14. package/app/components/GscHero.vue +0 -215
  15. package/app/components/GscLazyTopResult.vue +0 -45
  16. package/app/components/GscPerformanceChart.vue +0 -532
  17. package/app/components/GscPositionDistributionChart.vue +0 -63
  18. package/app/components/GscQueryLabel.vue +0 -63
  19. package/app/components/GscSitePageHeader.vue +0 -40
  20. package/app/components/GscSourceDebugPanel.vue +0 -195
  21. package/app/components/GscSyncStatusCell.vue +0 -54
  22. package/app/components/GscTopRollupCard.vue +0 -90
  23. package/app/composables/_useGscBackfill.ts +0 -111
  24. package/app/composables/_useGscQueryDispatcher.ts +0 -123
  25. package/app/composables/_useGscResource.ts +0 -114
  26. package/app/composables/_useGscSharedSiteResource.ts +0 -136
  27. package/app/composables/useGscAnalytics.ts +0 -205
  28. package/app/composables/useGscAnalyticsClient.ts +0 -9
  29. package/app/composables/useGscAnalyticsConfig.ts +0 -8
  30. package/app/composables/useGscAnalyticsSourceInfo.ts +0 -159
  31. package/app/composables/useGscAnalyzer.ts +0 -452
  32. package/app/composables/useGscAnalyzerBatch.ts +0 -106
  33. package/app/composables/useGscAnalyzerDefs.ts +0 -118
  34. package/app/composables/useGscAuth.ts +0 -115
  35. package/app/composables/useGscConsoleUrl.ts +0 -45
  36. package/app/composables/useGscCountries.ts +0 -47
  37. package/app/composables/useGscCurrentSite.ts +0 -15
  38. package/app/composables/useGscInspectionHistory.ts +0 -42
  39. package/app/composables/useGscInspections.ts +0 -52
  40. package/app/composables/useGscPanelContext.ts +0 -31
  41. package/app/composables/useGscParquetTable.ts +0 -198
  42. package/app/composables/useGscPeriod.ts +0 -243
  43. package/app/composables/useGscQuery.ts +0 -303
  44. package/app/composables/useGscQueryDispatcher.ts +0 -14
  45. package/app/composables/useGscRequestIndexingInspect.ts +0 -20
  46. package/app/composables/useGscResource.ts +0 -11
  47. package/app/composables/useGscRollup.ts +0 -252
  48. package/app/composables/useGscSearchAppearance.ts +0 -47
  49. package/app/composables/useGscSiteAnalyzer.ts +0 -500
  50. package/app/composables/useGscSitemapHistory.ts +0 -41
  51. package/app/composables/useGscSitemaps.ts +0 -45
  52. package/app/composables/useGscTableState.ts +0 -119
  53. package/app/plugins/analytics.ts +0 -57
  54. package/app/utils/anonymization.ts +0 -24
  55. package/app/utils/country-names.ts +0 -56
  56. package/app/utils/duckdb-wasm.ts +0 -166
  57. package/app/utils/gsc-constants.ts +0 -10
  58. package/app/utils/gsc-error.ts +0 -58
  59. package/app/utils/gsc-fetch.ts +0 -94
  60. package/app/utils/gsc-filters.ts +0 -32
  61. package/app/utils/gsc-rows.ts +0 -72
  62. package/app/utils/position.ts +0 -7
  63. package/module.ts +0 -81
  64. package/nuxt.config.ts +0 -41
  65. package/types.ts +0 -119
@@ -1,452 +0,0 @@
1
- // Per-site analyzer. Two modes, picked from `/source-info`:
2
- //
3
- // - `browser-attached` — server source is SQL-capable and advertises
4
- // `attachedTables`; we boot DuckDB-WASM in the browser and attach parquets
5
- // for local querying. Fastest path for large dashboards.
6
- // - `server` — everything else (GSC API live row source, engine sources
7
- // without attach support, etc.). `analyze()` proxies to the server POST
8
- // endpoint; `query()` throws since there's no local SQL engine.
9
- //
10
- // Cached per-site so navigation between panels on the same site doesn't
11
- // re-boot. Writes progress to the shared map so <GscBootProgress> lights up
12
- // on boot regardless of mode.
13
-
14
- import type { AnalysisParams, AnalysisResult } from '@gscdump/analysis'
15
- import type { AnalysisSourcesResponse, SourceInfoResponse } from '@gscdump/contracts'
16
- import type { AttachedTablesHandle, BrowserAnalysisRuntime, DuckDBWasmBootResult, QueryResult } from '@gscdump/engine-duckdb-wasm'
17
- import type { SiteLoadProgress } from './useGscAnalytics'
18
- import { defaultAnalyzerRegistry } from '@gscdump/analysis'
19
- import { coerceRow } from '@gscdump/engine'
20
- import { useGscSharedSiteResource } from './_useGscSharedSiteResource'
21
- import { useGscAnalyticsContext } from './useGscAnalytics'
22
- import { useGscAnalyticsClient } from './useGscAnalyticsClient'
23
- import { useGscAnalyticsConfig } from './useGscAnalyticsConfig'
24
- import { resolveGscAuthHeaders } from './useGscAuth'
25
-
26
- export interface GscAnalyzerTimings {
27
- bootMs: number
28
- manifestMs: number
29
- attachMs: number
30
- }
31
-
32
- export interface GscAnalyzerInstance {
33
- ready: Ref<boolean>
34
- initializing: Ref<boolean>
35
- error: Ref<Error | null>
36
- attachedTables: Ref<string[]>
37
- timings: Ref<GscAnalyzerTimings | null>
38
- /** Server-reported manifest version of the currently-attached snapshot. */
39
- manifestVersion: Ref<string | undefined>
40
- query: (sql: string, params?: unknown[]) => Promise<QueryResult>
41
- analyze: (params: AnalysisParams, opts?: { signal?: AbortSignal }) => Promise<AnalysisResult & { queryMs: number }>
42
- /**
43
- * Re-probe `analysis-sources`; if the manifest version changed since the
44
- * last attach, detach views and re-attach against the fresher parquet
45
- * partitions in place. No-op for `server`-mode analyzers and when the
46
- * version matches. Resolves to true if the runtime re-attached.
47
- */
48
- refresh: () => Promise<boolean>
49
- dispose: () => Promise<void>
50
- }
51
-
52
- const EMPTY_TABLES: readonly string[] = Object.freeze([])
53
- const DEFAULT_SEARCH_TYPE: NonNullable<AnalysisParams['searchType']> = 'web'
54
- const DEFAULT_ATTACH_FETCH_CONCURRENCY = 2
55
- const DEFAULT_ATTACH_MAX_FILES = 32
56
- const DEFAULT_ATTACH_MAX_BYTES = 16 * 1024 * 1024
57
-
58
- interface AnalyzerRange {
59
- start: string
60
- end: string
61
- }
62
-
63
- interface AnalysisSourcesClient {
64
- getAnalysisSources: (
65
- siteId: string,
66
- tables?: string[] | string | { tables?: string[] | string, searchType?: NonNullable<AnalysisParams['searchType']>, start?: string, end?: string },
67
- options?: { searchType?: NonNullable<AnalysisParams['searchType']>, start?: string, end?: string },
68
- ) => Promise<AnalysisSourcesResponse>
69
- getSourceInfo: (
70
- siteId: string,
71
- options?: { searchType?: NonNullable<AnalysisParams['searchType']>, start?: string, end?: string },
72
- ) => Promise<SourceInfoResponse>
73
- }
74
-
75
- function normalizeSearchType(searchType: AnalysisParams['searchType']): NonNullable<AnalysisParams['searchType']> {
76
- return searchType ?? DEFAULT_SEARCH_TYPE
77
- }
78
-
79
- function analyzerCacheKey(siteId: string, searchType: NonNullable<AnalysisParams['searchType']>, range: AnalyzerRange | null): string {
80
- return JSON.stringify([siteId, searchType, range?.start ?? null, range?.end ?? null])
81
- }
82
-
83
- function parseAnalyzerCacheKey(key: string): { siteId: string, searchType: NonNullable<AnalysisParams['searchType']>, range: AnalyzerRange | null } {
84
- const [siteId, searchType, start, end] = JSON.parse(key) as [string, NonNullable<AnalysisParams['searchType']>, string | null, string | null]
85
- return { siteId, searchType, range: start && end ? { start, end } : null }
86
- }
87
-
88
- function normalizeRange(range: AnalyzerRange | null | undefined): AnalyzerRange | null {
89
- return range?.start && range?.end ? range : null
90
- }
91
-
92
- function loadSourceInfo(siteId: string, searchType: NonNullable<AnalysisParams['searchType']>, range: AnalyzerRange | null): Promise<SourceInfoResponse> {
93
- return (useGscAnalyticsClient() as unknown as AnalysisSourcesClient).getSourceInfo(siteId, {
94
- searchType,
95
- ...(range ? { start: range.start, end: range.end } : {}),
96
- })
97
- }
98
-
99
- function loadAnalysisSources(siteId: string, searchType: NonNullable<AnalysisParams['searchType']>, range: AnalyzerRange | null): Promise<AnalysisSourcesResponse> {
100
- return (useGscAnalyticsClient() as unknown as AnalysisSourcesClient).getAnalysisSources(siteId, undefined, {
101
- searchType,
102
- ...(range ? { start: range.start, end: range.end } : {}),
103
- })
104
- }
105
-
106
- /**
107
- * Get (or create) an analyzer for a site. Per-site cached across the app so
108
- * pages sharing a site reuse the boot. Refcounted — auto-disposes when the
109
- * last consumer unmounts. The returned refs are `computed` over the currently
110
- * bound cached instance; switching `siteId` rebinds and the computeds track
111
- * the new instance with no manual mirroring.
112
- */
113
- export function useGscAnalyzer(
114
- siteId: MaybeRefOrGetter<string | null | undefined>,
115
- searchType: MaybeRefOrGetter<AnalysisParams['searchType']> = DEFAULT_SEARCH_TYPE,
116
- range: MaybeRefOrGetter<AnalyzerRange | null | undefined> = null,
117
- ): GscAnalyzerInstance & { currentSiteId: Ref<string | null> } {
118
- const ctx = useGscAnalyticsContext()
119
- const cacheKey = computed(() => {
120
- const id = toValue(siteId)
121
- return id ? analyzerCacheKey(id, normalizeSearchType(toValue(searchType)), normalizeRange(toValue(range))) : null
122
- })
123
- const { bound, currentSiteId: currentCacheKey } = useGscSharedSiteResource<GscAnalyzerInstance>('analyzer', cacheKey, {
124
- factory: (key) => {
125
- const parsed = parseAnalyzerCacheKey(key)
126
- return createInstance(parsed.siteId, parsed.searchType, parsed.range, ctx.patchProgress)
127
- },
128
- onDispose: inst => inst.dispose(),
129
- })
130
-
131
- // Computed views over the bound instance. Reactive on both site switch
132
- // (bound changes) and inner ref updates on the cached instance.
133
- const ready = computed(() => bound.value?.ready.value ?? false) as unknown as Ref<boolean>
134
- const initializing = computed(() => bound.value?.initializing.value ?? false) as unknown as Ref<boolean>
135
- const error = computed(() => bound.value?.error.value ?? null) as unknown as Ref<Error | null>
136
- const attachedTables = computed(() => bound.value?.attachedTables.value ?? (EMPTY_TABLES as string[])) as unknown as Ref<string[]>
137
- const timings = computed(() => bound.value?.timings.value ?? null) as unknown as Ref<GscAnalyzerTimings | null>
138
- const manifestVersion = computed(() => bound.value?.manifestVersion.value) as unknown as Ref<string | undefined>
139
- const currentSiteId = computed(() => {
140
- const key = currentCacheKey.value
141
- return key ? parseAnalyzerCacheKey(key).siteId : null
142
- }) as unknown as Ref<string | null>
143
-
144
- async function query(sql: string, params?: unknown[]): Promise<QueryResult> {
145
- const inst = bound.value
146
- if (!inst)
147
- throw new Error('useGscAnalyzer: no site bound')
148
- return inst.query(sql, params)
149
- }
150
-
151
- async function analyze(params: AnalysisParams, opts?: { signal?: AbortSignal }): Promise<AnalysisResult & { queryMs: number }> {
152
- const inst = bound.value
153
- if (!inst)
154
- throw new Error('useGscAnalyzer: no site bound')
155
- return inst.analyze(params, opts)
156
- }
157
-
158
- async function refresh(): Promise<boolean> {
159
- const inst = bound.value
160
- if (!inst)
161
- return false
162
- return inst.refresh()
163
- }
164
-
165
- return {
166
- ready,
167
- initializing,
168
- error,
169
- attachedTables,
170
- timings,
171
- manifestVersion,
172
- refresh,
173
- currentSiteId,
174
- query,
175
- analyze,
176
- // No-op: lifecycle is owned by the shared bag's onScopeDispose hook.
177
- // Kept for API compat with consumers that opportunistically call dispose().
178
- dispose: async (): Promise<void> => {},
179
- }
180
- }
181
-
182
- function createInstance(
183
- siteId: string,
184
- searchType: NonNullable<AnalysisParams['searchType']>,
185
- range: AnalyzerRange | null,
186
- patchProgress: (id: string, p: Partial<SiteLoadProgress>) => void,
187
- ): GscAnalyzerInstance {
188
- const ready = ref(false)
189
- const initializing = ref(true)
190
- const error = ref<Error | null>(null)
191
- const attachedTables = ref<string[]>([])
192
- const timings = ref<GscAnalyzerTimings | null>(null)
193
- const manifestVersion = ref<string | undefined>(undefined)
194
-
195
- function patch(p: Partial<SiteLoadProgress>): void {
196
- patchProgress(siteId, { source: 'duckdb', ...p })
197
- }
198
-
199
- let runtime: BrowserAnalysisRuntime | null = null
200
- let bootedDb: DuckDBWasmBootResult | null = null
201
- let attachedHandle: AttachedTablesHandle | null = null
202
- let mode: 'browser-attached' | 'server' = 'server'
203
- const lifetimeController = new AbortController()
204
- const inFlight = new Map<string, Promise<AnalysisResult & { queryMs: number }>>()
205
-
206
- // Cross-origin: if the host returns relative parquet URLs (`/api/r2-data/…`)
207
- // and `apiBase` is set, prefix them so DuckDB-WASM hits the data origin
208
- // rather than the consumer's own host. Same-origin / absolute URLs pass
209
- // through unchanged.
210
- function rewriteParquetUrl(url: string): string {
211
- const apiBase = useGscAnalyticsConfig().apiBase
212
- if (!apiBase || !url.startsWith('/'))
213
- return url
214
- return `${apiBase.replace(/\/+$/, '')}${url}`
215
- }
216
-
217
- async function attachFromSources(sources: AnalysisSourcesResponse, signal?: AbortSignal): Promise<{ attached: number, total: number }> {
218
- if (!bootedDb)
219
- throw new Error('useGscAnalyzer: attachFromSources called before DuckDB boot')
220
- if (sources.canUseBrowser === false)
221
- throw new Error(`useGscAnalyzer: browser attach unavailable: ${sources.reason ?? sources.fallback ?? 'coverage plan rejected'}`)
222
-
223
- const tables = Object.entries(sources.tables)
224
- .filter(([, urls]) => Array.isArray(urls) && urls.length > 0)
225
- .map(([table, urls]) => ({ table, urls: urls.map(rewriteParquetUrl) }))
226
-
227
- const total = tables.reduce((n, t) => n + t.urls.length, 0)
228
- patch({ stage: 'attach', filesTotal: total, filesAttached: 0 })
229
-
230
- // URL preflights can use the same host-supplied auth header as /api/__gsc/*.
231
- // The actual DuckDB range reads are authorized by the exact-key token
232
- // embedded in each analysis-sources URL, because registerFileURL cannot
233
- // carry custom fetch headers into DuckDB-WASM's internal HTTP reader.
234
- const extraHeaders = resolveGscAuthHeaders()
235
- const hasExtra = Object.keys(extraHeaders).length > 0
236
- let attached = 0
237
- const { attachParquetUrlTables } = await import('@gscdump/engine-duckdb-wasm')
238
- const handle = await attachParquetUrlTables({
239
- db: bootedDb.db,
240
- conn: bootedDb.conn,
241
- tables,
242
- version: sources.manifestVersion,
243
- fetchInit: hasExtra
244
- ? { credentials: 'omit', headers: extraHeaders }
245
- : { credentials: 'same-origin' },
246
- fetchConcurrency: DEFAULT_ATTACH_FETCH_CONCURRENCY,
247
- maxFiles: DEFAULT_ATTACH_MAX_FILES,
248
- maxBytes: DEFAULT_ATTACH_MAX_BYTES,
249
- signal,
250
- onFileAttached: () => {
251
- attached++
252
- patch({ filesAttached: attached })
253
- },
254
- })
255
- attachedHandle = handle
256
- // `handle.tables` reflects what *actually* attached — `attachParquetUrlTables`
257
- // drops tables on fetch failure, so the requested list can over-report.
258
- // Use the authoritative list so downstream pre-checks (e.g. the engine's
259
- // AttachedTableMissingError fast-fail) get an accurate view.
260
- attachedTables.value = handle.tables
261
- manifestVersion.value = sources.manifestVersion
262
- return { attached, total }
263
- }
264
-
265
- const boot = (async () => {
266
- patch({ stage: 'manifest', startedAt: Date.now(), filesAttached: 0, filesTotal: 0, error: undefined, endedAt: undefined })
267
- // Probe the server-resolved source first. Its kind + attachedTables bit
268
- // decides whether we boot DuckDB-WASM (expensive) or proxy to the server.
269
- const info = await loadSourceInfo(siteId, searchType, range)
270
- mode = info.browserAttachEligible ? 'browser-attached' : 'server'
271
-
272
- if (mode === 'server') {
273
- // No local runtime; analyze() posts to the server. Mark ready so the
274
- // shared progress UI stops spinning.
275
- timings.value = { bootMs: 0, manifestMs: 0, attachMs: 0 }
276
- ready.value = true
277
- patch({ stage: 'ready', endedAt: Date.now() })
278
- return null
279
- }
280
-
281
- const cfg = useGscAnalyticsConfig().duckdbBundleBase
282
- patch({ stage: 'wasm' })
283
- const t0 = performance.now()
284
- // Dynamic import so server/consumer-mode hosts (no browser SQL) never pull
285
- // the wasm engine into their client bundle. The static type-only import at
286
- // top of the file keeps the type signatures available without an emit.
287
- const { bootDuckDBWasm, createBrowserAnalysisRuntime } = await import('@gscdump/engine-duckdb-wasm')
288
- bootedDb = await bootDuckDBWasm(cfg
289
- ? {
290
- bundles: {
291
- mvp: { mainModule: `${cfg}/duckdb-mvp.wasm`, mainWorker: `${cfg}/duckdb-browser-mvp.worker.js` },
292
- eh: { mainModule: `${cfg}/duckdb-eh.wasm`, mainWorker: `${cfg}/duckdb-browser-eh.worker.js` },
293
- },
294
- }
295
- : undefined)
296
- const bootMs = performance.now() - t0
297
-
298
- patch({ stage: 'manifest' })
299
- const t1 = performance.now()
300
- const sources = await loadAnalysisSources(siteId, searchType, range)
301
- const manifestMs = performance.now() - t1
302
-
303
- const t2 = performance.now()
304
- const { total } = await attachFromSources(sources, lifetimeController.signal)
305
- const attachMs = performance.now() - t2
306
-
307
- runtime = createBrowserAnalysisRuntime(bootedDb, { schema: 'main', attachedTables: attachedTables.value })
308
- runtime.setVersion(sources.manifestVersion)
309
- timings.value = { bootMs, manifestMs, attachMs }
310
- ready.value = true
311
- patch({ stage: 'ready', filesAttached: total, endedAt: Date.now() })
312
- return runtime
313
- })()
314
- .catch((e) => {
315
- const err = e instanceof Error ? e : new Error(String(e))
316
- error.value = err
317
- patch({ stage: 'error', error: err.message, endedAt: Date.now() })
318
- throw err
319
- })
320
- .finally(() => {
321
- initializing.value = false
322
- })
323
-
324
- // Don't let the boot promise crash the runtime — error.value surfaces it.
325
- boot.catch(() => {})
326
-
327
- async function query(sql: string, params?: unknown[]): Promise<QueryResult> {
328
- const rt = await boot
329
- if (!rt)
330
- throw new Error('useGscAnalyzer: query() requires a SQL-capable source with attachedTables; current source routes through the server')
331
- return rt.query(sql, params)
332
- }
333
-
334
- async function runServerAnalyze(params: AnalysisParams, _signal?: AbortSignal): Promise<AnalysisResult & { queryMs: number }> {
335
- const out = await useGscAnalyticsClient().analyze<AnalysisResult & { queryMs?: number }>(siteId, { ...params, searchType: params.searchType ?? searchType })
336
- return {
337
- results: coerceResults(out.results) as AnalysisResult['results'],
338
- meta: out.meta as AnalysisResult['meta'],
339
- queryMs: out.queryMs ?? 0,
340
- }
341
- }
342
-
343
- async function analyze(params: AnalysisParams, opts?: { signal?: AbortSignal }): Promise<AnalysisResult & { queryMs: number }> {
344
- const rt = opts?.signal ? await raceSignal(boot, opts.signal) : await boot
345
- opts?.signal?.throwIfAborted?.()
346
-
347
- const scopedParams = { ...params, searchType: params.searchType ?? searchType }
348
- const key = JSON.stringify({ manifestVersion: manifestVersion.value, params: scopedParams, searchType, siteId })
349
- const existing = inFlight.get(key)
350
- if (existing)
351
- return opts?.signal ? raceSignal(existing, opts.signal) : existing
352
-
353
- const p = (async () => {
354
- if (!rt)
355
- return runServerAnalyze(scopedParams, opts?.signal)
356
- const out = await rt.analyze(scopedParams as never, defaultAnalyzerRegistry, { signal: opts?.signal })
357
- opts?.signal?.throwIfAborted?.()
358
- return {
359
- results: coerceResults(out.results) as AnalysisResult['results'],
360
- meta: out.meta as AnalysisResult['meta'],
361
- queryMs: out.queryMs,
362
- }
363
- })()
364
- if (opts?.signal) {
365
- return raceSignal(p, opts.signal)
366
- }
367
- inFlight.set(key, p)
368
- p.finally(() => {
369
- if (inFlight.get(key) === p)
370
- inFlight.delete(key)
371
- })
372
- return p
373
- }
374
-
375
- async function refresh(): Promise<boolean> {
376
- await boot
377
- if (mode !== 'browser-attached' || !runtime || !bootedDb)
378
- return false
379
- const sources = await loadAnalysisSources(siteId, searchType, range)
380
- if (!runtime.isStale(sources.manifestVersion))
381
- return false
382
- // Drop the stale views before swapping in the new partitions. The runtime
383
- // (db + conn) stays alive — we're swapping the data, not the engine.
384
- await attachedHandle?.detach().catch((e) => {
385
- console.warn('[analyzer] detach during refresh failed', e)
386
- })
387
- attachedHandle = null
388
- await attachFromSources(sources)
389
- runtime.setVersion(sources.manifestVersion)
390
- runtime.setAttachedTables(attachedTables.value)
391
- return true
392
- }
393
-
394
- async function dispose(): Promise<void> {
395
- lifetimeController.abort()
396
- await runtime?.close().catch((e) => {
397
- console.error('[analyzer] runtime.close failed', e)
398
- })
399
- runtime = null
400
- bootedDb = null
401
- attachedHandle = null
402
- ready.value = false
403
- attachedTables.value = []
404
- manifestVersion.value = undefined
405
- inFlight.clear()
406
- }
407
-
408
- return { ready, initializing, error, attachedTables, timings, manifestVersion, query, analyze, refresh, dispose }
409
- }
410
-
411
- function coerceResults(results: unknown): unknown {
412
- if (!Array.isArray(results))
413
- return results
414
- let changed = false
415
- const out: unknown[] = Array.from({ length: results.length })
416
- for (let i = 0; i < results.length; i++) {
417
- const r = results[i]
418
- if (r && typeof r === 'object') {
419
- const c = coerceRow(r as Record<string, unknown>)
420
- if (c !== r)
421
- changed = true
422
- out[i] = c
423
- }
424
- else {
425
- out[i] = r
426
- }
427
- }
428
- return changed ? out : results
429
- }
430
-
431
- function abortError(): DOMException {
432
- return new DOMException('aborted', 'AbortError')
433
- }
434
-
435
- function raceSignal<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
436
- if (signal.aborted)
437
- return Promise.reject(abortError())
438
- return new Promise<T>((resolve, reject) => {
439
- const onAbort = (): void => reject(abortError())
440
- signal.addEventListener('abort', onAbort, { once: true })
441
- promise.then(
442
- (v) => {
443
- signal.removeEventListener('abort', onAbort)
444
- resolve(v)
445
- },
446
- (e) => {
447
- signal.removeEventListener('abort', onAbort)
448
- reject(e)
449
- },
450
- )
451
- })
452
- }
@@ -1,106 +0,0 @@
1
- // Run N analyzers in parallel against a single analyzer runner, with per-id
2
- // status tracking, optional concurrency cap, and stale-token discard so
3
- // late-completing tasks from a superseded `run()` don't overwrite fresher state.
4
- //
5
- // Caller owns: result shaping (summarize, scoring), the trigger watcher,
6
- // and which ids to run.
7
-
8
- import type { MaybeRefOrGetter, Ref } from '@vue/runtime-core'
9
-
10
- export type GscAnalyzerBatchStatus = 'idle' | 'pending' | 'running' | 'done' | 'error' | 'skipped'
11
-
12
- export interface GscAnalyzerBatchEntry<TResult> {
13
- status: GscAnalyzerBatchStatus
14
- result: TResult | null
15
- error: Error | null
16
- }
17
-
18
- export interface GscAnalyzerBatchRunner {
19
- analyze: (params: { type: string, startDate?: string, endDate?: string }) => Promise<unknown>
20
- }
21
-
22
- export interface UseGscAnalyzerBatchOptions {
23
- /** Cap parallel analyzer runs. DuckDB-WASM is single-threaded; high parallelism just serializes behind one connection. Default 2. */
24
- concurrency?: number
25
- /** Drop ids before running (e.g. analyzers the current source can't serve). Returning `false` marks them `skipped`. */
26
- filter?: (id: string) => boolean
27
- }
28
-
29
- export interface UseGscAnalyzerBatchReturn<TResult> {
30
- states: Ref<Record<string, GscAnalyzerBatchEntry<TResult>>>
31
- running: Ref<boolean>
32
- run: () => Promise<void>
33
- }
34
-
35
- export function useGscAnalyzerBatch<TResult = unknown>(
36
- runner: GscAnalyzerBatchRunner,
37
- ids: MaybeRefOrGetter<readonly string[]>,
38
- dateRange: MaybeRefOrGetter<{ start: string, end: string }>,
39
- opts: UseGscAnalyzerBatchOptions = {},
40
- ): UseGscAnalyzerBatchReturn<TResult> {
41
- const states = ref<Record<string, GscAnalyzerBatchEntry<TResult>>>({}) as Ref<Record<string, GscAnalyzerBatchEntry<TResult>>>
42
- const running = ref(false)
43
- let token = 0
44
-
45
- function reset(currentIds: readonly string[]): void {
46
- const next: Record<string, GscAnalyzerBatchEntry<TResult>> = {}
47
- for (const id of currentIds)
48
- next[id] = { status: 'pending', result: null, error: null }
49
- states.value = next
50
- }
51
-
52
- async function run(): Promise<void> {
53
- const myToken = ++token
54
- const currentIds = toValue(ids)
55
- const range = toValue(dateRange)
56
- running.value = true
57
- reset(currentIds)
58
-
59
- const filter = opts.filter
60
- const concurrency = Math.max(1, opts.concurrency ?? 2)
61
-
62
- const queue: string[] = []
63
- for (const id of currentIds) {
64
- if (filter && !filter(id))
65
- states.value = { ...states.value, [id]: { status: 'skipped', result: null, error: null } }
66
- else
67
- queue.push(id)
68
- }
69
-
70
- async function runOne(id: string): Promise<void> {
71
- if (token !== myToken)
72
- return
73
- states.value = { ...states.value, [id]: { status: 'running', result: null, error: null } }
74
- try {
75
- const result = await runner.analyze({ type: id, startDate: range.start, endDate: range.end })
76
- if (token !== myToken)
77
- return
78
- states.value = { ...states.value, [id]: { status: 'done', result: result as TResult, error: null } }
79
- }
80
- catch (err) {
81
- if (token !== myToken)
82
- return
83
- const error = err instanceof Error ? err : new Error(String(err))
84
- states.value = { ...states.value, [id]: { status: 'error', result: null, error } }
85
- }
86
- }
87
-
88
- const workers = Array.from({ length: concurrency }, async () => {
89
- while (queue.length) {
90
- if (token !== myToken)
91
- return
92
- const id = queue.shift()
93
- if (!id)
94
- break
95
- await runOne(id)
96
- }
97
- })
98
-
99
- await Promise.all(workers)
100
-
101
- if (token === myToken)
102
- running.value = false
103
- }
104
-
105
- return { states, running, run }
106
- }
@@ -1,118 +0,0 @@
1
- // Registry of analyzer definitions. The layer owns the type shape and the
2
- // reader; hosts provide the array via a Nuxt plugin (`$gscAnalyzers`) or via
3
- // the module's `analyzers` option (which generates the plugin at build time).
4
- //
5
- // Callers:
6
- // - `/analyze` page builds its tab list from `useGscAnalyzerDefs()` and
7
- // renders each tab via `<GscAnalyzerPanel :def>` driven by `panel`.
8
- // - `/insights` page filters via `useGscAnalyzerDefsWithCapability('insightCard')`.
9
- // - `useActionPriority` filters via `useGscAnalyzerDefsWithCapability('actionPriority')`.
10
- //
11
- // Capability slots are typed; e.g. `actionPriority` only accepts the
12
- // `ActionSource` union from `@gscdump/analysis`, so misspelt or unknown
13
- // sources fail at typecheck instead of at runtime in the priority runner.
14
-
15
- import type { ActionSource, AnalysisTool } from '@gscdump/analysis'
16
- import type { Component } from '@vue/runtime-core'
17
-
18
- export type GscAnalyzerKind = 'analyzer' | 'semantic' | 'action'
19
-
20
- export type GscAnalyzerAccent = 'primary' | 'warning' | 'success' | 'error' | 'neutral'
21
-
22
- export interface GscAnalyzerInsightCard {
23
- icon: string
24
- accent: GscAnalyzerAccent
25
- description: string
26
- summarize: (res: { results: unknown[], meta: Record<string, unknown> }) => { headline: string, tagline: string }
27
- }
28
-
29
- export interface GscAnalyzerStatTile {
30
- label: string
31
- value: string | number
32
- /**
33
- * Optional CSS color string for the value (used by the few panels that
34
- * color-code direction, e.g. improved/worsened in change-points).
35
- */
36
- valueColor?: string
37
- }
38
-
39
- export interface GscAnalyzerPanelResult {
40
- results: unknown[]
41
- meta: Record<string, unknown>
42
- queryMs?: number | null
43
- }
44
-
45
- export interface GscAnalyzerPanelSpec {
46
- /**
47
- * Body component. Receives `{ rows, meta, range }` as props. Lazy-import
48
- * via `defineAsyncComponent(() => import(...))` to preserve route-level
49
- * codesplitting — pages that don't render the analyzer never pay for its
50
- * chart code.
51
- */
52
- component: Component
53
- /** Project a result to the header stat tiles. Empty array = no tiles. */
54
- summarize?: (res: GscAnalyzerPanelResult) => GscAnalyzerStatTile[]
55
- /** Italic footer caption explaining the underlying method. */
56
- caption?: string
57
- /**
58
- * When true, the shell skips its loading/error/empty gating and renders
59
- * the body component immediately — the panel manages its own phase state
60
- * (pipeline panels: action priority, content gap).
61
- */
62
- ownsLifecycle?: boolean
63
- }
64
-
65
- export interface GscAnalyzerCapabilities {
66
- /** Opt into the `/insights` curated grid by providing a card config. */
67
- insightCard?: GscAnalyzerInsightCard
68
- /** Opt into `useActionPriority`. The value is the typed `ActionSource` slug. */
69
- actionPriority?: ActionSource
70
- /**
71
- * Opt into the unified `/analyze` panel renderer. Without this, the tab
72
- * falls back to the generic table renderer in `<GscAnalyzerPanel>`.
73
- */
74
- panel?: GscAnalyzerPanelSpec
75
- }
76
-
77
- export type GscAnalyzerCapability = keyof GscAnalyzerCapabilities
78
-
79
- export interface GscAnalyzerDefinition {
80
- /** Stable analyzer id. Must match an `AnalysisTool` slug for built-in analyzers. */
81
- id: AnalysisTool | (string & {})
82
- label: string
83
- kind: GscAnalyzerKind
84
- /** Reads the per-query table; sums silently drop GSC-anonymized impressions. */
85
- isQueryGrained?: boolean
86
- /** Capability opt-ins. Each key gates a downstream consumer. */
87
- capabilities?: GscAnalyzerCapabilities
88
- }
89
-
90
- export type GscAnalyzerDefinitionWithCapability<K extends GscAnalyzerCapability>
91
- = GscAnalyzerDefinition & {
92
- capabilities: { [P in K]: NonNullable<GscAnalyzerCapabilities[P]> }
93
- }
94
-
95
- export function defineGscAnalyzer(def: GscAnalyzerDefinition): GscAnalyzerDefinition {
96
- return def
97
- }
98
-
99
- export function useGscAnalyzerDefs(): GscAnalyzerDefinition[] {
100
- return (useNuxtApp().$gscAnalyzers as GscAnalyzerDefinition[] | undefined) ?? []
101
- }
102
-
103
- export function useGscAnalyzerDef(id: string): GscAnalyzerDefinition | undefined {
104
- return useGscAnalyzerDefs().find(a => a.id === id)
105
- }
106
-
107
- /**
108
- * Typed filter that narrows defs to those with `capability` opted in.
109
- * The returned defs have `capabilities[capability]` typed as non-nullable.
110
- */
111
- export function useGscAnalyzerDefsWithCapability<K extends GscAnalyzerCapability>(
112
- capability: K,
113
- ): GscAnalyzerDefinitionWithCapability<K>[] {
114
- return useGscAnalyzerDefs().filter(
115
- (d): d is GscAnalyzerDefinitionWithCapability<K> =>
116
- d.capabilities?.[capability] != null,
117
- )
118
- }