@gscdump/nuxt 0.22.4 → 1.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -137
- package/dist/module.d.mts +22 -0
- package/dist/module.mjs +30 -0
- package/dist/runtime-config.d.mts +12 -0
- package/dist/runtime-config.mjs +27 -0
- package/package.json +26 -176
- package/app/assets/css/main.css +0 -2
- package/app/components/GscAnalyzerPanel.vue +0 -94
- package/app/components/GscAnonymizationBanner.vue +0 -46
- package/app/components/GscBootProgress.vue +0 -297
- package/app/components/GscCommandPalette.vue +0 -77
- package/app/components/GscDashboardPage.vue +0 -26
- package/app/components/GscEngineBadge.vue +0 -28
- package/app/components/GscHero.vue +0 -214
- package/app/components/GscLazyTopResult.vue +0 -47
- package/app/components/GscPerformanceChart.vue +0 -532
- package/app/components/GscPositionDistributionChart.vue +0 -63
- package/app/components/GscQueryLabel.vue +0 -63
- package/app/components/GscSitePageHeader.vue +0 -40
- package/app/components/GscSourceDebugPanel.vue +0 -196
- package/app/components/GscSyncStatusCell.vue +0 -54
- package/app/components/GscTopRollupCard.vue +0 -90
- package/app/composables/_useGscBackfill.ts +0 -119
- package/app/composables/_useGscQueryDispatcher.ts +0 -123
- package/app/composables/_useGscResource.ts +0 -202
- package/app/composables/_useGscSharedSiteResource.ts +0 -136
- package/app/composables/useGscAnalytics.ts +0 -206
- package/app/composables/useGscAnalyticsClient.ts +0 -9
- package/app/composables/useGscAnalyticsConfig.ts +0 -8
- package/app/composables/useGscAnalyticsSourceInfo.ts +0 -165
- package/app/composables/useGscAnalyzer.ts +0 -460
- package/app/composables/useGscAnalyzerBatch.ts +0 -106
- package/app/composables/useGscAnalyzerDefs.ts +0 -119
- package/app/composables/useGscAuth.ts +0 -115
- package/app/composables/useGscConsoleUrl.ts +0 -45
- package/app/composables/useGscCountries.ts +0 -46
- package/app/composables/useGscCurrentSite.ts +0 -36
- package/app/composables/useGscInspectionHistory.ts +0 -42
- package/app/composables/useGscInspections.ts +0 -52
- package/app/composables/useGscPanelContext.ts +0 -31
- package/app/composables/useGscPeriod.ts +0 -243
- package/app/composables/useGscQuery.ts +0 -301
- package/app/composables/useGscQueryDispatcher.ts +0 -14
- package/app/composables/useGscRequestIndexingInspect.ts +0 -29
- package/app/composables/useGscResource.ts +0 -11
- package/app/composables/useGscRollup.ts +0 -237
- package/app/composables/useGscSearchAppearance.ts +0 -46
- package/app/composables/useGscSiteAnalyzer.ts +0 -456
- package/app/composables/useGscSitemapHistory.ts +0 -41
- package/app/composables/useGscSitemaps.ts +0 -45
- package/app/composables/useGscTableState.ts +0 -119
- package/app/plugins/analytics.ts +0 -57
- package/app/queries/gsc.ts +0 -163
- package/app/utils/anonymization.ts +0 -24
- package/app/utils/country-names.ts +0 -56
- package/app/utils/duckdb-wasm.ts +0 -166
- package/app/utils/gsc-constants.ts +0 -10
- package/app/utils/gsc-error.ts +0 -58
- package/app/utils/gsc-fetch.ts +0 -94
- package/app/utils/gsc-filters.ts +0 -32
- package/app/utils/gsc-rows.ts +0 -72
- package/app/utils/gsc-rpc.ts +0 -18
- package/app/utils/position.ts +0 -7
- package/module.ts +0 -81
- package/nuxt.config.ts +0 -61
- package/types.ts +0 -115
|
@@ -1,165 +0,0 @@
|
|
|
1
|
-
// Per-site reactive probe of what the server-resolved AnalysisQuerySource
|
|
2
|
-
// can do. The layer exposes source shape (kind + analyzer set); tier logic
|
|
3
|
-
// lives in the host.
|
|
4
|
-
//
|
|
5
|
-
// Use this to render locked/ghost UI upfront without issuing a doomed
|
|
6
|
-
// analyze() call for each panel.
|
|
7
|
-
//
|
|
8
|
-
// The reactive composable and the non-reactive `loadSourceInfoFor` share one
|
|
9
|
-
// entry per site/searchType/range via the shared site-resource seam, so they
|
|
10
|
-
// collapse to one network read per session slice.
|
|
11
|
-
|
|
12
|
-
import type { SourceInfoOptions } from '@gscdump/contracts'
|
|
13
|
-
import type { SourceCapabilities } from '@gscdump/engine/source'
|
|
14
|
-
import { acquireSharedEntry, useGscSharedSiteResource } from './_useGscSharedSiteResource'
|
|
15
|
-
import { useGscAnalyticsClient } from './useGscAnalyticsClient'
|
|
16
|
-
|
|
17
|
-
export interface GscAnalyticsSourceInfo {
|
|
18
|
-
/** Source implementation name — e.g. "gsc-api", "engine", "browser". */
|
|
19
|
-
name: string
|
|
20
|
-
/** `row` = GSC API / in-memory; `sql` = DuckDB/SQLite-backed. */
|
|
21
|
-
kind: 'row' | 'sql'
|
|
22
|
-
capabilities: SourceCapabilities
|
|
23
|
-
/** Analyzer ids runnable against this source, from the default registry. */
|
|
24
|
-
supportedAnalyzerIds: string[]
|
|
25
|
-
/** Host declared attach hint — client should boot DuckDB-WASM and attach parquets. */
|
|
26
|
-
browserAttachEligible: boolean
|
|
27
|
-
/**
|
|
28
|
-
* Host-controlled identity attrs echoed back for debug/display (e.g.
|
|
29
|
-
* `{ tier: 'free' | 'pro' }`). Layer treats as opaque — never use these
|
|
30
|
-
* for gating on the client, the source provider is the gate.
|
|
31
|
-
*/
|
|
32
|
-
identityAttrs?: Record<string, unknown> | null
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
interface GscAnalyticsSourceInfoState {
|
|
36
|
-
info: Ref<GscAnalyticsSourceInfo | null>
|
|
37
|
-
loading: Ref<boolean>
|
|
38
|
-
error: Ref<Error | null>
|
|
39
|
-
refresh: () => Promise<void>
|
|
40
|
-
/** Convenience predicate for panel gating: true when the analyzer id is in `supportedAnalyzerIds`. */
|
|
41
|
-
supports: (analyzerId: MaybeRefOrGetter<string>) => ComputedRef<boolean>
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
interface SourceInfoEntry {
|
|
45
|
-
info: Ref<GscAnalyticsSourceInfo | null>
|
|
46
|
-
loading: Ref<boolean>
|
|
47
|
-
error: Ref<Error | null>
|
|
48
|
-
pending: Ref<Promise<void> | null>
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const SOURCE_INFO_NAMESPACE = 'source-info'
|
|
52
|
-
const DEFAULT_SEARCH_TYPE = 'web'
|
|
53
|
-
|
|
54
|
-
function createEntry(): SourceInfoEntry {
|
|
55
|
-
return {
|
|
56
|
-
info: ref<GscAnalyticsSourceInfo | null>(null),
|
|
57
|
-
loading: ref(false),
|
|
58
|
-
error: ref<Error | null>(null),
|
|
59
|
-
pending: shallowRef<Promise<void> | null>(null),
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function sourceInfoKey(siteId: string, options?: SourceInfoOptions): string {
|
|
64
|
-
return JSON.stringify([
|
|
65
|
-
siteId,
|
|
66
|
-
options?.searchType ?? DEFAULT_SEARCH_TYPE,
|
|
67
|
-
options?.start ?? options?.startDate ?? null,
|
|
68
|
-
options?.end ?? options?.endDate ?? null,
|
|
69
|
-
])
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function fetchInto(
|
|
73
|
-
entry: SourceInfoEntry,
|
|
74
|
-
siteId: string,
|
|
75
|
-
options?: SourceInfoOptions,
|
|
76
|
-
client = useGscAnalyticsClient(),
|
|
77
|
-
): Promise<void> {
|
|
78
|
-
if (entry.pending.value)
|
|
79
|
-
return entry.pending.value
|
|
80
|
-
entry.loading.value = true
|
|
81
|
-
entry.error.value = null
|
|
82
|
-
const p = (client.getSourceInfo(siteId, options) as Promise<GscAnalyticsSourceInfo>)
|
|
83
|
-
.then((data) => {
|
|
84
|
-
entry.info.value = data
|
|
85
|
-
})
|
|
86
|
-
.catch((err: unknown) => {
|
|
87
|
-
entry.error.value = err instanceof Error ? err : new Error(String(err))
|
|
88
|
-
})
|
|
89
|
-
.finally(() => {
|
|
90
|
-
entry.loading.value = false
|
|
91
|
-
entry.pending.value = null
|
|
92
|
-
})
|
|
93
|
-
entry.pending.value = p
|
|
94
|
-
return p
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Non-reactive source-info loader for callers outside a Vue scope (e.g. the
|
|
99
|
-
* analyzer's boot IIFE in `useGscAnalyzer.createInstance`). Shares the same
|
|
100
|
-
* per-site cache `useGscAnalyticsSourceInfo` consumes via the shared
|
|
101
|
-
* site-resource seam, so gating UI and the analyzer mode probe collapse to
|
|
102
|
-
* one network read per site per session.
|
|
103
|
-
*/
|
|
104
|
-
export async function loadSourceInfoFor(siteId: string, options?: SourceInfoOptions): Promise<GscAnalyticsSourceInfo> {
|
|
105
|
-
const entry = acquireSharedEntry(SOURCE_INFO_NAMESPACE, sourceInfoKey(siteId, options), createEntry)
|
|
106
|
-
if (entry.info.value)
|
|
107
|
-
return entry.info.value
|
|
108
|
-
await fetchInto(entry, siteId, options)
|
|
109
|
-
if (entry.error.value)
|
|
110
|
-
throw entry.error.value
|
|
111
|
-
if (!entry.info.value)
|
|
112
|
-
throw new Error(`loadSourceInfoFor(${siteId}): no info after fetch`)
|
|
113
|
-
return entry.info.value
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export function useGscAnalyticsSourceInfo(
|
|
117
|
-
siteId: MaybeRefOrGetter<string | null | undefined>,
|
|
118
|
-
options: MaybeRefOrGetter<SourceInfoOptions | null | undefined> = null,
|
|
119
|
-
): GscAnalyticsSourceInfoState {
|
|
120
|
-
const client = useGscAnalyticsClient()
|
|
121
|
-
const cacheKey = computed(() => {
|
|
122
|
-
const id = toValue(siteId)
|
|
123
|
-
return id ? sourceInfoKey(id, toValue(options) ?? undefined) : null
|
|
124
|
-
})
|
|
125
|
-
const { bound } = useGscSharedSiteResource<SourceInfoEntry>(SOURCE_INFO_NAMESPACE, cacheKey, {
|
|
126
|
-
factory: () => createEntry(),
|
|
127
|
-
// No onDispose: source-info is cheap and persistent across the session.
|
|
128
|
-
})
|
|
129
|
-
|
|
130
|
-
if (import.meta.client) {
|
|
131
|
-
watch(bound, (entry: SourceInfoEntry | null) => {
|
|
132
|
-
if (!entry)
|
|
133
|
-
return
|
|
134
|
-
const id = toValue(siteId)
|
|
135
|
-
if (!id)
|
|
136
|
-
return
|
|
137
|
-
if (entry.info.value == null && entry.pending.value == null && entry.error.value == null)
|
|
138
|
-
void fetchInto(entry, id, toValue(options) ?? undefined, client)
|
|
139
|
-
}, { immediate: true })
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const info = computed(() => bound.value?.info.value ?? null) as unknown as Ref<GscAnalyticsSourceInfo | null>
|
|
143
|
-
const loading = computed(() => bound.value?.loading.value ?? false) as unknown as Ref<boolean>
|
|
144
|
-
const error = computed(() => bound.value?.error.value ?? null) as unknown as Ref<Error | null>
|
|
145
|
-
|
|
146
|
-
async function refresh(): Promise<void> {
|
|
147
|
-
const entry = bound.value
|
|
148
|
-
const id = toValue(siteId)
|
|
149
|
-
if (!entry || !id)
|
|
150
|
-
return
|
|
151
|
-
entry.info.value = null
|
|
152
|
-
await fetchInto(entry, id, toValue(options) ?? undefined, client)
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function supports(analyzerId: MaybeRefOrGetter<string>): ComputedRef<boolean> {
|
|
156
|
-
return computed(() => {
|
|
157
|
-
const list = bound.value?.info.value?.supportedAnalyzerIds
|
|
158
|
-
if (!list)
|
|
159
|
-
return false
|
|
160
|
-
return list.includes(toValue(analyzerId))
|
|
161
|
-
})
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
return { info, loading, error, refresh, supports }
|
|
165
|
-
}
|
|
@@ -1,460 +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 { AnalysisSourcesResponse, SourceInfoResponse } from '@gscdump/contracts'
|
|
15
|
-
import type { AttachedTablesHandle, BrowserAnalysisRuntime, DuckDBWasmBootResult, QueryResult } from '@gscdump/engine-duckdb-wasm'
|
|
16
|
-
import type { AnalysisParams, AnalysisResult } from '@gscdump/engine/analysis-types'
|
|
17
|
-
import type { AnalyzerRegistry } from '@gscdump/engine/analyzer'
|
|
18
|
-
import type { SiteLoadProgress } from './useGscAnalytics'
|
|
19
|
-
import { coerceRow } from '@gscdump/engine'
|
|
20
|
-
import { gscQueries } from '../queries/gsc'
|
|
21
|
-
import { useGscRpc } from '../utils/gsc-rpc'
|
|
22
|
-
import { useGscSharedSiteResource } from './_useGscSharedSiteResource'
|
|
23
|
-
import { useGscAnalyticsContext } from './useGscAnalytics'
|
|
24
|
-
import { useGscAnalyticsClient } from './useGscAnalyticsClient'
|
|
25
|
-
import { useGscAnalyticsConfig } from './useGscAnalyticsConfig'
|
|
26
|
-
import { resolveGscAuthHeaders } from './useGscAuth'
|
|
27
|
-
|
|
28
|
-
export interface GscAnalyzerTimings {
|
|
29
|
-
bootMs: number
|
|
30
|
-
manifestMs: number
|
|
31
|
-
attachMs: number
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export interface GscAnalyzerInstance {
|
|
35
|
-
ready: Ref<boolean>
|
|
36
|
-
initializing: Ref<boolean>
|
|
37
|
-
error: Ref<Error | null>
|
|
38
|
-
attachedTables: Ref<string[]>
|
|
39
|
-
timings: Ref<GscAnalyzerTimings | null>
|
|
40
|
-
/** Server-reported manifest version of the currently-attached snapshot. */
|
|
41
|
-
manifestVersion: Ref<string | undefined>
|
|
42
|
-
query: (sql: string, params?: unknown[]) => Promise<QueryResult>
|
|
43
|
-
analyze: (params: AnalysisParams, opts?: { signal?: AbortSignal }) => Promise<AnalysisResult & { queryMs: number }>
|
|
44
|
-
/**
|
|
45
|
-
* Re-probe `analysis-sources`; if the manifest version changed since the
|
|
46
|
-
* last attach, detach views and re-attach against the fresher parquet
|
|
47
|
-
* partitions in place. No-op for `server`-mode analyzers and when the
|
|
48
|
-
* version matches. Resolves to true if the runtime re-attached.
|
|
49
|
-
*/
|
|
50
|
-
refresh: () => Promise<boolean>
|
|
51
|
-
dispose: () => Promise<void>
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const EMPTY_TABLES: readonly string[] = Object.freeze([])
|
|
55
|
-
const DEFAULT_SEARCH_TYPE: NonNullable<AnalysisParams['searchType']> = 'web'
|
|
56
|
-
const DEFAULT_ATTACH_FETCH_CONCURRENCY = 2
|
|
57
|
-
const DEFAULT_ATTACH_MAX_FILES = 32
|
|
58
|
-
const DEFAULT_ATTACH_MAX_BYTES = 16 * 1024 * 1024
|
|
59
|
-
|
|
60
|
-
let defaultAnalyzerRegistryPromise: Promise<AnalyzerRegistry> | null = null
|
|
61
|
-
|
|
62
|
-
interface AnalyzerRange {
|
|
63
|
-
start: string
|
|
64
|
-
end: string
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function normalizeSearchType(searchType: AnalysisParams['searchType']): NonNullable<AnalysisParams['searchType']> {
|
|
68
|
-
return searchType ?? DEFAULT_SEARCH_TYPE
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function analyzerCacheKey(siteId: string, searchType: NonNullable<AnalysisParams['searchType']>, range: AnalyzerRange | null): string {
|
|
72
|
-
return JSON.stringify([siteId, searchType, range?.start ?? null, range?.end ?? null])
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function parseAnalyzerCacheKey(key: string): { siteId: string, searchType: NonNullable<AnalysisParams['searchType']>, range: AnalyzerRange | null } {
|
|
76
|
-
const [siteId, searchType, start, end] = JSON.parse(key) as [string, NonNullable<AnalysisParams['searchType']>, string | null, string | null]
|
|
77
|
-
return { siteId, searchType, range: start && end ? { start, end } : null }
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function normalizeRange(range: AnalyzerRange | null | undefined): AnalyzerRange | null {
|
|
81
|
-
return range?.start && range?.end ? range : null
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
type AnalyticsClient = ReturnType<typeof useGscAnalyticsClient>
|
|
85
|
-
|
|
86
|
-
function loadSourceInfo(client: AnalyticsClient, siteId: string, searchType: NonNullable<AnalysisParams['searchType']>, range: AnalyzerRange | null): Promise<SourceInfoResponse> {
|
|
87
|
-
return client.getSourceInfo(siteId, {
|
|
88
|
-
searchType,
|
|
89
|
-
...(range ? { start: range.start, end: range.end } : {}),
|
|
90
|
-
}) as Promise<SourceInfoResponse>
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function loadAnalysisSources(client: AnalyticsClient, siteId: string, searchType: NonNullable<AnalysisParams['searchType']>, range: AnalyzerRange | null): Promise<AnalysisSourcesResponse> {
|
|
94
|
-
return client.getAnalysisSources(siteId, undefined, {
|
|
95
|
-
searchType,
|
|
96
|
-
...(range ? { start: range.start, end: range.end } : {}),
|
|
97
|
-
}) as Promise<AnalysisSourcesResponse>
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function loadDefaultAnalyzerRegistry(): Promise<AnalyzerRegistry> {
|
|
101
|
-
defaultAnalyzerRegistryPromise ??= import('@gscdump/analysis/registry')
|
|
102
|
-
.then(m => m.defaultAnalyzerRegistry)
|
|
103
|
-
return defaultAnalyzerRegistryPromise
|
|
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
|
-
const client = useGscAnalyticsClient()
|
|
195
|
-
const rpc = useGscRpc()
|
|
196
|
-
|
|
197
|
-
function patch(p: Partial<SiteLoadProgress>): void {
|
|
198
|
-
patchProgress(siteId, { source: 'duckdb', ...p })
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
let runtime: BrowserAnalysisRuntime | null = null
|
|
202
|
-
let bootedDb: DuckDBWasmBootResult | null = null
|
|
203
|
-
let attachedHandle: AttachedTablesHandle | null = null
|
|
204
|
-
let mode: 'browser-attached' | 'server' = 'server'
|
|
205
|
-
const lifetimeController = new AbortController()
|
|
206
|
-
const inFlight = new Map<string, Promise<AnalysisResult & { queryMs: number }>>()
|
|
207
|
-
|
|
208
|
-
// Cross-origin: if the host returns relative parquet URLs (`/api/r2-data/…`)
|
|
209
|
-
// and `apiBase` is set, prefix them so DuckDB-WASM hits the data origin
|
|
210
|
-
// rather than the consumer's own host. Same-origin / absolute URLs pass
|
|
211
|
-
// through unchanged.
|
|
212
|
-
function rewriteParquetUrl(url: string): string {
|
|
213
|
-
const apiBase = useGscAnalyticsConfig().apiBase
|
|
214
|
-
if (!apiBase || !url.startsWith('/'))
|
|
215
|
-
return url
|
|
216
|
-
return `${apiBase.replace(/\/+$/, '')}${url}`
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
async function attachFromSources(sources: AnalysisSourcesResponse, signal?: AbortSignal): Promise<{ attached: number, total: number }> {
|
|
220
|
-
if (!bootedDb)
|
|
221
|
-
throw new Error('useGscAnalyzer: attachFromSources called before DuckDB boot')
|
|
222
|
-
if (sources.canUseBrowser === false)
|
|
223
|
-
throw new Error(`useGscAnalyzer: browser attach unavailable: ${sources.reason ?? sources.fallback ?? 'coverage plan rejected'}`)
|
|
224
|
-
|
|
225
|
-
const tables = Object.entries(sources.tables)
|
|
226
|
-
.filter(([, urls]) => Array.isArray(urls) && urls.length > 0)
|
|
227
|
-
.map(([table, urls]) => ({ table, urls: urls.map(rewriteParquetUrl) }))
|
|
228
|
-
|
|
229
|
-
const total = tables.reduce((n, t) => n + t.urls.length, 0)
|
|
230
|
-
patch({ stage: 'attach', filesTotal: total, filesAttached: 0 })
|
|
231
|
-
|
|
232
|
-
// URL preflights can use the same host-supplied auth header as /api/__gsc/*.
|
|
233
|
-
// The actual DuckDB range reads are authorized by the exact-key token
|
|
234
|
-
// embedded in each analysis-sources URL, because registerFileURL cannot
|
|
235
|
-
// carry custom fetch headers into DuckDB-WASM's internal HTTP reader.
|
|
236
|
-
const extraHeaders = resolveGscAuthHeaders()
|
|
237
|
-
const hasExtra = Object.keys(extraHeaders).length > 0
|
|
238
|
-
let attached = 0
|
|
239
|
-
const { attachParquetUrlTables } = await import('@gscdump/engine-duckdb-wasm')
|
|
240
|
-
const handle = await attachParquetUrlTables({
|
|
241
|
-
db: bootedDb.db,
|
|
242
|
-
conn: bootedDb.conn,
|
|
243
|
-
tables,
|
|
244
|
-
version: sources.manifestVersion,
|
|
245
|
-
fetchInit: hasExtra
|
|
246
|
-
? { credentials: 'omit', headers: extraHeaders }
|
|
247
|
-
: { credentials: 'same-origin' },
|
|
248
|
-
fetchConcurrency: DEFAULT_ATTACH_FETCH_CONCURRENCY,
|
|
249
|
-
maxFiles: DEFAULT_ATTACH_MAX_FILES,
|
|
250
|
-
maxBytes: DEFAULT_ATTACH_MAX_BYTES,
|
|
251
|
-
signal,
|
|
252
|
-
onFileAttached: () => {
|
|
253
|
-
attached++
|
|
254
|
-
patch({ filesAttached: attached })
|
|
255
|
-
},
|
|
256
|
-
})
|
|
257
|
-
attachedHandle = handle
|
|
258
|
-
// `handle.tables` reflects what *actually* attached — `attachParquetUrlTables`
|
|
259
|
-
// drops tables on fetch failure, so the requested list can over-report.
|
|
260
|
-
// Use the authoritative list so downstream pre-checks (e.g. the engine's
|
|
261
|
-
// AttachedTableMissingError fast-fail) get an accurate view.
|
|
262
|
-
attachedTables.value = handle.tables
|
|
263
|
-
manifestVersion.value = sources.manifestVersion
|
|
264
|
-
return { attached, total }
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
const boot = (async () => {
|
|
268
|
-
patch({ stage: 'manifest', startedAt: Date.now(), filesAttached: 0, filesTotal: 0, error: undefined, endedAt: undefined })
|
|
269
|
-
// Probe the server-resolved source first. Its kind + attachedTables bit
|
|
270
|
-
// decides whether we boot DuckDB-WASM (expensive) or proxy to the server.
|
|
271
|
-
const info = await loadSourceInfo(client, siteId, searchType, range)
|
|
272
|
-
mode = info.browserAttachEligible ? 'browser-attached' : 'server'
|
|
273
|
-
|
|
274
|
-
if (mode === 'server') {
|
|
275
|
-
// No local runtime; analyze() posts to the server. Mark ready so the
|
|
276
|
-
// shared progress UI stops spinning.
|
|
277
|
-
timings.value = { bootMs: 0, manifestMs: 0, attachMs: 0 }
|
|
278
|
-
ready.value = true
|
|
279
|
-
patch({ stage: 'ready', endedAt: Date.now() })
|
|
280
|
-
return null
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
const cfg = useGscAnalyticsConfig().duckdbBundleBase
|
|
284
|
-
patch({ stage: 'wasm' })
|
|
285
|
-
const t0 = performance.now()
|
|
286
|
-
// Dynamic import so server/consumer-mode hosts (no browser SQL) never pull
|
|
287
|
-
// the wasm engine into their client bundle. The static type-only import at
|
|
288
|
-
// top of the file keeps the type signatures available without an emit.
|
|
289
|
-
const { bootDuckDBWasm, createBrowserAnalysisRuntime } = await import('@gscdump/engine-duckdb-wasm')
|
|
290
|
-
bootedDb = await bootDuckDBWasm(cfg
|
|
291
|
-
? {
|
|
292
|
-
bundles: {
|
|
293
|
-
mvp: { mainModule: `${cfg}/duckdb-mvp.wasm`, mainWorker: `${cfg}/duckdb-browser-mvp.worker.js` },
|
|
294
|
-
eh: { mainModule: `${cfg}/duckdb-eh.wasm`, mainWorker: `${cfg}/duckdb-browser-eh.worker.js` },
|
|
295
|
-
},
|
|
296
|
-
}
|
|
297
|
-
: undefined)
|
|
298
|
-
const bootMs = performance.now() - t0
|
|
299
|
-
|
|
300
|
-
patch({ stage: 'manifest' })
|
|
301
|
-
const t1 = performance.now()
|
|
302
|
-
const sources = await loadAnalysisSources(client, siteId, searchType, range)
|
|
303
|
-
const manifestMs = performance.now() - t1
|
|
304
|
-
|
|
305
|
-
const t2 = performance.now()
|
|
306
|
-
const { total } = await attachFromSources(sources, lifetimeController.signal)
|
|
307
|
-
const attachMs = performance.now() - t2
|
|
308
|
-
|
|
309
|
-
runtime = createBrowserAnalysisRuntime(bootedDb, { schema: 'main', attachedTables: attachedTables.value })
|
|
310
|
-
runtime.setVersion(sources.manifestVersion)
|
|
311
|
-
timings.value = { bootMs, manifestMs, attachMs }
|
|
312
|
-
ready.value = true
|
|
313
|
-
patch({ stage: 'ready', filesAttached: total, endedAt: Date.now() })
|
|
314
|
-
return runtime
|
|
315
|
-
})()
|
|
316
|
-
.catch((e) => {
|
|
317
|
-
const err = e instanceof Error ? e : new Error(String(e))
|
|
318
|
-
error.value = err
|
|
319
|
-
patch({ stage: 'error', error: err.message, endedAt: Date.now() })
|
|
320
|
-
throw err
|
|
321
|
-
})
|
|
322
|
-
.finally(() => {
|
|
323
|
-
initializing.value = false
|
|
324
|
-
})
|
|
325
|
-
|
|
326
|
-
// Don't let the boot promise crash the runtime — error.value surfaces it.
|
|
327
|
-
boot.catch(() => {})
|
|
328
|
-
|
|
329
|
-
async function query(sql: string, params?: unknown[]): Promise<QueryResult> {
|
|
330
|
-
const rt = await boot
|
|
331
|
-
if (!rt)
|
|
332
|
-
throw new Error('useGscAnalyzer: query() requires a SQL-capable source with attachedTables; current source routes through the server')
|
|
333
|
-
return rt.query(sql, params)
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
async function runServerAnalyze(params: AnalysisParams, _signal?: AbortSignal): Promise<AnalysisResult & { queryMs: number }> {
|
|
337
|
-
const out = await rpc.execute(
|
|
338
|
-
gscQueries.analyze(siteId),
|
|
339
|
-
{ ...params, searchType: params.searchType ?? searchType } as never,
|
|
340
|
-
{ silent: true },
|
|
341
|
-
) as AnalysisResult & { queryMs?: number }
|
|
342
|
-
return {
|
|
343
|
-
results: coerceResults(out.results) as AnalysisResult['results'],
|
|
344
|
-
meta: out.meta as AnalysisResult['meta'],
|
|
345
|
-
queryMs: out.queryMs ?? 0,
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
async function analyze(params: AnalysisParams, opts?: { signal?: AbortSignal }): Promise<AnalysisResult & { queryMs: number }> {
|
|
350
|
-
const rt = opts?.signal ? await raceSignal(boot, opts.signal) : await boot
|
|
351
|
-
opts?.signal?.throwIfAborted?.()
|
|
352
|
-
|
|
353
|
-
const scopedParams = { ...params, searchType: params.searchType ?? searchType }
|
|
354
|
-
const key = JSON.stringify({ manifestVersion: manifestVersion.value, params: scopedParams, searchType, siteId })
|
|
355
|
-
const existing = inFlight.get(key)
|
|
356
|
-
if (existing)
|
|
357
|
-
return opts?.signal ? raceSignal(existing, opts.signal) : existing
|
|
358
|
-
|
|
359
|
-
const p = (async () => {
|
|
360
|
-
if (!rt)
|
|
361
|
-
return runServerAnalyze(scopedParams, opts?.signal)
|
|
362
|
-
const registry = await loadDefaultAnalyzerRegistry()
|
|
363
|
-
opts?.signal?.throwIfAborted?.()
|
|
364
|
-
const out = await rt.analyze(scopedParams as never, registry, { signal: opts?.signal })
|
|
365
|
-
opts?.signal?.throwIfAborted?.()
|
|
366
|
-
return {
|
|
367
|
-
results: coerceResults(out.results) as AnalysisResult['results'],
|
|
368
|
-
meta: out.meta as AnalysisResult['meta'],
|
|
369
|
-
queryMs: out.queryMs,
|
|
370
|
-
}
|
|
371
|
-
})()
|
|
372
|
-
if (opts?.signal) {
|
|
373
|
-
return raceSignal(p, opts.signal)
|
|
374
|
-
}
|
|
375
|
-
inFlight.set(key, p)
|
|
376
|
-
p.finally(() => {
|
|
377
|
-
if (inFlight.get(key) === p)
|
|
378
|
-
inFlight.delete(key)
|
|
379
|
-
})
|
|
380
|
-
return p
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
async function refresh(): Promise<boolean> {
|
|
384
|
-
await boot
|
|
385
|
-
if (mode !== 'browser-attached' || !runtime || !bootedDb)
|
|
386
|
-
return false
|
|
387
|
-
const sources = await loadAnalysisSources(client, siteId, searchType, range)
|
|
388
|
-
if (!runtime.isStale(sources.manifestVersion))
|
|
389
|
-
return false
|
|
390
|
-
// Drop the stale views before swapping in the new partitions. The runtime
|
|
391
|
-
// (db + conn) stays alive — we're swapping the data, not the engine.
|
|
392
|
-
await attachedHandle?.detach().catch((e) => {
|
|
393
|
-
console.warn('[analyzer] detach during refresh failed', e)
|
|
394
|
-
})
|
|
395
|
-
attachedHandle = null
|
|
396
|
-
await attachFromSources(sources)
|
|
397
|
-
runtime.setVersion(sources.manifestVersion)
|
|
398
|
-
runtime.setAttachedTables(attachedTables.value)
|
|
399
|
-
return true
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
async function dispose(): Promise<void> {
|
|
403
|
-
lifetimeController.abort()
|
|
404
|
-
await runtime?.close().catch((e) => {
|
|
405
|
-
console.error('[analyzer] runtime.close failed', e)
|
|
406
|
-
})
|
|
407
|
-
runtime = null
|
|
408
|
-
bootedDb = null
|
|
409
|
-
attachedHandle = null
|
|
410
|
-
ready.value = false
|
|
411
|
-
attachedTables.value = []
|
|
412
|
-
manifestVersion.value = undefined
|
|
413
|
-
inFlight.clear()
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
return { ready, initializing, error, attachedTables, timings, manifestVersion, query, analyze, refresh, dispose }
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
function coerceResults(results: unknown): unknown {
|
|
420
|
-
if (!Array.isArray(results))
|
|
421
|
-
return results
|
|
422
|
-
let changed = false
|
|
423
|
-
const out: unknown[] = Array.from({ length: results.length })
|
|
424
|
-
for (let i = 0; i < results.length; i++) {
|
|
425
|
-
const r = results[i]
|
|
426
|
-
if (r && typeof r === 'object') {
|
|
427
|
-
const c = coerceRow(r as Record<string, unknown>)
|
|
428
|
-
if (c !== r)
|
|
429
|
-
changed = true
|
|
430
|
-
out[i] = c
|
|
431
|
-
}
|
|
432
|
-
else {
|
|
433
|
-
out[i] = r
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
return changed ? out : results
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
function abortError(): DOMException {
|
|
440
|
-
return new DOMException('aborted', 'AbortError')
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
function raceSignal<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
444
|
-
if (signal.aborted)
|
|
445
|
-
return Promise.reject(abortError())
|
|
446
|
-
return new Promise<T>((resolve, reject) => {
|
|
447
|
-
const onAbort = (): void => reject(abortError())
|
|
448
|
-
signal.addEventListener('abort', onAbort, { once: true })
|
|
449
|
-
promise.then(
|
|
450
|
-
(v) => {
|
|
451
|
-
signal.removeEventListener('abort', onAbort)
|
|
452
|
-
resolve(v)
|
|
453
|
-
},
|
|
454
|
-
(e) => {
|
|
455
|
-
signal.removeEventListener('abort', onAbort)
|
|
456
|
-
reject(e)
|
|
457
|
-
},
|
|
458
|
-
)
|
|
459
|
-
})
|
|
460
|
-
}
|