@gscdump/nuxt 0.21.3 → 0.22.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +36 -27
  2. package/app/components/GscDashboardPage.vue +2 -2
  3. package/app/components/GscHero.vue +3 -4
  4. package/app/components/GscLazyTopResult.vue +7 -5
  5. package/app/components/GscPerformanceChart.vue +4 -4
  6. package/app/components/GscSourceDebugPanel.vue +2 -1
  7. package/app/composables/_useGscBackfill.ts +13 -5
  8. package/app/composables/_useGscQueryDispatcher.ts +5 -4
  9. package/app/composables/_useGscResource.ts +144 -56
  10. package/app/composables/useGscAnalytics.ts +13 -4
  11. package/app/composables/useGscAnalyticsSourceInfo.ts +11 -5
  12. package/app/composables/useGscAnalyzer.ts +33 -25
  13. package/app/composables/useGscAnalyzerBatch.ts +2 -2
  14. package/app/composables/useGscAnalyzerDefs.ts +2 -1
  15. package/app/composables/useGscCountries.ts +5 -6
  16. package/app/composables/useGscCurrentSite.ts +32 -11
  17. package/app/composables/useGscInspectionHistory.ts +5 -5
  18. package/app/composables/useGscInspections.ts +5 -5
  19. package/app/composables/useGscPanelContext.ts +9 -2
  20. package/app/composables/useGscQuery.ts +6 -8
  21. package/app/composables/useGscQueryDispatcher.ts +14 -122
  22. package/app/composables/useGscRequestIndexingInspect.ts +20 -11
  23. package/app/composables/useGscResource.ts +11 -114
  24. package/app/composables/useGscRollup.ts +66 -81
  25. package/app/composables/useGscSearchAppearance.ts +5 -6
  26. package/app/composables/useGscSiteAnalyzer.ts +456 -0
  27. package/app/composables/useGscSitemapHistory.ts +5 -5
  28. package/app/composables/useGscSitemaps.ts +5 -5
  29. package/app/queries/gsc.ts +163 -0
  30. package/app/utils/duckdb-wasm.ts +166 -0
  31. package/app/utils/gsc-rpc.ts +18 -0
  32. package/module.ts +1 -1
  33. package/nuxt.config.ts +25 -0
  34. package/package.json +140 -8
  35. package/types.ts +8 -7
  36. package/app/composables/useGscEngine.ts +0 -16
  37. package/app/composables/useGscParquetTable.ts +0 -199
  38. package/app/composables/useGscRollupTable.ts +0 -78
  39. package/app/composables/useGscRowQuery.ts +0 -56
  40. package/app/composables/useGscSnapshotAnalyzer.contract.ts +0 -150
  41. package/app/composables/useGscSnapshotAnalyzer.routing.ts +0 -87
  42. package/app/composables/useGscSnapshotAnalyzer.ts +0 -559
  43. package/app/utils/setup-gsc-fetch-auth.ts +0 -62
package/README.md CHANGED
@@ -31,13 +31,25 @@ pnpm add @gscdump/nuxt
31
31
  export default defineNuxtConfig({
32
32
  extends: ['@gscdump/nuxt'],
33
33
  runtimeConfig: {
34
- analytics: {
35
- mode: 'origin', // or 'consumer' / 'local'
34
+ public: {
35
+ analytics: {
36
+ apiBase: '', // same-origin by default; set to your analytics origin in consumer mode
37
+ },
36
38
  },
37
39
  },
38
40
  })
39
41
  ```
40
42
 
43
+ Nuxt auto-imports the layer composables. If you prefer explicit imports, use
44
+ per-composable entrypoints:
45
+
46
+ ```ts
47
+ import { setGscAuth } from '@gscdump/nuxt/composables/useGscAuth'
48
+ import { useGscRollup } from '@gscdump/nuxt/composables/useGscRollup'
49
+ ```
50
+
51
+ The primary query interface is `useGscQuery` / `useGscAnalyzer`.
52
+
41
53
  ## Register an auth provider
42
54
 
43
55
  ```ts
@@ -60,11 +72,11 @@ export default defineNitroPlugin((nitro) => {
60
72
 
61
73
  | Need | Endpoint | Client composable |
62
74
  | --- | --- | --- |
63
- | Pre-aggregated widgets (top pages, daily totals) | `GET /api/sites/:siteId/rollup/:id` | `useGscRollup` / `useGscRollups` / `useGscRollupFanout` |
64
- | Raw rows filtered by dimension/range (`where(eq(page, ...))`) | `POST /api/sites/:siteId/rows` | `useGscRowQuery({ site, state })` |
65
- | Analyzer result (striking-distance, CTR anomaly, …) | `POST /api/sites/:siteId/analyze` | `useGscAnalyzer(siteId).analyze(...)` |
75
+ | Pre-aggregated widgets (top pages, daily totals) | `GET /api/__gsc/sites/:siteId/rollup/:id` | `useGscRollup` / `useGscRollups` / `useGscRollupFanout` |
76
+ | Raw rows filtered by dimension/range (`where(eq(page, ...))`) | `POST /api/__gsc/sites/:siteId/rows` | `useGscQuery({ siteId, params })` |
77
+ | Analyzer result (striking-distance, CTR anomaly, …) | `POST /api/__gsc/sites/:siteId/analyze` | `useGscAnalyzer(siteId).analyze(...)` |
66
78
 
67
- Rule of thumb: reach for `/rows` + `useGscRowQuery` for detail pages that
79
+ Rule of thumb: reach for `/rows` + `useGscQuery` for detail pages that
68
80
  need a freeform slice of the data. Only fall back to `/analyze` when the
69
81
  server runs an actual analyzer (anything in the registry); hitting it for
70
82
  plain row lookups routes through analyzer-gating logic you don't need.
@@ -78,39 +90,36 @@ the data — e.g. nuxtseo.com pro consuming gscdump.com. Two pieces:
78
90
  in `nuxt.config.ts` (or via `NUXT_PUBLIC_ANALYTICS_API_BASE`). Empty =
79
91
  same-origin (the default origin-mode shape).
80
92
 
81
- 2. **Hand the layer a per-viewer api key**: register a client plugin that
82
- trades the viewer's host session for an api key, then call
83
- `setGscFetchHeaders({ 'x-api-key': key })`. The layer's `useGscFetch`
93
+ 2. **Hand the layer per-viewer auth**: register a client plugin that trades
94
+ the viewer's host session for an api key, then call
95
+ `setGscAuth({ apiKey: key })`. The layer's `useGscFetch`
84
96
  attaches the header to every `/api/__gsc/*` call automatically. Browser
85
97
  parquet reads use exact-object URLs minted by the source endpoint; runtime
86
98
  preflights can include this header, while DuckDB-WASM range reads are
87
99
  authorized by the token embedded in each URL.
88
100
 
89
- The `setupGscFetchAuth` helper collapses the boilerplate:
101
+ Wire it from a `'pre'`-enforced client plugin so downstream layer plugins see
102
+ the auth on first read. The layer's `setGscAuth` accepts a value, a `Ref`,
103
+ or a getter — use a getter when credentials may refresh during the session:
90
104
 
91
105
  ```ts
92
106
  // plugins/00.gscdump-auth.client.ts
93
- export default setupGscFetchAuth({
94
- credentialsEndpoint: '/api/me/gscdump-credentials',
95
- // Optional: preload host-specific state. Runs inside runWithContext so
96
- // composables work. Useful for useState-backed flags read by the layer's
97
- // composables at construction time.
98
- async onReady() {
99
- const flag = useState<boolean | null>('app:browser-analyzer-enabled', () => null)
100
- if (flag.value !== null)
101
- return
102
- const settings = await useGscFetch()<{ browserAnalyzerEnabled: boolean }>('/api/user/settings').catch(() => null)
103
- flag.value = !!settings?.browserAnalyzerEnabled
107
+ export default defineNuxtPlugin({
108
+ name: 'gscdump-auth',
109
+ enforce: 'pre',
110
+ async setup() {
111
+ const creds = ref<{ apiKey: string, browserAnalyzerEnabled?: boolean } | null>(null)
112
+ creds.value = await $fetch('/api/me/gscdump-credentials').catch(() => null)
113
+ setGscAuth(() => ({
114
+ apiKey: creds.value?.apiKey ?? null,
115
+ browserAnalyzerEnabled: !!creds.value?.browserAnalyzerEnabled,
116
+ }))
104
117
  },
105
118
  })
106
119
  ```
107
120
 
108
- The helper is `enforce: 'pre'` and dedupes via an inflight promise; Nuxt
109
- blocks subsequent plugins until headers land, removing the auth-header race
110
- where a `useGscQuery` mounts before credentials resolve.
111
-
112
- `credentialsEndpoint` returns `{ apiKey: string, ... }` by default. Override
113
- the field name with `tokenField` and the header name with `headerName`.
121
+ `useGscQuery` mounts that race the credential fetch resolve once the
122
+ `'pre'` plugin's `await` settles Nuxt blocks subsequent plugins on it.
114
123
 
115
124
  Onboarding and lifecycle state stay outside this layer. A consumer app should
116
125
  use `@gscdump/sdk` from its host backend to register users/sites, read
@@ -14,10 +14,10 @@ const gapClass = computed(() => (
14
14
  </script>
15
15
 
16
16
  <template>
17
- <div class="flex flex-col min-h-screen">
17
+ <div class="flex flex-col min-h-screen overflow-x-clip">
18
18
  <slot name="header" />
19
19
  <div
20
- class="max-w-[1128px] px-4 sm:px-6 lg:px-9 pt-4 pb-10 flex flex-col flex-1 w-full"
20
+ class="max-w-[1128px] min-w-0 px-4 sm:px-6 lg:px-9 pt-4 pb-10 flex flex-col flex-1 w-full"
21
21
  :class="gapClass"
22
22
  >
23
23
  <slot />
@@ -204,10 +204,9 @@ const chartPrevData = computed(() => stats.value?.previous?.series ?? [])
204
204
  v-if="stats && chartData.length"
205
205
  class="rounded-lg border border-default bg-default p-4"
206
206
  >
207
- <PerformanceChart
208
- :data="chartData"
209
- :previous-data="chartPrevData"
210
- :show-comparison="compareMode !== 'none' && chartPrevData.length > 0"
207
+ <GscPerformanceChart
208
+ :value="chartData"
209
+ :prev-value="compareMode !== 'none' ? chartPrevData : null"
211
210
  :height="220"
212
211
  />
213
212
  </div>
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { useIntersectionObserver } from '@vueuse/core'
3
- import { useGscFetch } from '../utils/gsc-fetch'
3
+ import { gscQueries } from '../queries/gsc'
4
+ import { useGscRpc } from '../utils/gsc-rpc'
4
5
 
5
6
  const props = defineProps<{
6
7
  siteUrl: string
@@ -21,14 +22,15 @@ const { stop } = useIntersectionObserver(
21
22
  return
22
23
  loaded.value = true
23
24
  stop()
24
- useGscFetch()<{ value: string | null }>(`/api/__gsc/sites/${encodeURIComponent(props.siteUrl)}/data/top-association`, {
25
- query: {
25
+ const request = useGscRpc().query(
26
+ gscQueries.topAssociation(props.siteUrl, {
26
27
  type: props.type,
27
28
  identifier: props.identifier,
28
29
  startDate: props.startDate,
29
30
  endDate: props.endDate,
30
- },
31
- })
31
+ }),
32
+ ) as unknown as Promise<{ value: string | null }>
33
+ request
32
34
  .then(r => value.value = r.value)
33
35
  .catch(() => {})
34
36
  },
@@ -297,11 +297,11 @@ const position = (d: DataRow) => d.position ?? 0
297
297
  <div v-if="loading" class="loading-skeleton">
298
298
  <div class="flex-1 flex items-end gap-1">
299
299
  <div v-for="i in 30" :key="i" class="flex-1 h-full flex">
300
- <UiSkeleton type="bar" :index="i" />
300
+ <USkeleton class="w-full self-end" :style="{ height: `${28 + ((i * 17) % 56)}%` }" />
301
301
  </div>
302
302
  </div>
303
303
  <div class="flex justify-between mt-3">
304
- <UiSkeleton v-for="i in 5" :key="i" class="h-3 w-12" />
304
+ <USkeleton v-for="i in 5" :key="i" class="h-3 w-12" />
305
305
  </div>
306
306
  </div>
307
307
 
@@ -444,11 +444,11 @@ const position = (d: DataRow) => d.position ?? 0
444
444
  <div class="loading-skeleton">
445
445
  <div class="flex-1 flex items-end gap-1">
446
446
  <div v-for="i in 30" :key="i" class="flex-1">
447
- <UiSkeleton type="bar" :index="i" />
447
+ <USkeleton class="w-full self-end" :style="{ height: `${28 + ((i * 17) % 56)}%` }" />
448
448
  </div>
449
449
  </div>
450
450
  <div class="flex justify-between mt-3">
451
- <UiSkeleton v-for="i in 5" :key="i" class="h-3 w-12" />
451
+ <USkeleton v-for="i in 5" :key="i" class="h-3 w-12" />
452
452
  </div>
453
453
  </div>
454
454
  </template>
@@ -37,11 +37,12 @@ interface WhoamiResponse {
37
37
  }
38
38
  const whoami = ref<WhoamiResponse | null>(null)
39
39
  const whoamiLoading = ref(false)
40
+ const analyticsClient = useGscAnalyticsClient()
40
41
  watchEffect(() => {
41
42
  if (!import.meta.client || resolvedSite.value || !open.value || whoami.value || whoamiLoading.value)
42
43
  return
43
44
  whoamiLoading.value = true
44
- const request = useGscAnalyticsClient().whoami() as Promise<WhoamiResponse>
45
+ const request = analyticsClient.whoami() as Promise<WhoamiResponse>
45
46
  request
46
47
  .then((res) => { whoami.value = res })
47
48
  .catch(() => { /* surface via empty state, not a toast */ })
@@ -2,8 +2,9 @@
2
2
  // on-demand backfill request, polls sync-progress, and invokes a refetch once
3
3
  // the requested range is synced.
4
4
 
5
- import { useGscFetch } from '../utils/gsc-fetch'
6
- import { useGscAnalyticsClient } from './useGscAnalyticsClient'
5
+ import { invalidateNuxtQueries } from 'nuxt-use-query/query-cache'
6
+ import { gscQueries } from '../queries/gsc'
7
+ import { useGscRpc } from '../utils/gsc-rpc'
7
8
 
8
9
  interface BackfillRange {
9
10
  startDate: string
@@ -35,6 +36,7 @@ export function useGscBackfill(): GscBackfillState & {
35
36
  maybeTrigger: (meta: unknown, siteId: string, refetch: () => Promise<unknown> | unknown) => boolean
36
37
  reset: () => void
37
38
  } {
39
+ const rpc = useGscRpc()
38
40
  const pending = ref(false)
39
41
  const range = ref<BackfillRange | null>(null)
40
42
  const percent = ref(0)
@@ -48,7 +50,7 @@ export function useGscBackfill(): GscBackfillState & {
48
50
  }
49
51
 
50
52
  async function isRangeCovered(siteId: string, req: BackfillRange): Promise<boolean> {
51
- const progress = await useGscFetch()<SyncProgressResponse>('/api/sync-progress').catch(() => null)
53
+ const progress = await rpc.query(gscQueries.syncProgress(), { silent: true }).catch(() => null) as SyncProgressResponse | null
52
54
  const site = progress?.sites?.find(s => s.id === siteId)
53
55
  if (!site?.oldestDateSynced || !site?.newestDateSynced)
54
56
  return false
@@ -63,7 +65,7 @@ export function useGscBackfill(): GscBackfillState & {
63
65
  error.value = null
64
66
  attemptedKeys.add(rangeKey(siteId, req))
65
67
 
66
- await useGscAnalyticsClient().requestBackfill(siteId, req).catch((err) => {
68
+ await rpc.execute(gscQueries.backfill(siteId), { ...req }, { silent: true }).catch((err: unknown) => {
67
69
  error.value = (err as { data?: { message?: string }, message?: string })?.data?.message
68
70
  || (err as Error)?.message
69
71
  || 'Failed to queue backfill'
@@ -86,8 +88,14 @@ export function useGscBackfill(): GscBackfillState & {
86
88
  if (!covered)
87
89
  error.value = 'Backfill did not complete within the expected window. Try again later.'
88
90
 
89
- if (covered)
91
+ if (covered) {
92
+ // Invalidate everything that depends on the newly synced range so
93
+ // consumers refetch instead of relying on the caller-passed refetch.
94
+ invalidateNuxtQueries(key => key.startsWith(`gsc:source-info:${siteId}`)
95
+ || key.startsWith(`gsc:analysis-sources:${siteId}`)
96
+ || key === 'gsc:sync-progress')
90
97
  await refetch()
98
+ }
91
99
  pending.value = false
92
100
  range.value = null
93
101
  }
@@ -7,7 +7,8 @@
7
7
 
8
8
  import type { _useGscAuthInternal } from './useGscAuth'
9
9
  import type { GscQueryDecisionReason, GscQueryEngine } from './useGscQuery'
10
- import { useGscEngine } from './useGscEngine'
10
+
11
+ const ENGINE_STATE_KEY = 'gscdump:engine'
11
12
 
12
13
  type InternalAuthState = ReturnType<typeof _useGscAuthInternal>['value']
13
14
 
@@ -26,14 +27,14 @@ export interface GscFallbackEvent {
26
27
  }
27
28
 
28
29
  export interface PickEngineOpts {
29
- /** Per-call override. Wins over `useGscEngine()` + `runtimeConfig.public.analytics.defaultEngine`. */
30
+ /** Per-call override. Wins over the `gscdump:engine` useState + `runtimeConfig.public.analytics.defaultEngine`. */
30
31
  perCall?: GscQueryEngine
31
32
  }
32
33
 
33
34
  export interface GscQueryDispatcher {
34
35
  /**
35
36
  * Resolve the active engine into a concrete mode + reason. Consults the
36
- * resolution chain: `opts.perCall` → `useGscEngine()` → runtimeConfig →
37
+ * resolution chain: `opts.perCall` → `gscdump:engine` useState → runtimeConfig →
37
38
  * `'auto'`. Then maps `auto` against `auth.browserAnalyzerEnabled`.
38
39
  */
39
40
  pickEngine: (auth: InternalAuthState, opts?: PickEngineOpts) => GscEngineDecision
@@ -110,7 +111,7 @@ export function createDefaultGscQueryDispatcher(
110
111
  }
111
112
 
112
113
  function resolveDefaultEngine(): GscQueryEngine {
113
- const override = useGscEngine().value
114
+ const override = useState<GscQueryEngine | null>(ENGINE_STATE_KEY, () => null).value
114
115
  if (override)
115
116
  return override
116
117
  const cfg = useRuntimeConfig().public.analytics as { defaultEngine?: GscQueryEngine } | undefined
@@ -1,36 +1,42 @@
1
- // Site-keyed async resource composable. Owns key-watch, stale-token discard,
2
- // status classification, refresh, and dispose for every read-only `/api/__gsc/*`
3
- // fetcher in the layer. Resource composables (useGscSitemaps,
4
- // useGscIndexingDiagnostics, ) are thin adapters that wire reactive keys to
5
- // `useGscAnalyticsClient().getX(...)` plus optional derived computeds.
6
- //
7
- // Stale-token (not AbortController) because `@gscdump/sdk`'s AnalyticsClient
8
- // doesn't accept a signal. Late-arriving promises from a superseded run are
9
- // dropped on `data`/`status` writes; the in-flight fetch still runs to
10
- // completion, which is acceptable for read-only GETs.
1
+ // Site-keyed async resource composable. Owns key-watch, status classification,
2
+ // refresh, and cache policy for every read-only `/api/__gsc/*` operation in
3
+ // the layer. Resource composables stay thin adapters that wire reactive keys
4
+ // to `gscQueries.*(...)` operations (preferred) or to a `fetcher` for the
5
+ // rollup composables that mix multiple operations + progress side-effects.
11
6
 
12
7
  import type { ComputedRef, Ref, WatchSource } from '@vue/runtime-core'
8
+ import type { NuxtRpcQueryOperation } from 'nuxt-use-query/rpc'
13
9
  import type { GscErrorStatus } from '../utils/gsc-error'
10
+ import { useNuxtRpcQuery } from 'nuxt-use-query/rpc'
14
11
  import { classifyGscError } from '../utils/gsc-error'
12
+ import { useGscFetch } from '../utils/gsc-fetch'
15
13
 
16
14
  export type GscResourceStatus = 'idle' | 'pending' | 'success' | 'empty' | GscErrorStatus
17
15
 
18
16
  export interface UseGscResourceOptions<TArgs extends readonly unknown[], TData> {
19
17
  /** Reactive args passed to the fetcher. Resource stays idle while any required key is null/undefined. */
20
18
  keys: { [I in keyof TArgs]: MaybeRefOrGetter<TArgs[I] | null | undefined> }
21
- /** Async fetcher invoked with the resolved keys. */
22
- fetcher: (...args: TArgs) => Promise<TData | null>
19
+ /** RPC query operation invoked with the resolved keys. Preferred over `fetcher` — gets Zod validation + shared cache keying. */
20
+ operation?: (...args: TArgs) => NuxtRpcQueryOperation<any, any>
21
+ /** Composite async fetcher invoked with the resolved keys. Use only when an operation can't model the request (multi-call, progress side-effects). */
22
+ fetcher?: (...args: TArgs) => Promise<TData | null>
23
23
  /** Predicate for the `empty` status — defaults to checking `null`/`[]`/typical container fields. */
24
24
  isEmpty?: (data: TData) => boolean
25
25
  /** Extra reactive sources that should retrigger a fetch. */
26
26
  watchSources?: WatchSource[]
27
+ /** Optional namespace for the fetcher-mode cache key. Defaults to `gsc-resource`. Ignored when `operation` is provided (the operation owns its key). */
28
+ namespace?: string
29
+ /** Milliseconds before cached data is considered stale. Defaults to `nuxt-use-query`'s 0. */
30
+ staleTime?: number
31
+ /** Evict cached payload after the last consumer unmounts. Defaults to `nuxt-use-query`'s 5 min. */
32
+ gcTime?: number
27
33
  }
28
34
 
29
35
  export interface UseGscResourceReturn<TData> {
30
- data: Ref<TData | null>
31
- status: Ref<GscResourceStatus>
36
+ data: Readonly<Ref<TData | null>>
37
+ status: Readonly<Ref<GscResourceStatus>>
32
38
  loading: ComputedRef<boolean>
33
- error: Ref<Error | null>
39
+ error: Readonly<Ref<Error | null>>
34
40
  refresh: () => Promise<void>
35
41
  }
36
42
 
@@ -53,14 +59,8 @@ function defaultIsEmpty(v: unknown): boolean {
53
59
  export function useGscResource<TArgs extends readonly unknown[], TData>(
54
60
  opts: UseGscResourceOptions<TArgs, TData>,
55
61
  ): UseGscResourceReturn<TData> {
56
- const data = shallowRef<TData | null>(null)
57
- const status = ref<GscResourceStatus>('idle')
58
- const error = ref<Error | null>(null)
59
- const loading = computed(() => status.value === 'pending')
60
-
61
- let runToken = 0
62
-
63
- function resolveKeys(): TArgs | null {
62
+ const namespace = opts.namespace ?? 'gsc-resource'
63
+ const resolvedArgs = computed<TArgs | null>(() => {
64
64
  const out: unknown[] = []
65
65
  for (const k of opts.keys) {
66
66
  const v = toValue(k)
@@ -69,46 +69,134 @@ export function useGscResource<TArgs extends readonly unknown[], TData>(
69
69
  out.push(v)
70
70
  }
71
71
  return out as unknown as TArgs
72
- }
72
+ })
73
73
 
74
- async function refresh(): Promise<void> {
75
- const args = resolveKeys()
76
- const token = ++runToken
77
- if (!args) {
78
- data.value = null
79
- status.value = 'idle'
80
- error.value = null
81
- return
82
- }
83
- status.value = 'pending'
84
- error.value = null
85
- try {
86
- const out = await opts.fetcher(...args)
87
- if (token !== runToken)
88
- return
89
- data.value = out
90
- const empty = out == null || (opts.isEmpty ?? defaultIsEmpty)(out)
91
- status.value = empty ? 'empty' : 'success'
92
- }
93
- catch (e) {
94
- if (token !== runToken)
74
+ const enabled = computed(() => resolvedArgs.value != null)
75
+ const gscFetch = useGscFetch()
76
+
77
+ // Operation branch: route through useNuxtRpcQuery so we inherit Zod
78
+ // response validation, RPC key serialization, and shared cache invalidation.
79
+ if (opts.operation) {
80
+ const operation = computed<NuxtRpcQueryOperation<any, any> | null>(() => {
81
+ const args = resolvedArgs.value
82
+ return args ? opts.operation!(...args) : null
83
+ })
84
+
85
+ // Idle placeholder when args aren't ready — useNuxtRpcQuery is always
86
+ // mounted, but the `enabled` gate prevents the request firing.
87
+ const fallbackOp = { key: `${namespace}:idle`, path: `${namespace}:idle`, response: { parse: (v: unknown) => v } as any }
88
+
89
+ const query = useNuxtRpcQuery<any>(
90
+ () => operation.value ?? fallbackOp,
91
+ {
92
+ enabled,
93
+ watch: opts.watchSources,
94
+ staleTime: opts.staleTime,
95
+ gcTime: opts.gcTime,
96
+ $fetch: gscFetch as any,
97
+ } as any,
98
+ )
99
+
100
+ const data = computed<TData | null>(() => {
101
+ if (!enabled.value)
102
+ return null
103
+ return (query.displayData.value ?? null) as TData | null
104
+ })
105
+
106
+ const error = computed<Error | null>(() => {
107
+ if (!enabled.value)
108
+ return null
109
+ const e = query.error.value
110
+ return e == null ? null : e instanceof Error ? e : new Error(String(e))
111
+ })
112
+
113
+ const status = computed<GscResourceStatus>(() => {
114
+ if (!enabled.value)
115
+ return 'idle'
116
+ if (query.status.value === 'pending')
117
+ return 'pending'
118
+ if (query.status.value === 'error') {
119
+ const classified = classifyGscError(error.value)
120
+ return classified.status
121
+ }
122
+ const out = data.value
123
+ if (query.status.value === 'success')
124
+ return out == null || (opts.isEmpty ?? defaultIsEmpty)(out) ? 'empty' : 'success'
125
+ return 'idle'
126
+ })
127
+
128
+ const loading = computed(() => status.value === 'pending')
129
+
130
+ async function refresh(): Promise<void> {
131
+ if (!enabled.value)
95
132
  return
96
- const classified = classifyGscError(e)
97
- error.value = e instanceof Error ? e : new Error(String(e))
98
- status.value = classified.status
99
- data.value = null
133
+ await query.refresh()
100
134
  }
135
+
136
+ return { data, status, loading, error, refresh }
101
137
  }
102
138
 
103
- watch(
104
- [...opts.keys.map(k => () => toValue(k)), ...(opts.watchSources ?? [])],
105
- refresh,
106
- { immediate: true },
107
- )
139
+ // Fetcher branch: composables that can't be modelled as a single RPC
140
+ // operation (rollup fan-out, progress tracking) drop down to a raw
141
+ // useNuxtQuery + bound $fetch.
142
+ const queryKey = computed(() => {
143
+ const args = resolvedArgs.value
144
+ return args ? `${namespace}:${JSON.stringify(args)}` : `${namespace}:idle`
145
+ })
146
+
147
+ const fetchResource = (async () => {
148
+ const args = resolvedArgs.value
149
+ if (!args)
150
+ return null
151
+ if (opts.fetcher)
152
+ return await opts.fetcher(...args)
153
+ throw new Error('useGscResource: no operation or fetcher configured')
154
+ }) as unknown as typeof $fetch
155
+
156
+ const query = useNuxtQuery<TData | null, Error>(() => queryKey.value, {
157
+ key: () => queryKey.value,
158
+ enabled,
159
+ watch: opts.watchSources,
160
+ staleTime: opts.staleTime,
161
+ gcTime: opts.gcTime,
162
+ $fetch: fetchResource,
163
+ })
164
+
165
+ const data = computed<TData | null>(() => {
166
+ if (!enabled.value)
167
+ return null
168
+ return (query.displayData.value ?? null) as TData | null
169
+ })
170
+
171
+ const error = computed<Error | null>(() => {
172
+ if (!enabled.value)
173
+ return null
174
+ const e = query.error.value
175
+ return e == null ? null : e instanceof Error ? e : new Error(String(e))
176
+ })
108
177
 
109
- onScopeDispose(() => {
110
- runToken++
178
+ const status = computed<GscResourceStatus>(() => {
179
+ if (!enabled.value)
180
+ return 'idle'
181
+ if (query.status.value === 'pending')
182
+ return 'pending'
183
+ if (query.status.value === 'error') {
184
+ const classified = classifyGscError(error.value)
185
+ return classified.status
186
+ }
187
+ const out = data.value
188
+ if (query.status.value === 'success')
189
+ return out == null || (opts.isEmpty ?? defaultIsEmpty)(out) ? 'empty' : 'success'
190
+ return 'idle'
111
191
  })
112
192
 
193
+ const loading = computed(() => status.value === 'pending')
194
+
195
+ async function refresh(): Promise<void> {
196
+ if (!enabled.value)
197
+ return
198
+ await query.refresh()
199
+ }
200
+
113
201
  return { data, status, loading, error, refresh }
114
202
  }
@@ -127,6 +127,7 @@ export function useGscBootProgress(): {
127
127
  }
128
128
 
129
129
  function createContext(): GscAnalyticsContext {
130
+ const client = useGscAnalyticsClient()
130
131
  const sites = ref<SiteListItem[] | null>(null)
131
132
  const sitesLoading = ref(false)
132
133
  const sitesError = ref<Error | null>(null)
@@ -135,7 +136,7 @@ function createContext(): GscAnalyticsContext {
135
136
  async function refreshSites(): Promise<void> {
136
137
  sitesLoading.value = true
137
138
  sitesError.value = null
138
- sites.value = await useGscAnalyticsClient().listSites().catch((err) => {
139
+ sites.value = await client.listSites().catch((err: unknown) => {
139
140
  sitesError.value = err instanceof Error ? err : new Error(String(err))
140
141
  return null
141
142
  })
@@ -171,14 +172,22 @@ function createContext(): GscAnalyticsContext {
171
172
  // Lazy-load: defer refreshSites until something actually reads `sites`.
172
173
  // Eager kick-off used to race host plugins that set auth via `setGscAuth`;
173
174
  // first call would 401 and the rest of the session would silently degrade.
174
- // Triggering on first read defers the request to a microtask after plugin
175
- // setup, so auth state is in place.
175
+ // Triggering on first read still needs to wait until hydration is done:
176
+ // `refreshSites()` flips `sitesLoading` synchronously, and doing that during
177
+ // the client's first render makes the sidebar disagree with SSR markup.
176
178
  let kickedOff = false
177
179
  function maybeKickOff(): void {
178
180
  if (kickedOff || !import.meta.client)
179
181
  return
180
182
  kickedOff = true
181
- refreshSites()
183
+ const start = (): void => {
184
+ void refreshSites()
185
+ }
186
+ const nuxtApp = useNuxtApp()
187
+ if (nuxtApp.isHydrating)
188
+ onNuxtReady(start)
189
+ else
190
+ queueMicrotask(start)
182
191
  }
183
192
  const lazySites = computed<SiteListItem[] | null>(() => {
184
193
  maybeKickOff()
@@ -9,8 +9,8 @@
9
9
  // entry per site/searchType/range via the shared site-resource seam, so they
10
10
  // collapse to one network read per session slice.
11
11
 
12
- import type { SourceCapabilities } from '@gscdump/analysis'
13
12
  import type { SourceInfoOptions } from '@gscdump/contracts'
13
+ import type { SourceCapabilities } from '@gscdump/engine/source'
14
14
  import { acquireSharedEntry, useGscSharedSiteResource } from './_useGscSharedSiteResource'
15
15
  import { useGscAnalyticsClient } from './useGscAnalyticsClient'
16
16
 
@@ -69,12 +69,17 @@ function sourceInfoKey(siteId: string, options?: SourceInfoOptions): string {
69
69
  ])
70
70
  }
71
71
 
72
- function fetchInto(entry: SourceInfoEntry, siteId: string, options?: SourceInfoOptions): Promise<void> {
72
+ function fetchInto(
73
+ entry: SourceInfoEntry,
74
+ siteId: string,
75
+ options?: SourceInfoOptions,
76
+ client = useGscAnalyticsClient(),
77
+ ): Promise<void> {
73
78
  if (entry.pending.value)
74
79
  return entry.pending.value
75
80
  entry.loading.value = true
76
81
  entry.error.value = null
77
- const p = (useGscAnalyticsClient().getSourceInfo(siteId, options) as Promise<GscAnalyticsSourceInfo>)
82
+ const p = (client.getSourceInfo(siteId, options) as Promise<GscAnalyticsSourceInfo>)
78
83
  .then((data) => {
79
84
  entry.info.value = data
80
85
  })
@@ -112,6 +117,7 @@ export function useGscAnalyticsSourceInfo(
112
117
  siteId: MaybeRefOrGetter<string | null | undefined>,
113
118
  options: MaybeRefOrGetter<SourceInfoOptions | null | undefined> = null,
114
119
  ): GscAnalyticsSourceInfoState {
120
+ const client = useGscAnalyticsClient()
115
121
  const cacheKey = computed(() => {
116
122
  const id = toValue(siteId)
117
123
  return id ? sourceInfoKey(id, toValue(options) ?? undefined) : null
@@ -129,7 +135,7 @@ export function useGscAnalyticsSourceInfo(
129
135
  if (!id)
130
136
  return
131
137
  if (entry.info.value == null && entry.pending.value == null && entry.error.value == null)
132
- void fetchInto(entry, id, toValue(options) ?? undefined)
138
+ void fetchInto(entry, id, toValue(options) ?? undefined, client)
133
139
  }, { immediate: true })
134
140
  }
135
141
 
@@ -143,7 +149,7 @@ export function useGscAnalyticsSourceInfo(
143
149
  if (!entry || !id)
144
150
  return
145
151
  entry.info.value = null
146
- await fetchInto(entry, id, toValue(options) ?? undefined)
152
+ await fetchInto(entry, id, toValue(options) ?? undefined, client)
147
153
  }
148
154
 
149
155
  function supports(analyzerId: MaybeRefOrGetter<string>): ComputedRef<boolean> {