preliminary analytics work
9 unmodified lines101112131415161718192021229 unmodified lines9 unmodified linesREDIS_URL=redis://localhost:6379WISP_REVALIDATE_DEDUPE_TTL_SECONDS=60WISP_REVALIDATE_STORAGE_MISS_DEDUPE_TTL_SECONDS=600
# Privacy-minimal, aggregate site analytics (disabled by default)ANALYTICS_ENABLED=falseANALYTICS_FLUSH_INTERVAL_MS=60000ANALYTICS_MAX_PENDING_BUCKETS=10000
# S3 Storage (leave empty for local disk fallback)S3_BUCKET=S3_REGION=autoS3_ENDPOINT=9 unmodified lines3 unmodified lines45678910111213 unmodified lines26272829303132333419 unmodified lines54555657585960616227 unmodified lines909192939495969798991001011022 unmodified lines1051061071081091101111121131141151161173 unmodified linesimport { cache } from './lib/cache-manager'import { CACHE_ONLY, closeDatabase } from './lib/db'import { closeRevalidateQueue } from './lib/revalidate-queue'import { getStorageConfig, storage } from './lib/storage'import { siteAnalytics } from './lib/site-analytics'import app from './server'
const logger = createLogger('hosting-service')
13 unmodified lines}
// Start in-memory cache cleanupcache.startCleanup()siteAnalytics.start()
// Start cache invalidation subscriber (listens for firehose-service updates via Redis pub/sub)startCacheInvalidationSubscriber()
19 unmodified lines
return c.json({ status: 'ok', storage: storageStats, analytics: siteAnalytics.getStats(), })})
// Start HTTP server with Bun's native server27 unmodified lines logger.info('Shutting down...') cache.stopCleanup() await stopCacheInvalidationSubscriber() await closeRevalidateQueue() try { await siteAnalytics.stop() } catch (err) { logger.error('Final analytics flush failed', err) } await closeDatabase() server.stop(true) process.exit(0)})2 unmodified lines logger.info('Shutting down...') cache.stopCleanup() await stopCacheInvalidationSubscriber() await closeRevalidateQueue() try { await siteAnalytics.stop() } catch (err) { logger.error('Final analytics flush failed', err) } await closeDatabase() server.stop(true) process.exit(0)})13 unmodified lines1415161718192021222324252627282930313233343536373839404199 unmodified lines14114214314412814514614714814915015115215315423 unmodified lines17817918018116018218318418518618718818919019111 unmodified lines20320420520618020720820921021121221321421521610 unmodified lines22722822923019923123223323423523623723823913 unmodified linesimport { serveFromCache, serveFromCacheWithRewrite } from './lib/file-serving'import { privateNotFound, servePrivateSite } from './lib/private-serving'import { decodeRequestPathname, extractHeaders, isValidRkey } from './lib/request-utils'import { resolveDid } from './lib/utils'import { siteAnalytics } from './lib/site-analytics'
const logger = createLogger('hosting-service')
async function trackPublicSiteResponse( ownerDid: string, siteRkey: string, method: string, responsePromise: Promise<Response>,): Promise<Response> { try { const response = await responsePromise siteAnalytics.record(ownerDid, siteRkey, method, response.status, response.headers.get('content-type')) return response } catch (error) { siteAnalytics.record(ownerDid, siteRkey, method, 500, null) throw error }}
async function resolveDidCached(identifier: string): Promise<string | null> { if (identifier.startsWith('did:')) return identifier return cache.getOrFetch('handles', identifier, () => resolveDid(identifier), { cacheIf: (v) => v !== null,99 unmodified lines // Serve with HTML path rewriting to handle absolute paths const basePath = `/${identifier}/${site}/` logger.debug(`Serving with basePath: ${basePath}`) const headers = extractHeaders(c.req.raw.headers) return serveFromCacheWithRewrite(did, site, filePath, basePath, c.req.url, headers) return trackPublicSiteResponse( did, site, c.req.method, serveFromCacheWithRewrite(did, site, filePath, basePath, c.req.url, headers), ) }
// Check if this is a DNS hash subdomain const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/)23 unmodified lines return c.text('Invalid site configuration', 500) }
const headers = extractHeaders(c.req.raw.headers) return serveFromCache(customDomain.did, rkey, path, c.req.url, headers) return trackPublicSiteResponse( customDomain.did, rkey, c.req.method, serveFromCache(customDomain.did, rkey, path, c.req.url, headers), ) }
// Route 2: Registered subdomains - /*.wisp.place/* if (hostnameWithoutPort.endsWith(`.${BASE_HOST}`)) {11 unmodified lines return c.text('Invalid site configuration', 500) }
const headers = extractHeaders(c.req.raw.headers) return serveFromCache(domainInfo.did, rkey, path, c.req.url, headers) return trackPublicSiteResponse( domainInfo.did, rkey, c.req.method, serveFromCache(domainInfo.did, rkey, path, c.req.url, headers), ) }
// Route 1: Custom domains - /* const customDomain = await getCustomDomain(hostnameWithoutPort)10 unmodified lines return c.text('Invalid site configuration', 500) }
const headers = extractHeaders(c.req.raw.headers) return serveFromCache(customDomain.did, rkey, path, c.req.url, headers) return trackPublicSiteResponse( customDomain.did, rkey, c.req.method, serveFromCache(customDomain.did, rkey, path, c.req.url, headers), )})
export default app8 unmodified lines910111213131415161770 unmodified lines888990919293949596410 unmodified linesExpand all8 unmodified linesimport type { Context } from 'elysia'import { Elysia } from 'elysia'import { promptAdminSetup } from './lib/admin-auth'import { csrfProtection } from './lib/csrf'import { closeDatabase, getCookieSecret } from './lib/db'import { closeDatabase, getCookieSecret, pruneAnalyticsData } from './lib/db'import { DNSVerificationWorker } from './lib/dns-verification-worker'import { cleanupExpiredSessions, createClientMetadata,70 unmodified lines await cleanupExpiredSessions() await rotateKeysIfNeeded() await pruneHandoffs() await pruneSessions() await pruneAnalyticsData()}
// Run maintenance on startuprunMaintenance().catch((err) => logger.error('[Maintenance] Periodic maintenance failed', err))410 unmodified linesExpand all14 unmodified lines15161718192021222324252627282930313233343536373839404142434445464748141 unmodified linesExpand all19019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629764 unmodified lines14 unmodified lines
// Cache-only mode: skip all DB writes and only use tiered storageexport const CACHE_ONLY = process.env.CACHE_ONLY === 'true'
export interface SiteAnalyticsBucket { ownerDid: string siteRkey: string bucketStart: number requests: number htmlResponses: number status2xx: number status3xx: number status4xx: number status5xx: number}
export interface SiteAnalyticsBatch { batchId: string instanceId: string buckets: readonly SiteAnalyticsBucket[]}
export interface SiteAnalyticsCommitResult { duplicate: boolean acceptedBuckets: number acceptedRequests: number skippedBuckets: number skippedRequests: number}
// Short TTL for negative / unmapped lookups so newly-mapped domains appear quickly.const NEGATIVE_TTL_MS = 10_000
export async function getWispDomain(domain: string): Promise<DomainLookup | null> {141 unmodified linesExpand all activeLocks.delete(key) }}
export async function commitSiteAnalyticsBatch(batch: SiteAnalyticsBatch): Promise<SiteAnalyticsCommitResult> { if (CACHE_ONLY) { throw new Error('analytics writes are disabled in cache-only mode') } if (batch.buckets.length === 0) { return { duplicate: false, acceptedBuckets: 0, acceptedRequests: 0, skippedBuckets: 0, skippedRequests: 0, } }
return await writeSql.begin(async (tx) => { const inserted = await tx<Array<{ batch_id: string }>>` INSERT INTO analytics_ingest_batches (batch_id, instance_id) VALUES (${batch.batchId}, ${batch.instanceId}) ON CONFLICT (batch_id) DO NOTHING RETURNING batch_id ` if (inserted.length === 0) { return { duplicate: true, acceptedBuckets: 0, acceptedRequests: 0, skippedBuckets: 0, skippedRequests: 0, } }
const accepted = await Promise.all( batch.buckets.map(async (bucket) => { const rows = await tx` INSERT INTO site_analytics_hourly ( owner_did, site_rkey, bucket_start, requests, html_responses, status_2xx, status_3xx, status_4xx, status_5xx ) SELECT ${bucket.ownerDid}, ${bucket.siteRkey}, ${new Date(bucket.bucketStart)}, ${bucket.requests}, ${bucket.htmlResponses}, ${bucket.status2xx}, ${bucket.status3xx}, ${bucket.status4xx}, ${bucket.status5xx} WHERE EXISTS ( SELECT 1 FROM site_cache WHERE did = ${bucket.ownerDid} AND rkey = ${bucket.siteRkey} ) ON CONFLICT (owner_did, site_rkey, bucket_start) DO UPDATE SET requests = site_analytics_hourly.requests + EXCLUDED.requests, html_responses = site_analytics_hourly.html_responses + EXCLUDED.html_responses, status_2xx = site_analytics_hourly.status_2xx + EXCLUDED.status_2xx, status_3xx = site_analytics_hourly.status_3xx + EXCLUDED.status_3xx, status_4xx = site_analytics_hourly.status_4xx + EXCLUDED.status_4xx, status_5xx = site_analytics_hourly.status_5xx + EXCLUDED.status_5xx, updated_at = NOW() RETURNING owner_did ` return rows.length > 0 }), )
let acceptedBuckets = 0 let acceptedRequests = 0 let skippedRequests = 0 for (let index = 0; index < accepted.length; index++) { const bucket = batch.buckets[index] if (!bucket) continue if (accepted[index]) { acceptedBuckets++ acceptedRequests += bucket.requests } else { skippedRequests += bucket.requests } }
return { duplicate: false, acceptedBuckets, acceptedRequests, skippedBuckets: batch.buckets.length - acceptedBuckets, skippedRequests, } })}
/** * Close all database connections * Call this during graceful shutdown */64 unmodified lines123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126import { describe, expect, test } from 'bun:test'import type { SiteAnalyticsBatch, SiteAnalyticsCommitResult } from './db'import { createSiteAnalyticsCollector } from './site-analytics'
const accepted = (batch: SiteAnalyticsBatch): SiteAnalyticsCommitResult => ({ duplicate: false, acceptedBuckets: batch.buckets.length, acceptedRequests: batch.buckets.reduce((total, bucket) => total + bucket.requests, 0), skippedBuckets: 0, skippedRequests: 0,})
describe('site analytics collector', () => { test('emits only hourly site aggregates for public GET responses', async () => { const batches: SiteAnalyticsBatch[] = [] const collector = createSiteAnalyticsCollector({ enabled: true, now: () => Date.parse('2026-08-25T13:42:00Z'), newBatchId: () => '00000000-0000-4000-8000-000000000001', instanceId: 'test-instance', commit: async (batch) => { batches.push(batch) return accepted(batch) }, })
collector.record('did:plc:owner', 'my-site', 'GET', 200, 'text/html; charset=utf-8') collector.record('did:plc:owner', 'my-site', 'GET', 404, 'text/plain') collector.record('did:plc:owner', 'my-site', 'HEAD', 200, 'text/html') await collector.flush()
expect(batches).toHaveLength(1) expect(batches[0]?.batchId).toBe('00000000-0000-4000-8000-000000000001') expect(batches[0]?.instanceId).toBe('test-instance') expect(batches[0]?.buckets).toEqual([ { ownerDid: 'did:plc:owner', siteRkey: 'my-site', bucketStart: Date.parse('2026-08-25T13:00:00Z'), requests: 2, htmlResponses: 1, status2xx: 1, status3xx: 0, status4xx: 1, status5xx: 0, }, ]) expect(collector.getStats()).toMatchObject({ pendingBuckets: 0, collectedRequests: 2, deliveredRequests: 2, droppedRequests: 0, batchesCommitted: 1, }) })
test('retries an uncertain commit with the same batch id', async () => { const batchIds: string[] = [] let attempts = 0 const collector = createSiteAnalyticsCollector({ enabled: true, now: () => Date.parse('2026-08-25T13:42:00Z'), newBatchId: () => '00000000-0000-4000-8000-000000000002', commit: async (batch) => { batchIds.push(batch.batchId) attempts++ if (attempts === 1) throw new Error('connection lost after commit') return { ...accepted(batch), duplicate: true } }, })
collector.record('did:plc:owner', 'my-site', 'GET', 200, 'text/html') await expect(collector.flush()).rejects.toThrow('connection lost after commit') expect(collector.getStats()).toMatchObject({ retryPending: true, flushFailures: 1, pendingBuckets: 1 })
await collector.flush() expect(batchIds).toEqual([ '00000000-0000-4000-8000-000000000002', '00000000-0000-4000-8000-000000000002', ]) expect(collector.getStats()).toMatchObject({ retryPending: false, deliveredRequests: 1, duplicateBatches: 1, }) })
test('bounds new buckets without dropping traffic for an existing bucket', async () => { const batches: SiteAnalyticsBatch[] = [] const collector = createSiteAnalyticsCollector({ enabled: true, maxPendingBuckets: 1, now: () => Date.parse('2026-08-25T13:42:00Z'), commit: async (batch) => { batches.push(batch) return accepted(batch) }, })
collector.record('did:plc:owner', 'first-site', 'GET', 200, 'text/html') collector.record('did:plc:owner', 'second-site', 'GET', 200, 'text/html') collector.record('did:plc:owner', 'first-site', 'GET', 503, 'text/html') await collector.flush()
expect(batches[0]?.buckets).toHaveLength(1) expect(batches[0]?.buckets[0]).toMatchObject({ siteRkey: 'first-site', requests: 2, status2xx: 1, status5xx: 1 }) expect(collector.getStats()).toMatchObject({ collectedRequests: 2, droppedRequests: 1 }) })
test('does no work while disabled', async () => { let commits = 0 const collector = createSiteAnalyticsCollector({ enabled: false, commit: async (batch) => { commits++ return accepted(batch) }, })
collector.record('did:plc:owner', 'my-site', 'GET', 200, 'text/html') await collector.flush()
expect(commits).toBe(0) expect(collector.getStats()).toMatchObject({ enabled: false, collectedRequests: 0, pendingBuckets: 0 }) })})123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233import { randomUUID } from 'node:crypto'import os from 'node:os'import { createLogger } from '@wispplace/observability'import { CACHE_ONLY, commitSiteAnalyticsBatch, type SiteAnalyticsBatch, type SiteAnalyticsBucket, type SiteAnalyticsCommitResult,} from './db'
const logger = createLogger('site-analytics')const HOUR_MS = 60 * 60 * 1000const DEFAULT_FLUSH_INTERVAL_MS = 60_000const DEFAULT_MAX_PENDING_BUCKETS = 10_000
type CommitBatch = (batch: SiteAnalyticsBatch) => Promise<SiteAnalyticsCommitResult>type MutableBucket = Omit<SiteAnalyticsBucket, 'ownerDid' | 'siteRkey' | 'bucketStart'>type HourBuckets = Map<number, MutableBucket>type SiteBuckets = Map<string, HourBuckets>type OwnerBuckets = Map<string, SiteBuckets>
export interface SiteAnalyticsStats { enabled: boolean pendingBuckets: number retryPending: boolean collectedRequests: number deliveredRequests: number droppedRequests: number skippedRequests: number batchesCommitted: number duplicateBatches: number flushFailures: number lastFlushAt: string | null lastFailureAt: string | null}
export interface SiteAnalyticsCollector { record(ownerDid: string, siteRkey: string, method: string, statusCode: number, contentType: string | null): void flush(): Promise<void> start(): void stop(): Promise<void> getStats(): SiteAnalyticsStats}
export interface SiteAnalyticsCollectorOptions { enabled: boolean commit?: CommitBatch flushIntervalMs?: number maxPendingBuckets?: number now?: () => number newBatchId?: () => string instanceId?: string}
const positiveInteger = (value: string | undefined, fallback: number): number => { const parsed = Number.parseInt(value ?? '', 10) return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback}
const emptyBucket = (): MutableBucket => ({ requests: 0, htmlResponses: 0, status2xx: 0, status3xx: 0, status4xx: 0, status5xx: 0,})
const countRequests = (batch: SiteAnalyticsBatch): number => { let total = 0 for (const bucket of batch.buckets) total += bucket.requests return total}
export function createSiteAnalyticsCollector(options: SiteAnalyticsCollectorOptions): SiteAnalyticsCollector { const enabled = options.enabled const commit = options.commit ?? commitSiteAnalyticsBatch const flushIntervalMs = options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS const maxPendingBuckets = options.maxPendingBuckets ?? DEFAULT_MAX_PENDING_BUCKETS const now = options.now ?? Date.now const newBatchId = options.newBatchId ?? randomUUID const instanceId = options.instanceId ?? `hosting-${os.hostname()}/${process.pid}/${randomUUID()}`
let buckets: OwnerBuckets = new Map() let pendingBucketCount = 0 let retryBatch: SiteAnalyticsBatch | null = null let timer: NodeJS.Timeout | null = null let flushPromise: Promise<void> | null = null
let collectedRequests = 0 let deliveredRequests = 0 let droppedRequests = 0 let skippedRequests = 0 let batchesCommitted = 0 let duplicateBatches = 0 let flushFailures = 0 let lastFlushAt: number | null = null let lastFailureAt: number | null = null
const drain = (): SiteAnalyticsBucket[] => { const drained = buckets buckets = new Map() pendingBucketCount = 0
const rows: SiteAnalyticsBucket[] = [] for (const [ownerDid, sites] of drained) { for (const [siteRkey, hours] of sites) { for (const [bucketStart, bucket] of hours) { rows.push({ ownerDid, siteRkey, bucketStart, ...bucket }) } } } return rows }
const flushOnce = async (): Promise<void> => { if (!enabled) return if (!retryBatch && pendingBucketCount > 0) { retryBatch = { batchId: newBatchId(), instanceId, buckets: drain(), } } if (!retryBatch) return
const batch = retryBatch try { const result = await commit(batch) deliveredRequests += countRequests(batch) skippedRequests += result.skippedRequests if (result.duplicate) duplicateBatches++ else batchesCommitted++ lastFlushAt = now() retryBatch = null } catch (error) { flushFailures++ lastFailureAt = now() throw error } }
const flush = (): Promise<void> => { if (flushPromise) return flushPromise flushPromise = flushOnce().finally(() => { flushPromise = null }) return flushPromise }
return { record(ownerDid, siteRkey, method, statusCode, contentType) { if (!enabled || method !== 'GET' || !ownerDid || !siteRkey) return
const bucketStart = Math.floor(now() / HOUR_MS) * HOUR_MS const existingSites = buckets.get(ownerDid) const existingHours = existingSites?.get(siteRkey) let bucket = existingHours?.get(bucketStart)
if (!bucket) { if (pendingBucketCount >= maxPendingBuckets) { droppedRequests++ return } const sites = existingSites ?? new Map<string, HourBuckets>() if (!existingSites) buckets.set(ownerDid, sites) const hours = existingHours ?? new Map<number, MutableBucket>() if (!existingHours) sites.set(siteRkey, hours) bucket = emptyBucket() hours.set(bucketStart, bucket) pendingBucketCount++ }
bucket.requests++ if (contentType?.toLowerCase().startsWith('text/html')) bucket.htmlResponses++ if (statusCode >= 200 && statusCode < 300) bucket.status2xx++ else if (statusCode >= 300 && statusCode < 400) bucket.status3xx++ else if (statusCode >= 400 && statusCode < 500) bucket.status4xx++ else if (statusCode >= 500 && statusCode < 600) bucket.status5xx++ collectedRequests++ },
flush,
start() { if (!enabled || timer) return timer = setInterval(() => { void flush().catch((error) => { logger.error('aggregate flush failed; retaining batch for retry', error) }) }, flushIntervalMs) timer.unref?.() },
async stop() { if (timer) { clearInterval(timer) timer = null } await flush() if (!retryBatch && pendingBucketCount > 0) await flush() },
getStats() { return { enabled, pendingBuckets: pendingBucketCount + (retryBatch?.buckets.length ?? 0), retryPending: retryBatch !== null, collectedRequests, deliveredRequests, droppedRequests, skippedRequests, batchesCommitted, duplicateBatches, flushFailures, lastFlushAt: lastFlushAt === null ? null : new Date(lastFlushAt).toISOString(), lastFailureAt: lastFailureAt === null ? null : new Date(lastFailureAt).toISOString(), } }, }}
const requested = process.env.ANALYTICS_ENABLED === 'true'if (requested && CACHE_ONLY) { logger.warn('analytics requested but disabled because CACHE_ONLY=true')}
export const siteAnalytics = createSiteAnalyticsCollector({ enabled: requested && !CACHE_ONLY, flushIntervalMs: positiveInteger(process.env.ANALYTICS_FLUSH_INTERVAL_MS, DEFAULT_FLUSH_INTERVAL_MS), maxPendingBuckets: positiveInteger(process.env.ANALYTICS_MAX_PENDING_BUCKETS, DEFAULT_MAX_PENDING_BUCKETS),})201 unmodified linesExpand all202203204205206207208209210211212213214215216434 unmodified linesExpand all201 unmodified linesExpand all`
await runDatabaseMigrations(db)
export const pruneAnalyticsData = async (): Promise<void> => { await Promise.all([ db`DELETE FROM site_analytics_hourly WHERE bucket_start < NOW() - INTERVAL '90 days'`, db`DELETE FROM analytics_ingest_batches WHERE received_at < NOW() - INTERVAL '7 days'`, ])}
export const getDomainByDid = async (did: string): Promise<string | null> => { const rows = await db`SELECT domain FROM domains WHERE did = ${did} ORDER BY created_at ASC LIMIT 1` return rows[0]?.domain ?? null}434 unmodified linesExpand all191 unmodified linesExpand all19219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323433 unmodified lines26826927027127227327427527627727827928028128228328428544 unmodified lines191 unmodified linesExpand all }, { ignoreAlreadyExists: true }, )
// Owner-visible analytics are stored as aggregate counters only. The site // cache foreign key makes site deletion the lifecycle boundary for all rows. await runMigration('create site_analytics_hourly', async () => { await db` CREATE TABLE IF NOT EXISTS site_analytics_hourly ( owner_did TEXT NOT NULL, site_rkey TEXT NOT NULL, bucket_start TIMESTAMPTZ NOT NULL, requests BIGINT NOT NULL DEFAULT 0 CHECK (requests >= 0), html_responses BIGINT NOT NULL DEFAULT 0 CHECK (html_responses >= 0), status_2xx BIGINT NOT NULL DEFAULT 0 CHECK (status_2xx >= 0), status_3xx BIGINT NOT NULL DEFAULT 0 CHECK (status_3xx >= 0), status_4xx BIGINT NOT NULL DEFAULT 0 CHECK (status_4xx >= 0), status_5xx BIGINT NOT NULL DEFAULT 0 CHECK (status_5xx >= 0), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (owner_did, site_rkey, bucket_start), CONSTRAINT fk_site_analytics_owner FOREIGN KEY (owner_did, site_rkey) REFERENCES site_cache(did, rkey) ON UPDATE CASCADE ON DELETE CASCADE ) ` })
await runMigration('create analytics_ingest_batches', async () => { await db` CREATE TABLE IF NOT EXISTS analytics_ingest_batches ( batch_id UUID PRIMARY KEY, instance_id TEXT NOT NULL, received_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) ` })
// Seed initial supporter DID await runMigration('seed initial supporter', async () => { await db` INSERT INTO supporter (did)33 unmodified lines 'idx_site_cache_updated', async () => db`CREATE INDEX IF NOT EXISTS idx_site_cache_updated ON site_cache(updated_at)`, ], [ 'idx_site_analytics_hourly_bucket_start', async () => db`CREATE INDEX IF NOT EXISTS idx_site_analytics_hourly_bucket_start ON site_analytics_hourly(bucket_start)`, ], [ 'idx_analytics_ingest_batches_received_at', async () => db`CREATE INDEX IF NOT EXISTS idx_analytics_ingest_batches_received_at ON analytics_ingest_batches(received_at)`, ], [ 'idx_private_sites_owner', async () => db`CREATE INDEX IF NOT EXISTS idx_private_sites_owner ON private_sites(owner_did)`, ], [44 unmodified lines