@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.
- package/README.md +36 -27
- package/app/components/GscDashboardPage.vue +2 -2
- package/app/components/GscHero.vue +3 -4
- package/app/components/GscLazyTopResult.vue +7 -5
- package/app/components/GscPerformanceChart.vue +4 -4
- package/app/components/GscSourceDebugPanel.vue +2 -1
- package/app/composables/_useGscBackfill.ts +13 -5
- package/app/composables/_useGscQueryDispatcher.ts +5 -4
- package/app/composables/_useGscResource.ts +144 -56
- package/app/composables/useGscAnalytics.ts +13 -4
- package/app/composables/useGscAnalyticsSourceInfo.ts +11 -5
- package/app/composables/useGscAnalyzer.ts +33 -25
- package/app/composables/useGscAnalyzerBatch.ts +2 -2
- package/app/composables/useGscAnalyzerDefs.ts +2 -1
- package/app/composables/useGscCountries.ts +5 -6
- package/app/composables/useGscCurrentSite.ts +32 -11
- package/app/composables/useGscInspectionHistory.ts +5 -5
- package/app/composables/useGscInspections.ts +5 -5
- package/app/composables/useGscPanelContext.ts +9 -2
- package/app/composables/useGscQuery.ts +6 -8
- package/app/composables/useGscQueryDispatcher.ts +14 -122
- package/app/composables/useGscRequestIndexingInspect.ts +20 -11
- package/app/composables/useGscResource.ts +11 -114
- package/app/composables/useGscRollup.ts +66 -81
- package/app/composables/useGscSearchAppearance.ts +5 -6
- package/app/composables/useGscSiteAnalyzer.ts +456 -0
- package/app/composables/useGscSitemapHistory.ts +5 -5
- package/app/composables/useGscSitemaps.ts +5 -5
- package/app/queries/gsc.ts +163 -0
- package/app/utils/duckdb-wasm.ts +166 -0
- package/app/utils/gsc-rpc.ts +18 -0
- package/module.ts +1 -1
- package/nuxt.config.ts +25 -0
- package/package.json +140 -8
- package/types.ts +8 -7
- package/app/composables/useGscEngine.ts +0 -16
- package/app/composables/useGscParquetTable.ts +0 -199
- package/app/composables/useGscRollupTable.ts +0 -78
- package/app/composables/useGscRowQuery.ts +0 -56
- package/app/composables/useGscSnapshotAnalyzer.contract.ts +0 -150
- package/app/composables/useGscSnapshotAnalyzer.routing.ts +0 -87
- package/app/composables/useGscSnapshotAnalyzer.ts +0 -559
- package/app/utils/setup-gsc-fetch-auth.ts +0 -62
|
@@ -1,559 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* useGscSnapshotAnalyzer — the single consolidated GSC analyzer composable.
|
|
3
|
-
*
|
|
4
|
-
* Replaces `useGscAnalyzer.ts` (pkg) and `useBrowserAnalyzer.ts` (app). One
|
|
5
|
-
* OPFS-backed, attach-once, browser-eligibility-aware analyzer.
|
|
6
|
-
*
|
|
7
|
-
* Implements the frozen contract in `useGscSnapshotAnalyzer.contract.ts`.
|
|
8
|
-
*
|
|
9
|
-
* Flow per `(site, searchType, range)`:
|
|
10
|
-
* 1. RESOLVE — call the file-resolution endpoint (`/analysis-sources`). It
|
|
11
|
-
* returns per-table `mode: browser | server` and, for browser tables, the
|
|
12
|
-
* compacted Iceberg parquet files (content hash + signed URL).
|
|
13
|
-
* 2. BOOT — DuckDB-WASM boots once.
|
|
14
|
-
* 3. DOWNLOAD + ATTACH — browser-mode files download into OPFS (content-hash
|
|
15
|
-
* verified) and attach as views. ATTACH-ONCE: filter / range changes
|
|
16
|
-
* inside the attached span re-query, never re-attach.
|
|
17
|
-
* 4. QUERY — `query(archetype)` routes per-table: browser-mode tables run
|
|
18
|
-
* locally against OPFS; server-mode tables POST to the server tail.
|
|
19
|
-
* Identical queries replay from an in-memory LRU keyed by
|
|
20
|
-
* `snapshotVersion + queryHash`.
|
|
21
|
-
*
|
|
22
|
-
* Quota: a `QuotaExceededError` during OPFS download degrades the affected
|
|
23
|
-
* table to the server tail (`storage.degraded = true`); it never crashes.
|
|
24
|
-
*
|
|
25
|
-
* The instance is cached + refcounted per `(site, searchType, range)` at module
|
|
26
|
-
* scope (keyed by NuxtApp) so panels on the same site share one DuckDB-WASM
|
|
27
|
-
* boot and one OPFS attach.
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
import type { OpfsFileProgress } from '@gscdump/engine-duckdb-wasm'
|
|
31
|
-
import type {
|
|
32
|
-
ArchetypeQuery,
|
|
33
|
-
ArchetypeResult,
|
|
34
|
-
ArchetypeResultRow,
|
|
35
|
-
ArchetypeResultSource,
|
|
36
|
-
FileResolutionResponse,
|
|
37
|
-
GscSearchType,
|
|
38
|
-
ResolvedTable,
|
|
39
|
-
} from '@gscdump/sdk'
|
|
40
|
-
import type { NuxtApp } from 'nuxt/app'
|
|
41
|
-
import type {
|
|
42
|
-
AnalyzerProgress,
|
|
43
|
-
AnalyzerRange,
|
|
44
|
-
AnalyzerStorageState,
|
|
45
|
-
AnalyzerTableRouting,
|
|
46
|
-
GscSnapshotAnalyzer,
|
|
47
|
-
GscSnapshotAnalyzerOptions,
|
|
48
|
-
UseGscSnapshotAnalyzer,
|
|
49
|
-
} from './useGscSnapshotAnalyzer.contract'
|
|
50
|
-
import { createResultLru, resultCacheKey, routeArchetype } from './useGscSnapshotAnalyzer.routing'
|
|
51
|
-
|
|
52
|
-
const DEFAULT_SEARCH_TYPE: GscSearchType = 'web'
|
|
53
|
-
const DEFAULT_RESULT_CACHE_SIZE = 64
|
|
54
|
-
const DEFAULT_ATTACH_FETCH_CONCURRENCY = 2
|
|
55
|
-
|
|
56
|
-
const IDLE_PROGRESS: AnalyzerProgress = Object.freeze({
|
|
57
|
-
phase: 'idle',
|
|
58
|
-
filesReady: 0,
|
|
59
|
-
filesTotal: 0,
|
|
60
|
-
bytesReady: 0,
|
|
61
|
-
bytesTotal: 0,
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
const IDLE_STORAGE: AnalyzerStorageState = Object.freeze({
|
|
65
|
-
persisted: false,
|
|
66
|
-
degraded: false,
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
// ── instance cache (per NuxtApp, refcounted) ────────────────────────────────
|
|
70
|
-
|
|
71
|
-
interface CacheEntry {
|
|
72
|
-
instance: GscSnapshotAnalyzer
|
|
73
|
-
refs: number
|
|
74
|
-
}
|
|
75
|
-
const instanceBags = new WeakMap<NuxtApp, Map<string, CacheEntry>>()
|
|
76
|
-
|
|
77
|
-
function instanceCacheFor(app: NuxtApp): Map<string, CacheEntry> {
|
|
78
|
-
let bag = instanceBags.get(app)
|
|
79
|
-
if (!bag) {
|
|
80
|
-
bag = new Map()
|
|
81
|
-
instanceBags.set(app, bag)
|
|
82
|
-
}
|
|
83
|
-
return bag
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function instanceKey(siteId: string, searchType: GscSearchType, range: AnalyzerRange | null): string {
|
|
87
|
-
return JSON.stringify([siteId, searchType, range?.start ?? null, range?.end ?? null])
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function normalizeRange(range: AnalyzerRange | null | undefined): AnalyzerRange | null {
|
|
91
|
-
return range?.start && range?.end ? { start: range.start, end: range.end } : null
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// ── the composable ──────────────────────────────────────────────────────────
|
|
95
|
-
|
|
96
|
-
export const useGscSnapshotAnalyzer: UseGscSnapshotAnalyzer = (
|
|
97
|
-
siteId,
|
|
98
|
-
searchType,
|
|
99
|
-
range,
|
|
100
|
-
options,
|
|
101
|
-
) => {
|
|
102
|
-
const app = useNuxtApp()
|
|
103
|
-
const cache = instanceCacheFor(app)
|
|
104
|
-
|
|
105
|
-
const key = computed(() => {
|
|
106
|
-
const id = toValue(siteId)
|
|
107
|
-
if (!id)
|
|
108
|
-
return null
|
|
109
|
-
return instanceKey(
|
|
110
|
-
id,
|
|
111
|
-
toValue(searchType) ?? DEFAULT_SEARCH_TYPE,
|
|
112
|
-
normalizeRange(toValue(range)),
|
|
113
|
-
)
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
/** Acquire (or create) + refcount the cached instance for the current key. */
|
|
117
|
-
function acquire(k: string): GscSnapshotAnalyzer {
|
|
118
|
-
let entry = cache.get(k)
|
|
119
|
-
if (!entry) {
|
|
120
|
-
const [id, st, start, end] = JSON.parse(k) as [string, GscSearchType, string | null, string | null]
|
|
121
|
-
entry = {
|
|
122
|
-
instance: createSnapshotAnalyzerInstance(
|
|
123
|
-
id,
|
|
124
|
-
st,
|
|
125
|
-
start && end ? { start, end } : null,
|
|
126
|
-
options ?? {},
|
|
127
|
-
),
|
|
128
|
-
refs: 0,
|
|
129
|
-
}
|
|
130
|
-
cache.set(k, entry)
|
|
131
|
-
}
|
|
132
|
-
entry.refs++
|
|
133
|
-
return entry.instance
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function release(k: string): void {
|
|
137
|
-
const entry = cache.get(k)
|
|
138
|
-
if (!entry)
|
|
139
|
-
return
|
|
140
|
-
entry.refs--
|
|
141
|
-
if (entry.refs <= 0) {
|
|
142
|
-
cache.delete(k)
|
|
143
|
-
entry.instance.dispose().catch((e) => {
|
|
144
|
-
console.error('[useGscSnapshotAnalyzer] dispose failed', e)
|
|
145
|
-
})
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Bind to the current key; rebind (release old, acquire new) on change.
|
|
150
|
-
let boundKey: string | null = null
|
|
151
|
-
const bound = shallowRef<GscSnapshotAnalyzer | null>(null)
|
|
152
|
-
|
|
153
|
-
watch(key, (next, prev) => {
|
|
154
|
-
if (next === prev)
|
|
155
|
-
return
|
|
156
|
-
if (boundKey)
|
|
157
|
-
release(boundKey)
|
|
158
|
-
boundKey = next
|
|
159
|
-
bound.value = next ? acquire(next) : null
|
|
160
|
-
}, { immediate: true })
|
|
161
|
-
|
|
162
|
-
tryOnScopeDispose(() => {
|
|
163
|
-
if (boundKey)
|
|
164
|
-
release(boundKey)
|
|
165
|
-
boundKey = null
|
|
166
|
-
})
|
|
167
|
-
|
|
168
|
-
// Reactive views over the bound instance — track both site switch and inner
|
|
169
|
-
// ref updates. A null-bound analyzer reports a safe idle state.
|
|
170
|
-
const ready = computed(() => bound.value?.ready.value ?? false) as Ref<boolean>
|
|
171
|
-
const initializing = computed(() => bound.value?.initializing.value ?? false) as Ref<boolean>
|
|
172
|
-
const error = computed(() => bound.value?.error.value ?? null) as Ref<Error | null>
|
|
173
|
-
const progress = computed<AnalyzerProgress>(() => bound.value?.progress.value ?? IDLE_PROGRESS) as Ref<AnalyzerProgress>
|
|
174
|
-
const storage = computed<AnalyzerStorageState>(() => bound.value?.storage.value ?? IDLE_STORAGE) as Ref<AnalyzerStorageState>
|
|
175
|
-
const routing = computed<AnalyzerTableRouting>(() => bound.value?.routing.value ?? {}) as Ref<AnalyzerTableRouting>
|
|
176
|
-
const snapshotVersion = computed(() => bound.value?.snapshotVersion.value) as Ref<string | undefined>
|
|
177
|
-
const currentSiteId = computed(() => bound.value?.currentSiteId.value ?? null) as Ref<string | null>
|
|
178
|
-
|
|
179
|
-
return {
|
|
180
|
-
ready,
|
|
181
|
-
initializing,
|
|
182
|
-
error,
|
|
183
|
-
progress,
|
|
184
|
-
storage,
|
|
185
|
-
routing,
|
|
186
|
-
snapshotVersion,
|
|
187
|
-
currentSiteId,
|
|
188
|
-
query: async <R extends ArchetypeResultRow = ArchetypeResultRow>(
|
|
189
|
-
q: ArchetypeQuery,
|
|
190
|
-
opts?: { signal?: AbortSignal },
|
|
191
|
-
): Promise<ArchetypeResult<R>> => {
|
|
192
|
-
const inst = bound.value
|
|
193
|
-
if (!inst)
|
|
194
|
-
throw new Error('useGscSnapshotAnalyzer: no site bound')
|
|
195
|
-
return inst.query<R>(q, opts)
|
|
196
|
-
},
|
|
197
|
-
refresh: () => bound.value?.refresh() ?? Promise.resolve(false),
|
|
198
|
-
clearCache: () => bound.value?.clearCache(),
|
|
199
|
-
// Lifecycle owned by the refcounted cache; kept for API compatibility.
|
|
200
|
-
dispose: async () => {},
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// ── the per-instance implementation ─────────────────────────────────────────
|
|
205
|
-
|
|
206
|
-
function createSnapshotAnalyzerInstance(
|
|
207
|
-
siteId: string,
|
|
208
|
-
searchType: GscSearchType,
|
|
209
|
-
range: AnalyzerRange | null,
|
|
210
|
-
options: GscSnapshotAnalyzerOptions,
|
|
211
|
-
): GscSnapshotAnalyzer {
|
|
212
|
-
const ready = ref(false)
|
|
213
|
-
const initializing = ref(true)
|
|
214
|
-
const error = ref<Error | null>(null)
|
|
215
|
-
const progress = ref<AnalyzerProgress>({ ...IDLE_PROGRESS })
|
|
216
|
-
const storage = ref<AnalyzerStorageState>({ ...IDLE_STORAGE })
|
|
217
|
-
const routing = ref<AnalyzerTableRouting>({})
|
|
218
|
-
const snapshotVersion = ref<string | undefined>(undefined)
|
|
219
|
-
const currentSiteId = ref<string | null>(siteId)
|
|
220
|
-
|
|
221
|
-
const resultLru = createResultLru<ArchetypeResult>(
|
|
222
|
-
options.resultCacheSize ?? DEFAULT_RESULT_CACHE_SIZE,
|
|
223
|
-
)
|
|
224
|
-
|
|
225
|
-
const lifetime = new AbortController()
|
|
226
|
-
|
|
227
|
-
// DuckDB-WASM runtime + OPFS handle — populated by `boot`. Typed loosely
|
|
228
|
-
// because the runtime types come from a dynamically-imported package.
|
|
229
|
-
let db: { conn: unknown, db: unknown } | null = null
|
|
230
|
-
let conn: any = null
|
|
231
|
-
let attachedHandle: { detach: () => Promise<void>, tables: string[] } | null = null
|
|
232
|
-
// The server-tail directive endpoint, when any table routed server-side.
|
|
233
|
-
let serverEndpoint: string | null = null
|
|
234
|
-
|
|
235
|
-
function patchProgress(p: Partial<AnalyzerProgress>): void {
|
|
236
|
-
progress.value = { ...progress.value, ...p }
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/** Call the file-resolution endpoint. */
|
|
240
|
-
async function resolveFiles(signal: AbortSignal): Promise<FileResolutionResponse> {
|
|
241
|
-
patchProgress({ phase: 'resolving' })
|
|
242
|
-
const qs = new URLSearchParams({ searchType })
|
|
243
|
-
if (range) {
|
|
244
|
-
qs.set('start', range.start)
|
|
245
|
-
qs.set('end', range.end)
|
|
246
|
-
}
|
|
247
|
-
// Hit the `__gsc` layer surface (session-backed, credentialed) on the
|
|
248
|
-
// gscdump data origin — not a bare relative path, which would resolve
|
|
249
|
-
// against the embedding app's own origin and 404.
|
|
250
|
-
const apiBase = (useGscAnalyticsConfig().apiBase ?? '').replace(/\/+$/, '')
|
|
251
|
-
return $fetch<FileResolutionResponse>(`${apiBase}/api/__gsc/sites/${siteId}/analysis-sources?${qs}`, {
|
|
252
|
-
headers: { 'cache-control': 'no-cache' },
|
|
253
|
-
credentials: 'include',
|
|
254
|
-
signal,
|
|
255
|
-
})
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/** Apply a resolution response to `routing` + return the browser tables. */
|
|
259
|
-
function applyResolution(res: FileResolutionResponse): ResolvedTable[] {
|
|
260
|
-
snapshotVersion.value = res.snapshotVersion
|
|
261
|
-
serverEndpoint = res.serverTail?.endpoint ?? null
|
|
262
|
-
const nextRouting: AnalyzerTableRouting = {}
|
|
263
|
-
for (const t of res.tables)
|
|
264
|
-
nextRouting[t.table] = t.mode
|
|
265
|
-
routing.value = nextRouting
|
|
266
|
-
return res.tables.filter(t => t.mode === 'browser' && t.files.length > 0)
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
const boot = (async () => {
|
|
270
|
-
const signal = lifetime.signal
|
|
271
|
-
patchProgress({ phase: 'resolving', startedAt: Date.now() })
|
|
272
|
-
|
|
273
|
-
const resolution = await resolveFiles(signal)
|
|
274
|
-
const browserTables = applyResolution(resolution)
|
|
275
|
-
|
|
276
|
-
// Fully server-tail-routed site — nothing to attach. Mark ready; queries
|
|
277
|
-
// proxy to the server.
|
|
278
|
-
if (browserTables.length === 0) {
|
|
279
|
-
patchProgress({ phase: 'ready', endedAt: Date.now() })
|
|
280
|
-
ready.value = true
|
|
281
|
-
return
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// ---- boot DuckDB-WASM -------------------------------------------------
|
|
285
|
-
patchProgress({ phase: 'booting' })
|
|
286
|
-
const cfg = useGscAnalyticsConfig()
|
|
287
|
-
const bundleBase = cfg.duckdbBundleBase as string | undefined
|
|
288
|
-
const { bootDuckDBWasm } = await import('@gscdump/engine-duckdb-wasm')
|
|
289
|
-
db = await bootDuckDBWasm(bundleBase
|
|
290
|
-
? {
|
|
291
|
-
bundles: {
|
|
292
|
-
mvp: { mainModule: `${bundleBase}/duckdb-mvp.wasm`, mainWorker: `${bundleBase}/duckdb-browser-mvp.worker.js` },
|
|
293
|
-
eh: { mainModule: `${bundleBase}/duckdb-eh.wasm`, mainWorker: `${bundleBase}/duckdb-browser-eh.worker.js` },
|
|
294
|
-
},
|
|
295
|
-
}
|
|
296
|
-
: undefined)
|
|
297
|
-
conn = db.conn
|
|
298
|
-
|
|
299
|
-
// ---- download + attach OPFS parquet ----------------------------------
|
|
300
|
-
const { attachOpfsParquetTables, estimateOpfsStorage, requestPersistentStorage } = await import('@gscdump/engine-duckdb-wasm')
|
|
301
|
-
|
|
302
|
-
const persisted = await requestPersistentStorage()
|
|
303
|
-
const est = await estimateOpfsStorage()
|
|
304
|
-
storage.value = { persisted, degraded: false, usageBytes: est.usageBytes, quotaBytes: est.quotaBytes }
|
|
305
|
-
|
|
306
|
-
const opfsTables = browserTables.map(t => ({
|
|
307
|
-
table: t.table,
|
|
308
|
-
files: t.files.map(f => ({
|
|
309
|
-
url: f.url,
|
|
310
|
-
bytes: f.bytes,
|
|
311
|
-
contentHash: f.contentHash,
|
|
312
|
-
rowCount: f.rowCount,
|
|
313
|
-
})),
|
|
314
|
-
}))
|
|
315
|
-
const filesTotal = opfsTables.reduce((n, t) => n + t.files.length, 0)
|
|
316
|
-
const bytesTotal = browserTables.reduce((n, t) => n + t.totalBytes, 0)
|
|
317
|
-
patchProgress({ phase: 'downloading', filesTotal, bytesTotal, filesReady: 0, bytesReady: 0 })
|
|
318
|
-
|
|
319
|
-
let filesReady = 0
|
|
320
|
-
let bytesReady = 0
|
|
321
|
-
const handle = await attachOpfsParquetTables({
|
|
322
|
-
db: db.db as any,
|
|
323
|
-
conn: conn as any,
|
|
324
|
-
tables: opfsTables,
|
|
325
|
-
schema: 'main',
|
|
326
|
-
version: resolution.snapshotVersion,
|
|
327
|
-
fetchInit: { credentials: 'same-origin' },
|
|
328
|
-
fetchConcurrency: DEFAULT_ATTACH_FETCH_CONCURRENCY,
|
|
329
|
-
signal,
|
|
330
|
-
onFileProgress: (info: OpfsFileProgress) => {
|
|
331
|
-
filesReady++
|
|
332
|
-
bytesReady += info.bytes
|
|
333
|
-
patchProgress({ phase: 'downloading', filesReady, bytesReady })
|
|
334
|
-
},
|
|
335
|
-
})
|
|
336
|
-
attachedHandle = { detach: handle.detach, tables: handle.tables }
|
|
337
|
-
|
|
338
|
-
patchProgress({ phase: 'attaching' })
|
|
339
|
-
|
|
340
|
-
// A table that hit a quota error degrades to the server tail. Flip its
|
|
341
|
-
// routing + mark storage degraded so the UI can warn.
|
|
342
|
-
if (handle.degradedTables.length > 0) {
|
|
343
|
-
const next = { ...routing.value }
|
|
344
|
-
for (const t of handle.degradedTables)
|
|
345
|
-
next[t] = 'server'
|
|
346
|
-
routing.value = next
|
|
347
|
-
storage.value = { ...storage.value, degraded: true }
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
const estAfter = await estimateOpfsStorage()
|
|
351
|
-
storage.value = { ...storage.value, usageBytes: estAfter.usageBytes, quotaBytes: estAfter.quotaBytes }
|
|
352
|
-
|
|
353
|
-
patchProgress({ phase: 'ready', endedAt: Date.now(), filesReady, bytesReady })
|
|
354
|
-
ready.value = true
|
|
355
|
-
})()
|
|
356
|
-
.catch((e) => {
|
|
357
|
-
const err = e instanceof Error ? e : new Error(String(e))
|
|
358
|
-
error.value = err
|
|
359
|
-
patchProgress({ phase: 'error', error: err.message, endedAt: Date.now() })
|
|
360
|
-
throw err
|
|
361
|
-
})
|
|
362
|
-
.finally(() => {
|
|
363
|
-
initializing.value = false
|
|
364
|
-
})
|
|
365
|
-
boot.catch(() => {})
|
|
366
|
-
|
|
367
|
-
// ---- query routing -----------------------------------------------------
|
|
368
|
-
|
|
369
|
-
async function runBrowserQuery<R extends ArchetypeResultRow>(
|
|
370
|
-
q: ArchetypeQuery,
|
|
371
|
-
signal?: AbortSignal,
|
|
372
|
-
): Promise<ArchetypeResult<R>> {
|
|
373
|
-
const { compileArchetypeSql } = await import('@gscdump/engine-duckdb-wasm')
|
|
374
|
-
const compiled = compileArchetypeSql(q)
|
|
375
|
-
const t0 = performance.now()
|
|
376
|
-
signal?.throwIfAborted()
|
|
377
|
-
// DuckDB-WASM connection is not concurrency-safe; the shared connection is
|
|
378
|
-
// serialized by callers running queries one-at-a-time here.
|
|
379
|
-
let result: any
|
|
380
|
-
if (compiled.params.length === 0) {
|
|
381
|
-
result = await conn.query(compiled.sql)
|
|
382
|
-
}
|
|
383
|
-
else {
|
|
384
|
-
const stmt = await conn.prepare(compiled.sql)
|
|
385
|
-
try {
|
|
386
|
-
result = await stmt.query(...compiled.params)
|
|
387
|
-
}
|
|
388
|
-
finally {
|
|
389
|
-
await stmt.close()
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
signal?.throwIfAborted()
|
|
393
|
-
const rows = arrowToRows<R>(result)
|
|
394
|
-
return {
|
|
395
|
-
archetype: q.archetype,
|
|
396
|
-
rows,
|
|
397
|
-
source: 'browser',
|
|
398
|
-
meta: { rowCount: rows.length, queryMs: performance.now() - t0 },
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
async function runServerQuery<R extends ArchetypeResultRow>(
|
|
403
|
-
q: ArchetypeQuery,
|
|
404
|
-
where: 'server' | 'cloud',
|
|
405
|
-
signal?: AbortSignal,
|
|
406
|
-
): Promise<ArchetypeResult<R>> {
|
|
407
|
-
const t0 = performance.now()
|
|
408
|
-
// Server tail: POST the archetype to the directive endpoint, or fall back
|
|
409
|
-
// to the conventional per-site analysis endpoint when no directive exists.
|
|
410
|
-
const endpoint = where === 'cloud'
|
|
411
|
-
? `/api/sites/${siteId}/archetype-query`
|
|
412
|
-
: (serverEndpoint ?? `/api/sites/${siteId}/archetype-query`)
|
|
413
|
-
const res = await $fetch<ArchetypeResult<R>>(endpoint, {
|
|
414
|
-
method: 'POST',
|
|
415
|
-
body: { siteId, query: q },
|
|
416
|
-
signal,
|
|
417
|
-
})
|
|
418
|
-
// Trust the server's `source` when present; otherwise tag from routing.
|
|
419
|
-
const source: ArchetypeResultSource = res.source
|
|
420
|
-
?? (where === 'cloud' ? 'cloud' : 'server-r2-sql')
|
|
421
|
-
return {
|
|
422
|
-
...res,
|
|
423
|
-
source,
|
|
424
|
-
meta: { rowCount: res.rows?.length ?? 0, queryMs: performance.now() - t0, ...res.meta },
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
async function query<R extends ArchetypeResultRow = ArchetypeResultRow>(
|
|
429
|
-
q: ArchetypeQuery,
|
|
430
|
-
opts?: { signal?: AbortSignal },
|
|
431
|
-
): Promise<ArchetypeResult<R>> {
|
|
432
|
-
await boot
|
|
433
|
-
opts?.signal?.throwIfAborted()
|
|
434
|
-
|
|
435
|
-
// ---- result LRU — keyed by snapshotVersion + queryHash ----------------
|
|
436
|
-
const cacheKey = resultCacheKey(snapshotVersion.value, q)
|
|
437
|
-
const cached = resultLru.get(cacheKey)
|
|
438
|
-
if (cached)
|
|
439
|
-
return cached as ArchetypeResult<R>
|
|
440
|
-
|
|
441
|
-
const { tableForArchetype } = await import('@gscdump/engine-duckdb-wasm')
|
|
442
|
-
const where = routeArchetype(q, routing.value, tableForArchetype)
|
|
443
|
-
|
|
444
|
-
let result: ArchetypeResult<R>
|
|
445
|
-
if (where === 'browser' && conn) {
|
|
446
|
-
result = await runBrowserQuery<R>(q, opts?.signal)
|
|
447
|
-
}
|
|
448
|
-
else {
|
|
449
|
-
result = await runServerQuery<R>(q, where === 'cloud' ? 'cloud' : 'server', opts?.signal)
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
resultLru.set(cacheKey, result as ArchetypeResult)
|
|
453
|
-
return result
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
async function refresh(): Promise<boolean> {
|
|
457
|
-
await boot.catch(() => {})
|
|
458
|
-
if (!db)
|
|
459
|
-
return false // fully server-tail-routed — nothing to re-attach.
|
|
460
|
-
const resolution = await resolveFiles(lifetime.signal)
|
|
461
|
-
if (resolution.snapshotVersion === snapshotVersion.value)
|
|
462
|
-
return false
|
|
463
|
-
// Snapshot moved — detach the stale views, re-attach the fresher parquet.
|
|
464
|
-
// The DuckDB-WASM runtime (db + conn) stays alive.
|
|
465
|
-
const browserTables = applyResolution(resolution)
|
|
466
|
-
await attachedHandle?.detach().catch((e) => {
|
|
467
|
-
console.warn('[useGscSnapshotAnalyzer] detach during refresh failed', e)
|
|
468
|
-
})
|
|
469
|
-
attachedHandle = null
|
|
470
|
-
resultLru.clear()
|
|
471
|
-
if (browserTables.length === 0)
|
|
472
|
-
return true
|
|
473
|
-
const { attachOpfsParquetTables } = await import('@gscdump/engine-duckdb-wasm')
|
|
474
|
-
const handle = await attachOpfsParquetTables({
|
|
475
|
-
db: (db as any).db,
|
|
476
|
-
conn: conn as any,
|
|
477
|
-
tables: browserTables.map(t => ({
|
|
478
|
-
table: t.table,
|
|
479
|
-
files: t.files.map(f => ({ url: f.url, bytes: f.bytes, contentHash: f.contentHash, rowCount: f.rowCount })),
|
|
480
|
-
})),
|
|
481
|
-
schema: 'main',
|
|
482
|
-
version: resolution.snapshotVersion,
|
|
483
|
-
fetchInit: { credentials: 'same-origin' },
|
|
484
|
-
fetchConcurrency: DEFAULT_ATTACH_FETCH_CONCURRENCY,
|
|
485
|
-
signal: lifetime.signal,
|
|
486
|
-
})
|
|
487
|
-
attachedHandle = { detach: handle.detach, tables: handle.tables }
|
|
488
|
-
if (handle.degradedTables.length > 0) {
|
|
489
|
-
const next = { ...routing.value }
|
|
490
|
-
for (const t of handle.degradedTables)
|
|
491
|
-
next[t] = 'server'
|
|
492
|
-
routing.value = next
|
|
493
|
-
storage.value = { ...storage.value, degraded: true }
|
|
494
|
-
}
|
|
495
|
-
return true
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
function clearCache(): void {
|
|
499
|
-
resultLru.clear()
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
async function dispose(): Promise<void> {
|
|
503
|
-
lifetime.abort()
|
|
504
|
-
await attachedHandle?.detach().catch((e) => {
|
|
505
|
-
console.error('[useGscSnapshotAnalyzer] detach on dispose failed', e)
|
|
506
|
-
})
|
|
507
|
-
if (conn) {
|
|
508
|
-
await conn.close().catch(() => {})
|
|
509
|
-
}
|
|
510
|
-
if (db && (db.db as any)?.terminate) {
|
|
511
|
-
await (db.db as any).terminate().catch(() => {})
|
|
512
|
-
}
|
|
513
|
-
attachedHandle = null
|
|
514
|
-
conn = null
|
|
515
|
-
db = null
|
|
516
|
-
resultLru.clear()
|
|
517
|
-
ready.value = false
|
|
518
|
-
routing.value = {}
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
return {
|
|
522
|
-
ready,
|
|
523
|
-
initializing,
|
|
524
|
-
error,
|
|
525
|
-
progress,
|
|
526
|
-
storage,
|
|
527
|
-
routing,
|
|
528
|
-
snapshotVersion,
|
|
529
|
-
currentSiteId,
|
|
530
|
-
query,
|
|
531
|
-
refresh,
|
|
532
|
-
clearCache,
|
|
533
|
-
dispose,
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
/**
|
|
538
|
-
* Convert an Apache Arrow result (DuckDB-WASM `conn.query` return) to plain
|
|
539
|
-
* row objects. Kept local so the composable has no Arrow type dependency.
|
|
540
|
-
*/
|
|
541
|
-
function arrowToRows<R extends ArchetypeResultRow>(result: unknown): R[] {
|
|
542
|
-
if (!result || typeof result !== 'object')
|
|
543
|
-
return []
|
|
544
|
-
const table = result as { toArray?: () => unknown[] }
|
|
545
|
-
if (typeof table.toArray !== 'function')
|
|
546
|
-
return []
|
|
547
|
-
return table.toArray().map((row) => {
|
|
548
|
-
// Arrow rows expose `toJSON()`; fall back to a shallow copy.
|
|
549
|
-
const r = row as { toJSON?: () => Record<string, unknown> }
|
|
550
|
-
const obj = typeof r.toJSON === 'function' ? r.toJSON() : { ...(row as Record<string, unknown>) }
|
|
551
|
-
// Normalise BigInt (DuckDB SUM returns BigInt) to number for JSON safety.
|
|
552
|
-
for (const k of Object.keys(obj)) {
|
|
553
|
-
const v = obj[k]
|
|
554
|
-
if (typeof v === 'bigint')
|
|
555
|
-
obj[k] = Number(v)
|
|
556
|
-
}
|
|
557
|
-
return obj as R
|
|
558
|
-
})
|
|
559
|
-
}
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
// Plugin factory for consumer-mode auth wiring.
|
|
2
|
-
//
|
|
3
|
-
// In consumer mode, the host app authenticates the viewer against its own
|
|
4
|
-
// origin and exchanges that for a gscdump.com api key. `setupGscFetchAuth`
|
|
5
|
-
// collapses the boilerplate of "fetch credentials, call setGscAuth, dedupe
|
|
6
|
-
// via inflight promise, enforce: 'pre'" into a single call. The returned
|
|
7
|
-
// value is a plugin definition; consumers re-export it from a client plugin
|
|
8
|
-
// file (`plugins/00.gscdump-auth.client.ts`).
|
|
9
|
-
//
|
|
10
|
-
// `enforce: 'pre'` + the returned promise make Nuxt block subsequent plugins
|
|
11
|
-
// until headers land, which avoids the auth-header race where a query mounts
|
|
12
|
-
// before credentials resolve.
|
|
13
|
-
|
|
14
|
-
import { setGscAuth } from '../composables/useGscAuth'
|
|
15
|
-
|
|
16
|
-
interface SetupGscFetchAuthOptions<TCreds extends Record<string, any> = { apiKey: string }> {
|
|
17
|
-
/** Host endpoint returning credentials. Called once per page load. */
|
|
18
|
-
credentialsEndpoint: string
|
|
19
|
-
/** Field on the response holding the api key. Default: `apiKey`. */
|
|
20
|
-
tokenField?: keyof TCreds & string
|
|
21
|
-
/** Header name to send to gscdump.com. Default: `x-api-key`. Set this only when the host uses a custom header name; the layer maps `apiKey` → `x-api-key` by default. */
|
|
22
|
-
headerName?: string
|
|
23
|
-
/**
|
|
24
|
-
* Optional hook fired after auth is set. Use for host-specific state
|
|
25
|
-
* preloading (e.g. user settings flags). Runs inside `nuxtApp.runWithContext`.
|
|
26
|
-
*/
|
|
27
|
-
onReady?: (ctx: { credentials: TCreds }) => void | Promise<void>
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function setupGscFetchAuth<TCreds extends Record<string, any> = { apiKey: string }>(
|
|
31
|
-
options: SetupGscFetchAuthOptions<TCreds>,
|
|
32
|
-
): ReturnType<typeof defineNuxtPlugin> {
|
|
33
|
-
const { credentialsEndpoint, tokenField = 'apiKey' as keyof TCreds & string, headerName = 'x-api-key', onReady } = options
|
|
34
|
-
let inflight: Promise<void> | null = null
|
|
35
|
-
|
|
36
|
-
return defineNuxtPlugin({
|
|
37
|
-
name: 'gscdump-analytics-auth',
|
|
38
|
-
enforce: 'pre',
|
|
39
|
-
async setup(nuxtApp) {
|
|
40
|
-
if (inflight)
|
|
41
|
-
return inflight
|
|
42
|
-
inflight = (async () => {
|
|
43
|
-
const credentials = await $fetch<TCreds>(credentialsEndpoint).catch(() => null)
|
|
44
|
-
const apiKey = credentials?.[tokenField] as string | undefined
|
|
45
|
-
if (!apiKey)
|
|
46
|
-
return
|
|
47
|
-
// Default header name = `x-api-key` is already handled by the layer
|
|
48
|
-
// when `apiKey` is set on auth state. A custom `headerName` is routed
|
|
49
|
-
// through the opaque `headers` bag.
|
|
50
|
-
setGscAuth({
|
|
51
|
-
apiKey: headerName === 'x-api-key' ? apiKey : null,
|
|
52
|
-
browserAnalyzerEnabled: false,
|
|
53
|
-
headers: headerName === 'x-api-key' ? undefined : { [headerName]: apiKey },
|
|
54
|
-
})
|
|
55
|
-
if (onReady) {
|
|
56
|
-
await nuxtApp.runWithContext(() => onReady({ credentials: credentials! }))
|
|
57
|
-
}
|
|
58
|
-
})()
|
|
59
|
-
return inflight
|
|
60
|
-
},
|
|
61
|
-
})
|
|
62
|
-
}
|