@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
@@ -0,0 +1,166 @@
1
+ // Shared browser-DuckDB-WASM helpers used by per-site analyzers and the
2
+ // home-page multi-site fanout. Both consumers boot the same WASM bundle and
3
+ // run the same in-memory parquet attach path; OPFS attach lives in the caller
4
+ // because the two lifecycle models (refcounted long-lived vs ephemeral
5
+ // per-run) need different handle ownership.
6
+
7
+ import type { AsyncDuckDB, AsyncDuckDBConnection } from '@duckdb/duckdb-wasm'
8
+ import type { DuckDBWasmBootResult, OpfsAttachedHandle } from '@gscdump/engine-duckdb-wasm'
9
+
10
+ /**
11
+ * Boot DuckDB-WASM using the layer's configured bundle base. The dynamic
12
+ * `import('@gscdump/engine-duckdb-wasm')` keeps the WASM bundle out of the
13
+ * SSR build; callers should also gate the call behind `import.meta.client`.
14
+ *
15
+ * The `filesystem` config disables HEAD-request preflight and forces full
16
+ * HTTP reads — Cloudflare Workers strips `Content-Length` on streamed
17
+ * responses, which makes DuckDB's default range-request path 404.
18
+ */
19
+ export async function bootGscDuckDBWasm(bundleBase?: string): Promise<DuckDBWasmBootResult> {
20
+ const { bootDuckDBWasm } = await import('@gscdump/engine-duckdb-wasm')
21
+ return bootDuckDBWasm({
22
+ ...(bundleBase
23
+ ? {
24
+ bundles: {
25
+ mvp: { mainModule: `${bundleBase}/duckdb-mvp.wasm`, mainWorker: `${bundleBase}/duckdb-browser-mvp.worker.js` },
26
+ eh: { mainModule: `${bundleBase}/duckdb-eh.wasm`, mainWorker: `${bundleBase}/duckdb-browser-eh.worker.js` },
27
+ },
28
+ }
29
+ : {}),
30
+ config: { filesystem: { reliableHeadRequests: false, allowFullHTTPReads: true } },
31
+ })
32
+ }
33
+
34
+ /**
35
+ * Process-wide shared boot. The ~800ms WASM boot is the dominant cost on the
36
+ * warm path (OPFS cache-hit means downloads are skipped); paying it once per
37
+ * session instead of per refresh turns date-range changes / search-type
38
+ * swaps into near-instant updates.
39
+ *
40
+ * Callers that want their own throwaway instance can keep using
41
+ * {@link bootGscDuckDBWasm}; those that want session-wide reuse — the home
42
+ * page fanout and the per-site analyzer — go through this.
43
+ *
44
+ * Browser-only. The `bundleBase` is captured from the first call; subsequent
45
+ * calls reuse the promise and ignore the argument (it's a deployment-wide
46
+ * constant in practice).
47
+ */
48
+ let sharedBootPromise: Promise<DuckDBWasmBootResult> | null = null
49
+ let sharedBootColdMs: number | undefined
50
+ export function sharedGscDuckDBWasm(bundleBase?: string): Promise<DuckDBWasmBootResult> {
51
+ if (!sharedBootPromise) {
52
+ if (typeof performance !== 'undefined')
53
+ performance.mark('gsc:duckdb:boot:start')
54
+ const start = typeof performance !== 'undefined' ? performance.now() : 0
55
+ sharedBootPromise = bootGscDuckDBWasm(bundleBase).then((b) => {
56
+ if (typeof performance !== 'undefined') {
57
+ sharedBootColdMs = performance.now() - start
58
+ performance.mark('gsc:duckdb:boot:end')
59
+ }
60
+ return b
61
+ })
62
+ }
63
+ else if (typeof performance !== 'undefined') {
64
+ performance.mark('gsc:duckdb:boot:reuse')
65
+ }
66
+ return sharedBootPromise
67
+ }
68
+
69
+ /** Diagnostics: ms paid for the one-time cold boot, or undefined if not yet booted. */
70
+ export function sharedGscDuckDBWasmColdMs(): number | undefined {
71
+ return sharedBootColdMs
72
+ }
73
+
74
+ /**
75
+ * Fetch every URL into a `Uint8Array` in parallel. `onFileFetched` ticks
76
+ * once per successful download. Separate from `registerParquetView` so
77
+ * callers can run fetches outside any connection mutex.
78
+ */
79
+ export async function fetchParquetBuffers(
80
+ urls: readonly string[],
81
+ onFileFetched?: () => void,
82
+ ): Promise<Uint8Array[]> {
83
+ return Promise.all(urls.map(async (url) => {
84
+ const r = await fetch(url, { credentials: 'omit' })
85
+ if (!r.ok)
86
+ throw new Error(`GET ${url} failed: ${r.status}`)
87
+ const buf = new Uint8Array(await r.arrayBuffer())
88
+ onFileFetched?.()
89
+ return buf
90
+ }))
91
+ }
92
+
93
+ /**
94
+ * Register pre-fetched parquet buffers as virtual files, then build a single
95
+ * view that unions them. `CAST(date AS DATE)` canonicalises the legacy-VARCHAR
96
+ * / new-DATE encodings. File-name uniqueness is the caller's job; pass a
97
+ * `viewName` that already encodes any site/table salt so concurrent attaches
98
+ * across sites don't collide on the DuckDB virtual filesystem. Call under the
99
+ * DuckDB-WASM single-connection mutex.
100
+ */
101
+ export async function registerParquetView(opts: {
102
+ db: AsyncDuckDB
103
+ conn: AsyncDuckDBConnection
104
+ viewName: string
105
+ buffers: readonly Uint8Array[]
106
+ }): Promise<void> {
107
+ const { db, conn, viewName, buffers } = opts
108
+ const fileNames = buffers.map((_, i) => `${viewName}_${i}.parquet`)
109
+ for (let i = 0; i < buffers.length; i++)
110
+ await db.registerFileBuffer(fileNames[i]!, buffers[i]!)
111
+ const fileList = fileNames.map(n => `'${n.replace(/'/g, '\'\'')}'`).join(',')
112
+ await conn.query(`CREATE OR REPLACE VIEW ${viewName} AS SELECT * REPLACE (CAST(date AS DATE) AS date) FROM read_parquet([${fileList}], union_by_name = true)`)
113
+ }
114
+
115
+ /**
116
+ * Attach a parquet file set into DuckDB-WASM, preferring the OPFS-backed
117
+ * path (cache-hit skips download) and falling back to in-memory buffers when
118
+ * OPFS is opted out, unavailable, or quota-exhausted.
119
+ *
120
+ * Returns the OPFS handle when the OPFS path succeeded — caller must keep
121
+ * the handle alive and `detach()` on cleanup. Returns `null` when the
122
+ * fallback path was used; nothing to detach. `onFileProgress` ticks once per
123
+ * downloaded file in either path so the caller can drive a progress UI.
124
+ *
125
+ * `withDb` wraps the DDL/file-register calls when the caller serialises
126
+ * against other DuckDB activity (e.g. a shared connection); pass `undefined`
127
+ * when the caller already owns a private connection.
128
+ */
129
+ export async function attachParquetWithFallback(args: {
130
+ db: AsyncDuckDB
131
+ conn: AsyncDuckDBConnection
132
+ viewName: string
133
+ files: readonly { url: string, bytes: number, contentHash: string }[]
134
+ version: string
135
+ useOpfsCache: boolean
136
+ fetchConcurrency?: number
137
+ onFileProgress?: () => void
138
+ withDb?: <T>(fn: () => Promise<T>) => Promise<T>
139
+ }): Promise<OpfsAttachedHandle | null> {
140
+ const { db, conn, viewName, files, version, useOpfsCache, onFileProgress } = args
141
+ const lock = args.withDb ?? (<T>(fn: () => Promise<T>) => fn())
142
+ const fetchConcurrency = args.fetchConcurrency ?? Math.min(files.length, 4)
143
+
144
+ if (useOpfsCache) {
145
+ const { attachOpfsParquetTables } = await import('@gscdump/engine-duckdb-wasm')
146
+ const handle = await lock(() => attachOpfsParquetTables({
147
+ db,
148
+ conn,
149
+ tables: [{
150
+ table: viewName,
151
+ files: files.map(f => ({ url: f.url, bytes: f.bytes, contentHash: f.contentHash })),
152
+ }],
153
+ version,
154
+ fetchConcurrency,
155
+ onFileProgress,
156
+ }))
157
+ if (!handle.degradedTables.includes(viewName))
158
+ return handle
159
+ // OPFS quota / failure — fall through to buffer path. Caller's progress
160
+ // counter restarts; both paths report one tick per fetched file.
161
+ }
162
+
163
+ const buffers = await fetchParquetBuffers(files.map(f => f.url), onFileProgress)
164
+ await lock(() => registerParquetView({ db, conn, viewName, buffers }))
165
+ return null
166
+ }
@@ -0,0 +1,18 @@
1
+ import type { NuxtRpcErrorEvent, UseNuxtRpcOptions } from 'nuxt-use-query/rpc'
2
+ import { toHumanNuxtRpcError, useNuxtRpc } from 'nuxt-use-query/rpc'
3
+ import { useGscFetch } from './gsc-fetch'
4
+
5
+ // Layer-shared RPC executor. Routes through `useGscFetch()` so auth headers and
6
+ // the configured `apiBase` (cross-origin gscdump.com deployments) are honoured.
7
+ // Defaults `onError` to log a humanised message via `toHumanNuxtRpcError`; the
8
+ // toast is already emitted by `useGscFetch`'s `onResponseError`, so this is
9
+ // non-duplicate diagnostic output that hosts can override.
10
+ export function useGscRpc(options: UseNuxtRpcOptions = {}): ReturnType<typeof useNuxtRpc> {
11
+ return useNuxtRpc({
12
+ ...options,
13
+ fetch: options.fetch ?? (useGscFetch() as UseNuxtRpcOptions['fetch']),
14
+ onError: options.onError ?? ((event: NuxtRpcErrorEvent) => {
15
+ console.warn(`[gsc-rpc] ${event.operation.method} ${event.operation.path}: ${toHumanNuxtRpcError(event.error)}`)
16
+ }),
17
+ })
18
+ }
package/module.ts CHANGED
@@ -67,7 +67,7 @@ export default defineNuxtModule<ModuleOptions>({
67
67
  })
68
68
  }
69
69
 
70
- // Strip underscore-prefixed composables from the consumer auto-import surface.
70
+ // Strip internal composables from the consumer auto-import surface.
71
71
  // `app/composables/_useFoo.ts` stays usable inside the layer via explicit
72
72
  // relative imports, but never appears as a global auto-import in host apps.
73
73
  nuxt.hook('imports:extend', (imports) => {
package/nuxt.config.ts CHANGED
@@ -14,12 +14,37 @@ import { fileURLToPath } from 'node:url'
14
14
  export default defineNuxtConfig({
15
15
  // Register the layer's module so hooks + runtime config land on host apps
16
16
  // without them having to wire each module individually.
17
+ //
18
+ // `nuxt-use-query` provides `useNuxtQuery` / `useQueryCache` — TanStack-Query-
19
+ // shaped wrapper over `useFetch`. Composables in this layer fetch through
20
+ // it for SWR + dedup.
17
21
  modules: [
18
22
  fileURLToPath(new URL('./module.ts', import.meta.url)),
19
23
  '@nuxt/ui',
20
24
  '@vueuse/nuxt',
25
+ 'nuxt-use-query',
21
26
  ],
22
27
 
28
+ // Enforce shared/contracts + app/queries pattern at build time so host apps
29
+ // can't drift back to hardcoded `/api/*` literals in components / composables.
30
+ // Hosts can opt out per-site by overriding `nuxtUseQuery.contracts.enabled`.
31
+ nuxtUseQuery: {
32
+ contracts: {
33
+ enabled: true,
34
+ // `@gscdump/contracts` is the shared package alias for hosted-API wire
35
+ // shapes — query files must import from there (or a host
36
+ // `shared/contracts/*`).
37
+ apiPrefixes: ['/api/__gsc', '/api/sync-progress'],
38
+ queryDirs: ['app/queries', 'layers/*/app/queries'],
39
+ contractDirs: ['shared/contracts', 'layers/*/shared/contracts', '@gscdump/contracts'],
40
+ serverApiDirs: ['server/api', 'layers/*/server/api'],
41
+ requireServerContracts: false,
42
+ // The config file declares these prefixes so the scanner must skip it.
43
+ // (overrides the default ignore set — keep the defaults alongside it.)
44
+ ignore: ['.git', '.nuxt', '.output', 'coverage', 'dist', 'node_modules', 'nuxt.config.ts'],
45
+ },
46
+ },
47
+
23
48
  css: [
24
49
  fileURLToPath(new URL('./app/assets/css/main.css', import.meta.url)),
25
50
  ],
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gscdump/nuxt",
3
3
  "type": "module",
4
- "version": "0.21.3",
4
+ "version": "0.22.4",
5
5
  "description": "Nuxt layer: GSC analytics UI + DuckDB-WASM integration. Frontend-only; server primitives live in @gscdump/analysis, @gscdump/engine-sqlite, @gscdump/cloudflare, and host apps.",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",
@@ -30,6 +30,136 @@
30
30
  "types": "./module.ts",
31
31
  "import": "./module.ts",
32
32
  "default": "./module.ts"
33
+ },
34
+ "./queries/gsc": {
35
+ "types": "./app/queries/gsc.ts",
36
+ "import": "./app/queries/gsc.ts",
37
+ "default": "./app/queries/gsc.ts"
38
+ },
39
+ "./composables/useGscAnalytics": {
40
+ "types": "./app/composables/useGscAnalytics.ts",
41
+ "import": "./app/composables/useGscAnalytics.ts",
42
+ "default": "./app/composables/useGscAnalytics.ts"
43
+ },
44
+ "./composables/useGscAnalyticsClient": {
45
+ "types": "./app/composables/useGscAnalyticsClient.ts",
46
+ "import": "./app/composables/useGscAnalyticsClient.ts",
47
+ "default": "./app/composables/useGscAnalyticsClient.ts"
48
+ },
49
+ "./composables/useGscAnalyticsConfig": {
50
+ "types": "./app/composables/useGscAnalyticsConfig.ts",
51
+ "import": "./app/composables/useGscAnalyticsConfig.ts",
52
+ "default": "./app/composables/useGscAnalyticsConfig.ts"
53
+ },
54
+ "./composables/useGscAnalyticsSourceInfo": {
55
+ "types": "./app/composables/useGscAnalyticsSourceInfo.ts",
56
+ "import": "./app/composables/useGscAnalyticsSourceInfo.ts",
57
+ "default": "./app/composables/useGscAnalyticsSourceInfo.ts"
58
+ },
59
+ "./composables/useGscAnalyzer": {
60
+ "types": "./app/composables/useGscAnalyzer.ts",
61
+ "import": "./app/composables/useGscAnalyzer.ts",
62
+ "default": "./app/composables/useGscAnalyzer.ts"
63
+ },
64
+ "./composables/useGscAnalyzerBatch": {
65
+ "types": "./app/composables/useGscAnalyzerBatch.ts",
66
+ "import": "./app/composables/useGscAnalyzerBatch.ts",
67
+ "default": "./app/composables/useGscAnalyzerBatch.ts"
68
+ },
69
+ "./composables/useGscAnalyzerDefs": {
70
+ "types": "./app/composables/useGscAnalyzerDefs.ts",
71
+ "import": "./app/composables/useGscAnalyzerDefs.ts",
72
+ "default": "./app/composables/useGscAnalyzerDefs.ts"
73
+ },
74
+ "./composables/useGscAuth": {
75
+ "types": "./app/composables/useGscAuth.ts",
76
+ "import": "./app/composables/useGscAuth.ts",
77
+ "default": "./app/composables/useGscAuth.ts"
78
+ },
79
+ "./composables/useGscConsoleUrl": {
80
+ "types": "./app/composables/useGscConsoleUrl.ts",
81
+ "import": "./app/composables/useGscConsoleUrl.ts",
82
+ "default": "./app/composables/useGscConsoleUrl.ts"
83
+ },
84
+ "./composables/useGscCountries": {
85
+ "types": "./app/composables/useGscCountries.ts",
86
+ "import": "./app/composables/useGscCountries.ts",
87
+ "default": "./app/composables/useGscCountries.ts"
88
+ },
89
+ "./composables/useGscCurrentSite": {
90
+ "types": "./app/composables/useGscCurrentSite.ts",
91
+ "import": "./app/composables/useGscCurrentSite.ts",
92
+ "default": "./app/composables/useGscCurrentSite.ts"
93
+ },
94
+ "./composables/useGscInspectionHistory": {
95
+ "types": "./app/composables/useGscInspectionHistory.ts",
96
+ "import": "./app/composables/useGscInspectionHistory.ts",
97
+ "default": "./app/composables/useGscInspectionHistory.ts"
98
+ },
99
+ "./composables/useGscInspections": {
100
+ "types": "./app/composables/useGscInspections.ts",
101
+ "import": "./app/composables/useGscInspections.ts",
102
+ "default": "./app/composables/useGscInspections.ts"
103
+ },
104
+ "./composables/useGscPanelContext": {
105
+ "types": "./app/composables/useGscPanelContext.ts",
106
+ "import": "./app/composables/useGscPanelContext.ts",
107
+ "default": "./app/composables/useGscPanelContext.ts"
108
+ },
109
+ "./composables/useGscPeriod": {
110
+ "types": "./app/composables/useGscPeriod.ts",
111
+ "import": "./app/composables/useGscPeriod.ts",
112
+ "default": "./app/composables/useGscPeriod.ts"
113
+ },
114
+ "./composables/useGscQuery": {
115
+ "types": "./app/composables/useGscQuery.ts",
116
+ "import": "./app/composables/useGscQuery.ts",
117
+ "default": "./app/composables/useGscQuery.ts"
118
+ },
119
+ "./composables/useGscQueryDispatcher": {
120
+ "types": "./app/composables/useGscQueryDispatcher.ts",
121
+ "import": "./app/composables/useGscQueryDispatcher.ts",
122
+ "default": "./app/composables/useGscQueryDispatcher.ts"
123
+ },
124
+ "./composables/useGscRequestIndexingInspect": {
125
+ "types": "./app/composables/useGscRequestIndexingInspect.ts",
126
+ "import": "./app/composables/useGscRequestIndexingInspect.ts",
127
+ "default": "./app/composables/useGscRequestIndexingInspect.ts"
128
+ },
129
+ "./composables/useGscResource": {
130
+ "types": "./app/composables/useGscResource.ts",
131
+ "import": "./app/composables/useGscResource.ts",
132
+ "default": "./app/composables/useGscResource.ts"
133
+ },
134
+ "./composables/useGscRollup": {
135
+ "types": "./app/composables/useGscRollup.ts",
136
+ "import": "./app/composables/useGscRollup.ts",
137
+ "default": "./app/composables/useGscRollup.ts"
138
+ },
139
+ "./composables/useGscSearchAppearance": {
140
+ "types": "./app/composables/useGscSearchAppearance.ts",
141
+ "import": "./app/composables/useGscSearchAppearance.ts",
142
+ "default": "./app/composables/useGscSearchAppearance.ts"
143
+ },
144
+ "./composables/useGscSiteAnalyzer": {
145
+ "types": "./app/composables/useGscSiteAnalyzer.ts",
146
+ "import": "./app/composables/useGscSiteAnalyzer.ts",
147
+ "default": "./app/composables/useGscSiteAnalyzer.ts"
148
+ },
149
+ "./composables/useGscSitemapHistory": {
150
+ "types": "./app/composables/useGscSitemapHistory.ts",
151
+ "import": "./app/composables/useGscSitemapHistory.ts",
152
+ "default": "./app/composables/useGscSitemapHistory.ts"
153
+ },
154
+ "./composables/useGscSitemaps": {
155
+ "types": "./app/composables/useGscSitemaps.ts",
156
+ "import": "./app/composables/useGscSitemaps.ts",
157
+ "default": "./app/composables/useGscSitemaps.ts"
158
+ },
159
+ "./composables/useGscTableState": {
160
+ "types": "./app/composables/useGscTableState.ts",
161
+ "import": "./app/composables/useGscTableState.ts",
162
+ "default": "./app/composables/useGscTableState.ts"
33
163
  }
34
164
  },
35
165
  "main": "./nuxt.config.ts",
@@ -47,20 +177,22 @@
47
177
  },
48
178
  "dependencies": {
49
179
  "@duckdb/duckdb-wasm": "^1.32.0",
50
- "@nuxt/ui": "^4.7.1",
180
+ "@nuxt/ui": "^4.8.0",
51
181
  "@unovis/ts": "^1.6.5",
52
182
  "@unovis/vue": "^1.6.5",
53
183
  "@vueuse/core": "^14.3.0",
54
184
  "@vueuse/nuxt": "^14.3.0",
55
185
  "defu": "^6.1.7",
186
+ "nuxt-use-query": "^0.2.4",
56
187
  "ofetch": "^1.5.1",
57
188
  "tailwindcss": "^4.3.0",
58
- "@gscdump/analysis": "0.21.3",
59
- "@gscdump/contracts": "0.21.3",
60
- "@gscdump/engine": "0.21.3",
61
- "@gscdump/engine-duckdb-wasm": "0.21.3",
62
- "gscdump": "0.21.3",
63
- "@gscdump/sdk": "0.21.3"
189
+ "zod": "^4.4.3",
190
+ "@gscdump/analysis": "0.22.4",
191
+ "@gscdump/contracts": "0.22.4",
192
+ "@gscdump/engine": "0.22.4",
193
+ "@gscdump/engine-duckdb-wasm": "0.22.4",
194
+ "@gscdump/sdk": "0.22.4",
195
+ "gscdump": "0.22.4"
64
196
  },
65
197
  "devDependencies": {
66
198
  "@nuxt/kit": "^4.4.6",
package/types.ts CHANGED
@@ -8,9 +8,9 @@
8
8
 
9
9
  import type { AnalyticsClient } from '@gscdump/sdk'
10
10
  import type { $Fetch } from 'ofetch'
11
- import type { GscQueryDispatcher } from './app/composables/_useGscQueryDispatcher'
12
11
  import type { GscAnalyticsContext } from './app/composables/useGscAnalytics'
13
12
  import type { GscAnalyzerDefinition } from './app/composables/useGscAnalyzerDefs'
13
+ import type { GscQueryDispatcher } from './app/composables/useGscQueryDispatcher'
14
14
 
15
15
  export interface GscAnalyticsRuntimeConfig {
16
16
  /**
@@ -46,6 +46,7 @@ declare module '#app' {
46
46
  }
47
47
  }
48
48
 
49
+ export type { GscAnalyzerInstance, GscAnalyzerTimings } from './app/composables/useGscAnalyzer'
49
50
  export type {
50
51
  GscAnalyzerBatchEntry,
51
52
  GscAnalyzerBatchRunner,
@@ -65,14 +66,9 @@ export type {
65
66
  GscAnalyzerPanelSpec,
66
67
  GscAnalyzerStatTile,
67
68
  } from './app/composables/useGscAnalyzerDefs'
68
-
69
69
  export { gscPanelRunnerKey, useGscPanelRunner } from './app/composables/useGscPanelContext'
70
70
 
71
71
  export type { GscPanelRunnerContext } from './app/composables/useGscPanelContext'
72
- export type {
73
- UseGscParquetTableOptions,
74
- UseGscParquetTableReturn,
75
- } from './app/composables/useGscParquetTable'
76
72
 
77
73
  export type {
78
74
  GscBackfillRunner,
@@ -85,7 +81,12 @@ export type {
85
81
  UseGscQueryReturn,
86
82
  } from './app/composables/useGscQuery'
87
83
 
88
- export type { UseGscRollupTableOptions } from './app/composables/useGscRollupTable'
84
+ export type {
85
+ GscFactTable,
86
+ GscTableStage,
87
+ GscTableStatus,
88
+ UseGscSiteAnalyzerReturn,
89
+ } from './app/composables/useGscSiteAnalyzer'
89
90
 
90
91
  export type {
91
92
  AnalysisSourcesResponse,
@@ -1,16 +0,0 @@
1
- // App-wide engine override for `useGscQuery`. SSR-safe via `useState`.
2
- //
3
- // Resolution priority lives in `useGscQueryDispatcher.pickEngine`:
4
- // per-call `opts.engine` → `useGscEngine().value` → `runtimeConfig.public.
5
- // analytics.defaultEngine` → `'auto'`.
6
- //
7
- // Set the value once on the consumer side (e.g. from your auth plugin's
8
- // `onReady` hook reading a per-user feature flag) and every query inherits.
9
-
10
- import type { GscQueryEngine } from './useGscQuery'
11
-
12
- const STATE_KEY = 'gscdump:engine'
13
-
14
- export function useGscEngine(): Ref<GscQueryEngine | null> {
15
- return useState<GscQueryEngine | null>(STATE_KEY, () => null)
16
- }
@@ -1,199 +0,0 @@
1
- // Parquet-attached DuckDB table view: fuzzy-search + sortable metrics +
2
- // pagination over a single attached parquet (`main.<table>`), date-filtered.
3
- //
4
- // Sibling of `useGscRollupTable` — the rollup variant fetches a precomputed
5
- // JSON payload; this variant fires SQL against a parquet view in the
6
- // browser-attached DuckDB-WASM runtime. Two callers today (analyze.vue's
7
- // `pages` and `keywords` raw tabs); third+ callers are mechanical.
8
- //
9
- // Caller passes the `query` fn from `useGscAnalyzer(siteId)`; the composable
10
- // owns the SQL builder, the debounce, the watcher, and the count+page
11
- // Promise.all. It does NOT own `period`/`sort`/`page`/`q` — those are passed
12
- // in so callers can deep-link and share state (the `/analyze` page wires one
13
- // `useGscTableState` across raw + analyzer tabs).
14
-
15
- import type { MaybeRefOrGetter, Ref } from '@vue/runtime-core'
16
- import type { GscSortState } from './useGscTableState'
17
-
18
- interface QueryResult { rows: Record<string, unknown>[], queryMs: number }
19
-
20
- export interface UseGscParquetTableOptions {
21
- /** Attached view name in `main.<table>` (e.g. `pages`, `keywords`). */
22
- table: MaybeRefOrGetter<string>
23
- /** Dimension column rows group by + fuzzy-search against. */
24
- dim: MaybeRefOrGetter<string>
25
- /** `useGscAnalyzer(siteId).query`. */
26
- query: (sql: string, params?: unknown[]) => Promise<QueryResult>
27
- /** Date window applied as `date BETWEEN ? AND ?`. */
28
- dateRange: MaybeRefOrGetter<{ start: string, end: string }>
29
- /**
30
- * External table state — search, sort, page, pageSize. Usually a shared
31
- * `useGscTableState()` so URL deep-links survive tab switches.
32
- */
33
- q: Ref<string>
34
- sort: Ref<GscSortState | null>
35
- page: Ref<number>
36
- pageSize: Ref<number>
37
- /** Gate firing the query until the underlying runtime is ready. */
38
- ready: MaybeRefOrGetter<boolean>
39
- /** Re-fire whenever any of these change. (Period state, stableData flag, …) */
40
- triggers?: MaybeRefOrGetter<unknown>[]
41
- /** Search debounce. Default 200ms. */
42
- debounceMs?: number
43
- }
44
-
45
- export interface UseGscParquetTableReturn {
46
- rows: Ref<Record<string, unknown>[]>
47
- totalRows: Ref<number | null>
48
- totalPages: Ref<number | null>
49
- queryMs: Ref<number | null>
50
- loading: Ref<boolean>
51
- error: Ref<string | null>
52
- }
53
-
54
- const SQL_SORT_COLS = new Set(['clicks', 'impressions', 'ctr', 'avg_position'])
55
- const WHITESPACE_RE = /\s+/
56
-
57
- function tokenize(s: string): string[] {
58
- const seen = new Set<string>()
59
- const out: string[] = []
60
- for (const t of s.trim().split(WHITESPACE_RE).filter(Boolean)) {
61
- const lower = t.toLowerCase()
62
- if (!seen.has(lower)) {
63
- seen.add(lower)
64
- out.push(lower)
65
- }
66
- }
67
- return out
68
- }
69
-
70
- interface SearchClauses { where: string, rank: string, params: unknown[] }
71
-
72
- function buildSearchClauses(dim: string, tokens: string[]): SearchClauses {
73
- if (tokens.length === 0)
74
- return { where: '', rank: '', params: [] }
75
- const patterns = tokens.map(t => `%${t}%`)
76
- const ors = tokens.map(() => `${dim} ILIKE ?`).join(' OR ')
77
- const cases = tokens.map(() => `(CASE WHEN ${dim} ILIKE ? THEN 1 ELSE 0 END)`).join(' + ')
78
- return {
79
- where: `WHERE (${ors})`,
80
- rank: `${cases} DESC,`,
81
- params: [...patterns, ...patterns],
82
- }
83
- }
84
-
85
- function withDateFilter(
86
- searchWhere: string,
87
- searchParams: unknown[],
88
- start: string,
89
- end: string,
90
- ): { where: string, params: unknown[] } {
91
- const dateClause = 'date BETWEEN ? AND ?'
92
- const where = searchWhere ? `${searchWhere} AND ${dateClause}` : `WHERE ${dateClause}`
93
- return { where, params: [...searchParams, start, end] }
94
- }
95
-
96
- function sortKey(dim: string, sort: GscSortState | null): string {
97
- const col = sort?.column ?? 'clicks'
98
- if (SQL_SORT_COLS.has(col))
99
- return col
100
- if (col === dim)
101
- return dim
102
- return 'clicks'
103
- }
104
-
105
- export function useGscParquetTable(opts: UseGscParquetTableOptions): UseGscParquetTableReturn {
106
- const rows = ref<Record<string, unknown>[]>([])
107
- const totalRows = ref<number | null>(null)
108
- const queryMs = ref<number | null>(null)
109
- const loading = ref(false)
110
- const error = ref<string | null>(null)
111
-
112
- const searchDebounced = ref('')
113
- let handle: ReturnType<typeof setTimeout> | null = null
114
- watch(opts.q, (v: string) => {
115
- if (handle)
116
- clearTimeout(handle)
117
- handle = setTimeout(() => {
118
- searchDebounced.value = v
119
- }, opts.debounceMs ?? 200)
120
- })
121
-
122
- async function run(): Promise<void> {
123
- if (!toValue(opts.ready))
124
- return
125
- const table = toValue(opts.table)
126
- const dim = toValue(opts.dim)
127
- const { start, end } = toValue(opts.dateRange)
128
- const tokens = tokenize(searchDebounced.value)
129
- const { where: searchWhere, rank, params: searchParams } = buildSearchClauses(dim, tokens)
130
- const dir = (opts.sort.value?.direction ?? 'desc').toUpperCase()
131
- const limit = opts.pageSize.value
132
- const offset = (opts.page.value - 1) * limit
133
-
134
- const wherePart = withDateFilter(searchWhere, searchParams.slice(0, tokens.length), start, end)
135
- const rankParams = searchParams.slice(tokens.length)
136
-
137
- const pageSql = `
138
- SELECT ${dim},
139
- SUM(clicks)::BIGINT AS clicks,
140
- SUM(impressions)::BIGINT AS impressions,
141
- CASE WHEN SUM(impressions) > 0
142
- THEN ROUND(SUM(clicks) * 1.0 / SUM(impressions), 4)
143
- ELSE 0 END AS ctr,
144
- CASE WHEN SUM(impressions) > 0
145
- THEN ROUND(SUM(sum_position) / SUM(impressions) + 1, 2)
146
- ELSE 0 END AS avg_position
147
- FROM main.${table}
148
- ${wherePart.where}
149
- GROUP BY ${dim}
150
- ORDER BY ${rank} ${sortKey(dim, opts.sort.value)} ${dir}
151
- LIMIT ${limit} OFFSET ${offset}
152
- `
153
- const countSql = `SELECT COUNT(DISTINCT ${dim})::BIGINT AS n FROM main.${table} ${wherePart.where}`
154
-
155
- loading.value = true
156
- error.value = null
157
- rows.value = []
158
- queryMs.value = null
159
- try {
160
- const [pageRes, countRes] = await Promise.all([
161
- opts.query(pageSql, [...wherePart.params, ...rankParams]),
162
- opts.query(countSql, wherePart.params),
163
- ])
164
- rows.value = pageRes.rows
165
- queryMs.value = pageRes.queryMs
166
- const n = countRes.rows[0]?.n
167
- totalRows.value = typeof n === 'bigint' ? Number(n) : typeof n === 'number' ? n : null
168
- }
169
- catch (err) {
170
- error.value = err instanceof Error ? err.message : String(err)
171
- }
172
- finally {
173
- loading.value = false
174
- }
175
- }
176
-
177
- const triggerSources = [
178
- () => toValue(opts.ready),
179
- () => toValue(opts.table),
180
- () => toValue(opts.dim),
181
- () => toValue(opts.dateRange),
182
- opts.sort,
183
- opts.page,
184
- opts.pageSize,
185
- searchDebounced,
186
- ...(opts.triggers ?? []).map(t => () => toValue(t as MaybeRefOrGetter<unknown>)),
187
- ]
188
- watch(triggerSources, () => {
189
- void run()
190
- })
191
-
192
- const totalPages = computed(() => {
193
- if (totalRows.value == null)
194
- return null
195
- return Math.max(1, Math.ceil(totalRows.value / opts.pageSize.value))
196
- })
197
-
198
- return { rows, totalRows, totalPages, queryMs, loading, error }
199
- }