@gscdump/nuxt 0.22.4 → 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 (66) hide show
  1. package/README.md +10 -137
  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 +26 -176
  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 -214
  15. package/app/components/GscLazyTopResult.vue +0 -47
  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 -196
  21. package/app/components/GscSyncStatusCell.vue +0 -54
  22. package/app/components/GscTopRollupCard.vue +0 -90
  23. package/app/composables/_useGscBackfill.ts +0 -119
  24. package/app/composables/_useGscQueryDispatcher.ts +0 -123
  25. package/app/composables/_useGscResource.ts +0 -202
  26. package/app/composables/_useGscSharedSiteResource.ts +0 -136
  27. package/app/composables/useGscAnalytics.ts +0 -206
  28. package/app/composables/useGscAnalyticsClient.ts +0 -9
  29. package/app/composables/useGscAnalyticsConfig.ts +0 -8
  30. package/app/composables/useGscAnalyticsSourceInfo.ts +0 -165
  31. package/app/composables/useGscAnalyzer.ts +0 -460
  32. package/app/composables/useGscAnalyzerBatch.ts +0 -106
  33. package/app/composables/useGscAnalyzerDefs.ts +0 -119
  34. package/app/composables/useGscAuth.ts +0 -115
  35. package/app/composables/useGscConsoleUrl.ts +0 -45
  36. package/app/composables/useGscCountries.ts +0 -46
  37. package/app/composables/useGscCurrentSite.ts +0 -36
  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/useGscPeriod.ts +0 -243
  42. package/app/composables/useGscQuery.ts +0 -301
  43. package/app/composables/useGscQueryDispatcher.ts +0 -14
  44. package/app/composables/useGscRequestIndexingInspect.ts +0 -29
  45. package/app/composables/useGscResource.ts +0 -11
  46. package/app/composables/useGscRollup.ts +0 -237
  47. package/app/composables/useGscSearchAppearance.ts +0 -46
  48. package/app/composables/useGscSiteAnalyzer.ts +0 -456
  49. package/app/composables/useGscSitemapHistory.ts +0 -41
  50. package/app/composables/useGscSitemaps.ts +0 -45
  51. package/app/composables/useGscTableState.ts +0 -119
  52. package/app/plugins/analytics.ts +0 -57
  53. package/app/queries/gsc.ts +0 -163
  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/gsc-rpc.ts +0 -18
  63. package/app/utils/position.ts +0 -7
  64. package/module.ts +0 -81
  65. package/nuxt.config.ts +0 -61
  66. 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
- }