import { DataSource } from "../storage.mjs"; import { ScheduleState } from "../schedule.mjs"; import { Row, TenantCtx } from "@gscdump/contracts"; /** GSC sitemap record we persist. Matches `Schema$WmxSitemap` but as plain JSON. */ interface SitemapRecord { /** The sitemap URL (feedpath) as returned by GSC. */ path: string; /** ISO-8601 timestamp of the snapshot run that captured this record. */ capturedAt: string; /** Last time Google downloaded this sitemap (RFC 3339, from the API). */ lastDownloaded?: string; /** Last time the sitemap was submitted. */ lastSubmitted?: string; type?: string; isPending?: boolean; isSitemapsIndex?: boolean; errors?: string; warnings?: string; /** Per-content-type counts (web, image, video, news). */ contents?: Array<{ type?: string; submitted?: string; indexed?: string; }>; /** Raw payload for fields we don't promote to first-class columns. */ raw?: unknown; /** Number of URLs observed in this feedpath at last snapshot. */ urlCount?: number; /** Stable hash of the sorted normalized loc list at last snapshot. */ contentHash?: string; /** Adaptive cadence state owned by `sitemapPolicy`. */ schedule?: ScheduleState; } interface SitemapIndex { version: 1; /** Map of feedpathHash → latest SitemapRecord. */ records: Record; } interface SitemapHistoryDoc { version: 1; path: string; capturedAt: string; record: SitemapRecord; } /** Parsed URL entry from a sitemap XML. */ interface ParsedUrl { loc: string; /** ISO-8601 lastmod from the sitemap, if present. */ lastmod?: string; } /** A single URL row in the urls/index.parquet partition. */ interface SitemapUrlRecord { feedpath: string; feedpathHash: string; urlHash: string; loc: string; lastmod?: string; firstSeenAt: number; lastSeenAt: number; /** Set when the URL has been removed. Null/undefined = currently live. */ removedAt?: number; } interface SnapshotUrlsResult { added: number; removed: number; kept: number; contentHash: string; /** True when current membership did not change. Initial history seeding may write. */ unchanged: boolean; } interface CompleteSitemapGeneration { _tag: 'complete'; id: string; /** Unix epoch milliseconds. */ observedAt: number; } interface ReconcileResult { /** Feedpaths that were absent from the live set and had their live URLs pruned. */ feedpathsPruned: number; /** Total URL rows transitioned live → removed across pruned feedpaths. */ urlsRemoved: number; } /** * Bounds on a single `compactUrls` call. `compactUrls` is memory-bounded (one * feedpath at a time) but was previously unbounded in TIME: a tenant with a * large accumulated delta backlog could run past a caller's execution budget. * Both bounds are checked BETWEEN feedpaths and only after at least one has * been compacted, so a call always makes forward progress. */ interface CompactUrlsOptions { /** Stop before starting another feedpath once this many ms have elapsed. */ deadlineMs?: number; /** Stop after compacting this many feedpaths. */ maxFeedpaths?: number; } interface CompactUrlsResult { /** Feedpaths whose active deltas were folded and retired by this call. */ compactedFeedpaths: number; /** * Feedpaths that still hold outstanding deltas. Call `compactUrls` again to * continue. No cursor is needed because the projection watermark excludes a * compacted feedpath's grace-retained deltas from the next call. */ remainingFeedpaths: number; } interface DeltaEntry { feedpath: string; feedpathHash: string; urlHash: string; op: 'added' | 'removed'; loc: string; lastmod?: string; at: number; } interface SitemapMembershipEvent { feedpath: string; feedpathHash: string; urlHash: string; op: 'added' | 'removed'; loc: string; lastmod?: string; generationId: string; /** Unix epoch milliseconds. */ observedAt: number; /** Stable ordering within one generation. */ sequence: number; /** Whether this row also mutates the disposable current-state projection. */ projectsState: boolean; } interface DateRange { /** YYYY-MM-DD inclusive. */ from?: string; /** YYYY-MM-DD inclusive. */ to?: string; } interface LoadUrlsOptions { includeRemoved?: boolean; } interface SitemapReadStore { /** Load the full site index (latest record per feedpath). */ loadIndex: (ctx: TenantCtx) => Promise; /** Fetch the latest snapshot for a feedpath, or undefined. */ getLatest: (ctx: TenantCtx, path: string) => Promise; /** Stream live (and optionally removed) URL rows for a feedpath. */ loadUrls: (ctx: TenantCtx, feedpath: string, opts?: LoadUrlsOptions) => AsyncIterable; /** Stream all delta entries within `[from, to]` (YYYY-MM-DD inclusive). */ loadDeltas: (ctx: TenantCtx, dateRange?: DateRange) => AsyncIterable; /** Stream immutable membership events within `[from, to]`. */ loadEvents: (ctx: TenantCtx, dateRange?: DateRange) => AsyncIterable; } interface SitemapStore extends SitemapReadStore { /** * Persist a snapshot run. Updates the index + writes one immutable * history doc per record under `history/__.json`. */ writeSnapshot: (ctx: TenantCtx, records: readonly SitemapRecord[]) => Promise; /** * Diff a complete sitemap generation against current state. The immutable * membership event lands before its disposable state delta. */ snapshotUrls: (ctx: TenantCtx, generation: CompleteSitemapGeneration, feedpath: string, urls: readonly ParsedUrl[]) => Promise; /** * Fold accumulated deltas into the prior index, one feedpath at a time: * rewrites each touched feedpath's `by-feed//index.parquet` and deletes * the consumed delta files. Bounded per feedpath, so it stays within memory * regardless of total site URL count. * * Optionally bounded in TIME too (`opts.deadlineMs` / `opts.maxFeedpaths`). * A bounded call is safe to stop mid-way: each rewritten feedpath advances * the projection watermark, so the remainder is simply what the next call * finds. Callers drive this from `remainingFeedpaths`, not a cursor. */ compactUrls: (ctx: TenantCtx, opts?: CompactUrlsOptions) => Promise; /** * Site-wide convergence: mark every still-live URL whose owning feedpath is * absent from `liveFeedpaths` as removed. `compactUrls`/`snapshotUrls` only * prune URLs *inside* a feedpath that was re-observed; a whole feed dropped * from the sitemap list (no `snapshotUrls` call) leaves its URLs frozen-live * forever. This is the sidecar mirror of the D1 generation sweep: it rewrites * each dropped feedpath's `by-feed//index.parquet` with `removedAt` set, * advances its projection watermark, and grace-retires outstanding deltas. * Bounded per feedpath, so memory stays flat regardless of site size. Live * feedpaths are never touched. */ reconcile: (ctx: TenantCtx, generation: CompleteSitemapGeneration, opts: { liveFeedpaths: readonly string[]; }) => Promise; } interface CreateSitemapReadStoreOptions { dataSource: DataSource; /** Override the feedpath hash (test seam). */ hash?: (path: string) => string; } type SitemapMutation = (ctx: TenantCtx, fn: () => Promise) => Promise; interface CreateSitemapStoreOptions extends CreateSitemapReadStoreOptions { withMutation: SitemapMutation; now?: () => number; } export { CompactUrlsOptions, CompactUrlsResult, CompleteSitemapGeneration, CreateSitemapReadStoreOptions, CreateSitemapStoreOptions, DateRange, DeltaEntry, LoadUrlsOptions, ParsedUrl, ReconcileResult, SitemapHistoryDoc, SitemapIndex, SitemapMembershipEvent, SitemapMutation, SitemapReadStore, SitemapRecord, SitemapStore, SitemapUrlRecord, SnapshotUrlsResult };