SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
1 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%

Foundation: core types, DB schema, connector SDK, API contract, infra docs

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 21 days ago (Sep 11, 2026)

47 changed files +10,323 −0

added .env.example +19 −0
@@ -0,0 +1,19 @@
1 +# DataCenterIndex — variables d'environnement (jamais de secrets dans git)
2 +DATABASE_URL=postgres://dci:dci@127.0.0.1:5432/dci
3 +REDIS_URL=redis://127.0.0.1:6379/0
4 +CLICKHOUSE_URL=http://127.0.0.1:8123
5 +CLICKHOUSE_DB=dci
6 +S3_ENDPOINT=http://127.0.0.1:9000
7 +S3_BUCKET=dci-raw
8 +S3_ACCESS_KEY=dci
9 +S3_SECRET_KEY=change-me
10 +SCRAPFLY_API_KEY=
11 +FIRECRAWL_API_KEY=
12 +DCI_USER_AGENT="DataCenterIndexBot/0.1 (+https://www.datacenterindex.io/bot; contact@spboucher.ai)"
13 +DCI_ADMIN_TOKEN=change-me
14 +API_PORT=8311
15 +WEB_PORT=8310
16 +NEXT_PUBLIC_API_URL=http://127.0.0.1:8311
17 +NEXT_PUBLIC_SITE_URL=https://www.datacenterindex.io
18 +DCI_SCRAPFLY_DAILY_BUDGET=400
19 +DCI_FIRECRAWL_DAILY_BUDGET=200
added .gitignore +17 −0
@@ -0,0 +1,17 @@
1 +node_modules
2 +dist
3 +.next
4 +*.log
5 +.env
6 +.env.*
7 +!.env.example
8 +data/
9 +deploy/*.secret*
10 +deploy/.env*
11 +!deploy/.env.example
12 +.DS_Store
13 +coverage
14 +.turbo
15 +tsconfig.tsbuildinfo
16 +*.tsbuildinfo
17 +.admin-token.local
added CLAUDE.md +43 −0
@@ -0,0 +1,43 @@
1 +# DataCenterIndex.io — repo guide (read first)
2 +
3 +**Product**: the global index of data center infrastructure (facilities, campuses, operators, cloud regions, metros, countries, projects, IXPs, power, connectivity) with a live change feed. Feels like Our World in Data × a Bloomberg terminal for digital infrastructure × a global infrastructure map. **The product is the structured graph and its history, not the crawler.**
4 +
5 +**Pipeline**: discover → fetch → archive → extract → normalize → reconcile → validate → version → detect changes → publish. Never `scrape → dump → show`.
6 +
7 +## Non-negotiables
8 +- **Never invent data.** No fake facilities, capacities, coordinates, dates. A figure that is an estimate is flagged `isEstimate`. City-level coordinates are `geo_precision = city`, never `exact`.
9 +- **Provenance on every field** (`provenance` table: source, URL, first/last observed, retrieved, confidence, method).
10 +- **Legal crawling**: robots.txt respected, no auth/paywall/CAPTCHA bypass, rate limits per host, conditional GET, content hashing — never refetch unchanged pages with premium credits. Attribution + license recorded per source (`sources.license/attribution`).
11 +- **Escalation**: L1 direct HTTP → L2 direct + browser identity → L3 Firecrawl → L4 Scrapfly rendered. Escalate only when blocked / JS shell. Premium daily budgets (`DCI_SCRAPFLY_DAILY_BUDGET`, `DCI_FIRECRAWL_DAILY_BUDGET`).
12 +- **Secrets** only in env / deploy secrets, never in git, never sent to the browser.
13 +- **Mobile-first** UI (320–430 px first), then tablet/desktop. Industrial, premium, restrained: no neon, no fake terminal, no giant hero, no card soup.
14 +
15 +## Layout
16 +```
17 +packages/core types (entities, provenance, events), ids, hashing, normalize (MW, dates, status, money), geo, confidence, diff, ssrf, api-types (API contract)
18 +packages/db Drizzle schema (Postgres) + plain SQL migrations (migrations/*.sql, runner src/migrate.ts) + ClickHouse client/DDL
19 +packages/connectors connector SDK: types, YAML config schema, fetchers (direct/firecrawl/scrapfly + escalation), robots, ratelimit, discovery (sitemap/rss/links), extract (JSON-LD, embedded JSON, selectors, PDF), classify, registry (parsers/implementations), GenericConnector
20 +config/connectors/*.yaml one file per source (declarative; `parser:` or `implementation:` for code-backed logic)
21 +apps/worker runtime: ConnectorContext, document registry + storage (MinIO), change detection, ingest (reconcile/persist/events), scheduler (BullMQ), rankings/metrics, CLI `pnpm dci …`
22 +apps/api Fastify REST `/api/v1/*` (public) + `/api/admin/*` (token) — see packages/core/src/api-types.ts
23 +apps/web Next 16 (App Router, Tailwind v4, MapLibre) — SSR for content, lazy map/charts
24 +deploy/ Dockerfiles, compose (data node + crawl node), edge Caddy, backups, monitoring
25 +docs/ ARCHITECTURE.md, CONNECTORS.md (authoring guide), DEPLOY.md
26 +```
27 +
28 +## Conventions
29 +- pnpm workspaces, TypeScript strict, ESM, `verbatimModuleSyntax` (use `import type`). Run `pnpm typecheck` before finishing.
30 +- Ids: prefixed (`fac_`, `op_`, `prj_`, `evt_`, `doc_`…) via `newId()` / `stableId()` in `@dci/core`. Slugs unique per table.
31 +- Drizzle: `casing: "snake_case"`. `sql\`${arr}\`` spreads arrays → use `textArray()` or `sql.raw`. `db.execute` returns timestamps as strings.
32 +- Partial dates are strings: `2027`, `2027-06`, `2027-06-15`, `2027-Q2`, `2027-H1` (`parsePartialDate`, `formatPartialDate`).
33 +- Connector keys are connector-scoped and stable (`peeringdb:fac:12`), mapped in `entity_keys` for idempotent re-ingestion.
34 +- Events are deduplicated by `fingerprint` (entity + type + new value + day).
35 +- API envelope `{ data, meta, sources }`. Web reads `NEXT_PUBLIC_API_URL` server-side (`API_URL_INTERNAL` preferred when set).
36 +- Ports: web 8310 (Next, loopback in prod), api 8311, edge 8300 (Caddy in compose, bound to the WireGuard IP). Public: https://www.datacenterindex.io via BHS64 gateway.
37 +- Local dev: Postgres `postgres://dci:dci@127.0.0.1:5432/dci`, Redis 6379, ClickHouse 8123, MinIO 9000 (see `.env.example`, `deploy/dev-compose.yml`).
38 +
39 +## Connector authoring (short)
40 +1. `config/connectors/<id>.yaml` (id, name, domain, kind, license/attribution, fetch levels, discovery, schedule, extractors).
41 +2. Declarative fields when possible; otherwise a parser in `apps/worker/src/parsers/<id>.ts` registered with `registerParser`, or a full `implementation` (`registerImplementation`) for APIs/datasets.
42 +3. Validate against LIVE pages: `pnpm dci run <id> --dry-run --limit 5` must yield valid entities (no errors in the ValidationReport). Nothing ships unverified.
43 +4. Document the source (what, license, update cadence, quirks) in `docs/connectors/<id>.md`.
added apps/worker/src/ingest/contract.ts +39 −0
@@ -0,0 +1,39 @@
1 +/**
2 + * Boundary between the crawl runtime (context, documents, scheduling) and the ingest layer
3 + * (reconciliation, persistence, provenance, events). Both sides implement against this file.
4 + */
5 +import type { NormalizedEntity, DetectedChange } from "@dci/core";
6 +
7 +export interface IngestRun {
8 + connectorId: string;
9 + sourceId: string;
10 + runId: string;
11 + sourceKind: string;
12 + sourcePriority: number;
13 + dryRun: boolean;
14 +}
15 +
16 +export interface IngestDocRef {
17 + documentId: string;
18 + url: string;
19 + pageType: string;
20 + fetchedAt: string;
21 +}
22 +
23 +export interface IngestStats {
24 + received: number;
25 + created: number;
26 + updated: number;
27 + unchanged: number;
28 + merged: number;
29 + pendingMatches: number;
30 + rejected: number;
31 + events: number;
32 + provenanceRows: number;
33 + byType: Record<string, number>;
34 + changes: DetectedChange[];
35 + /** entity ids touched: [{type,id}] for documents.entity_refs */
36 + refs: Array<{ type: string; id: string }>;
37 +}
38 +
39 +export type IngestFn = (run: IngestRun, entities: NormalizedEntity[], doc: IngestDocRef | null) => Promise<IngestStats>;
added config/connectors/_example.yaml.disabled +50 −0
@@ -0,0 +1,50 @@
1 +# Example connector definition (disabled: file extension not .yaml). Copy to <id>.yaml.
2 +id: example-operator
3 +name: Example Operator
4 +domain: example.com
5 +kind: operator # operator | government | filing | utility | dataset | community | secondary | news | cloud_provider | registry
6 +mode: hybrid
7 +priority: 2 # 1 best … 5
8 +license: "Publicly accessible operator pages; facts only, attribution kept"
9 +attribution: "Example Operator"
10 +coverage: { global: true }
11 +fetch:
12 + level: 1 # start direct
13 + maxLevel: 4 # allow escalation to Scrapfly when blocked
14 + rpm: 20
15 + concurrency: 2
16 + respectRobots: true
17 + maxCreditsPerRun: 150
18 +discovery:
19 + sitemap: true
20 + include: ["/data-centers/", "/newsroom/"]
21 + exclude: ["/login", "\\?page="]
22 + rss: ["/newsroom/rss.xml"]
23 + classify:
24 + - { pattern: "/data-centers/[a-z0-9-]+/[a-z0-9-]+$", group: facility_pages, pageType: facility_page, priority: 60 }
25 + - { pattern: "/newsroom/", group: newsroom, pageType: press_release, priority: 70 }
26 +schedule:
27 + facility_pages: weekly
28 + newsroom: daily
29 + sitemap: weekly
30 +extractors:
31 + facility:
32 + pageTypes: [facility_page]
33 + kind: facility
34 + key: "example:{code}"
35 + fields:
36 + name: "h1"
37 + code: { selector: "h1", regex: "\\b([A-Z]{2}\\d{1,2})\\b" }
38 + address: { jsonld: "Place", jsonPath: "address.streetAddress" }
39 + city: { jsonld: "Place", jsonPath: "address.addressLocality" }
40 + countryIso2: { jsonld: "Place", jsonPath: "address.addressCountry", transform: country }
41 + lat: { jsonld: "Place", jsonPath: "geo.latitude", transform: float }
42 + lng: { jsonld: "Place", jsonPath: "geo.longitude", transform: float }
43 + totalPowerMw: { selector: ".specs", regex: "(\\d+(?:\\.\\d+)?\\s*MW)", transform: mw }
44 + news:
45 + pageTypes: [press_release, project_announcement, expansion, acquisition]
46 + kind: news_event
47 + parser: article_v1
48 +defaults:
49 + operatorName: Example Operator
50 + facilityType: colocation
added deploy/dev-compose.yml +23 −0
@@ -0,0 +1,23 @@
1 +# Local development infra (laptop): ClickHouse + MinIO in Docker; Postgres 17 and Redis run from Homebrew.
2 +services:
3 + clickhouse:
4 + image: clickhouse/clickhouse-server:25.8
5 + container_name: dci-dev-clickhouse
6 + ports: ["127.0.0.1:8123:8123", "127.0.0.1:9009:9000"]
7 + environment:
8 + CLICKHOUSE_DB: dci
9 + CLICKHOUSE_USER: default
10 + CLICKHOUSE_PASSWORD: ""
11 + CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: "1"
12 + ulimits: { nofile: { soft: 262144, hard: 262144 } }
13 + volumes: ["dci-dev-clickhouse:/var/lib/clickhouse"]
14 + minio:
15 + image: minio/minio:latest
16 + container_name: dci-dev-minio
17 + command: server /data --console-address ":9001"
18 + ports: ["127.0.0.1:9000:9000", "127.0.0.1:9001:9001"]
19 + environment: { MINIO_ROOT_USER: dci, MINIO_ROOT_PASSWORD: dci-dev-secret }
20 + volumes: ["dci-dev-minio:/data"]
21 +volumes:
22 + dci-dev-clickhouse: {}
23 + dci-dev-minio: {}
added docs/ARCHITECTURE.md +23 −0
@@ -0,0 +1,23 @@
1 +# Architecture
2 +
3 +## Stores
4 +- **PostgreSQL** (truth): countries, metros, operators, campuses, facilities (+aliases, entity_keys), cloud_regions, ixps, facility_ixps, facility_tenants, projects (+timeline), sources, connectors (+runs), documents (+versions), provenance, events, news_items, entity_matches, rankings, daily_metrics, admin_sessions, system_alerts.
5 +- **ClickHouse** (append-only analytics): crawl_log, observations, page_changes, metrics, entity_daily, api_requests.
6 +- **Redis**: BullMQ queues (`dci:crawl`, `dci:ingest`, `dci:maintenance`), rate-limit + locks, API cache.
7 +- **MinIO (S3)**: raw bodies `raw/<connector>/<doc_id>/<content_hash>.<ext>.zst`, PDFs, exports.
8 +
9 +## Crawl flow
10 +Scheduler (per connector group interval, adaptive by `change_frequency_score`) → Connector.discover → URL registry (`documents`, fingerprint, classify, priority) → priority queue → fetch (robots, rate limit, conditional GET, escalation L1→L4, budgets) → archive raw → content fingerprint → if changed: extract → normalize → validate → reconcile (entity_matches) → persist entities + provenance + entity_keys → diff old/new field values → events (+ ClickHouse observations / page_changes) → recompute `documents.next_check`.
11 +
12 +## Entity resolution (facilities)
13 +Score candidates by: same connector key (exact) → external ids → normalized name equality / facility code match (DC12, FR5) → same operator + distance ≤ radius(precision) → address similarity → alias table. ≥ 0.92 auto-merge, 0.6–0.92 → `entity_matches` pending (admin approves), < 0.6 → create. Never merge across different operators unless name+address exact.
14 +
15 +## Confidence
16 +`computeConfidence()` in core: source kind base (operator/government/filing/utility/cloud/registry = high; dataset/community/secondary/news = moderate), extraction certainty, corroborations (≥2 independent primary → verified), conflicts, age.
17 +
18 +## Change detection
19 +`contentFingerprint` (noise-stripped) per fetch; on change store a `document_versions` row with line diff + `detectedChanges` (field-level from normalized entity diff), significance 0–100 (status change 90, MW change 80, date change 60, operator change 85, text-only 10–30).
20 +
21 +## Two-node deployment
22 +- **BHS128 (data/public)**: edge Caddy :8300 → web :8310 / api :8311; postgres, clickhouse, redis, minio bound to `10.68.0.1` (private WireGuard) + docker network. Public traffic arrives via the BHS64 gateway over WireGuard `wg1` (10.67.0.60).
23 +- **BHS64b (crawl)**: worker + scheduler containers reaching the data services over `dci0` (10.68.0.2 → 10.68.0.1). No public ports.
added package.json +26 −0
@@ -0,0 +1,26 @@
1 +{
2 + "name": "datacenterindex",
3 + "version": "0.1.0",
4 + "private": true,
5 + "description": "DataCenterIndex.io — the global index of data center infrastructure: facilities, campuses, operators, cloud regions, markets, projects, power and connectivity, built on a homemade connector and crawling platform.",
6 + "type": "module",
7 + "packageManager": "pnpm@11.1.2",
8 + "engines": { "node": ">=22.15" },
9 + "scripts": {
10 + "typecheck": "pnpm -r --parallel run typecheck",
11 + "test": "pnpm -r run test",
12 + "build": "pnpm -r run build",
13 + "db:migrate": "pnpm --filter @dci/db run migrate",
14 + "db:seed": "pnpm --filter @dci/db run seed",
15 + "dev:api": "pnpm --filter @dci/api run dev",
16 + "dev:web": "pnpm --filter @dci/web run dev",
17 + "dev:worker": "pnpm --filter @dci/worker run dev",
18 + "dci": "node node_modules/tsx/dist/cli.mjs apps/worker/src/cli.ts"
19 + },
20 + "devDependencies": {
21 + "@types/node": "^24.0.0",
22 + "tsx": "^4.20.0",
23 + "typescript": "^5.9.3",
24 + "vitest": "^3.2.0"
25 + }
26 +}
added packages/connectors/package.json +20 −0
@@ -0,0 +1,20 @@
1 +{
2 + "name": "@dci/connectors",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "main": "./src/index.ts",
7 + "types": "./src/index.ts",
8 + "exports": { ".": "./src/index.ts" },
9 + "scripts": { "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests" },
10 + "dependencies": {
11 + "@dci/core": "workspace:*",
12 + "cheerio": "^1.1.0",
13 + "fast-xml-parser": "^5.2.0",
14 + "undici": "^7.10.0",
15 + "unpdf": "^1.0.0",
16 + "yaml": "^2.8.0",
17 + "zod": "^4.1.0"
18 + },
19 + "devDependencies": { "@types/node": "^24.0.0", "typescript": "^5.9.3", "vitest": "^3.2.0" }
20 +}
added packages/connectors/src/classify.ts +69 −0
@@ -0,0 +1,69 @@
1 +import type { EventType, PageType } from "@dci/core";
2 +import { parseAllMw, inferStatus } from "@dci/core";
3 +
4 +/**
5 + * Deterministic page classification. URL rules first, then title/body keyword rules.
6 + * An LLM may be plugged in later as an assistive classifier for "unknown" only.
7 + */
8 +const URL_RULES: Array<[RegExp, PageType]> = [
9 + [/\/(data-?cent(er|re)s?|facilities|locations|colocation|ibx|campus(es)?)\/[a-z0-9-]+\/?$/i, "facility_page"],
10 + [/\/(data-?cent(er|re)s?|facilities|locations|colocation)\/?$/i, "facility_index"],
11 + [/\/(news(room)?|press(-releases?)?|media|announcements|blog)\/?$/i, "news_index"],
12 + [/\/(news|press|media|announcements|newsroom)\/./i, "press_release"],
13 + [/\/(regions?|global-infrastructure|locations|about\/locations|infrastructure\/regions)/i, "cloud_region"],
14 + [/\.pdf(\?|$)/i, "planning_document"],
15 + [/sitemap.*\.xml/i, "sitemap"],
16 + [/\/(investors?|ir|financials?|sec-filings|annual-report|10-?k|quarterly)/i, "financial_disclosure"],
17 + [/\/(planning|permits?|zoning|development-applications?|applications?)/i, "planning_document"],
18 +];
19 +
20 +const TEXT_RULES: Array<[RegExp, PageType]> = [
21 + [/\b(acquires|acquisition of|to acquire|completes acquisition|agreed to acquire|acquired)\b/i, "acquisition"],
22 + [/\b(expansion|expands|expanding|adds? \d+ ?MW|additional (capacity|phase)|new phase)\b/i, "expansion"],
23 + [/\b(closes?|closure|closing|decommission|shut(ting)? down|exit(s|ing)? (the )?market)\b/i, "closure"],
24 + [/\b(planning (application|permission|commission|board)|rezon(e|ing)|site plan|conditional use|environmental (assessment|impact)|permit(ting)?)\b/i, "planning_document"],
25 + [/\b(under construction|breaks? ground|groundbreaking|topping out|construction (update|progress|milestone)|shell (is )?complete)\b/i, "construction_update"],
26 + [/\b(substation|transmission line|power (purchase|supply) agreement|PPA|grid connection|interconnection agreement|utility|megawatts of power|gigawatt|nuclear|SMR|gas turbine|solar farm|wind farm)\b/i, "power_infrastructure"],
27 + [/\b(cloud region|availability zones?|launches? (a )?new region|region (now )?(available|generally available|GA)|sovereign cloud)\b/i, "cloud_region"],
28 + [/\b(announces?|unveils?|plans (to build|for)|to build|will build|new (data ?cent(er|re)|campus|facility)|investment of|to invest|\$\d+(\.\d+)? ?(billion|million)|€\d+|£\d+)\b/i, "project_announcement"],
29 + [/\b(press release|for immediate release|newsroom|media contact)\b/i, "press_release"],
30 + [/\b(colocation|carrier-neutral|interconnection|meet-me room|rack|cabinet|cage|cross[- ]connect|N\+1|2N|redundan|PUE|Tier (III|IV|3|4)|uptime institute)\b/i, "facility_page"],
31 +];
32 +
33 +export interface Classification { pageType: PageType; rule: string; eventType?: EventType; mw: number[]; status?: string | null }
34 +
35 +export function classifyPage(url: string, title: string | null, text: string): Classification {
36 + for (const [re, pt] of URL_RULES) if (re.test(url)) return finish(pt, `url:${re.source.slice(0, 30)}`, title, text);
37 + const head = `${title ?? ""}\n${text.slice(0, 4000)}`;
38 + for (const [re, pt] of TEXT_RULES) if (re.test(head)) return finish(pt, `text:${re.source.slice(0, 30)}`, title, text);
39 + return finish("unknown", "none", title, text);
40 +}
41 +
42 +function finish(pageType: PageType, rule: string, title: string | null, text: string): Classification {
43 + const head = `${title ?? ""}\n${text.slice(0, 6000)}`;
44 + const mw = parseAllMw(head);
45 + const status = inferStatus(head);
46 + const eventType = eventTypeFor(pageType, head);
47 + return { pageType, rule, eventType, mw, status };
48 +}
49 +
50 +export function eventTypeFor(pageType: PageType, text: string): EventType | undefined {
51 + switch (pageType) {
52 + case "acquisition": return "acquisition";
53 + case "expansion": return "expansion_announced";
54 + case "closure": return "closure";
55 + case "construction_update": return /\b(breaks? ground|groundbreaking|construction (has )?(started|begun|commenced))\b/i.test(text) ? "construction_started" : "project_status_changed";
56 + case "planning_document": return /\b(approved|approval granted|granted permission)\b/i.test(text) ? "planning_approved" : "planning_filed";
57 + case "power_infrastructure": return "power_agreement";
58 + case "cloud_region": return /\b(announc|plans?|upcoming|coming)/i.test(text) && !/\b(now available|generally available|launched|is live|opens)\b/i.test(text) ? "cloud_region_announced" : "cloud_region_launched";
59 + case "project_announcement": return /\b(invest(ment|s)? of|to invest|\$\d)/i.test(text) && !/\b(data ?cent|campus|MW)\b/i.test(text) ? "investment_announced" : "project_announced";
60 + case "press_release": return "news";
61 + case "facility_page": return "facility_updated";
62 + default: return undefined;
63 + }
64 +}
65 +
66 +/** Is this text about data centers at all? Used to drop irrelevant newsroom items early. */
67 +export function isDataCenterRelevant(text: string): boolean {
68 + return /\b(data ?cent(er|re)s?|colocation|hyperscale|cloud region|availability zone|MW|megawatt|campus|interconnection|IBX|server farm|AI infrastructure|GPU cluster|compute capacity|digital infrastructure)\b/i.test(text);
69 +}
added packages/connectors/src/config.ts +142 −0
@@ -0,0 +1,142 @@
1 +import { z } from "zod";
2 +import { parse as parseYaml } from "yaml";
3 +import { readFileSync, readdirSync } from "node:fs";
4 +import { join } from "node:path";
5 +import { SOURCE_KINDS, PAGE_TYPES } from "@dci/core";
6 +
7 +/**
8 + * Connector configuration (YAML in config/connectors/*.yaml). Declarative first; `parser:` names a
9 + * code-backed parser from the registry when selectors are not enough.
10 + */
11 +const selectorRule = z.union([
12 + z.string(),
13 + z.object({
14 + selector: z.string().optional(),
15 + attr: z.string().optional(),
16 + regex: z.string().optional(),
17 + regexFlags: z.string().optional(),
18 + group: z.number().int().optional(),
19 + all: z.boolean().optional(),
20 + jsonPath: z.string().optional(), // for embedded JSON / JSON-LD ("$.address.addressLocality")
21 + jsonld: z.string().optional(), // JSON-LD @type to look in (e.g. "Place", "LocalBusiness")
22 + meta: z.string().optional(), // <meta property|name=…>
23 + transform: z.enum(["mw", "sqm", "ha", "money", "date", "status", "type", "country", "int", "float", "trim", "lower", "url", "bool"]).optional(),
24 + default: z.unknown().optional(),
25 + }),
26 +]);
27 +
28 +export const extractorSchema = z.object({
29 + /** page types this extractor handles */
30 + pageTypes: z.array(z.enum(PAGE_TYPES)).optional(),
31 + /** which URL patterns select this extractor */
32 + match: z.array(z.string()).optional(),
33 + kind: z.enum(["facility", "operator", "campus", "cloud_region", "ixp", "project", "news_event", "country"]).default("facility"),
34 + parser: z.string().optional(),
35 + params: z.record(z.string(), z.unknown()).optional(),
36 + /** declarative field rules */
37 + fields: z.record(z.string(), selectorRule).optional(),
38 + /** repeat over a list container: each match yields one record */
39 + each: z.string().optional(),
40 + /** stable key template, e.g. "equinix:{code}" or "{url}" */
41 + key: z.string().optional(),
42 + minCertainty: z.number().min(0).max(1).optional(),
43 +});
44 +
45 +export const connectorConfigSchema = z.object({
46 + id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/),
47 + name: z.string(),
48 + domain: z.string(),
49 + kind: z.enum(SOURCE_KINDS),
50 + mode: z.enum(["html", "sitemap", "rss", "pdf", "json", "hybrid", "api", "dataset"]).default("hybrid"),
51 + enabled: z.boolean().default(true),
52 + priority: z.number().int().min(1).max(5).default(3),
53 + license: z.string().optional(),
54 + attribution: z.string().optional(),
55 + homepage: z.string().url().optional(),
56 + notes: z.string().optional(),
57 + /** which countries / operators this source mostly covers (for admin + scheduling) */
58 + coverage: z.object({ countries: z.array(z.string()).optional(), operators: z.array(z.string()).optional(), global: z.boolean().optional() }).optional(),
59 + fetch: z
60 + .object({
61 + /** starting level; the runtime escalates on 403/429/JS-shell detection if `maxLevel` allows */
62 + level: z.number().int().min(1).max(4).default(1),
63 + maxLevel: z.number().int().min(1).max(4).default(2),
64 + renderJs: z.boolean().optional(),
65 + country: z.string().optional(),
66 + waitForSelector: z.string().optional(),
67 + userAgent: z.enum(["bot", "browser"]).default("bot"),
68 + accept: z.string().optional(),
69 + headers: z.record(z.string(), z.string()).optional(),
70 + timeoutMs: z.number().int().optional(),
71 + maxBytes: z.number().int().optional(),
72 + /** requests per minute against this domain */
73 + rpm: z.number().min(1).max(600).default(30),
74 + concurrency: z.number().int().min(1).max(8).default(2),
75 + respectRobots: z.boolean().default(true),
76 + /** premium credit budget per run */
77 + maxCreditsPerRun: z.number().int().default(200),
78 + })
79 + .prefault({}),
80 + discovery: z
81 + .object({
82 + sitemap: z.union([z.boolean(), z.array(z.string())]).default(false),
83 + rss: z.array(z.string()).default([]),
84 + seeds: z.array(z.union([z.string(), z.object({ url: z.string(), group: z.string().default("seed"), pageType: z.enum(PAGE_TYPES).optional(), minLevel: z.number().int().min(1).max(4).optional() })])).default([]),
85 + /** follow internal links matching these patterns (regex), from index pages only */
86 + follow: z.array(z.string()).default([]),
87 + include: z.array(z.string()).default([]),
88 + exclude: z.array(z.string()).default([]),
89 + /** map URL regex → group / pageType */
90 + classify: z
91 + .array(z.object({ pattern: z.string(), group: z.string(), pageType: z.enum(PAGE_TYPES).optional(), priority: z.number().int().optional(), minLevel: z.number().int().min(1).max(4).optional() }))
92 + .default([]),
93 + maxUrlsPerRun: z.number().int().default(2000),
94 + /** pagination: "?page={n}" or "/page/{n}" appended to index seeds, until no new links */
95 + pagination: z.object({ template: z.string(), start: z.number().int().default(1), max: z.number().int().default(20) }).optional(),
96 + })
97 + .prefault({}),
98 + schedule: z.record(z.string(), z.string()).default({ seed: "weekly" }),
99 + extractors: z.record(z.string(), extractorSchema).default({}),
100 + /** defaults merged into every normalized entity from this connector */
101 + defaults: z
102 + .object({ operatorName: z.string().optional(), operatorKind: z.string().optional(), countryIso2: z.string().optional(), facilityType: z.string().optional(), status: z.string().optional(), isHyperscale: z.boolean().optional(), providerName: z.string().optional() })
103 + .optional(),
104 + /** code-backed connector class overriding the generic pipeline (e.g. "peeringdb", "overpass") */
105 + implementation: z.string().optional(),
106 + params: z.record(z.string(), z.unknown()).optional(),
107 + parserVersion: z.string().default("v1"),
108 +});
109 +
110 +export type ConnectorConfig = z.infer<typeof connectorConfigSchema>;
111 +export type ExtractorConfig = z.infer<typeof extractorSchema>;
112 +export type SelectorRule = z.infer<typeof selectorRule>;
113 +
114 +export function parseConnectorConfig(text: string, file = "<inline>"): ConnectorConfig {
115 + const raw = parseYaml(text);
116 + const res = connectorConfigSchema.safeParse(raw);
117 + if (!res.success) throw new Error(`Invalid connector config ${file}: ${res.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`);
118 + return res.data;
119 +}
120 +
121 +export function loadConnectorConfigs(dir: string): ConnectorConfig[] {
122 + const out: ConnectorConfig[] = [];
123 + const seen = new Set<string>();
124 + for (const f of readdirSync(dir).filter((x) => /\.ya?ml$/.test(x)).sort()) {
125 + const cfg = parseConnectorConfig(readFileSync(join(dir, f), "utf8"), f);
126 + if (seen.has(cfg.id)) throw new Error(`duplicate connector id ${cfg.id} in ${f}`);
127 + seen.add(cfg.id);
128 + out.push(cfg);
129 + }
130 + return out;
131 +}
132 +
133 +/** "daily" | "weekly" | "monthly" | "hourly" | "3h" | "12h" | "30m" → milliseconds */
134 +export function intervalMs(spec: string): number {
135 + const s = spec.trim().toLowerCase();
136 + const named: Record<string, number> = { hourly: 3_600_000, daily: 86_400_000, weekly: 7 * 86_400_000, biweekly: 14 * 86_400_000, monthly: 30 * 86_400_000, quarterly: 91 * 86_400_000, never: Number.POSITIVE_INFINITY };
137 + if (named[s]) return named[s]!;
138 + const m = s.match(/^(\d+)\s*(m|min|h|d|w)$/);
139 + if (!m) throw new Error(`bad interval: ${spec}`);
140 + const n = Number(m[1]);
141 + return { m: 60_000, min: 60_000, h: 3_600_000, d: 86_400_000, w: 7 * 86_400_000 }[m[2]!]! * n;
142 +}
added packages/connectors/src/discovery.ts +153 −0
@@ -0,0 +1,153 @@
1 +import { XMLParser } from "fast-xml-parser";
2 +import { load } from "cheerio";
3 +import type { PageType } from "@dci/core";
4 +import type { ConnectorConfig } from "./config.js";
5 +import type { ConnectorContext, DiscoveredUrl, RawDocument } from "./types.js";
6 +
7 +const xml = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_", textNodeName: "#text", trimValues: true, parseTagValue: false });
8 +
9 +/** Parse a sitemap or sitemap index. Returns child sitemaps and URL entries. */
10 +export function parseSitemap(text: string): { sitemaps: string[]; urls: Array<{ loc: string; lastmod: string | null }> } {
11 + const out = { sitemaps: [] as string[], urls: [] as Array<{ loc: string; lastmod: string | null }> };
12 + let doc: Record<string, unknown>;
13 + try { doc = xml.parse(text) as Record<string, unknown>; } catch { return out; }
14 + const idx = (doc.sitemapindex as { sitemap?: unknown })?.sitemap;
15 + if (idx) for (const s of Array.isArray(idx) ? idx : [idx]) { const loc = (s as { loc?: string }).loc; if (loc) out.sitemaps.push(String(loc).trim()); }
16 + const set = (doc.urlset as { url?: unknown })?.url;
17 + if (set) for (const u of Array.isArray(set) ? set : [set]) { const e = u as { loc?: string; lastmod?: string }; if (e.loc) out.urls.push({ loc: String(e.loc).trim(), lastmod: e.lastmod ? String(e.lastmod) : null }); }
18 + if (!idx && !set) {
19 + // plain-text sitemap
20 + for (const line of text.split(/\r?\n/)) { const l = line.trim(); if (/^https?:\/\//.test(l)) out.urls.push({ loc: l, lastmod: null }); }
21 + }
22 + return out;
23 +}
24 +
25 +export interface FeedItem { title: string; link: string; published: string | null; summary: string | null; id: string | null; categories: string[] }
26 +
27 +/** RSS 2.0 / Atom / RDF feed parser. */
28 +export function parseFeed(text: string): FeedItem[] {
29 + let doc: Record<string, unknown>;
30 + try { doc = xml.parse(text) as Record<string, unknown>; } catch { return []; }
31 + const items: FeedItem[] = [];
32 + const txt = (v: unknown): string | null => { if (v == null) return null; if (typeof v === "string") return v.trim() || null; if (typeof v === "object") { const o = v as Record<string, unknown>; return txt(o["#text"] ?? o["@_href"] ?? null); } return String(v); };
33 + const chan = (doc.rss as { channel?: { item?: unknown } })?.channel?.item ?? (doc["rdf:RDF"] as { item?: unknown })?.item;
34 + if (chan) for (const it of Array.isArray(chan) ? chan : [chan]) {
35 + const o = it as Record<string, unknown>;
36 + const link = txt(o.link) ?? txt(o.guid);
37 + if (!link) continue;
38 + const cats = o.category ? (Array.isArray(o.category) ? o.category : [o.category]).map((c) => txt(c)).filter((c): c is string => Boolean(c)) : [];
39 + items.push({ title: txt(o.title) ?? link, link, published: txt(o.pubDate) ?? txt(o["dc:date"]) ?? null, summary: txt(o.description) ?? txt(o["content:encoded"]) ?? null, id: txt(o.guid) ?? null, categories: cats });
40 + }
41 + const entries = (doc.feed as { entry?: unknown })?.entry;
42 + if (entries) for (const en of Array.isArray(entries) ? entries : [entries]) {
43 + const o = en as Record<string, unknown>;
44 + const links = Array.isArray(o.link) ? o.link : o.link ? [o.link] : [];
45 + const alt = (links as Array<Record<string, unknown>>).find((l) => !l["@_rel"] || l["@_rel"] === "alternate") ?? (links as Array<Record<string, unknown>>)[0];
46 + const link = alt ? String(alt["@_href"] ?? txt(alt) ?? "") : "";
47 + if (!link) continue;
48 + const cats = o.category ? (Array.isArray(o.category) ? o.category : [o.category]).map((c) => String((c as Record<string, unknown>)["@_term"] ?? txt(c) ?? "")).filter(Boolean) : [];
49 + items.push({ title: txt(o.title) ?? link, link, published: txt(o.published) ?? txt(o.updated) ?? null, summary: txt(o.summary) ?? txt(o.content) ?? null, id: txt(o.id) ?? null, categories: cats });
50 + }
51 + return items;
52 +}
53 +
54 +/** Absolute, same-site links from an HTML page (anchors only). */
55 +export function extractLinks(html: string, baseUrl: string, sameHostOnly = true): string[] {
56 + const $ = load(html);
57 + const base = new URL(baseUrl);
58 + const out = new Set<string>();
59 + $("a[href]").each((_, a) => {
60 + const href = $(a).attr("href");
61 + if (!href || /^(mailto:|tel:|javascript:|#)/i.test(href)) return;
62 + try {
63 + const u = new URL(href, base);
64 + if (!/^https?:$/.test(u.protocol)) return;
65 + if (sameHostOnly && u.hostname.replace(/^www\./, "") !== base.hostname.replace(/^www\./, "")) return;
66 + u.hash = "";
67 + out.add(u.toString());
68 + } catch { /* ignore */ }
69 + });
70 + return [...out];
71 +}
72 +
73 +function compile(patterns: string[]): RegExp[] { return patterns.map((p) => new RegExp(p, "i")); }
74 +
75 +/** Apply include/exclude/classify rules from the config to a URL. Returns null when excluded. */
76 +export function classifyUrl(cfg: ConnectorConfig, url: string, discoveredFrom: string | null = null): DiscoveredUrl | null {
77 + const d = cfg.discovery;
78 + if (d.exclude.length && compile(d.exclude).some((re) => re.test(url))) return null;
79 + if (d.include.length && !compile(d.include).some((re) => re.test(url))) return null;
80 + for (const rule of d.classify) {
81 + if (new RegExp(rule.pattern, "i").test(url)) return { url, group: rule.group, pageType: rule.pageType as PageType | undefined, priority: rule.priority ?? 50, discoveredFrom, minLevel: rule.minLevel as DiscoveredUrl["minLevel"] };
82 + }
83 + return { url, group: "default", priority: 30, discoveredFrom };
84 +}
85 +
86 +/** Generic discovery: sitemaps (auto or listed), RSS feeds, seeds, and link-following from index pages. */
87 +export async function discoverGeneric(cfg: ConnectorConfig, ctx: ConnectorContext): Promise<DiscoveredUrl[]> {
88 + const found = new Map<string, DiscoveredUrl>();
89 + const add = (u: DiscoveredUrl | null) => { if (u && !found.has(u.url) && found.size < cfg.discovery.maxUrlsPerRun) found.set(u.url, u); };
90 + const origin = `https://${cfg.domain.replace(/^https?:\/\//, "").replace(/\/$/, "")}`;
91 +
92 + // seeds
93 + for (const s of cfg.discovery.seeds) {
94 + const seed = typeof s === "string" ? { url: s, group: "seed" as string, pageType: undefined as PageType | undefined, minLevel: undefined as number | undefined } : s;
95 + const url = seed.url.startsWith("http") ? seed.url : origin + seed.url;
96 + const c = classifyUrl(cfg, url, null);
97 + add({ url, group: seed.group ?? c?.group ?? "seed", pageType: seed.pageType ?? c?.pageType, priority: 80, minLevel: (seed.minLevel ?? c?.minLevel) as DiscoveredUrl["minLevel"], discoveredFrom: null });
98 + }
99 +
100 + // sitemaps
101 + const sitemapUrls: string[] = [];
102 + if (cfg.discovery.sitemap === true) sitemapUrls.push(`${origin}/sitemap.xml`, `${origin}/sitemap_index.xml`, `${origin}/sitemap-index.xml`);
103 + else if (Array.isArray(cfg.discovery.sitemap)) for (const s of cfg.discovery.sitemap) sitemapUrls.push(s.startsWith("http") ? s : origin + s);
104 + const seenMaps = new Set<string>();
105 + let mapBudget = 40;
106 + while (sitemapUrls.length && mapBudget-- > 0) {
107 + const sm = sitemapUrls.shift()!;
108 + if (seenMaps.has(sm)) continue;
109 + seenMaps.add(sm);
110 + const doc = await ctx.fetch(sm, { group: "sitemap", accept: "application/xml,text/xml,*/*", maxBytes: 60 * 1024 * 1024 });
111 + if (doc.error || doc.status !== 200) { ctx.log("debug", `sitemap ${sm} → ${doc.error?.code ?? doc.status}`); continue; }
112 + const parsed = parseSitemap(doc.text);
113 + for (const child of parsed.sitemaps) if (!seenMaps.has(child)) sitemapUrls.push(child);
114 + for (const u of parsed.urls) { const c = classifyUrl(cfg, u.loc, sm); if (c) add({ ...c, lastmod: u.lastmod }); }
115 + ctx.log("info", `sitemap ${sm}: ${parsed.urls.length} urls, ${parsed.sitemaps.length} children`);
116 + }
117 +
118 + // rss
119 + for (const f of cfg.discovery.rss) {
120 + const url = f.startsWith("http") ? f : origin + f;
121 + const doc = await ctx.fetch(url, { group: "rss", accept: "application/rss+xml,application/atom+xml,application/xml,text/xml,*/*" });
122 + if (doc.error || doc.status !== 200) { ctx.log("warn", `rss ${url} → ${doc.error?.code ?? doc.status}`); continue; }
123 + const items = parseFeed(doc.text);
124 + for (const it of items) { const c = classifyUrl(cfg, it.link, url); if (c) add({ ...c, group: c.group === "default" ? "newsroom" : c.group, pageType: c.pageType ?? "press_release", priority: 70, lastmod: it.published, meta: { title: it.title, summary: it.summary, published: it.published, categories: it.categories } }); }
125 + ctx.log("info", `rss ${url}: ${items.length} items`);
126 + }
127 +
128 + // link following from index pages (seeds + pagination)
129 + if (cfg.discovery.follow.length) {
130 + const follow = compile(cfg.discovery.follow);
131 + const indexPages = [...found.values()].filter((u) => u.group === "seed" || u.pageType === "facility_index" || u.pageType === "news_index");
132 + const pages: string[] = indexPages.map((u) => u.url);
133 + if (cfg.discovery.pagination) for (const ip of indexPages) for (let n = cfg.discovery.pagination.start; n <= cfg.discovery.pagination.max; n++) pages.push(ip.url.replace(/\/$/, "") + cfg.discovery.pagination.template.replace("{n}", String(n)));
134 + let stale = 0;
135 + for (const p of pages) {
136 + if (found.size >= cfg.discovery.maxUrlsPerRun) break;
137 + const doc = await ctx.fetch(p, { group: "index" });
138 + if (doc.error || doc.status !== 200) { if (++stale >= 3) break; continue; }
139 + const before = found.size;
140 + for (const l of extractLinks(doc.text, doc.finalUrl)) if (follow.some((re) => re.test(l))) add(classifyUrl(cfg, l, p));
141 + if (found.size === before) { if (++stale >= 3) break; } else stale = 0;
142 + }
143 + }
144 + return [...found.values()];
145 +}
146 +
147 +/** Robots-declared sitemaps are useful even when discovery.sitemap is false. */
148 +export function pickSitemapsFromRobots(sitemaps: string[], domain: string): string[] {
149 + const d = domain.replace(/^www\./, "");
150 + return sitemaps.filter((s) => { try { return new URL(s).hostname.replace(/^www\./, "").endsWith(d); } catch { return false; } });
151 +}
152 +
153 +export function docIsXml(doc: RawDocument): boolean { return /xml/i.test(doc.contentType ?? "") || /^\s*<\?xml/.test(doc.text); }
added packages/connectors/src/extract.ts +186 −0
@@ -0,0 +1,186 @@
1 +import { load, type CheerioAPI } from "cheerio";
2 +import { extractText as pdfExtractText, getDocumentProxy } from "unpdf";
3 +import { parseMw, parseAreaSqm, parseAreaHa, parseMoney, parsePartialDate, normalizeStatus, inferFacilityType, countryFromText, cleanText, htmlToText } from "@dci/core";
4 +import type { SelectorRule } from "./config.js";
5 +import type { RawDocument } from "./types.js";
6 +
7 +/** All JSON-LD objects on a page (flattened @graph), optionally filtered by @type. */
8 +export function jsonLd(html: string, type?: string): Record<string, unknown>[] {
9 + const $ = load(html);
10 + const out: Record<string, unknown>[] = [];
11 + $('script[type="application/ld+json"]').each((_, el) => {
12 + const raw = $(el).contents().text();
13 + try {
14 + const parsed = JSON.parse(raw.replace(/^\s*<!--|-->\s*$/g, ""));
15 + const arr = Array.isArray(parsed) ? parsed : [parsed];
16 + for (const p of arr) {
17 + if (p && typeof p === "object" && Array.isArray((p as { "@graph"?: unknown[] })["@graph"])) out.push(...((p as { "@graph": Record<string, unknown>[] })["@graph"]));
18 + else if (p && typeof p === "object") out.push(p as Record<string, unknown>);
19 + }
20 + } catch { /* malformed */ }
21 + });
22 + if (!type) return out;
23 + const t = type.toLowerCase();
24 + return out.filter((o) => { const ty = o["@type"]; const list = Array.isArray(ty) ? ty : [ty]; return list.some((x) => String(x).toLowerCase() === t); });
25 +}
26 +
27 +/** Embedded JSON blobs: __NEXT_DATA__, window.__INITIAL_STATE__ = {...}, application/json scripts, Nuxt/Drupal settings. */
28 +export function embeddedJson(html: string): Array<{ id: string; data: unknown }> {
29 + const $ = load(html);
30 + const out: Array<{ id: string; data: unknown }> = [];
31 + $('script[type="application/json"], script#__NEXT_DATA__, script[data-drupal-selector="drupal-settings-json"]').each((_, el) => {
32 + const id = $(el).attr("id") ?? $(el).attr("data-drupal-selector") ?? "json";
33 + try { out.push({ id, data: JSON.parse($(el).contents().text()) }); } catch { /* skip */ }
34 + });
35 + const re = /(?:window\.|var\s+|const\s+|let\s+)?(__INITIAL_STATE__|__NUXT__|__PRELOADED_STATE__|__APOLLO_STATE__|__DATA__|__INITIAL_DATA__|initialData|pageData|__NEXT_DATA__)\s*=\s*(\{[\s\S]*?\})\s*;?\s*(?:<\/script>|\n)/g;
36 + for (const m of html.matchAll(re)) { try { out.push({ id: m[1]!, data: JSON.parse(m[2]!) }); } catch { /* not plain JSON */ } }
37 + return out;
38 +}
39 +
40 +/** Tiny JSONPath subset: "$.a.b[0].c", "a.b", "[*]" not supported (use walk). */
41 +export function jsonPath(obj: unknown, path: string): unknown {
42 + const parts = path.replace(/^\$\.?/, "").split(/\.|\[(\d+)\]/).filter((p) => p !== undefined && p !== "");
43 + let cur: unknown = obj;
44 + for (const p of parts) {
45 + if (cur == null) return undefined;
46 + cur = (cur as Record<string, unknown>)[p];
47 + }
48 + return cur;
49 +}
50 +
51 +/** Depth-first walk collecting objects satisfying a predicate (used to find facility-like nodes in embedded JSON). */
52 +export function walkJson(obj: unknown, pred: (o: Record<string, unknown>) => boolean, out: Record<string, unknown>[] = [], depth = 0): Record<string, unknown>[] {
53 + if (depth > 12 || obj == null) return out;
54 + if (Array.isArray(obj)) { for (const x of obj) walkJson(x, pred, out, depth + 1); return out; }
55 + if (typeof obj === "object") {
56 + const o = obj as Record<string, unknown>;
57 + if (pred(o)) out.push(o);
58 + for (const v of Object.values(o)) walkJson(v, pred, out, depth + 1);
59 + }
60 + return out;
61 +}
62 +
63 +export function applyTransform(v: unknown, t: SelectorRule extends string ? never : Exclude<SelectorRule, string>["transform"]): unknown {
64 + if (v == null) return null;
65 + const s = typeof v === "string" ? v : String(v);
66 + switch (t) {
67 + case "mw": return parseMw(s);
68 + case "sqm": return parseAreaSqm(s);
69 + case "ha": return parseAreaHa(s);
70 + case "money": return parseMoney(s);
71 + case "date": return parsePartialDate(s);
72 + case "status": return normalizeStatus(s);
73 + case "type": return inferFacilityType(s);
74 + case "country": return /^[A-Z]{2}$/.test(s.trim()) ? s.trim() : countryFromText(s);
75 + case "int": { const n = parseInt(s.replace(/[^\d-]/g, ""), 10); return Number.isFinite(n) ? n : null; }
76 + case "float": { const n = parseFloat(s.replace(/[^\d.-]/g, "")); return Number.isFinite(n) ? n : null; }
77 + case "trim": return cleanText(s);
78 + case "lower": return s.trim().toLowerCase();
79 + case "url": return s.trim();
80 + case "bool": return /^(true|yes|1|y)$/i.test(s.trim());
81 + default: return typeof v === "string" ? cleanText(v) : v;
82 + }
83 +}
84 +
85 +/** Evaluate one declarative selector rule against a page. */
86 +export function evalRule($: CheerioAPI, html: string, rule: SelectorRule, scope?: ReturnType<CheerioAPI>): { value: unknown; method: string } {
87 + const r: Exclude<SelectorRule, string> = typeof rule === "string" ? { selector: rule } : rule;
88 + let value: unknown = null;
89 + let method = "";
90 + if (r.jsonld) {
91 + const objs = jsonLd(html, r.jsonld);
92 + value = objs.length && r.jsonPath ? jsonPath(objs[0], r.jsonPath) : objs[0] ?? null;
93 + method = `json-ld:${r.jsonld}${r.jsonPath ? "." + r.jsonPath : ""}`;
94 + } else if (r.meta) {
95 + value = $(`meta[property="${r.meta}"], meta[name="${r.meta}"]`).first().attr("content") ?? null;
96 + method = `meta:${r.meta}`;
97 + } else if (r.jsonPath && !r.selector) {
98 + for (const b of embeddedJson(html)) { const v = jsonPath(b.data, r.jsonPath); if (v != null) { value = v; method = `embedded-json:${b.id}.${r.jsonPath}`; break; } }
99 + } else if (r.selector) {
100 + const sel = scope ? scope.find(r.selector) : $(r.selector);
101 + if (r.all) value = sel.map((_, el) => (r.attr ? $(el).attr(r.attr) : $(el).text())).get().map((x) => cleanText(x)).filter(Boolean);
102 + else value = r.attr ? sel.first().attr(r.attr) ?? null : cleanText(sel.first().text());
103 + method = `selector:${r.selector}${r.attr ? "@" + r.attr : ""}`;
104 + } else if (r.regex) {
105 + value = htmlToText(html);
106 + method = "regex";
107 + }
108 + if (r.regex && value != null) {
109 + const re = new RegExp(r.regex, r.regexFlags ?? "i");
110 + const apply = (s: string) => { const m = s.match(re); return m ? (m[r.group ?? 1] ?? m[0]) : null; };
111 + value = Array.isArray(value) ? value.map((x) => apply(String(x))).filter(Boolean) : apply(String(value));
112 + method += `+regex:${r.regex.slice(0, 40)}`;
113 + }
114 + if (r.transform && value != null) { value = Array.isArray(value) ? value.map((x) => applyTransform(x, r.transform)) : applyTransform(value, r.transform); method += `>${r.transform}`; }
115 + if ((value == null || value === "") && r.default !== undefined) value = r.default;
116 + return { value, method };
117 +}
118 +
119 +export function pageTitle(html: string): string | null {
120 + const $ = load(html);
121 + return cleanText($('meta[property="og:title"]').attr("content") ?? $("title").first().text() ?? $("h1").first().text());
122 +}
123 +
124 +export function metaDescription(html: string): string | null {
125 + const $ = load(html);
126 + return cleanText($('meta[name="description"]').attr("content") ?? $('meta[property="og:description"]').attr("content") ?? null);
127 +}
128 +
129 +/** Main text of an article-like page (heuristic: largest <article>/<main>/content block). */
130 +export function mainText(html: string): string {
131 + const $ = load(html);
132 + $("script,style,noscript,nav,header,footer,aside,form,iframe,svg").remove();
133 + const candidates = ["article", "main", '[role="main"]', ".article-body", ".press-release", ".entry-content", ".post-content", ".content", "#content", "body"];
134 + for (const c of candidates) {
135 + const el = $(c).first();
136 + if (el.length) { const t = htmlToText(el.html() ?? ""); if (t.length > 300) return t; }
137 + }
138 + return htmlToText($("body").html() ?? html);
139 +}
140 +
141 +/** Publication date from meta tags / JSON-LD / <time>. */
142 +export function publishedDate(html: string): string | null {
143 + const $ = load(html);
144 + const cands = [$('meta[property="article:published_time"]').attr("content"), $('meta[name="pubdate"]').attr("content"), $('meta[name="publish-date"]').attr("content"), $('meta[name="date"]').attr("content"), $('meta[itemprop="datePublished"]').attr("content"), $("time[datetime]").first().attr("datetime"), (jsonLd(html, "NewsArticle")[0]?.datePublished as string | undefined), (jsonLd(html, "Article")[0]?.datePublished as string | undefined)];
145 + for (const c of cands) if (c) { const d = parsePartialDate(String(c)); if (d) return d; }
146 + return null;
147 +}
148 +
149 +export async function pdfText(buf: Buffer, maxPages = 60): Promise<{ text: string; pages: number; title: string | null }> {
150 + const pdf = await getDocumentProxy(new Uint8Array(buf));
151 + const { text, totalPages } = await pdfExtractText(pdf, { mergePages: true });
152 + const meta = await pdf.getMetadata().catch(() => null);
153 + const title = (meta?.info as { Title?: string } | undefined)?.Title ?? null;
154 + const body = Array.isArray(text) ? text.join("\n") : String(text);
155 + return { text: body.split("\f").slice(0, maxPages).join("\n").replace(/[ \t]+/g, " ").trim(), pages: totalPages, title: cleanText(title) };
156 +}
157 +
158 +export function isPdf(doc: RawDocument): boolean { return /pdf/i.test(doc.contentType ?? "") || doc.body.subarray(0, 5).toString("latin1") === "%PDF-"; }
159 +export function isJson(doc: RawDocument): boolean { return /json/i.test(doc.contentType ?? "") || /^\s*[\[{]/.test(doc.text.slice(0, 20)); }
160 +
161 +/** Loose geo extraction from any HTML: JSON-LD geo, meta geo.position / ICBM, data-lat attributes, Google Maps links. */
162 +export function extractGeo(html: string): { lat: number; lng: number; method: string } | null {
163 + for (const o of jsonLd(html)) {
164 + const g = (o.geo ?? (o.location as Record<string, unknown> | undefined)?.geo) as { latitude?: unknown; longitude?: unknown } | undefined;
165 + if (g && g.latitude != null && g.longitude != null) { const lat = Number(g.latitude), lng = Number(g.longitude); if (Number.isFinite(lat) && Number.isFinite(lng)) return { lat, lng, method: "json-ld:geo" }; }
166 + }
167 + const $ = load(html);
168 + const pos = $('meta[name="geo.position"]').attr("content") ?? $('meta[name="ICBM"]').attr("content");
169 + if (pos) { const m = pos.match(/(-?\d+(?:\.\d+)?)[;,\s]+(-?\d+(?:\.\d+)?)/); if (m) return { lat: Number(m[1]), lng: Number(m[2]), method: "meta:geo.position" }; }
170 + const el = $("[data-lat][data-lng], [data-latitude][data-longitude]").first();
171 + if (el.length) { const lat = Number(el.attr("data-lat") ?? el.attr("data-latitude")), lng = Number(el.attr("data-lng") ?? el.attr("data-longitude")); if (Number.isFinite(lat) && Number.isFinite(lng)) return { lat, lng, method: "attr:data-lat" }; }
172 + const gm = html.match(/maps\.google\.[a-z.]+\/(?:maps)?\?q=(-?\d+\.\d+),(-?\d+\.\d+)|google\.com\/maps[^"']*?@(-?\d+\.\d+),(-?\d+\.\d+)|!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)/);
173 + if (gm) { const lat = Number(gm[1] ?? gm[3] ?? gm[5]), lng = Number(gm[2] ?? gm[4] ?? gm[6]); if (Number.isFinite(lat) && Number.isFinite(lng)) return { lat, lng, method: "link:google-maps" }; }
174 + return null;
175 +}
176 +
177 +/** Postal address from JSON-LD (PostalAddress) on any page. */
178 +export function extractAddress(html: string): { street: string | null; city: string | null; region: string | null; postal: string | null; country: string | null } | null {
179 + for (const o of jsonLd(html)) {
180 + const a = (o.address ?? (o.location as Record<string, unknown> | undefined)?.address) as Record<string, unknown> | string | undefined;
181 + if (!a) continue;
182 + if (typeof a === "string") return { street: cleanText(a), city: null, region: null, postal: null, country: countryFromText(a) };
183 + return { street: cleanText(String(a.streetAddress ?? "")), city: cleanText(String(a.addressLocality ?? "")), region: cleanText(String(a.addressRegion ?? "")), postal: cleanText(String(a.postalCode ?? "")), country: a.addressCountry ? (typeof a.addressCountry === "string" ? (/^[A-Z]{2}$/.test(a.addressCountry) ? a.addressCountry : countryFromText(a.addressCountry)) : countryFromText(String((a.addressCountry as Record<string, unknown>).name ?? ""))) : null };
184 + }
185 + return null;
186 +}
added packages/connectors/src/fetchers.ts +230 −0
@@ -0,0 +1,230 @@
1 +import { Agent, fetch as undiciFetch, type Dispatcher } from "undici";
2 +import { gunzipSync } from "node:zlib";
3 +import { assertUrlAllowed, safeLookup, UrlPolicyError } from "@dci/core";
4 +import type { FetchLevel } from "@dci/core";
5 +import type { Fetcher, FetchOptions, RawDocument } from "./types.js";
6 +
7 +/**
8 + * Fetch abstraction with escalation levels:
9 + * 1 — direct HTTP (conditional GET, ETag / Last-Modified)
10 + * 2 — direct HTTP + parsing hints (browser UA, accept html) [same transport, different identity]
11 + * 3 — Firecrawl (markdown + html normalization)
12 + * 4 — Scrapfly rendered request (anti-bot + JS rendering)
13 + * Every URL passes the SSRF policy before any network activity, on every redirect hop.
14 + */
15 +
16 +export const DEFAULT_UA = process.env.DCI_USER_AGENT ?? "DataCenterIndexBot/0.1 (+https://www.datacenterindex.io/bot; contact@spboucher.ai)";
17 +export const BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15";
18 +const DEFAULT_TIMEOUT = 30_000;
19 +const DEFAULT_MAX_BYTES = 25 * 1024 * 1024;
20 +
21 +let agent: Dispatcher | null = null;
22 +let agentH1: Dispatcher | null = null;
23 +const h1Hosts = new Set<string>();
24 +function dispatcher(h1 = false): Dispatcher {
25 + if (h1) return (agentH1 ??= new Agent({ connect: { lookup: safeLookup as never, timeout: 10_000 }, connections: 32, pipelining: 1, keepAliveTimeout: 15_000, headersTimeout: 30_000, bodyTimeout: 45_000, allowH2: false }));
26 + return (agent ??= new Agent({ connect: { lookup: safeLookup as never, timeout: 10_000 }, connections: 64, pipelining: 1, keepAliveTimeout: 15_000, headersTimeout: 30_000, bodyTimeout: 45_000, allowH2: true }));
27 +}
28 +export async function closeFetchers(): Promise<void> {
29 + await agent?.close();
30 + await agentH1?.close();
31 + agent = agentH1 = null;
32 +}
33 +
34 +function hdrs(h: Headers): Record<string, string> {
35 + const out: Record<string, string> = {};
36 + h.forEach((v, k) => { if (/^(content-type|content-length|etag|last-modified|cache-control|server|retry-after|date|x-robots-tag|link|location|content-disposition|x-ratelimit-[a-z-]+)$/i.test(k)) out[k.toLowerCase()] = v; });
37 + return out;
38 +}
39 +
40 +function decodeText(body: Buffer, contentType: string | null): string {
41 + const m = contentType?.match(/charset=([\w-]+)/i);
42 + const cs = (m?.[1] ?? "utf-8").toLowerCase();
43 + try {
44 + if (cs === "utf-8" || cs === "utf8") return body.toString("utf8");
45 + return new TextDecoder(cs as string).decode(body);
46 + } catch {
47 + return body.toString("utf8");
48 + }
49 +}
50 +
51 +export function failDoc(url: string, level: FetchLevel, fetcher: RawDocument["fetcher"], code: string, message: string, started: number, status = 0): RawDocument {
52 + return { url, finalUrl: url, fetchedAt: new Date().toISOString(), status, contentType: null, body: Buffer.alloc(0), text: "", headers: {}, etag: null, lastModified: null, notModified: false, fetcher, level, durationMs: Date.now() - started, credits: 0, error: { code, message } };
53 +}
54 +
55 +export class DirectFetcher implements Fetcher {
56 + readonly name = "direct" as const;
57 + constructor(readonly level: FetchLevel = 1) {}
58 + available(): boolean { return true; }
59 + async fetch(urlStr: string, opts: FetchOptions = {}): Promise<RawDocument> {
60 + const started = Date.now();
61 + const level = opts.level ?? this.level;
62 + const headers: Record<string, string> = {
63 + "user-agent": level >= 2 ? BROWSER_UA : DEFAULT_UA,
64 + accept: opts.accept ?? "text/html,application/xhtml+xml,application/xml;q=0.9,application/json;q=0.9,application/pdf;q=0.8,*/*;q=0.5",
65 + "accept-language": "en-US,en;q=0.9,fr;q=0.6,de;q=0.4",
66 + "accept-encoding": "gzip, deflate, br",
67 + ...(level >= 2 ? { "sec-fetch-dest": "document", "sec-fetch-mode": "navigate", "sec-fetch-site": "none", "upgrade-insecure-requests": "1" } : {}),
68 + ...opts.headers,
69 + };
70 + if (opts.etag) headers["if-none-match"] = opts.etag;
71 + if (opts.lastModified) headers["if-modified-since"] = opts.lastModified;
72 + let current = urlStr;
73 + let redirects = 0;
74 + for (;;) {
75 + try { await assertUrlAllowed(current); } catch (e) { return failDoc(urlStr, level, "direct", "ssrf_blocked", e instanceof UrlPolicyError ? e.message : String(e), started); }
76 + const ac = new AbortController();
77 + const timer = setTimeout(() => ac.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT);
78 + let res: Response;
79 + const host = new URL(current).hostname;
80 + try {
81 + res = (await undiciFetch(current, { method: "GET", headers, redirect: "manual", signal: ac.signal, dispatcher: dispatcher(h1Hosts.has(host)) } as never)) as unknown as Response;
82 + } catch (e) {
83 + clearTimeout(timer);
84 + const msg = e instanceof Error ? `${e.name}: ${e.message}${(e as { cause?: Error }).cause ? " — " + String((e as { cause?: Error }).cause?.message) : ""}` : String(e);
85 + if (/NGHTTP2|HTTP\/2/i.test(msg) && !h1Hosts.has(host)) { h1Hosts.add(host); continue; }
86 + const code = ac.signal.aborted ? "timeout" : /ENOTFOUND|EAI_AGAIN|getaddrinfo/i.test(msg) ? "dns" : /blocked/i.test(msg) ? "ssrf_blocked" : /CERT|TLS|SSL|certificate/i.test(msg) ? "tls" : /ECONNREFUSED|ECONNRESET|EHOSTUNREACH|ETIMEDOUT|socket/i.test(msg) ? "connection" : "fetch_failed";
87 + return failDoc(urlStr, level, "direct", code, msg, started);
88 + }
89 + if ([301, 302, 303, 307, 308].includes(res.status)) {
90 + clearTimeout(timer);
91 + const loc = res.headers.get("location");
92 + if (!loc) return failDoc(urlStr, level, "direct", "redirect_without_location", `HTTP ${res.status}`, started, res.status);
93 + if (++redirects > 6) return failDoc(urlStr, level, "direct", "too_many_redirects", "more than 6 redirects", started, res.status);
94 + try { current = new URL(loc, current).toString(); } catch { return failDoc(urlStr, level, "direct", "bad_redirect", loc, started, res.status); }
95 + delete headers["if-none-match"]; delete headers["if-modified-since"];
96 + continue;
97 + }
98 + const h = hdrs(res.headers);
99 + const base = { url: urlStr, finalUrl: current, fetchedAt: new Date().toISOString(), status: res.status, contentType: res.headers.get("content-type"), headers: h, etag: res.headers.get("etag"), lastModified: res.headers.get("last-modified"), fetcher: "direct" as const, level, credits: 0 };
100 + if (res.status === 304) { clearTimeout(timer); return { ...base, body: Buffer.alloc(0), text: "", notModified: true, durationMs: Date.now() - started }; }
101 + const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
102 + const declared = Number(res.headers.get("content-length") ?? 0);
103 + if (declared > maxBytes) { clearTimeout(timer); return failDoc(urlStr, level, "direct", "too_large", `content-length ${declared}`, started, res.status); }
104 + const chunks: Uint8Array[] = [];
105 + let total = 0;
106 + try {
107 + const reader = res.body?.getReader();
108 + if (reader) for (;;) { const { done, value } = await reader.read(); if (done) break; total += value.byteLength; if (total > maxBytes) { await reader.cancel(); clearTimeout(timer); return failDoc(urlStr, level, "direct", "too_large", `body > ${maxBytes}`, started, res.status); } chunks.push(value); }
109 + } catch (e) {
110 + clearTimeout(timer);
111 + return failDoc(urlStr, level, "direct", ac.signal.aborted ? "timeout" : "body_read_failed", (e as Error).message, started, res.status);
112 + }
113 + clearTimeout(timer);
114 + let body = Buffer.concat(chunks);
115 + if (/\.gz$/i.test(current) || (h["content-type"] ?? "").includes("gzip")) { try { body = gunzipSync(body); } catch { /* not gzip */ } }
116 + const ct = base.contentType;
117 + const isText = !ct || /text|json|xml|javascript|html|csv|markdown/i.test(ct);
118 + return { ...base, body, text: isText ? decodeText(body, ct) : "", notModified: false, durationMs: Date.now() - started };
119 + }
120 + }
121 +}
122 +
123 +/** Daily budget bookkeeping shared by premium fetchers. */
124 +class Budget {
125 + private day = "";
126 + private used = 0;
127 + constructor(private readonly envKey: string, private readonly fallback: number) {}
128 + private roll() { const d = new Date().toISOString().slice(0, 10); if (d !== this.day) { this.day = d; this.used = 0; } }
129 + limit(): number { return Number(process.env[this.envKey] ?? this.fallback); }
130 + remaining(): number { this.roll(); return Math.max(0, this.limit() - this.used); }
131 + spend(n = 1) { this.roll(); this.used += n; }
132 + snapshot() { this.roll(); return { day: this.day, used: this.used, limit: this.limit() }; }
133 +}
134 +export const scrapflyBudget = new Budget("DCI_SCRAPFLY_DAILY_BUDGET", 400);
135 +export const firecrawlBudget = new Budget("DCI_FIRECRAWL_DAILY_BUDGET", 200);
136 +
137 +export class FirecrawlFetcher implements Fetcher {
138 + readonly name = "firecrawl" as const;
139 + readonly level: FetchLevel = 3;
140 + available(): boolean { return Boolean(process.env.FIRECRAWL_API_KEY) && firecrawlBudget.remaining() > 0; }
141 + async fetch(url: string, opts: FetchOptions = {}): Promise<RawDocument> {
142 + const started = Date.now();
143 + if (!this.available()) return failDoc(url, 3, "firecrawl", "firecrawl_unavailable", "no key or daily budget exhausted", started);
144 + try { await assertUrlAllowed(url); } catch (e) { return failDoc(url, 3, "firecrawl", "ssrf_blocked", (e as Error).message, started); }
145 + firecrawlBudget.spend(1);
146 + const ac = new AbortController();
147 + const timer = setTimeout(() => ac.abort(), opts.timeoutMs ?? 90_000);
148 + try {
149 + const res = await fetch("https://api.firecrawl.dev/v1/scrape", { method: "POST", signal: ac.signal, headers: { "content-type": "application/json", authorization: `Bearer ${process.env.FIRECRAWL_API_KEY}` }, body: JSON.stringify({ url, formats: ["markdown", "html"], onlyMainContent: false, waitFor: opts.waitForSelector ? 3000 : 0 }) });
150 + const json = (await res.json()) as { success?: boolean; data?: { markdown?: string; html?: string; metadata?: { statusCode?: number; sourceURL?: string; url?: string; title?: string } }; error?: string };
151 + if (!res.ok || !json.success || !json.data) return failDoc(url, 3, "firecrawl", "firecrawl_error", `${res.status} ${json.error ?? "no data"}`, started);
152 + const html = json.data.html ?? "";
153 + const body = Buffer.from(html || json.data.markdown || "", "utf8");
154 + return { url, finalUrl: json.data.metadata?.url ?? json.data.metadata?.sourceURL ?? url, fetchedAt: new Date().toISOString(), status: json.data.metadata?.statusCode ?? 200, contentType: html ? "text/html; charset=utf-8" : "text/markdown", body, text: body.toString("utf8"), headers: {}, etag: null, lastModified: null, notModified: false, fetcher: "firecrawl", level: 3, durationMs: Date.now() - started, credits: 1, markdown: json.data.markdown ?? null };
155 + } catch (e) {
156 + return failDoc(url, 3, "firecrawl", ac.signal.aborted ? "timeout" : "firecrawl_error", (e as Error).message, started);
157 + } finally { clearTimeout(timer); }
158 + }
159 +}
160 +
161 +/** Scrapfly: https://scrapfly.io/docs/scrape-api/getting-started — GET /scrape?key&url&asp&render_js&country&cache */
162 +export class ScrapflyFetcher implements Fetcher {
163 + readonly name = "scrapfly" as const;
164 + readonly level: FetchLevel = 4;
165 + available(): boolean { return Boolean(process.env.SCRAPFLY_API_KEY) && scrapflyBudget.remaining() > 0; }
166 + async fetch(url: string, opts: FetchOptions = {}): Promise<RawDocument> {
167 + const started = Date.now();
168 + if (!this.available()) return failDoc(url, 4, "scrapfly", "scrapfly_unavailable", "no key or daily budget exhausted", started);
169 + try { await assertUrlAllowed(url); } catch (e) { return failDoc(url, 4, "scrapfly", "ssrf_blocked", (e as Error).message, started); }
170 + const q = new URLSearchParams({ key: process.env.SCRAPFLY_API_KEY!, url, asp: "true", render_js: opts.renderJs === false ? "false" : "true", country: opts.country ?? "us", retry: "true", cache: "true", cache_ttl: "3600" });
171 + if (opts.waitForSelector) q.set("wait_for_selector", opts.waitForSelector);
172 + if (opts.renderJs !== false) q.set("rendering_wait", "1500");
173 + const ac = new AbortController();
174 + const timer = setTimeout(() => ac.abort(), opts.timeoutMs ?? 120_000);
175 + try {
176 + const res = await fetch(`https://api.scrapfly.io/scrape?${q}`, { signal: ac.signal, headers: { accept: "application/json" } });
177 + const json = (await res.json()) as { result?: { content?: string; status_code?: number; success?: boolean; reason?: string; response_headers?: Record<string, string>; url?: string; error?: { message?: string }; format?: string }; context?: { cost?: { total?: number } }; message?: string; code?: string };
178 + const r = json.result;
179 + const cost = Number(json.context?.cost?.total ?? 1) || 1;
180 + scrapflyBudget.spend(cost);
181 + if (!res.ok || !r) return failDoc(url, 4, "scrapfly", "scrapfly_error", `${res.status} ${json.message ?? json.code ?? "no result"}`, started);
182 + if (!r.success) return { ...failDoc(url, 4, "scrapfly", "scrapfly_failed", `${r.status_code ?? 0} ${r.reason ?? r.error?.message ?? "upstream failed"}`, started, r.status_code ?? 0), credits: cost };
183 + const h = Object.fromEntries(Object.entries(r.response_headers ?? {}).map(([k, v]) => [k.toLowerCase(), String(v)]));
184 + const body = Buffer.from(r.content ?? "", "utf8");
185 + return { url, finalUrl: r.url ?? url, fetchedAt: new Date().toISOString(), status: r.status_code ?? 200, contentType: h["content-type"] ?? "text/html; charset=utf-8", body, text: body.toString("utf8"), headers: h, etag: null, lastModified: h["last-modified"] ?? null, notModified: false, fetcher: "scrapfly", level: 4, durationMs: Date.now() - started, credits: cost };
186 + } catch (e) {
187 + return failDoc(url, 4, "scrapfly", ac.signal.aborted ? "timeout" : "scrapfly_error", (e as Error).message, started);
188 + } finally { clearTimeout(timer); }
189 + }
190 +}
191 +
192 +export const fetchers: Record<FetchLevel, Fetcher> = { 1: new DirectFetcher(1), 2: new DirectFetcher(2), 3: new FirecrawlFetcher(), 4: new ScrapflyFetcher() };
193 +
194 +/** Heuristics: a 200 that is really an anti-bot interstitial or an empty JS shell. */
195 +export function looksBlockedOrEmpty(doc: RawDocument): boolean {
196 + if (doc.status === 403 || doc.status === 429 || doc.status === 503 || doc.status === 401) return true;
197 + if (doc.status !== 200 || !doc.contentType || !/html/i.test(doc.contentType)) return false;
198 + const t = doc.text;
199 + if (/(Just a moment|Attention Required|cf-browser-verification|_Incapsula_Resource|Access Denied|Request unsuccessful|challenge-platform|Please enable JavaScript to view|Enable JavaScript and cookies to continue|Pardon Our Interruption|are you a robot|verify you are human|Reference #\d+\.[0-9a-f]+)/i.test(t)) return true;
200 + const textLen = t.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().length;
201 + return textLen < 400 && /<(div id="(root|app|__next|__nuxt)"|app-root|noscript)/i.test(t);
202 +}
203 +
204 +/**
205 + * Escalating fetch: start at `level`, escalate up to `maxLevel` when blocked/empty. Premium fetchers are
206 + * skipped when unavailable. Returns the best document obtained (never throws).
207 + */
208 +export async function fetchWithEscalation(url: string, opts: FetchOptions & { maxLevel?: FetchLevel } = {}): Promise<RawDocument> {
209 + let level: FetchLevel = opts.level ?? 1;
210 + const max: FetchLevel = opts.maxLevel ?? 2;
211 + let best: RawDocument | null = null;
212 + const attempts: string[] = [];
213 + while (level <= max) {
214 + const f = fetchers[level];
215 + if (!f.available()) { attempts.push(`L${level}:unavailable`); level = (level + 1) as FetchLevel; continue; }
216 + const doc = await f.fetch(url, { ...opts, level });
217 + doc.meta = { ...(doc.meta ?? {}), attempts: [...attempts, `L${level}:${doc.error?.code ?? doc.status}`] };
218 + if (doc.notModified) return doc;
219 + if (!doc.error && !looksBlockedOrEmpty(doc)) return doc;
220 + // hard failures that escalation cannot fix
221 + if (doc.error && ["ssrf_blocked", "dns", "too_large"].includes(doc.error.code)) return doc;
222 + if (!doc.error && doc.status === 404) return doc;
223 + if (!doc.error && doc.status === 410) return doc;
224 + best = best ?? doc;
225 + if (!doc.error && doc.status >= 200 && doc.status < 300) best = doc; // blocked-looking 200 still better than nothing
226 + attempts.push(`L${level}:${doc.error?.code ?? doc.status}`);
227 + level = (level + 1) as FetchLevel;
228 + }
229 + return best ?? failDoc(url, max, "direct", "no_fetcher", attempts.join(","), Date.now());
230 +}
added packages/connectors/src/generic.ts +189 −0
@@ -0,0 +1,189 @@
1 +import { load } from "cheerio";
2 +import type { NormalizedEntity, NormalizedFacility, NormalizedNewsEvent, NormalizedProject, ValidationReport, ValidationIssue, PageType, FacilityStatus, FacilityType } from "@dci/core";
3 +import { parseAllMw, parseMoney, parsePartialDate, validLatLng, inferStatus, inferFacilityType, countryFromText, cleanText, sha256, urlFingerprint } from "@dci/core";
4 +import type { ConnectorConfig, ExtractorConfig } from "./config.js";
5 +import type { Connector, ConnectorContext, DiscoveredUrl, ExtractedRecord, RawDocument } from "./types.js";
6 +import { discoverGeneric } from "./discovery.js";
7 +import { evalRule, pageTitle, mainText, publishedDate, extractGeo, extractAddress, pdfText, isPdf, metaDescription } from "./extract.js";
8 +import { classifyPage, isDataCenterRelevant } from "./classify.js";
9 +import { getParser } from "./registry.js";
10 +
11 +/**
12 + * Generic, config-driven connector. Covers: sitemap/RSS/seed discovery, escalating fetch, declarative
13 + * field extraction (selectors / JSON-LD / embedded JSON / regex), code-backed parsers, article
14 + * classification for newsrooms, PDF text, normalization and validation. Source-specific connectors
15 + * either extend this via YAML or register an `implementation`.
16 + */
17 +export class GenericConnector implements Connector {
18 + id: string; sourceName: string; sourceDomain: string; sourceKind: Connector["sourceKind"]; type: Connector["type"]; parserVersion: string; schedule: Connector["schedule"]; license: string | null; attribution: string | null; priority: number;
19 + constructor(readonly cfg: ConnectorConfig) {
20 + this.id = cfg.id; this.sourceName = cfg.name; this.sourceDomain = cfg.domain; this.sourceKind = cfg.kind; this.type = cfg.mode; this.parserVersion = cfg.parserVersion; this.schedule = cfg.schedule; this.license = cfg.license ?? null; this.attribution = cfg.attribution ?? null; this.priority = cfg.priority;
21 + }
22 +
23 + async discover(ctx: ConnectorContext): Promise<DiscoveredUrl[]> { return discoverGeneric(this.cfg, ctx); }
24 +
25 + async fetch(ctx: ConnectorContext, u: DiscoveredUrl): Promise<RawDocument> {
26 + const doc = await ctx.fetch(u.url, { group: u.group, level: u.minLevel, accept: u.pageType === "planning_document" ? "application/pdf,text/html,*/*" : undefined });
27 + doc.group = u.group; doc.pageType = u.pageType; doc.meta = { ...(doc.meta ?? {}), ...(u.meta ?? {}) };
28 + return doc;
29 + }
30 +
31 + /** Pick extractors whose `match` regex hits the URL (or whose pageTypes include the doc's page type). */
32 + selectExtractors(doc: RawDocument, pageType: PageType): Array<[string, ExtractorConfig]> {
33 + const out: Array<[string, ExtractorConfig]> = [];
34 + for (const [name, ex] of Object.entries(this.cfg.extractors)) {
35 + if (ex.match?.length && !ex.match.some((m) => new RegExp(m, "i").test(doc.finalUrl) || new RegExp(m, "i").test(doc.url))) continue;
36 + if (ex.pageTypes?.length && !ex.pageTypes.includes(pageType)) continue;
37 + out.push([name, ex]);
38 + }
39 + return out;
40 + }
41 +
42 + async extract(ctx: ConnectorContext, doc: RawDocument): Promise<ExtractedRecord[]> {
43 + if (doc.error || doc.notModified || doc.status >= 400 || !doc.body.length) return [];
44 + let html = doc.text;
45 + let text = "";
46 + let title: string | null = null;
47 + if (isPdf(doc)) {
48 + try { const p = await pdfText(doc.body); text = p.text; title = p.title ?? doc.url.split("/").pop() ?? null; html = ""; } catch (e) { ctx.log("warn", `pdf parse failed ${doc.url}: ${(e as Error).message}`); return []; }
49 + } else if (/html|xml/.test(doc.contentType ?? "") || /<html/i.test(html.slice(0, 2000))) {
50 + title = pageTitle(html);
51 + text = mainText(html);
52 + } else if (doc.markdown) { text = doc.markdown; }
53 + else text = doc.text;
54 + const cls = classifyPage(doc.finalUrl, title, text);
55 + const pageType: PageType = doc.pageType && doc.pageType !== "unknown" && !["press_release", "news_index"].includes(doc.pageType) ? doc.pageType : cls.pageType;
56 + doc.meta = { ...(doc.meta ?? {}), title, pageType, classifier: cls.rule, textLength: text.length };
57 + const records: ExtractedRecord[] = [];
58 + const extractors = this.selectExtractors(doc, pageType);
59 + for (const [name, ex] of extractors) {
60 + if (ex.parser) {
61 + const parser = getParser(ex.parser);
62 + const recs = await parser.parse(doc, ctx, ex.params);
63 + for (const r of recs) records.push({ ...r, pageType: r.pageType ?? pageType });
64 + continue;
65 + }
66 + if (ex.fields && html) {
67 + const $ = load(html);
68 + const scopes = ex.each ? $(ex.each).toArray().map((el) => $(el)) : [undefined];
69 + for (const scope of scopes) {
70 + const data: Record<string, unknown> = {};
71 + const methods: Record<string, string> = {};
72 + for (const [field, rule] of Object.entries(ex.fields)) {
73 + const { value, method } = evalRule($, html, rule, scope);
74 + if (value != null && value !== "") { data[field] = value; methods[field] = method; }
75 + }
76 + if (!Object.keys(data).length) continue;
77 + const key = ex.key ? ex.key.replace(/\{(\w+)\}/g, (_, k: string) => (k === "url" ? doc.finalUrl : String(data[k] ?? ""))) : `${this.id}:${urlFingerprint(doc.finalUrl)}`;
78 + const certainty = Math.min(1, Object.keys(data).length / Math.max(3, Object.keys(ex.fields).length * 0.6));
79 + if (ex.minCertainty && certainty < ex.minCertainty) continue;
80 + records.push({ kind: ex.kind, key, data: { ...data, _title: title, _url: doc.finalUrl, _extractor: name }, methods, certainty, url: doc.finalUrl, pageType });
81 + }
82 + }
83 + }
84 + // Newsroom / announcement pages with no dedicated extractor: produce a news_event record when relevant.
85 + if (!records.length && ["press_release", "project_announcement", "acquisition", "expansion", "closure", "construction_update", "planning_document", "power_infrastructure", "cloud_region", "financial_disclosure"].includes(pageType) && isDataCenterRelevant(`${title ?? ""} ${text.slice(0, 5000)}`)) {
86 + const summary = cleanText((doc.meta?.summary as string | undefined) ?? metaDescription(html) ?? text.slice(0, 400));
87 + const published = (doc.meta?.published as string | undefined) ? parsePartialDate(String(doc.meta?.published)) : html ? publishedDate(html) : null;
88 + const money = parseMoney(`${title ?? ""} ${text.slice(0, 3000)}`);
89 + records.push({ kind: "news_event", key: `${this.id}:news:${urlFingerprint(doc.finalUrl)}`, data: { title: title ?? doc.finalUrl, url: doc.finalUrl, publishedAt: published, summary, pageType, eventType: cls.eventType, mw: cls.mw, status: cls.status, money, text: text.slice(0, 20_000) }, methods: { title: "html:title", publishedAt: "meta:published", mw: "regex:mw_v1" }, certainty: 0.6, url: doc.finalUrl, pageType });
90 + }
91 + return records;
92 + }
93 +
94 + async normalize(ctx: ConnectorContext, records: ExtractedRecord[]): Promise<NormalizedEntity[]> {
95 + const out: NormalizedEntity[] = [];
96 + const d = this.cfg.defaults ?? {};
97 + for (const r of records) {
98 + const prov = ctx.provenance(r.url, { method: r.methods ? Object.values(r.methods).slice(0, 3).join(",") : undefined, extractorVersion: this.parserVersion });
99 + const x = r.data;
100 + const str = (k: string): string | null => { const v = x[k]; return v == null ? null : cleanText(String(v)); };
101 + const num = (k: string): number | null => { const v = x[k]; if (v == null || v === "") return null; const n = typeof v === "number" ? v : Number(String(v).replace(/[^\d.-]/g, "")); return Number.isFinite(n) ? n : null; };
102 + if (r.kind === "facility") {
103 + const lat = num("lat"), lng = num("lng");
104 + const geo = validLatLng(lat, lng) ? { lat: lat!, lng: lng!, precision: (str("geoPrecision") as NormalizedFacility["geo"] extends infer G ? (G extends { precision: infer P } ? P : never) : never) ?? "exact", source: `operator:${this.id}` } : null;
105 + const name = str("name") ?? str("_title");
106 + if (!name) continue;
107 + const f: NormalizedFacility = {
108 + entityType: "facility", key: r.key, name, aliases: Array.isArray(x.aliases) ? (x.aliases as string[]) : [],
109 + operatorName: str("operatorName") ?? d.operatorName ?? null, campusName: str("campusName"), address: str("address"), city: str("city"), regionName: str("regionName"),
110 + countryIso2: (str("countryIso2") ?? (str("country") ? (/^[A-Z]{2}$/.test(str("country")!) ? str("country") : countryFromText(str("country"))) : null) ?? d.countryIso2 ?? null) as string | null,
111 + postalCode: str("postalCode"), geo,
112 + status: ((str("status") as FacilityStatus | null) ?? (d.status as FacilityStatus | undefined) ?? inferStatus(str("statusText")) ?? "operational"),
113 + facilityType: ((str("facilityType") as FacilityType | null) ?? (d.facilityType as FacilityType | undefined) ?? inferFacilityType(`${name} ${str("description") ?? ""}`) ?? "colocation"),
114 + tier: str("tier"), buildingSqm: num("buildingSqm"), siteAreaHa: num("siteAreaHa"), itCapacityMw: num("itCapacityMw"), totalPowerMw: num("totalPowerMw"), plannedPowerMw: num("plannedPowerMw"), rackCount: num("rackCount"), pue: num("pue"),
115 + coolingType: str("coolingType"), renewableClaim: str("renewableClaim"), openedOn: str("openedOn") ? parsePartialDate(str("openedOn")) : null, constructionStartedOn: str("constructionStartedOn") ? parsePartialDate(str("constructionStartedOn")) : null, announcedOn: str("announcedOn") ? parsePartialDate(str("announcedOn")) : null,
116 + website: str("website") ?? r.url, isAi: typeof x.isAi === "boolean" ? x.isAi : null, isHyperscale: typeof x.isHyperscale === "boolean" ? x.isHyperscale : d.isHyperscale ?? null,
117 + certifications: Array.isArray(x.certifications) ? (x.certifications as string[]) : [], carriers: Array.isArray(x.carriers) ? (x.carriers as string[]) : [], cloudProviders: Array.isArray(x.cloudProviders) ? (x.cloudProviders as string[]) : [], ixps: Array.isArray(x.ixps) ? (x.ixps as string[]) : [],
118 + externalIds: (x.externalIds as Record<string, string | number> | undefined) ?? {}, description: str("description"), provenance: prov,
119 + };
120 + // when no explicit geo but page has JSON-LD/meta geo, the extractor may already have set lat/lng; otherwise leave null (never fake coordinates)
121 + out.push(f);
122 + } else if (r.kind === "news_event") {
123 + const n: NormalizedNewsEvent = { entityType: "news_event", key: r.key, title: str("title") ?? r.url, url: (str("url") ?? r.url)!, publishedAt: str("publishedAt"), summary: str("summary"), pageType: (str("pageType") as PageType | null) ?? r.pageType, eventType: (str("eventType") as NormalizedNewsEvent["eventType"]) ?? undefined, mentions: { mw: Array.isArray(x.mw) ? (x.mw as number[]) : [], operators: d.operatorName ? [d.operatorName] : [], countriesIso2: [countryFromText(`${str("title")} ${str("summary")}`) ?? ""].filter(Boolean) }, provenance: prov };
124 + out.push(n);
125 + // Project candidate: announcement with MW figure + status keyword
126 + const mw = (Array.isArray(x.mw) ? (x.mw as number[]) : parseAllMw(`${n.title} ${n.summary ?? ""}`))[0];
127 + const st = str("status") as FacilityStatus | null;
128 + if (mw && st && ["announced", "proposed", "permitting", "approved", "under_construction", "expansion", "rumored", "delayed"].includes(st) && ["project_announcement", "expansion", "construction_update", "planning_document"].includes(n.pageType ?? "")) {
129 + const money = x.money as { amount: number; currency: string } | null;
130 + const p: NormalizedProject = { entityType: "project", key: `${r.key}:project`, name: n.title.slice(0, 160), operatorName: d.operatorName ?? null, countryIso2: n.mentions?.countriesIso2?.[0] ?? d.countryIso2 ?? null, status: st, announcedOn: n.publishedAt, plannedMw: mw, investmentUsd: money && money.currency === "USD" ? money.amount : null, description: n.summary, sourceUrl: n.url, timeline: n.publishedAt ? [{ date: n.publishedAt, type: st === "under_construction" ? "construction_started" : "project_announced", description: n.title, url: n.url }] : [], provenance: { ...prov, confidence: "moderate", method: "regex:announcement_v1" } };
131 + out.push(p);
132 + }
133 + } else if (r.kind === "project") {
134 + const name = str("name") ?? str("_title"); if (!name) continue;
135 + const lat = num("lat"), lng = num("lng");
136 + out.push({ entityType: "project", key: r.key, name, operatorName: str("operatorName") ?? d.operatorName ?? null, city: str("city"), regionName: str("regionName"), countryIso2: str("countryIso2") ?? d.countryIso2 ?? null, geo: validLatLng(lat, lng) ? { lat: lat!, lng: lng!, precision: "approximate", source: this.id } : null, status: (str("status") as FacilityStatus | null) ?? "announced", announcedOn: str("announcedOn"), expectedOpening: str("expectedOpening"), plannedMw: num("plannedMw"), investmentUsd: num("investmentUsd"), acreage: num("acreage"), phaseCount: num("phaseCount"), description: str("description"), sourceUrl: r.url, provenance: prov });
137 + } else if (r.kind === "operator") {
138 + const name = str("name"); if (!name) continue;
139 + out.push({ entityType: "operator", key: r.key, name, website: str("website"), kind: (str("kind") as NormalizedEntity extends { kind?: infer K } ? K : never) ?? null, hqCountryIso2: str("hqCountryIso2"), description: str("description"), provenance: prov });
140 + } else if (r.kind === "cloud_region") {
141 + const code = str("code"), name = str("name"); if (!code || !name) continue;
142 + const lat = num("lat"), lng = num("lng");
143 + out.push({ entityType: "cloud_region", key: r.key, providerName: str("providerName") ?? d.providerName ?? this.sourceName, code, name, city: str("city"), regionName: str("regionName"), countryIso2: str("countryIso2") ?? (str("country") ? countryFromText(str("country")) : null), geo: validLatLng(lat, lng) ? { lat: lat!, lng: lng!, precision: (str("geoPrecision") as "city" | "exact" | null) ?? "city", source: this.id } : null, availabilityZones: num("availabilityZones"), launchedOn: str("launchedOn") ? parsePartialDate(str("launchedOn")) : null, status: (str("status") as "announced" | "operational" | null) ?? "operational", isSovereign: Boolean(x.isSovereign), sourceUrl: r.url, provenance: prov });
144 + }
145 + }
146 + return out;
147 + }
148 +
149 + async validate(_ctx: ConnectorContext, entities: NormalizedEntity[]): Promise<ValidationReport> { return validateEntities(entities); }
150 +}
151 +
152 +/** Shared validation: hard errors reject the record; warnings are recorded. Never accept impossible figures. */
153 +export function validateEntities(entities: NormalizedEntity[]): ValidationReport {
154 + const issues: ValidationIssue[] = [];
155 + let rejected = 0;
156 + for (const e of entities) {
157 + const errs: ValidationIssue[] = [];
158 + const warn = (field: string, message: string) => issues.push({ key: e.key, field, level: "warn", message });
159 + const err = (field: string, message: string) => errs.push({ key: e.key, field, level: "error", message });
160 + if (e.entityType === "facility") {
161 + if (!e.name || e.name.length < 2 || e.name.length > 200) err("name", "missing or implausible name");
162 + if (e.geo && !validLatLng(e.geo.lat, e.geo.lng)) err("geo", "invalid coordinates");
163 + for (const f of ["itCapacityMw", "totalPowerMw", "plannedPowerMw"] as const) { const v = e[f]; if (v != null && (v <= 0 || v > 10_000)) err(f, `implausible MW ${v}`); }
164 + if (e.itCapacityMw != null && e.totalPowerMw != null && e.itCapacityMw > e.totalPowerMw * 1.05) warn("itCapacityMw", "IT capacity exceeds total power");
165 + if (e.pue != null && (e.pue < 1 || e.pue > 3)) err("pue", `implausible PUE ${e.pue}`);
166 + if (e.buildingSqm != null && (e.buildingSqm < 20 || e.buildingSqm > 2_000_000)) err("buildingSqm", "implausible building size");
167 + if (e.countryIso2 && !/^[A-Z]{2}$/.test(e.countryIso2)) err("countryIso2", "bad country code");
168 + if (!e.countryIso2 && !e.geo) warn("countryIso2", "no country and no coordinates");
169 + if (e.openedOn && Number(e.openedOn.slice(0, 4)) < 1950) err("openedOn", "opening year before 1950");
170 + } else if (e.entityType === "project") {
171 + if (!e.name) err("name", "missing name");
172 + if (e.plannedMw != null && (e.plannedMw <= 0 || e.plannedMw > 20_000)) err("plannedMw", `implausible MW ${e.plannedMw}`);
173 + if (e.investmentUsd != null && (e.investmentUsd < 1e5 || e.investmentUsd > 1e12)) warn("investmentUsd", "investment outside plausible range");
174 + if (!e.countryIso2 && !e.geo) warn("countryIso2", "no location");
175 + } else if (e.entityType === "cloud_region") {
176 + if (!e.code || !e.name) err("code", "missing code/name");
177 + if (e.geo && !validLatLng(e.geo.lat, e.geo.lng)) err("geo", "invalid coordinates");
178 + } else if (e.entityType === "news_event") {
179 + if (!/^https?:\/\//.test(e.url)) err("url", "bad url");
180 + if (!e.title) err("title", "missing title");
181 + }
182 + if (errs.length) { rejected++; issues.push(...errs); }
183 + }
184 + return { total: entities.length, valid: entities.length - rejected, rejected, issues };
185 +}
186 +
187 +export function entityIsValid(report: ValidationReport, key: string): boolean { return !report.issues.some((i) => i.key === key && i.level === "error"); }
188 +
189 +export function newsKey(connectorId: string, url: string): string { return `${connectorId}:news:${sha256(url).slice(0, 24)}`; }
added packages/connectors/src/index.ts +10 −0
@@ -0,0 +1,10 @@
1 +export * from "./types.js";
2 +export * from "./config.js";
3 +export * from "./fetchers.js";
4 +export * from "./robots.js";
5 +export * from "./ratelimit.js";
6 +export * from "./discovery.js";
7 +export * from "./extract.js";
8 +export * from "./classify.js";
9 +export * from "./registry.js";
10 +export * from "./generic.js";
added packages/connectors/src/ratelimit.ts +30 −0
@@ -0,0 +1,30 @@
1 +/** Per-host token bucket + concurrency gate. In-process; the worker runs one process per node so this is sufficient. */
2 +interface Bucket { tokens: number; last: number; rpm: number; inflight: number; concurrency: number; waiters: Array<() => void> }
3 +const buckets = new Map<string, Bucket>();
4 +
5 +export function configureHost(host: string, rpm: number, concurrency: number): void {
6 + const b = buckets.get(host) ?? { tokens: rpm, last: Date.now(), rpm, inflight: 0, concurrency, waiters: [] };
7 + b.rpm = rpm; b.concurrency = concurrency;
8 + buckets.set(host, b);
9 +}
10 +
11 +export async function acquire(host: string, minDelayMs = 0): Promise<() => void> {
12 + const b = buckets.get(host) ?? (buckets.set(host, { tokens: 30, last: Date.now(), rpm: 30, inflight: 0, concurrency: 2, waiters: [] }), buckets.get(host)!);
13 + for (;;) {
14 + const now = Date.now();
15 + b.tokens = Math.min(b.rpm, b.tokens + ((now - b.last) / 60_000) * b.rpm);
16 + b.last = now;
17 + if (b.tokens >= 1 && b.inflight < b.concurrency) { b.tokens -= 1; b.inflight += 1; break; }
18 + const wait = b.tokens < 1 ? Math.ceil(((1 - b.tokens) / b.rpm) * 60_000) : 50;
19 + await new Promise((r) => setTimeout(r, Math.max(25, Math.min(wait, 5000), minDelayMs)));
20 + }
21 + if (minDelayMs > 0) await new Promise((r) => setTimeout(r, minDelayMs));
22 + let released = false;
23 + return () => { if (!released) { released = true; b.inflight -= 1; } };
24 +}
25 +
26 +export function hostStats(): Record<string, { rpm: number; inflight: number; tokens: number }> {
27 + const out: Record<string, { rpm: number; inflight: number; tokens: number }> = {};
28 + for (const [h, b] of buckets) out[h] = { rpm: b.rpm, inflight: b.inflight, tokens: Math.round(b.tokens) };
29 + return out;
30 +}
added packages/connectors/src/registry.ts +15 −0
@@ -0,0 +1,15 @@
1 +import type { Connector, Parser } from "./types.js";
2 +import type { ConnectorConfig } from "./config.js";
3 +
4 +/** Code-backed parsers (referenced by name from YAML `extractors.<x>.parser`). */
5 +const parsers = new Map<string, Parser>();
6 +export function registerParser(p: Parser): void { if (parsers.has(p.name)) throw new Error(`parser ${p.name} already registered`); parsers.set(p.name, p); }
7 +export function getParser(name: string): Parser { const p = parsers.get(name); if (!p) throw new Error(`unknown parser ${name}`); return p; }
8 +export function listParsers(): Parser[] { return [...parsers.values()]; }
9 +
10 +/** Code-backed connector implementations (referenced from YAML `implementation:`), built from the config. */
11 +type Factory = (cfg: ConnectorConfig) => Connector;
12 +const implementations = new Map<string, Factory>();
13 +export function registerImplementation(name: string, f: Factory): void { implementations.set(name, f); }
14 +export function getImplementation(name: string): Factory | undefined { return implementations.get(name); }
15 +export function listImplementations(): string[] { return [...implementations.keys()]; }
added packages/connectors/src/robots.ts +65 −0
@@ -0,0 +1,65 @@
1 +import { DirectFetcher } from "./fetchers.js";
2 +
3 +/** Minimal robots.txt evaluator (User-agent groups, Allow/Disallow longest-match, Crawl-delay, Sitemap). Cached per host for 24 h. */
4 +interface Rules { allow: string[]; disallow: string[]; crawlDelay: number | null; sitemaps: string[]; fetchedAt: number }
5 +const cache = new Map<string, Rules>();
6 +const TTL = 24 * 3_600_000;
7 +
8 +export function parseRobots(text: string, ua = "datacenterindexbot"): Rules {
9 + const lines = text.split(/\r?\n/).map((l) => l.replace(/#.*/, "").trim()).filter(Boolean);
10 + const groups: Array<{ agents: string[]; allow: string[]; disallow: string[]; crawlDelay: number | null }> = [];
11 + const sitemaps: string[] = [];
12 + let cur: (typeof groups)[number] | null = null;
13 + let lastWasAgent = false;
14 + for (const line of lines) {
15 + const i = line.indexOf(":");
16 + if (i < 0) continue;
17 + const k = line.slice(0, i).trim().toLowerCase();
18 + const v = line.slice(i + 1).trim();
19 + if (k === "user-agent") {
20 + if (!cur || !lastWasAgent) { cur = { agents: [], allow: [], disallow: [], crawlDelay: null }; groups.push(cur); }
21 + cur.agents.push(v.toLowerCase());
22 + lastWasAgent = true;
23 + continue;
24 + }
25 + lastWasAgent = false;
26 + if (k === "sitemap") { sitemaps.push(v); continue; }
27 + if (!cur) continue;
28 + if (k === "allow") cur.allow.push(v);
29 + else if (k === "disallow") cur.disallow.push(v);
30 + else if (k === "crawl-delay") { const d = Number(v); if (Number.isFinite(d)) cur.crawlDelay = d; }
31 + }
32 + const mine = groups.find((g) => g.agents.some((a) => a !== "*" && ua.includes(a))) ?? groups.find((g) => g.agents.includes("*"));
33 + return { allow: mine?.allow ?? [], disallow: mine?.disallow ?? [], crawlDelay: mine?.crawlDelay ?? null, sitemaps, fetchedAt: Date.now() };
34 +}
35 +
36 +function matchLen(pattern: string, path: string): number {
37 + if (!pattern) return -1;
38 + const re = new RegExp("^" + pattern.split("*").map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*") + (pattern.endsWith("$") ? "" : ""));
39 + return re.test(path) ? pattern.length : -1;
40 +}
41 +
42 +export function robotsAllows(rules: Rules, url: string): boolean {
43 + const u = new URL(url);
44 + const path = u.pathname + u.search;
45 + let bestAllow = -1, bestDis = -1;
46 + for (const p of rules.allow) bestAllow = Math.max(bestAllow, matchLen(p, path));
47 + for (const p of rules.disallow) bestDis = Math.max(bestDis, matchLen(p, path));
48 + if (bestDis < 0) return true;
49 + return bestAllow >= bestDis;
50 +}
51 +
52 +export async function getRobots(url: string): Promise<Rules> {
53 + const origin = new URL(url).origin;
54 + const hit = cache.get(origin);
55 + if (hit && Date.now() - hit.fetchedAt < TTL) return hit;
56 + const doc = await new DirectFetcher(1).fetch(`${origin}/robots.txt`, { timeoutMs: 15_000, maxBytes: 512 * 1024, accept: "text/plain,*/*" });
57 + const rules = doc.status === 200 && doc.text ? parseRobots(doc.text) : { allow: [], disallow: [], crawlDelay: null, sitemaps: [], fetchedAt: Date.now() };
58 + cache.set(origin, rules);
59 + return rules;
60 +}
61 +
62 +export async function isAllowedByRobots(url: string): Promise<{ allowed: boolean; crawlDelay: number | null; sitemaps: string[] }> {
63 + const rules = await getRobots(url);
64 + return { allowed: robotsAllows(rules, url), crawlDelay: rules.crawlDelay, sitemaps: rules.sitemaps };
65 +}
added packages/connectors/src/types.ts +129 −0
@@ -0,0 +1,129 @@
1 +import type { FetchLevel, NormalizedEntity, PageType, SourceKind, ValidationReport, Provenance } from "@dci/core";
2 +
3 +/** A URL found by discovery, with a hint about what it is and how urgently it should be crawled. */
4 +export interface DiscoveredUrl {
5 + url: string;
6 + pageType?: PageType;
7 + /** logical group in the connector schedule: "facility_pages", "newsroom", "regions", "dataset", … */
8 + group: string;
9 + priority?: number; // 0..100 (higher = sooner)
10 + lastmod?: string | null;
11 + discoveredFrom?: string | null;
12 + /** minimum fetch level (a connector may know a page needs rendering) */
13 + minLevel?: FetchLevel;
14 + meta?: Record<string, unknown>;
15 +}
16 +
17 +export interface RawDocument {
18 + url: string;
19 + finalUrl: string;
20 + fetchedAt: string;
21 + status: number;
22 + contentType: string | null;
23 + body: Buffer;
24 + /** best-effort text body (utf-8) — for JSON/HTML/XML/markdown */
25 + text: string;
26 + headers: Record<string, string>;
27 + etag: string | null;
28 + lastModified: string | null;
29 + notModified: boolean;
30 + fetcher: "direct" | "firecrawl" | "scrapfly" | "cache";
31 + level: FetchLevel;
32 + durationMs: number;
33 + credits: number; // premium credits spent (Scrapfly/Firecrawl)
34 + /** Firecrawl markdown (when used) */
35 + markdown?: string | null;
36 + error?: { code: string; message: string } | null;
37 + /** hints from discovery */
38 + group?: string;
39 + pageType?: PageType;
40 + meta?: Record<string, unknown>;
41 +}
42 +
43 +/** Extracted record = one candidate entity as found on one page, before normalization. */
44 +export interface ExtractedRecord {
45 + kind: NormalizedEntity["entityType"];
46 + key: string; // connector-scoped stable key
47 + data: Record<string, unknown>;
48 + /** per-field extraction method for provenance: { it_capacity_mw: "regex:mw_v1", lat: "json-ld:geo" } */
49 + methods?: Record<string, string>;
50 + certainty?: number; // 0..1 overall extraction certainty
51 + url: string;
52 + pageType?: PageType;
53 +}
54 +
55 +export interface FetchOptions {
56 + level?: FetchLevel;
57 + etag?: string | null;
58 + lastModified?: string | null;
59 + timeoutMs?: number;
60 + maxBytes?: number;
61 + accept?: string;
62 + headers?: Record<string, string>;
63 + renderJs?: boolean;
64 + country?: string;
65 + /** group in the connector schedule (used for rate limits / cost attribution) */
66 + group?: string;
67 + waitForSelector?: string;
68 +}
69 +
70 +export interface Fetcher {
71 + readonly name: "direct" | "firecrawl" | "scrapfly";
72 + readonly level: FetchLevel;
73 + available(): boolean;
74 + fetch(url: string, options?: FetchOptions): Promise<RawDocument>;
75 +}
76 +
77 +export interface ConnectorSchedule {
78 + [group: string]: string; // "daily" | "weekly" | "monthly" | "6h" | "12h" | "3h" | cron-like interval string
79 +}
80 +
81 +export interface ConnectorContext {
82 + connectorId: string;
83 + sourceId: string;
84 + runId: string;
85 + /** fetch with the connector's rate limit, robots policy, cache and escalation policy applied */
86 + fetch(url: string, options?: FetchOptions): Promise<RawDocument>;
87 + /** previously stored state for this connector (cursor, seen keys, etc.) */
88 + getState<T = unknown>(key: string): Promise<T | null>;
89 + setState(key: string, value: unknown): Promise<void>;
90 + log(level: "debug" | "info" | "warn" | "error", msg: string, extra?: Record<string, unknown>): void;
91 + /** true when this URL was fetched before and unchanged since `lastFetched` (used to skip expensive work) */
92 + isKnownUnchanged(url: string, contentHash: string): Promise<boolean>;
93 + now(): string;
94 + /** default provenance skeleton for this connector (sourceId, connectorId, retrievedAt…) */
95 + provenance(url: string, extra?: Partial<Provenance>): Provenance;
96 + env: Record<string, string | undefined>;
97 + /** dry run: nothing persisted */
98 + dryRun: boolean;
99 +}
100 +
101 +/**
102 + * A connector = one source, one set of discovery + extraction rules.
103 + * Implementations are mostly config-driven (YAML) with optional code-backed parsers.
104 + */
105 +export interface Connector {
106 + id: string;
107 + sourceName: string;
108 + sourceDomain: string;
109 + sourceKind: SourceKind;
110 + type: "html" | "sitemap" | "rss" | "pdf" | "json" | "hybrid" | "api" | "dataset";
111 + parserVersion: string;
112 + schedule: ConnectorSchedule;
113 + license?: string | null;
114 + attribution?: string | null;
115 + priority: number; // 1 best … 5
116 + discover(ctx: ConnectorContext): Promise<DiscoveredUrl[]>;
117 + fetch(ctx: ConnectorContext, url: DiscoveredUrl): Promise<RawDocument>;
118 + extract(ctx: ConnectorContext, doc: RawDocument): Promise<ExtractedRecord[]>;
119 + normalize(ctx: ConnectorContext, records: ExtractedRecord[]): Promise<NormalizedEntity[]>;
120 + validate(ctx: ConnectorContext, entities: NormalizedEntity[]): Promise<ValidationReport>;
121 +}
122 +
123 +/** Code-backed parser: turns a document into extracted records. Registered by name and referenced from YAML. */
124 +export interface Parser {
125 + name: string; // e.g. "equinix_facility_v1"
126 + version: string;
127 + pageTypes?: PageType[];
128 + parse(doc: RawDocument, ctx: ConnectorContext, params?: Record<string, unknown>): Promise<ExtractedRecord[]> | ExtractedRecord[];
129 +}
added packages/connectors/tsconfig.json +1 −0
@@ -0,0 +1 @@
1 +{ "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true, "types": ["node"], "lib": ["ES2023", "DOM"] }, "include": ["src"] }
added packages/core/package.json +12 −0
@@ -0,0 +1,12 @@
1 +{
2 + "name": "@dci/core",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "main": "./src/index.ts",
7 + "types": "./src/index.ts",
8 + "exports": { ".": "./src/index.ts" },
9 + "scripts": { "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests" },
10 + "dependencies": { "zod": "^4.1.0" },
11 + "devDependencies": { "@types/node": "^24.0.0", "typescript": "^5.9.3", "vitest": "^3.2.0" }
12 +}
added packages/core/src/api-types.ts +469 −0
@@ -0,0 +1,469 @@
1 +/**
2 + * Public API contract (v1) shared by @dci/api (producer) and @dci/web (consumer).
3 + * Every response is wrapped: { data, meta?, sources? }. Dates are ISO strings; partial dates use "YYYY", "YYYY-MM", "YYYY-Qn".
4 + */
5 +import type { ConfidenceLevel, EntityType, EventType, FacilityStatus, FacilityType, GeoPrecision, OperatorKind, ReviewStatus, SourceKind } from "./types.js";
6 +
7 +export interface ApiEnvelope<T> {
8 + data: T;
9 + meta?: { total?: number; page?: number; perPage?: number; generatedAt?: string; methodology?: string; [k: string]: unknown };
10 + sources?: SourceRef[];
11 +}
12 +
13 +export interface SourceRef {
14 + id: string;
15 + name: string;
16 + domain: string;
17 + kind: SourceKind;
18 + url?: string | null;
19 + license?: string | null;
20 + attribution?: string | null;
21 +}
22 +
23 +export interface ProvenanceDTO {
24 + field: string;
25 + value: unknown;
26 + sourceId: string;
27 + sourceName: string;
28 + sourceKind: SourceKind;
29 + url: string;
30 + firstObserved: string;
31 + lastObserved: string;
32 + retrievedAt: string;
33 + confidence: ConfidenceLevel;
34 + isEstimate: boolean;
35 + method?: string | null;
36 +}
37 +
38 +export interface OperatorSummary {
39 + id: string;
40 + slug: string;
41 + name: string;
42 + kind: OperatorKind | null;
43 + website?: string | null;
44 + hqCountryIso2?: string | null;
45 + facilityCount: number;
46 + countryCount: number;
47 + metroCount?: number;
48 + knownMw?: number | null;
49 + plannedMw?: number | null;
50 + projectCount?: number;
51 +}
52 +
53 +export interface OperatorDetail extends OperatorSummary {
54 + aliases: string[];
55 + description?: string | null;
56 + parent?: { id: string; slug: string; name: string } | null;
57 + countries: Array<{ iso2: string; name: string; slug: string; facilityCount: number; knownMw: number | null }>;
58 + metros: Array<{ id: string; slug: string; name: string; countryIso2: string; facilityCount: number }>;
59 + statusBreakdown: Partial<Record<FacilityStatus, number>>;
60 + facilities: FacilitySummary[];
61 + projects: ProjectSummary[];
62 + recentEvents: EventDTO[];
63 + sourceHistory: SourceHistoryItem[];
64 + externalIds: Record<string, string | number>;
65 + updatedAt: string;
66 +}
67 +
68 +export interface FacilitySummary {
69 + id: string;
70 + slug: string;
71 + name: string;
72 + operator: { id: string; slug: string; name: string } | null;
73 + city: string | null;
74 + regionName: string | null;
75 + countryIso2: string | null;
76 + countryName?: string | null;
77 + metro: { id: string; slug: string; name: string } | null;
78 + lat: number | null;
79 + lng: number | null;
80 + geoPrecision: GeoPrecision;
81 + status: FacilityStatus;
82 + facilityType: FacilityType;
83 + itCapacityMw: number | null;
84 + totalPowerMw: number | null;
85 + plannedPowerMw: number | null;
86 + isAi: boolean;
87 + isHyperscale: boolean;
88 + confidence: ConfidenceLevel;
89 + completeness: number; // 0..100
90 + openedOn: string | null;
91 + lastVerified: string | null;
92 + carriersCount?: number | null;
93 + ixpCount?: number | null;
94 + networksCount?: number | null;
95 +}
96 +
97 +export interface FacilityDetail extends FacilitySummary {
98 + aliases: string[];
99 + owner: { id: string; slug: string; name: string } | null;
100 + campus: { id: string; slug: string; name: string } | null;
101 + address: string | null;
102 + postalCode: string | null;
103 + geoSource: string | null;
104 + tier: string | null;
105 + buildingSqm: number | null;
106 + siteAreaHa: number | null;
107 + rackCount: number | null;
108 + pue: number | null;
109 + coolingType: string | null;
110 + renewableClaim: string | null;
111 + constructionStartedOn: string | null;
112 + announcedOn: string | null;
113 + website: string | null;
114 + description: string | null;
115 + certifications: string[];
116 + carriers: Array<{ id: string; slug: string; name: string; asn?: number | null }>;
117 + cloudProviders: Array<{ id: string; slug: string; name: string }>;
118 + ixps: Array<{ id: string; slug: string; name: string }>;
119 + cloudRegions: CloudRegionSummary[];
120 + nearby: FacilitySummary[];
121 + projects: ProjectSummary[];
122 + provenance: ProvenanceDTO[];
123 + events: EventDTO[];
124 + sourceHistory: SourceHistoryItem[];
125 + externalIds: Record<string, string | number>;
126 + firstSeen: string;
127 + updatedAt: string;
128 +}
129 +
130 +export interface SourceHistoryItem {
131 + date: string;
132 + sourceId: string;
133 + sourceName: string;
134 + sourceKind: SourceKind;
135 + url: string;
136 + kind: "observed" | "updated" | "detected" | "announced" | "verified" | "changed";
137 + description: string;
138 +}
139 +
140 +export interface CloudRegionSummary {
141 + id: string;
142 + slug: string;
143 + provider: { id: string; slug: string; name: string };
144 + code: string;
145 + name: string;
146 + city: string | null;
147 + countryIso2: string | null;
148 + lat: number | null;
149 + lng: number | null;
150 + geoPrecision: GeoPrecision;
151 + availabilityZones: number | null;
152 + launchedOn: string | null;
153 + status: "announced" | "operational" | "restricted" | "retired";
154 + isSovereign: boolean;
155 + sourceUrl: string | null;
156 +}
157 +
158 +export interface ProjectSummary {
159 + id: string;
160 + slug: string;
161 + name: string;
162 + operator: { id: string; slug: string; name: string } | null;
163 + facility: { id: string; slug: string; name: string } | null;
164 + city: string | null;
165 + regionName: string | null;
166 + countryIso2: string | null;
167 + metro: { id: string; slug: string; name: string } | null;
168 + lat: number | null;
169 + lng: number | null;
170 + status: FacilityStatus;
171 + announcedOn: string | null;
172 + expectedOpening: string | null;
173 + plannedMw: number | null;
174 + investmentUsd: number | null;
175 + acreage: number | null;
176 + phaseCount: number | null;
177 + confidence: ConfidenceLevel;
178 + lastUpdate: string;
179 +}
180 +
181 +export interface ProjectDetail extends ProjectSummary {
182 + description: string | null;
183 + sourceUrl: string | null;
184 + timeline: Array<{ date: string; type: string; description: string; url: string | null; sourceName: string | null }>;
185 + statusHistory: Array<{ date: string; from: FacilityStatus | null; to: FacilityStatus; url: string | null }>;
186 + provenance: ProvenanceDTO[];
187 + events: EventDTO[];
188 + sourceHistory: SourceHistoryItem[];
189 +}
190 +
191 +export interface EventDTO {
192 + id: string;
193 + entityType: EntityType;
194 + entityId: string | null;
195 + entity?: { slug: string; name: string } | null;
196 + eventType: EventType;
197 + detectedAt: string;
198 + effectiveDate: string | null;
199 + oldValue: unknown;
200 + newValue: unknown;
201 + sourceId: string;
202 + sourceName: string;
203 + sourceKind: SourceKind;
204 + url: string;
205 + title: string;
206 + summary: string | null;
207 + significance: number;
208 + confidence: ConfidenceLevel;
209 + reviewStatus: ReviewStatus;
210 + countryIso2: string | null;
211 + operator: { id: string; slug: string; name: string } | null;
212 +}
213 +
214 +export interface CountrySummary {
215 + iso2: string;
216 + iso3: string | null;
217 + slug: string;
218 + name: string;
219 + region: string | null;
220 + subregion: string | null;
221 + population: number | null;
222 + gdpUsd: number | null;
223 + facilityCount: number;
224 + operationalCount: number;
225 + constructionCount: number;
226 + plannedCount: number;
227 + knownMw: number | null;
228 + constructionMw: number | null;
229 + plannedMw: number | null;
230 + operatorCount: number;
231 + cloudRegionCount: number;
232 + hyperscaleCount: number;
233 + aiCount: number;
234 + projectCount: number;
235 + mwCoverage: number; // share of facilities with a known MW figure, 0..1
236 + lat: number | null;
237 + lng: number | null;
238 +}
239 +
240 +export interface CountryDetail extends CountrySummary {
241 + topOperators: OperatorSummary[];
242 + metros: MetroSummary[];
243 + cloudRegions: CloudRegionSummary[];
244 + recentProjects: ProjectSummary[];
245 + recentEvents: EventDTO[];
246 + growth: Array<{ year: number; facilities: number; knownMw: number | null }>;
247 + statusBreakdown: Partial<Record<FacilityStatus, number>>;
248 + typeBreakdown: Partial<Record<FacilityType, number>>;
249 + announcedInvestmentUsd: number | null;
250 + renewableShare: number | null;
251 + electricityTwh: number | null;
252 + rankings: Array<{ key: string; label: string; rank: number; total: number; value: number | null }>;
253 + facilities: FacilitySummary[];
254 +}
255 +
256 +export interface MetroSummary {
257 + id: string;
258 + slug: string;
259 + name: string;
260 + countryIso2: string;
261 + countryName?: string;
262 + regionName: string | null;
263 + lat: number;
264 + lng: number;
265 + facilityCount: number;
266 + operationalCount: number;
267 + constructionCount: number;
268 + plannedCount: number;
269 + knownMw: number | null;
270 + constructionMw: number | null;
271 + plannedMw: number | null;
272 + operatorCount: number;
273 + cloudRegionCount: number;
274 + ixpCount: number;
275 + projectCount: number;
276 +}
277 +
278 +export interface MetroDetail extends MetroSummary {
279 + aliases: string[];
280 + description: string | null;
281 + operators: OperatorSummary[];
282 + cloudProviders: Array<{ id: string; slug: string; name: string; regionCount: number }>;
283 + cloudRegions: CloudRegionSummary[];
284 + ixps: Array<{ id: string; slug: string; name: string; networkCount: number | null }>;
285 + facilities: FacilitySummary[];
286 + projects: ProjectSummary[];
287 + recentEvents: EventDTO[];
288 + constraints: Array<{ kind: "power" | "land" | "water" | "moratorium" | "grid"; summary: string; url: string; sourceName: string; date: string | null }>;
289 + growth: Array<{ year: number; facilities: number; knownMw: number | null }>;
290 + rankings: Array<{ key: string; label: string; rank: number; total: number; value: number | null }>;
291 +}
292 +
293 +export interface IxpSummary {
294 + id: string;
295 + slug: string;
296 + name: string;
297 + nameLong: string | null;
298 + city: string | null;
299 + countryIso2: string | null;
300 + website: string | null;
301 + networkCount: number | null;
302 + facilityCount: number;
303 +}
304 +
305 +export interface MapPoint {
306 + id: string;
307 + slug: string;
308 + n: string; // name
309 + o?: string | null; // operator name
310 + lat: number;
311 + lng: number;
312 + s: FacilityStatus;
313 + t: FacilityType;
314 + mw?: number | null; // best known MW (it, total or planned)
315 + p: GeoPrecision;
316 + ai?: 1;
317 + hs?: 1;
318 + c?: string | null; // country iso2
319 +}
320 +
321 +export interface MapCluster {
322 + key: string;
323 + lat: number;
324 + lng: number;
325 + count: number;
326 + mw: number | null;
327 + statuses: Partial<Record<FacilityStatus, number>>;
328 + label?: string | null; // metro/country label at low zoom
329 +}
330 +
331 +export interface MapResponse {
332 + zoom: number;
333 + mode: "clusters" | "points";
334 + clusters?: MapCluster[];
335 + points?: MapPoint[];
336 + total: number;
337 +}
338 +
339 +export interface SearchHit {
340 + type: "facility" | "operator" | "country" | "metro" | "city" | "cloud_region" | "project" | "ixp";
341 + id: string;
342 + slug: string;
343 + title: string;
344 + subtitle: string | null;
345 + href: string;
346 + score: number;
347 + meta?: Record<string, unknown>;
348 +}
349 +
350 +export interface SearchResponse {
351 + query: string;
352 + interpreted?: { countryIso2?: string; operator?: string; status?: FacilityStatus; minMw?: number; facilityType?: FacilityType; text?: string };
353 + hits: SearchHit[];
354 + facilities?: { total: number; items: FacilitySummary[] };
355 +}
356 +
357 +export interface RankingRow {
358 + rank: number;
359 + id: string;
360 + slug: string;
361 + name: string;
362 + href: string;
363 + value: number | null;
364 + secondary?: number | null;
365 + countryIso2?: string | null;
366 + coverage?: number | null;
367 + delta?: number | null;
368 +}
369 +
370 +export interface Ranking {
371 + key: string;
372 + label: string;
373 + unit: string;
374 + scope: "countries" | "metros" | "operators" | "facilities";
375 + methodology: string;
376 + computedAt: string;
377 + minCoverage?: number;
378 + rows: RankingRow[];
379 + total: number;
380 +}
381 +
382 +export interface DashboardStats {
383 + facilities: number;
384 + operational: number;
385 + underConstruction: number;
386 + planned: number;
387 + knownOperationalMw: number;
388 + constructionMw: number;
389 + plannedMw: number;
390 + mwCoverage: number;
391 + countries: number;
392 + metros: number;
393 + operators: number;
394 + cloudRegions: number;
395 + ixps: number;
396 + projects: number;
397 + events24h: number;
398 + events7d: number;
399 + sources: number;
400 + documents: number;
401 + lastCrawlAt: string | null;
402 + generatedAt: string;
403 +}
404 +
405 +export interface Dashboard {
406 + stats: DashboardStats;
407 + capacityOverTime: Array<{ year: number; facilities: number; knownMw: number | null; cumulativeMw: number | null }>;
408 + projectsByStatus: Array<{ status: FacilityStatus; count: number; mw: number | null }>;
409 + topCountries: RankingRow[];
410 + topMetros: RankingRow[];
411 + topOperators: RankingRow[];
412 + typeBreakdown: Array<{ type: FacilityType; count: number }>;
413 + pipelineByYear: Array<{ year: string; count: number; mw: number | null }>;
414 + aiExpansion: { facilities: number; projects: number; plannedMw: number | null; recent: ProjectSummary[] };
415 + latestEvents: EventDTO[];
416 + newProjects: ProjectSummary[];
417 + recentlyVerified: FacilitySummary[];
418 +}
419 +
420 +export interface ConnectorHealthDTO {
421 + id: string;
422 + sourceName: string;
423 + domain: string;
424 + kind: SourceKind;
425 + mode: string;
426 + enabled: boolean;
427 + health: "ok" | "degraded" | "failing" | "paused" | "never_run";
428 + parserVersion: string;
429 + lastRunAt: string | null;
430 + nextRunAt: string | null;
431 + lastStatus: string | null;
432 + discovered: number;
433 + fetched: number;
434 + changed: number;
435 + failed: number;
436 + extracted: number;
437 + extractionSuccess: number | null;
438 + avgResponseMs: number | null;
439 + cost: { direct: number; firecrawl: number; scrapfly: number; credits: number };
440 + schedule: Record<string, string>;
441 +}
442 +
443 +export interface FacilityFilters {
444 + q?: string;
445 + country?: string; // iso2, comma list
446 + region?: string;
447 + metro?: string; // slug
448 + operator?: string; // slug
449 + status?: string; // comma list
450 + type?: string; // comma list
451 + min_mw?: number;
452 + max_mw?: number;
453 + opened_from?: number;
454 + opened_to?: number;
455 + planned_from?: number;
456 + planned_to?: number;
457 + cloud?: string; // provider slug
458 + hyperscale?: boolean;
459 + colocation?: boolean;
460 + ai?: boolean;
461 + renewable?: boolean;
462 + has_mw?: boolean;
463 + has_geo?: boolean;
464 + confidence?: string;
465 + sort?: "name" | "mw" | "updated" | "opened" | "completeness";
466 + order?: "asc" | "desc";
467 + page?: number;
468 + per_page?: number;
469 +}
added packages/core/src/confidence.ts +49 −0
@@ -0,0 +1,49 @@
1 +import type { ConfidenceLevel, SourceKind } from "./types.js";
2 +
3 +export const CONFIDENCE_RANK: Record<ConfidenceLevel, number> = { verified: 5, high: 4, moderate: 3, estimated: 2, unverified: 1 };
4 +
5 +/** Base confidence by source category (before adjustments). */
6 +export const SOURCE_KIND_BASE: Record<SourceKind, ConfidenceLevel> = {
7 + operator: "high",
8 + government: "high",
9 + filing: "high",
10 + utility: "high",
11 + cloud_provider: "high",
12 + registry: "high",
13 + dataset: "moderate",
14 + community: "moderate",
15 + secondary: "moderate",
16 + news: "moderate",
17 +};
18 +
19 +export interface ConfidenceInput {
20 + sourceKind: SourceKind;
21 + sourcePriority?: number; // 1 (best) … 5
22 + extractionCertainty?: number; // 0..1 (structured field = 1, regex on prose = 0.6, llm = 0.4)
23 + ageDays?: number;
24 + corroborations?: number; // number of independent sources agreeing
25 + conflicts?: number; // independent sources disagreeing
26 + isEstimate?: boolean;
27 +}
28 +
29 +export function computeConfidence(i: ConfidenceInput): ConfidenceLevel {
30 + if (i.isEstimate) return "estimated";
31 + let score = CONFIDENCE_RANK[SOURCE_KIND_BASE[i.sourceKind]];
32 + const cert = i.extractionCertainty ?? 1;
33 + if (cert < 0.5) score -= 2;
34 + else if (cert < 0.8) score -= 1;
35 + if ((i.corroborations ?? 0) >= 2) score += 1;
36 + if ((i.conflicts ?? 0) >= 1) score -= 1;
37 + if ((i.ageDays ?? 0) > 730) score -= 1;
38 + if ((i.sourcePriority ?? 3) >= 5) score -= 1;
39 + score = Math.max(1, Math.min(5, score));
40 + // "verified" requires corroboration by ≥2 independent primary-grade sources
41 + if (score === 5 && !((i.corroborations ?? 0) >= 2 && cert >= 0.8)) score = 4;
42 + return (Object.keys(CONFIDENCE_RANK) as ConfidenceLevel[]).find((k) => CONFIDENCE_RANK[k] === score) ?? "unverified";
43 +}
44 +
45 +export function bestConfidence(levels: ConfidenceLevel[]): ConfidenceLevel {
46 + return levels.reduce<ConfidenceLevel>((best, l) => (CONFIDENCE_RANK[l] > CONFIDENCE_RANK[best] ? l : best), "unverified");
47 +}
48 +
49 +export const CONFIDENCE_LABEL: Record<ConfidenceLevel, string> = { verified: "Verified", high: "High confidence", moderate: "Moderate confidence", estimated: "Estimated", unverified: "Unverified" };
added packages/core/src/diff.ts +17 −0
@@ -0,0 +1,17 @@
1 +/** Line-based diff (LCS) producing a compact unified-style summary. Good enough for page-change inspection. */
2 +export interface LineDiff { added: string[]; removed: string[]; addedCount: number; removedCount: number; ratio: number }
3 +
4 +export function diffLines(oldText: string, newText: string, maxLines = 400): LineDiff {
5 + const a = oldText.split("\n").map((s) => s.trim()).filter(Boolean);
6 + const b = newText.split("\n").map((s) => s.trim()).filter(Boolean);
7 + const setA = new Map<string, number>();
8 + const setB = new Map<string, number>();
9 + for (const l of a) setA.set(l, (setA.get(l) ?? 0) + 1);
10 + for (const l of b) setB.set(l, (setB.get(l) ?? 0) + 1);
11 + const removed: string[] = [];
12 + const added: string[] = [];
13 + for (const [l, n] of setA) { const m = setB.get(l) ?? 0; for (let i = 0; i < n - m; i++) removed.push(l); }
14 + for (const [l, n] of setB) { const m = setA.get(l) ?? 0; for (let i = 0; i < n - m; i++) added.push(l); }
15 + const total = Math.max(1, a.length + b.length);
16 + return { added: added.slice(0, maxLines), removed: removed.slice(0, maxLines), addedCount: added.length, removedCount: removed.length, ratio: (added.length + removed.length) / total };
17 +}
added packages/core/src/geo.ts +49 −0
@@ -0,0 +1,49 @@
1 +import type { GeoPrecision } from "./types.js";
2 +
3 +const R = 6371.0088;
4 +export function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number {
5 + const toRad = (d: number) => (d * Math.PI) / 180;
6 + const dLat = toRad(lat2 - lat1);
7 + const dLng = toRad(lng2 - lng1);
8 + const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
9 + return 2 * R * Math.asin(Math.sqrt(a));
10 +}
11 +
12 +export function validLatLng(lat: unknown, lng: unknown): lat is number {
13 + return typeof lat === "number" && typeof lng === "number" && Number.isFinite(lat) && Number.isFinite(lng) && Math.abs(lat) <= 90 && Math.abs(lng) <= 180 && !(lat === 0 && lng === 0);
14 +}
15 +
16 +/** Radius (km) within which two points may be the same facility, depending on the worse precision of the pair. */
17 +export function matchRadiusKm(p: GeoPrecision, q: GeoPrecision): number {
18 + const rank: Record<GeoPrecision, number> = { exact: 0.25, parcel: 0.5, street: 1, city: 15, metro: 40, approximate: 25, unknown: 25 };
19 + return Math.max(rank[p], rank[q]);
20 +}
21 +
22 +export const PRECISION_RANK: Record<GeoPrecision, number> = { exact: 6, parcel: 5, street: 4, approximate: 3, city: 2, metro: 1, unknown: 0 };
23 +
24 +/** Simple geohash (base32) for bucketing / cluster ids. */
25 +export function geohash(lat: number, lng: number, precision = 6): string {
26 + const BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz";
27 + let idx = 0, bit = 0, even = true, hash = "";
28 + let latMin = -90, latMax = 90, lngMin = -180, lngMax = 180;
29 + while (hash.length < precision) {
30 + if (even) {
31 + const mid = (lngMin + lngMax) / 2;
32 + if (lng >= mid) { idx = idx * 2 + 1; lngMin = mid; } else { idx *= 2; lngMax = mid; }
33 + } else {
34 + const mid = (latMin + latMax) / 2;
35 + if (lat >= mid) { idx = idx * 2 + 1; latMin = mid; } else { idx *= 2; latMax = mid; }
36 + }
37 + even = !even;
38 + if (++bit === 5) { hash += BASE32[idx]; bit = 0; idx = 0; }
39 + }
40 + return hash;
41 +}
42 +
43 +/** Grid cell for map aggregation at a given zoom (degrees per cell shrinks with zoom). */
44 +export function gridCell(lat: number, lng: number, zoom: number): { key: string; lat: number; lng: number } {
45 + const size = 360 / Math.pow(2, Math.max(0, Math.min(zoom, 12)) + 1);
46 + const i = Math.floor((lng + 180) / size);
47 + const j = Math.floor((lat + 90) / size);
48 + return { key: `${zoom}:${i}:${j}`, lat: j * size - 90 + size / 2, lng: i * size - 180 + size / 2 };
49 +}
added packages/core/src/hash.ts +65 −0
@@ -0,0 +1,65 @@
1 +import { createHash } from "node:crypto";
2 +
3 +export function sha256(input: string | Uint8Array): string {
4 + return createHash("sha256").update(input).digest("hex");
5 +}
6 +
7 +/** Canonical URL for fingerprinting: lowercase host, no fragment, sorted query, trailing slash trimmed, tracking params dropped. */
8 +export function canonicalUrl(raw: string): string {
9 + const u = new URL(raw);
10 + u.hash = "";
11 + u.hostname = u.hostname.toLowerCase();
12 + if ((u.protocol === "https:" && u.port === "443") || (u.protocol === "http:" && u.port === "80")) u.port = "";
13 + const drop = /^(utm_|fbclid|gclid|mc_cid|mc_eid|ref$|source$|_ga$|_gl$)/i;
14 + const params = [...u.searchParams.entries()].filter(([k]) => !drop.test(k)).sort(([a], [b]) => a.localeCompare(b));
15 + u.search = "";
16 + for (const [k, v] of params) u.searchParams.append(k, v);
17 + let s = u.toString();
18 + if (u.pathname !== "/" && s.endsWith("/") && !u.search) s = s.slice(0, -1);
19 + return s;
20 +}
21 +
22 +export function urlFingerprint(raw: string): string {
23 + return sha256(canonicalUrl(raw)).slice(0, 32);
24 +}
25 +
26 +/**
27 + * Content fingerprint that ignores volatile noise: whitespace runs, HTML comments, script/style bodies,
28 + * nonces, CSRF tokens, timestamps in attributes. Two fetches of an unchanged page should hash identically.
29 + */
30 +export function contentFingerprint(body: string, contentType: string | null | undefined): string {
31 + let s = body;
32 + if (!contentType || /html/i.test(contentType)) {
33 + s = s
34 + .replace(/<!--[\s\S]*?-->/g, "")
35 + .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "")
36 + .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "")
37 + .replace(/<noscript\b[^>]*>[\s\S]*?<\/noscript>/gi, "")
38 + .replace(/\s(nonce|data-nonce|csrf-token|data-csrf|data-timestamp|data-build|data-reactroot)="[^"]*"/gi, "")
39 + .replace(/<meta[^>]+(csrf|nonce|build|generated)[^>]*>/gi, "")
40 + .replace(/<input[^>]+type="hidden"[^>]*>/gi, "");
41 + }
42 + s = s.replace(/\s+/g, " ").trim();
43 + return sha256(s);
44 +}
45 +
46 +/** Plain-text projection of HTML for diffing and regex extraction. */
47 +export function htmlToText(html: string): string {
48 + return html
49 + .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ")
50 + .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ")
51 + .replace(/<!--[\s\S]*?-->/g, " ")
52 + .replace(/<\/(p|div|li|tr|h[1-6]|section|article|br|table|ul|ol|dd|dt)>/gi, "\n")
53 + .replace(/<br\s*\/?>/gi, "\n")
54 + .replace(/<[^>]+>/g, " ")
55 + .replace(/&nbsp;/g, " ")
56 + .replace(/&amp;/g, "&")
57 + .replace(/&lt;/g, "<")
58 + .replace(/&gt;/g, ">")
59 + .replace(/&quot;/g, '"')
60 + .replace(/&#39;|&apos;/g, "'")
61 + .replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)))
62 + .replace(/[ \t]+/g, " ")
63 + .replace(/\n\s*\n+/g, "\n")
64 + .trim();
65 +}
added packages/core/src/ids.ts +41 −0
@@ -0,0 +1,41 @@
1 +import { randomBytes, createHash } from "node:crypto";
2 +
3 +const ALPHABET = "0123456789abcdefghijkmnpqrstuvwxyz"; // no l/o to avoid confusion
4 +
5 +export const ID_PREFIXES = {
6 + facility: "fac",
7 + campus: "cmp",
8 + operator: "op",
9 + cloud_region: "cr",
10 + metro: "met",
11 + ixp: "ix",
12 + project: "prj",
13 + event: "evt",
14 + document: "doc",
15 + source: "src",
16 + news: "nws",
17 + run: "run",
18 + match: "mtc",
19 + provenance: "prv",
20 + ranking: "rnk",
21 +} as const;
22 +export type IdKind = keyof typeof ID_PREFIXES;
23 +
24 +export function newId(kind: IdKind, len = 12): string {
25 + const bytes = randomBytes(len);
26 + let out = "";
27 + for (let i = 0; i < len; i++) out += ALPHABET[bytes[i]! % ALPHABET.length];
28 + return `${ID_PREFIXES[kind]}_${out}`;
29 +}
30 +
31 +/** Deterministic id from a stable key (used for documents/provenance so re-runs are idempotent). */
32 +export function stableId(kind: IdKind, key: string, len = 16): string {
33 + const h = createHash("sha256").update(key).digest("hex").slice(0, len);
34 + return `${ID_PREFIXES[kind]}_${h}`;
35 +}
36 +
37 +export function kindOfId(id: string): IdKind | null {
38 + const p = id.split("_")[0];
39 + for (const [k, v] of Object.entries(ID_PREFIXES)) if (v === p) return k as IdKind;
40 + return null;
41 +}
added packages/core/src/index.ts +9 −0
@@ -0,0 +1,9 @@
1 +export * from "./types.js";
2 +export * from "./ids.js";
3 +export * from "./hash.js";
4 +export * from "./normalize.js";
5 +export * from "./geo.js";
6 +export * from "./confidence.js";
7 +export * from "./diff.js";
8 +export * from "./ssrf.js";
9 +export * from "./api-types.js";
added packages/core/src/normalize.ts +284 −0
@@ -0,0 +1,284 @@
1 +import type { FacilityStatus, FacilityType } from "./types.js";
2 +
3 +export function slugify(s: string): string {
4 + return s
5 + .normalize("NFKD")
6 + .replace(/[̀-ͯ]/g, "")
7 + .toLowerCase()
8 + .replace(/&/g, " and ")
9 + .replace(/[^a-z0-9]+/g, "-")
10 + .replace(/^-+|-+$/g, "")
11 + .slice(0, 96);
12 +}
13 +
14 +const NOISE_WORDS = new Set(["data", "center", "centre", "datacenter", "datacentre", "dc", "facility", "campus", "site", "the", "of", "inc", "llc", "ltd", "limited", "corp", "corporation", "gmbh", "sa", "ag", "bv", "nv", "plc", "co", "company", "group", "holdings", "s.a.", "s.a", "srl", "sas", "pty", "kk", "k.k."]);
15 +
16 +/** Aggressive name normalization for entity resolution: lowercase, strip punctuation, drop noise tokens, sort-independent. */
17 +export function normalizeName(name: string): string {
18 + return name
19 + .normalize("NFKD")
20 + .replace(/[̀-ͯ]/g, "")
21 + .toLowerCase()
22 + .replace(/\(.*?\)/g, " ")
23 + .replace(/[^a-z0-9]+/g, " ")
24 + .split(" ")
25 + .filter((t) => t && !NOISE_WORDS.has(t))
26 + .join(" ")
27 + .trim();
28 +}
29 +
30 +/** Lighter normalization keeping identifiers like "DC12", "FR5", "AM3". */
31 +export function normalizeNameKeepCodes(name: string): string {
32 + return name
33 + .normalize("NFKD")
34 + .replace(/[̀-ͯ]/g, "")
35 + .toLowerCase()
36 + .replace(/\b(data\s*cent(er|re)s?|datacent(er|re)s?)\b/g, " ")
37 + .replace(/[^a-z0-9]+/g, " ")
38 + .replace(/\s+/g, " ")
39 + .trim();
40 +}
41 +
42 +/** Extract facility codes such as DC12, FR5, AM3, LD8, SV1, NY9, TY11, CH1-CH4. */
43 +export function extractFacilityCodes(name: string): string[] {
44 + const out = new Set<string>();
45 + for (const m of name.toUpperCase().matchAll(/\b([A-Z]{2,4})\s?-?(\d{1,3}[A-Z]?)\b/g)) out.add(`${m[1]}${m[2]}`);
46 + return [...out];
47 +}
48 +
49 +export function tokenSet(s: string): Set<string> {
50 + return new Set(normalizeName(s).split(" ").filter(Boolean));
51 +}
52 +
53 +export function jaccard(a: Set<string>, b: Set<string>): number {
54 + if (!a.size && !b.size) return 1;
55 + let inter = 0;
56 + for (const t of a) if (b.has(t)) inter++;
57 + return inter / (a.size + b.size - inter);
58 +}
59 +
60 +/** Parse a power figure in MW from free text like "36 MW", "36MW", "120 megawatts", "1.2 GW", "36,000 kW". Returns MW. */
61 +export function parseMw(text: string | null | undefined): number | null {
62 + if (!text) return null;
63 + const s = text.replace(/ /g, " ");
64 + const m = s.match(/(\d{1,3}(?:[.,]\d{3})+|\d+(?:[.,]\d+)?)\s*(gigawatts?|gw|megawatts?|mw|kilowatts?|kw)\b/i);
65 + if (!m) return null;
66 + let num = m[1]!;
67 + // "36,000" → 36000 ; "1,2" (fr decimal) → 1.2 ; "1.2" → 1.2
68 + if (/^\d{1,3}(,\d{3})+$/.test(num)) num = num.replace(/,/g, "");
69 + else if (/^\d{1,3}(\.\d{3})+$/.test(num) && !/^\d+\.\d{1,2}$/.test(num)) num = num.replace(/\./g, "");
70 + else num = num.replace(",", ".");
71 + const v = Number(num);
72 + if (!Number.isFinite(v)) return null;
73 + const unit = m[2]!.toLowerCase();
74 + if (unit.startsWith("g")) return round(v * 1000, 3);
75 + if (unit.startsWith("k")) return round(v / 1000, 3);
76 + return round(v, 3);
77 +}
78 +
79 +/** All MW figures in a text, deduplicated, descending. */
80 +export function parseAllMw(text: string): number[] {
81 + const out = new Set<number>();
82 + for (const m of text.matchAll(/(\d{1,3}(?:[.,]\d{3})+|\d+(?:[.,]\d+)?)\s*(gigawatts?|gw|megawatts?|mw)\b/gi)) {
83 + const v = parseMw(m[0]);
84 + if (v != null && v > 0 && v < 100_000) out.add(v);
85 + }
86 + return [...out].sort((a, b) => b - a);
87 +}
88 +
89 +/** Square feet / square metres → m². */
90 +export function parseAreaSqm(text: string | null | undefined): number | null {
91 + if (!text) return null;
92 + const m = text.replace(/ /g, " ").match(/(\d{1,3}(?:[.,]\d{3})+|\d+(?:\.\d+)?)\s*(million\s+)?(sq\.?\s*ft|square\s*feet|sqft|ft²|sq\.?\s*m|square\s*met(?:er|re)s?|sqm|m²|m2)/i);
93 + if (!m) return null;
94 + let v = Number(m[1]!.replace(/,/g, "").replace(/\.(?=\d{3}\b)/g, ""));
95 + if (m[2]) v *= 1_000_000;
96 + if (!Number.isFinite(v)) return null;
97 + const unit = m[3]!.toLowerCase();
98 + return /ft|feet/.test(unit) ? round(v * 0.09290304, 1) : round(v, 1);
99 +}
100 +
101 +/** Acres / hectares → hectares. */
102 +export function parseAreaHa(text: string | null | undefined): number | null {
103 + if (!text) return null;
104 + const m = text.match(/(\d{1,3}(?:,\d{3})+|\d+(?:\.\d+)?)\s*(acres?|hectares?|ha)\b/i);
105 + if (!m) return null;
106 + const v = Number(m[1]!.replace(/,/g, ""));
107 + if (!Number.isFinite(v)) return null;
108 + return /acre/i.test(m[2]!) ? round(v * 0.40468564, 2) : round(v, 2);
109 +}
110 +
111 +/** Money in USD from "$1.2 billion", "US$450 million", "€800m", "£2bn". Non-USD is returned as-is with currency flag. */
112 +export function parseMoney(text: string | null | undefined): { amount: number; currency: string } | null {
113 + if (!text) return null;
114 + const m = text.replace(/ /g, " ").match(/(US\$|USD|\$|CA\$|C\$|€|EUR|£|GBP|A\$|AUD|¥|JPY|S\$|SGD|₹|INR|CHF|SEK|NOK|DKK)\s?(\d{1,3}(?:,\d{3})+|\d+(?:\.\d+)?)\s*(trillion|billion|million|thousand|bn|m|k|b|tn)?\b/i);
115 + if (!m) return null;
116 + let v = Number(m[2]!.replace(/,/g, ""));
117 + const mult = (m[3] ?? "").toLowerCase();
118 + if (mult === "billion" || mult === "bn" || mult === "b") v *= 1e9;
119 + else if (mult === "million" || mult === "m") v *= 1e6;
120 + else if (mult === "thousand" || mult === "k") v *= 1e3;
121 + else if (mult === "trillion" || mult === "tn") v *= 1e12;
122 + const cur = m[1]!.toUpperCase();
123 + const currency = cur === "$" || cur === "US$" || cur === "USD" ? "USD" : cur === "CA$" || cur === "C$" ? "CAD" : cur === "€" || cur === "EUR" ? "EUR" : cur === "£" || cur === "GBP" ? "GBP" : cur === "A$" || cur === "AUD" ? "AUD" : cur === "¥" || cur === "JPY" ? "JPY" : cur === "S$" || cur === "SGD" ? "SGD" : cur === "₹" || cur === "INR" ? "INR" : cur;
124 + return { amount: Math.round(v), currency };
125 +}
126 +
127 +const MONTHS: Record<string, string> = { jan: "01", feb: "02", mar: "03", apr: "04", may: "05", jun: "06", jul: "07", aug: "08", sep: "09", sept: "09", oct: "10", nov: "11", dec: "12", janvier: "01", février: "02", fevrier: "02", mars: "03", avril: "04", mai: "05", juin: "06", juillet: "07", août: "08", aout: "08", septembre: "09", octobre: "10", novembre: "11", décembre: "12", decembre: "12" };
128 +
129 +/**
130 + * Parse a (possibly partial) date into ISO-like partial: "2027", "2027-Q2", "2027-06", "2027-06-15", "2027-H1".
131 + * Accepts "Q2 2027", "second half of 2027", "mid-2027", "June 2027", "2027-06-15T…", "15 June 2027".
132 + */
133 +export function parsePartialDate(text: string | null | undefined): string | null {
134 + if (!text) return null;
135 + const s = text.trim();
136 + let m = s.match(/^(\d{4})-(\d{2})-(\d{2})/);
137 + if (m) return `${m[1]}-${m[2]}-${m[3]}`;
138 + m = s.match(/^(\d{4})-(\d{2})$/);
139 + if (m) return `${m[1]}-${m[2]}`;
140 + m = s.match(/\b(?:Q|quarter\s*)([1-4])\s*(?:of\s*)?(\d{4})\b/i) ?? s.match(/\b(\d{4})\s*-?\s*Q([1-4])\b/i);
141 + if (m) {
142 + const [q, y] = /^\d{4}/.test(m[1]!) ? [m[2], m[1]] : [m[1], m[2]];
143 + return `${y}-Q${q}`;
144 + }
145 + m = s.match(/\b(first|second|1st|2nd)\s+half\s+(?:of\s+)?(\d{4})\b/i) ?? s.match(/\bH([12])\s*(\d{4})\b/i);
146 + if (m) {
147 + const h = /^(first|1st|1)$/i.test(m[1]!) ? 1 : 2;
148 + return `${m[2]}-H${h}`;
149 + }
150 + m = s.match(/\b(\d{1,2})(?:st|nd|rd|th)?\s+([A-Za-zéû]+)\.?\s+(\d{4})\b/);
151 + if (m && MONTHS[m[2]!.toLowerCase()]) return `${m[3]}-${MONTHS[m[2]!.toLowerCase()]}-${m[1]!.padStart(2, "0")}`;
152 + m = s.match(/\b([A-Za-zéû]+)\.?\s+(\d{1,2})(?:st|nd|rd|th)?,?\s+(\d{4})\b/);
153 + if (m && MONTHS[m[1]!.toLowerCase()]) return `${m[3]}-${MONTHS[m[1]!.toLowerCase()]}-${m[2]!.padStart(2, "0")}`;
154 + m = s.match(/\b([A-Za-zéû]+)\.?\s+(\d{4})\b/);
155 + if (m && MONTHS[m[1]!.toLowerCase()]) return `${m[2]}-${MONTHS[m[1]!.toLowerCase()]}`;
156 + m = s.match(/\b(?:mid|early|late|end of|by|in)?-?\s*(20\d{2}|19\d{2})\b/);
157 + if (m) return m[1]!;
158 + return null;
159 +}
160 +
161 +/** Sortable numeric key for a partial date (for ordering timelines): YYYY.fraction */
162 +export function partialDateSortKey(d: string | null | undefined): number {
163 + if (!d) return Number.POSITIVE_INFINITY;
164 + const y = Number(d.slice(0, 4));
165 + if (!Number.isFinite(y)) return Number.POSITIVE_INFINITY;
166 + let frac = 0.5;
167 + const rest = d.slice(5);
168 + if (/^Q[1-4]/.test(rest)) frac = (Number(rest[1]) - 0.5) / 4;
169 + else if (/^H[12]/.test(rest)) frac = rest[1] === "1" ? 0.25 : 0.75;
170 + else if (/^\d{2}/.test(rest)) {
171 + const mo = Number(rest.slice(0, 2));
172 + const day = /^\d{2}-\d{2}/.test(rest) ? Number(rest.slice(3, 5)) : 15;
173 + frac = (mo - 1 + day / 31) / 12;
174 + }
175 + return y + frac;
176 +}
177 +
178 +export function formatPartialDate(d: string | null | undefined): string {
179 + if (!d) return "—";
180 + const y = d.slice(0, 4);
181 + const rest = d.slice(5);
182 + if (!rest) return y;
183 + if (/^[QH]\d/.test(rest)) return `${rest} ${y}`;
184 + const mo = Number(rest.slice(0, 2));
185 + const names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
186 + const mn = names[mo - 1] ?? rest.slice(0, 2);
187 + if (/^\d{2}-\d{2}/.test(rest)) return `${mn} ${Number(rest.slice(3, 5))}, ${y}`;
188 + return `${mn} ${y}`;
189 +}
190 +
191 +const STATUS_PATTERNS: Array<[RegExp, FacilityStatus]> = [
192 + [/\b(cancel+ed|scrapped|abandoned|withdrawn)\b/i, "cancelled"],
193 + [/\b(closed|decommissioned|shut\s?down|ceased operations)\b/i, "closed"],
194 + [/\b(delayed|postponed|on hold|paused)\b/i, "delayed"],
195 + [/\b(partially operational|phase\s*1\s*(is\s*)?(live|operational|complete))\b/i, "partially_operational"],
196 + [/\b(under construction|being built|construction (is )?under ?way|breaking ground|broke ground|groundbreaking|topped out|construction (has )?(started|began|commenced))\b/i, "under_construction"],
197 + [/\b(approved|approval granted|granted planning|received approval|rezoning approved|permit(s)? (were |was )?(granted|issued))\b/i, "approved"],
198 + [/\b(planning application|permit application|rezoning (request|application)|seeking approval|applied for|filed plans|submitted plans|under review by)\b/i, "permitting"],
199 + [/\b(announced|unveils?|unveiled|plans to build|will build|to build|to develop|reveals? plans|launch(es|ed)? plans)\b/i, "announced"],
200 + [/\b(proposed|proposal)\b/i, "proposed"],
201 + [/\b(rumou?red|reportedly|is said to|considering|exploring|in talks)\b/i, "rumored"],
202 + [/\b(expansion|expanding|expands|adds? capacity|new phase|additional \d+ ?MW)\b/i, "expansion"],
203 + [/\b(now open|opened|opens|operational|live|online|commissioned|inaugurat(ed|es|ion)|ready for service|RFS)\b/i, "operational"],
204 +];
205 +
206 +export function inferStatus(text: string | null | undefined): FacilityStatus | null {
207 + if (!text) return null;
208 + for (const [re, st] of STATUS_PATTERNS) if (re.test(text)) return st;
209 + return null;
210 +}
211 +
212 +export function normalizeStatus(raw: string | null | undefined): FacilityStatus | null {
213 + if (!raw) return null;
214 + const s = raw.toLowerCase().trim().replace(/[\s-]+/g, "_");
215 + const direct: Record<string, FacilityStatus> = {
216 + operational: "operational", live: "operational", active: "operational", open: "operational", ok: "operational", operating: "operational", existing: "operational",
217 + under_construction: "under_construction", construction: "under_construction", building: "under_construction",
218 + planned: "announced", plan: "announced", announced: "announced", proposed: "proposed", rumored: "rumored", rumoured: "rumored",
219 + permitting: "permitting", approved: "approved", delayed: "delayed", cancelled: "cancelled", canceled: "cancelled", closed: "closed", decommissioned: "closed",
220 + expansion: "expansion", partially_operational: "partially_operational", partial: "partially_operational",
221 + coming_soon: "announced", future: "announced", in_development: "announced", development: "announced",
222 + };
223 + return direct[s] ?? inferStatus(raw);
224 +}
225 +
226 +export function inferFacilityType(text: string | null | undefined): FacilityType | null {
227 + if (!text) return null;
228 + const s = text.toLowerCase();
229 + if (/\b(internet exchange|ixp?\b|peering exchange)/.test(s)) return "internet_exchange";
230 + if (/\b(carrier hotel|meet-?me room|interconnection hub)/.test(s)) return "carrier_hotel";
231 + if (/\b(ai (data ?center|campus|factory|cluster|infrastructure)|gpu (cluster|campus|farm)|accelerated computing|training cluster|inference)/.test(s)) return "ai";
232 + if (/\b(supercomput|hpc|high[- ]performance computing|exascale|petaflop)/.test(s)) return "hpc";
233 + if (/\b(sovereign cloud|sovereign region)/.test(s)) return "sovereign_cloud";
234 + if (/\b(cloud region|availability zone|aws region|azure region|gcp region)/.test(s)) return "cloud_region";
235 + if (/\b(hyperscale|hyperscaler)/.test(s)) return "hyperscale";
236 + if (/\b(edge (data ?center|site|node|facility)|micro data ?center)/.test(s)) return "edge";
237 + if (/\b(wholesale|build[- ]to[- ]suit|powered shell)/.test(s)) return "wholesale";
238 + if (/\b(government|federal|ministry|public sector|municipal)/.test(s)) return "government";
239 + if (/\b(university|research|laboratory|national lab)/.test(s)) return "research";
240 + if (/\b(colocation|colo\b|retail colocation|interconnection)/.test(s)) return "colocation";
241 + return null;
242 +}
243 +
244 +export function round(v: number, digits = 2): number {
245 + const f = 10 ** digits;
246 + return Math.round(v * f) / f;
247 +}
248 +
249 +export function clamp(v: number, lo: number, hi: number): number {
250 + return Math.min(hi, Math.max(lo, v));
251 +}
252 +
253 +export function cleanText(s: string | null | undefined): string | null {
254 + if (s == null) return null;
255 + const t = s.replace(/\s+/g, " ").trim();
256 + return t || null;
257 +}
258 +
259 +/** Country ISO-2 from common names / ISO-3 / demonyms found in text. */
260 +export const COUNTRY_ALIASES: Record<string, string> = {
261 + "united states": "US", usa: "US", "u.s.": "US", "u.s.a.": "US", "united states of america": "US", america: "US",
262 + canada: "CA", "united kingdom": "GB", uk: "GB", "u.k.": "GB", britain: "GB", "great britain": "GB", england: "GB", scotland: "GB", wales: "GB",
263 + germany: "DE", deutschland: "DE", france: "FR", netherlands: "NL", "the netherlands": "NL", holland: "NL", ireland: "IE", spain: "ES", portugal: "PT", italy: "IT",
264 + sweden: "SE", norway: "NO", denmark: "DK", finland: "FI", iceland: "IS", belgium: "BE", luxembourg: "LU", switzerland: "CH", austria: "AT", poland: "PL",
265 + czechia: "CZ", "czech republic": "CZ", hungary: "HU", romania: "RO", bulgaria: "BG", greece: "GR", turkey: "TR", türkiye: "TR", russia: "RU", ukraine: "UA",
266 + japan: "JP", singapore: "SG", australia: "AU", "new zealand": "NZ", india: "IN", china: "CN", "hong kong": "HK", taiwan: "TW", "south korea": "KR", korea: "KR",
267 + malaysia: "MY", indonesia: "ID", thailand: "TH", vietnam: "VN", philippines: "PH", "united arab emirates": "AE", uae: "AE", "saudi arabia": "SA", qatar: "QA",
268 + bahrain: "BH", oman: "OM", kuwait: "KW", israel: "IL", "south africa": "ZA", nigeria: "NG", kenya: "KE", egypt: "EG", morocco: "MA", brazil: "BR", mexico: "MX",
269 + chile: "CL", argentina: "AR", colombia: "CO", peru: "PE", uruguay: "UY", estonia: "EE", latvia: "LV", lithuania: "LT", slovakia: "SK", slovenia: "SI", croatia: "HR",
270 + serbia: "RS", cyprus: "CY", malta: "MT", pakistan: "PK", bangladesh: "BD", "sri lanka": "LK", kazakhstan: "KZ", ghana: "GH", tanzania: "TZ", uganda: "UG",
271 + rwanda: "RW", ethiopia: "ET", angola: "AO", "côte d'ivoire": "CI", "ivory coast": "CI", senegal: "SN", tunisia: "TN", algeria: "DZ", jordan: "JO", panama: "PA",
272 + "costa rica": "CR", "puerto rico": "PR", "dominican republic": "DO", ecuador: "EC", bolivia: "BO", paraguay: "PY", venezuela: "VE", guatemala: "GT",
273 + cambodia: "KH", laos: "LA", myanmar: "MM", nepal: "NP", mongolia: "MN", macau: "MO", macao: "MO", brunei: "BN", fiji: "FJ", "papua new guinea": "PG",
274 + georgia: "GE", armenia: "AM", azerbaijan: "AZ", uzbekistan: "UZ", belarus: "BY", moldova: "MD", albania: "AL", "north macedonia": "MK", "bosnia and herzegovina": "BA", montenegro: "ME", kosovo: "XK",
275 + usa_: "US",
276 +};
277 +
278 +export function countryFromText(text: string | null | undefined): string | null {
279 + if (!text) return null;
280 + const s = text.toLowerCase();
281 + const keys = Object.keys(COUNTRY_ALIASES).sort((a, b) => b.length - a.length);
282 + for (const k of keys) if (new RegExp(`(^|[^a-z])${k.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^a-z]|$)`, "i").test(s)) return COUNTRY_ALIASES[k]!;
283 + return null;
284 +}
added packages/core/src/ssrf.ts +198 −0
@@ -0,0 +1,198 @@
1 +import dns from "node:dns/promises";
2 +import net from "node:net";
3 +
4 +/**
5 + * SSRF protection. Every URL the crawler fetches — seed, discovered or redirect hop —
6 + * must pass `assertUrlAllowed()` before any network activity. We validate the scheme,
7 + * the hostname, and every resolved address. `safeLookup` is used by the HTTP dispatcher
8 + * so the socket connects only to a validated address (defeats DNS rebinding).
9 + */
10 +
11 +const BLOCKED_HOSTNAMES = new Set([
12 + "localhost",
13 + "localhost.localdomain",
14 + "ip6-localhost",
15 + "ip6-loopback",
16 + "metadata.google.internal",
17 + "metadata",
18 + "instance-data",
19 + "kubernetes.default",
20 + "kubernetes.default.svc",
21 +]);
22 +
23 +const BLOCKED_SUFFIXES = [".localhost", ".local", ".internal", ".localdomain", ".home.arpa", ".in-addr.arpa", ".ip6.arpa", ".maclustr.io", ".ts.net"];
24 +
25 +function ipv4ToInt(ip: string): number {
26 + const p = ip.split(".").map((x) => Number(x));
27 + return ((p[0]! << 24) >>> 0) + (p[1]! << 16) + (p[2]! << 8) + p[3]!;
28 +}
29 +
30 +function inCidr4(ip: string, cidr: string): boolean {
31 + const [base, bitsStr] = cidr.split("/");
32 + const bits = Number(bitsStr);
33 + const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
34 + return ((ipv4ToInt(ip) & mask) >>> 0) === ((ipv4ToInt(base!) & mask) >>> 0);
35 +}
36 +
37 +const BLOCKED_V4 = [
38 + "0.0.0.0/8",
39 + "10.0.0.0/8",
40 + "100.64.0.0/10",
41 + "127.0.0.0/8",
42 + "169.254.0.0/16",
43 + "172.16.0.0/12",
44 + "192.0.0.0/24",
45 + "192.0.2.0/24",
46 + "192.168.0.0/16",
47 + "198.18.0.0/15",
48 + "198.51.100.0/24",
49 + "203.0.113.0/24",
50 + "224.0.0.0/4",
51 + "240.0.0.0/4",
52 + "255.255.255.255/32",
53 +];
54 +
55 +export function isBlockedIPv4(ip: string): boolean {
56 + return BLOCKED_V4.some((c) => inCidr4(ip, c));
57 +}
58 +
59 +export function isBlockedIPv6(ip: string): boolean {
60 + const lower = ip.toLowerCase();
61 + if (lower === "::" || lower === "::1") return true;
62 + const mapped = lower.match(/^(?:0*:)*ffff:(\d+\.\d+\.\d+\.\d+)$/);
63 + if (mapped) return isBlockedIPv4(mapped[1]!);
64 + const mappedHex = lower.match(/^(?:0*:)*ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
65 + if (mappedHex) {
66 + const a = parseInt(mappedHex[1]!, 16);
67 + const b = parseInt(mappedHex[2]!, 16);
68 + return isBlockedIPv4(`${a >> 8}.${a & 255}.${b >> 8}.${b & 255}`);
69 + }
70 + if (/^fe[89ab]/.test(lower)) return true; // link-local
71 + if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // unique local
72 + if (lower.startsWith("ff")) return true; // multicast
73 + // NAT64 (RFC 6052 well-known prefix 64:ff9b::/96, and the local-use 64:ff9b:1::/48): the last 32 bits
74 + // embed an IPv4 address — apply the IPv4 policy to it instead of blocking the whole prefix, so that
75 + // IPv6-only networks (464XLAT hotspots) can still reach public IPv4 hosts while private IPv4 stays blocked.
76 + const nat64 = lower.match(/^64:ff9b:(?:1:[0-9a-f]{1,4}:[0-9a-f]{1,4}:)?(?:0*:)*([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
77 + if (nat64) {
78 + const a = parseInt(nat64[1]!, 16);
79 + const b = parseInt(nat64[2]!, 16);
80 + return isBlockedIPv4(`${a >> 8}.${a & 255}.${b >> 8}.${b & 255}`);
81 + }
82 + if (lower.startsWith("64:ff9b:")) return true; // malformed NAT64 form
83 + if (lower.startsWith("2001:db8:")) return true; // documentation
84 + return false;
85 +}
86 +
87 +export function isBlockedIP(ip: string): boolean {
88 + const v = net.isIP(ip);
89 + if (v === 4) return isBlockedIPv4(ip);
90 + if (v === 6) return isBlockedIPv6(ip);
91 + return true;
92 +}
93 +
94 +export function isBlockedHostname(hostname: string): boolean {
95 + const h = hostname.toLowerCase().replace(/\.$/, "");
96 + if (BLOCKED_HOSTNAMES.has(h)) return true;
97 + if (BLOCKED_SUFFIXES.some((s) => h.endsWith(s))) return true;
98 + if (!h.includes(".") && net.isIP(h) === 0) return true; // bare single-label hosts
99 + return false;
100 +}
101 +
102 +export class UrlPolicyError extends Error {
103 + constructor(
104 + message: string,
105 + public readonly reason: string,
106 + ) {
107 + super(message);
108 + this.name = "UrlPolicyError";
109 + }
110 +}
111 +
112 +export interface AllowedUrl {
113 + url: URL;
114 + hostname: string;
115 + addresses: string[];
116 +}
117 +
118 +const DNS_FALLBACK_SERVERS = ["1.1.1.1", "8.8.8.8", "9.9.9.9"];
119 +
120 +/** Resolve all A/AAAA records, falling back to public resolvers when the system resolver fails. */
121 +export async function resolveAll(hostname: string): Promise<string[]> {
122 + try {
123 + const res = await dns.lookup(hostname, { all: true, verbatim: true });
124 + const addrs = res.map((r) => r.address);
125 + if (addrs.length) return addrs;
126 + } catch {
127 + // fall through
128 + }
129 + const r = new dns.Resolver({ timeout: 4000, tries: 2 });
130 + r.setServers(DNS_FALLBACK_SERVERS);
131 + const out: string[] = [];
132 + const [a, aaaa] = await Promise.allSettled([r.resolve4(hostname), r.resolve6(hostname)]);
133 + if (a.status === "fulfilled") out.push(...a.value);
134 + if (aaaa.status === "fulfilled") out.push(...aaaa.value);
135 + return out;
136 +}
137 +
138 +export interface UrlPolicyOptions {
139 + /** Resolve DNS and validate each address. Default true. */
140 + resolve?: boolean;
141 + /** Allow http:// (default true). */
142 + allowHttp?: boolean;
143 +}
144 +
145 +/** Validate a URL (scheme, host, resolved addresses). Throws UrlPolicyError. */
146 +export async function assertUrlAllowed(input: string | URL, opts: UrlPolicyOptions = {}): Promise<AllowedUrl> {
147 + let url: URL;
148 + try {
149 + url = typeof input === "string" ? new URL(input) : input;
150 + } catch {
151 + throw new UrlPolicyError("Malformed URL.", "malformed");
152 + }
153 + if (url.protocol !== "https:" && !(url.protocol === "http:" && (opts.allowHttp ?? true))) {
154 + throw new UrlPolicyError(`Scheme ${url.protocol} not allowed.`, "scheme");
155 + }
156 + if (url.username || url.password) throw new UrlPolicyError("Credentials in URL are not allowed.", "credentials");
157 + const hostname = url.hostname.replace(/^\[|\]$/g, "");
158 + if (!hostname) throw new UrlPolicyError("Missing host.", "host");
159 + if (isBlockedHostname(hostname)) throw new UrlPolicyError(`Host ${hostname} is not allowed.`, "blocked_host");
160 + if (net.isIP(hostname)) {
161 + if (isBlockedIP(hostname)) throw new UrlPolicyError(`Address ${hostname} is not allowed.`, "blocked_ip");
162 + return { url, hostname, addresses: [hostname] };
163 + }
164 + if (opts.resolve === false) return { url, hostname, addresses: [] };
165 + const addresses = await resolveAll(hostname);
166 + if (!addresses.length) throw new UrlPolicyError(`DNS resolution failed for ${hostname}.`, "dns");
167 + const bad = addresses.find((a) => isBlockedIP(a));
168 + if (bad) throw new UrlPolicyError(`Host ${hostname} resolves to a blocked address (${bad}).`, "blocked_ip");
169 + return { url, hostname, addresses };
170 +}
171 +
172 +/**
173 + * `lookup` implementation for net.connect / undici connect options: only returns
174 + * addresses that pass the policy, so the TCP connection can never reach a private range
175 + * even if DNS answers differently than during validation (rebinding).
176 + */
177 +export function safeLookup(
178 + hostname: string,
179 + options: unknown,
180 + callback: (err: NodeJS.ErrnoException | null, address: string | { address: string; family: number }[], family?: number) => void,
181 +): void {
182 + const all = typeof options === "object" && options !== null && (options as { all?: boolean }).all === true;
183 + if (isBlockedHostname(hostname)) {
184 + callback(Object.assign(new Error(`blocked host ${hostname}`), { code: "EBLOCKED" }), all ? [] : "", 4);
185 + return;
186 + }
187 + resolveAll(hostname)
188 + .then((addrs) => {
189 + const ok = addrs.filter((a) => !isBlockedIP(a));
190 + if (!ok.length) {
191 + callback(Object.assign(new Error(`no allowed address for ${hostname}`), { code: "EBLOCKED" }), all ? [] : "", 4);
192 + return;
193 + }
194 + if (all) callback(null, ok.map((a) => ({ address: a, family: net.isIP(a) })));
195 + else callback(null, ok[0]!, net.isIP(ok[0]!));
196 + })
197 + .catch((err) => callback(err, all ? [] : "", 4));
198 +}
added packages/core/src/types.ts +375 −0
@@ -0,0 +1,375 @@
1 +/**
2 + * DataCenterIndex — shared domain types.
3 + * The product is the structured graph + its history: every value carries provenance and a confidence.
4 + */
5 +
6 +export const FACILITY_STATUSES = [
7 + "rumored",
8 + "proposed",
9 + "announced",
10 + "permitting",
11 + "approved",
12 + "under_construction",
13 + "partially_operational",
14 + "operational",
15 + "expansion",
16 + "delayed",
17 + "cancelled",
18 + "closed",
19 + "unknown",
20 +] as const;
21 +export type FacilityStatus = (typeof FACILITY_STATUSES)[number];
22 +
23 +/** Statuses that count as "pipeline" (not yet fully operational). */
24 +export const PIPELINE_STATUSES: readonly FacilityStatus[] = ["rumored", "proposed", "announced", "permitting", "approved", "under_construction", "partially_operational", "delayed"];
25 +
26 +export const FACILITY_TYPES = [
27 + "colocation",
28 + "hyperscale",
29 + "cloud_region",
30 + "edge",
31 + "carrier_hotel",
32 + "internet_exchange",
33 + "ai",
34 + "hpc",
35 + "sovereign_cloud",
36 + "government",
37 + "research",
38 + "enterprise",
39 + "telecom",
40 + "wholesale",
41 + "unknown",
42 +] as const;
43 +export type FacilityType = (typeof FACILITY_TYPES)[number];
44 +
45 +export const OPERATOR_KINDS = ["colocation", "hyperscaler", "cloud", "carrier", "enterprise", "government", "research", "developer", "investor", "ixp_operator", "other"] as const;
46 +export type OperatorKind = (typeof OPERATOR_KINDS)[number];
47 +
48 +export const GEO_PRECISIONS = ["exact", "parcel", "street", "city", "metro", "approximate", "unknown"] as const;
49 +export type GeoPrecision = (typeof GEO_PRECISIONS)[number];
50 +
51 +export const CONFIDENCE_LEVELS = ["verified", "high", "moderate", "estimated", "unverified"] as const;
52 +export type ConfidenceLevel = (typeof CONFIDENCE_LEVELS)[number];
53 +
54 +export const SOURCE_KINDS = ["operator", "government", "filing", "utility", "dataset", "community", "secondary", "news", "cloud_provider", "registry"] as const;
55 +export type SourceKind = (typeof SOURCE_KINDS)[number];
56 +
57 +export const ENTITY_TYPES = [
58 + "facility",
59 + "campus",
60 + "operator",
61 + "company",
62 + "cloud_region",
63 + "metro",
64 + "city",
65 + "country",
66 + "power_project",
67 + "substation",
68 + "ixp",
69 + "fiber_route",
70 + "carrier",
71 + "project",
72 + "construction_phase",
73 + "acquisition",
74 + "expansion",
75 + "incident",
76 + "news_event",
77 + "source",
78 +] as const;
79 +export type EntityType = (typeof ENTITY_TYPES)[number];
80 +
81 +export const PAGE_TYPES = [
82 + "facility_page",
83 + "facility_index",
84 + "operator_page",
85 + "project_announcement",
86 + "press_release",
87 + "planning_document",
88 + "construction_update",
89 + "acquisition",
90 + "expansion",
91 + "closure",
92 + "cloud_region",
93 + "power_infrastructure",
94 + "utility_announcement",
95 + "financial_disclosure",
96 + "dataset",
97 + "news_index",
98 + "sitemap",
99 + "unknown",
100 +] as const;
101 +export type PageType = (typeof PAGE_TYPES)[number];
102 +
103 +export const EVENT_TYPES = [
104 + "facility_discovered",
105 + "facility_updated",
106 + "status_changed",
107 + "capacity_changed",
108 + "planned_capacity_changed",
109 + "opening_date_changed",
110 + "operator_changed",
111 + "owner_changed",
112 + "project_announced",
113 + "project_status_changed",
114 + "phase_announced",
115 + "planning_filed",
116 + "planning_approved",
117 + "construction_started",
118 + "facility_opened",
119 + "expansion_announced",
120 + "acquisition",
121 + "closure",
122 + "cloud_region_announced",
123 + "cloud_region_launched",
124 + "cloud_region_updated",
125 + "power_agreement",
126 + "investment_announced",
127 + "incident",
128 + "page_changed",
129 + "news",
130 +] as const;
131 +export type EventType = (typeof EVENT_TYPES)[number];
132 +
133 +export type ReviewStatus = "auto" | "pending" | "approved" | "rejected";
134 +
135 +/** Fetch escalation levels — Level 1 direct HTTP … Level 4 Scrapfly rendered. */
136 +export type FetchLevel = 1 | 2 | 3 | 4;
137 +
138 +export interface Provenance {
139 + sourceId: string;
140 + connectorId: string;
141 + url: string;
142 + documentId?: string | null;
143 + firstObserved: string; // ISO
144 + lastObserved: string; // ISO
145 + retrievedAt: string; // ISO
146 + confidence: ConfidenceLevel;
147 + isEstimate?: boolean;
148 + method?: string; // e.g. "json-ld", "html-selector:.capacity", "regex:mw_v1", "dataset-field:latitude"
149 + extractorVersion?: string;
150 + note?: string | null;
151 +}
152 +
153 +/** A single extracted value with provenance. */
154 +export interface Fact<T = unknown> {
155 + field: string;
156 + value: T;
157 + provenance: Provenance;
158 +}
159 +
160 +export interface GeoPoint {
161 + lat: number;
162 + lng: number;
163 + precision: GeoPrecision;
164 + source: string; // "operator" | "dataset:peeringdb" | "geocoder:nominatim" | "planning" | …
165 +}
166 +
167 +/** Normalized entity payloads produced by connectors (before reconciliation). */
168 +export interface NormalizedFacility {
169 + entityType: "facility";
170 + key: string; // connector-scoped stable key, e.g. "peeringdb:fac:1"
171 + name: string;
172 + aliases?: string[];
173 + operatorName?: string | null;
174 + operatorKey?: string | null;
175 + ownerName?: string | null;
176 + campusName?: string | null;
177 + campusKey?: string | null;
178 + address?: string | null;
179 + city?: string | null;
180 + regionName?: string | null; // state / province
181 + countryIso2?: string | null;
182 + postalCode?: string | null;
183 + geo?: GeoPoint | null;
184 + status?: FacilityStatus | null;
185 + facilityType?: FacilityType | null;
186 + tier?: string | null;
187 + buildingSqm?: number | null;
188 + siteAreaHa?: number | null;
189 + itCapacityMw?: number | null;
190 + totalPowerMw?: number | null;
191 + plannedPowerMw?: number | null;
192 + rackCount?: number | null;
193 + pue?: number | null;
194 + coolingType?: string | null;
195 + renewableClaim?: string | null;
196 + openedOn?: string | null; // ISO date or partial YYYY / YYYY-MM
197 + constructionStartedOn?: string | null;
198 + announcedOn?: string | null;
199 + website?: string | null;
200 + isAi?: boolean | null;
201 + isHyperscale?: boolean | null;
202 + certifications?: string[];
203 + carriers?: string[];
204 + cloudProviders?: string[];
205 + ixps?: string[];
206 + externalIds?: Record<string, string | number>; // { peeringdb_fac: 1, osm: "way/123", wikidata: "Q42" }
207 + description?: string | null;
208 + /** per-field provenance overrides; default provenance applies to all other fields */
209 + facts?: Fact[];
210 + provenance: Provenance;
211 +}
212 +
213 +export interface NormalizedOperator {
214 + entityType: "operator";
215 + key: string;
216 + name: string;
217 + aliases?: string[];
218 + kind?: OperatorKind | null;
219 + website?: string | null;
220 + hqCountryIso2?: string | null;
221 + hqCity?: string | null;
222 + parentName?: string | null;
223 + description?: string | null;
224 + externalIds?: Record<string, string | number>;
225 + provenance: Provenance;
226 +}
227 +
228 +export interface NormalizedCampus {
229 + entityType: "campus";
230 + key: string;
231 + name: string;
232 + operatorName?: string | null;
233 + city?: string | null;
234 + countryIso2?: string | null;
235 + geo?: GeoPoint | null;
236 + externalIds?: Record<string, string | number>;
237 + provenance: Provenance;
238 +}
239 +
240 +export interface NormalizedCloudRegion {
241 + entityType: "cloud_region";
242 + key: string;
243 + providerName: string; // AWS, Microsoft Azure, Google Cloud, …
244 + code: string; // us-east-1
245 + name: string; // US East (N. Virginia)
246 + city?: string | null;
247 + regionName?: string | null;
248 + countryIso2?: string | null;
249 + geo?: GeoPoint | null;
250 + availabilityZones?: number | null;
251 + launchedOn?: string | null;
252 + status?: "announced" | "operational" | "restricted" | "retired" | null;
253 + isSovereign?: boolean | null;
254 + sourceUrl?: string | null;
255 + externalIds?: Record<string, string | number>;
256 + provenance: Provenance;
257 +}
258 +
259 +export interface NormalizedIxp {
260 + entityType: "ixp";
261 + key: string;
262 + name: string;
263 + nameLong?: string | null;
264 + city?: string | null;
265 + countryIso2?: string | null;
266 + regionContinent?: string | null;
267 + website?: string | null;
268 + networkCount?: number | null;
269 + facilityKeys?: string[]; // NormalizedFacility keys where this IX is present
270 + externalIds?: Record<string, string | number>;
271 + provenance: Provenance;
272 +}
273 +
274 +export interface NormalizedProject {
275 + entityType: "project";
276 + key: string;
277 + name: string;
278 + operatorName?: string | null;
279 + facilityKey?: string | null;
280 + city?: string | null;
281 + regionName?: string | null;
282 + countryIso2?: string | null;
283 + geo?: GeoPoint | null;
284 + status?: FacilityStatus | null;
285 + announcedOn?: string | null;
286 + expectedOpening?: string | null;
287 + plannedMw?: number | null;
288 + investmentUsd?: number | null;
289 + acreage?: number | null;
290 + phaseCount?: number | null;
291 + description?: string | null;
292 + sourceUrl?: string | null;
293 + timeline?: Array<{ date: string; type: EventType | string; description: string; url?: string }>;
294 + externalIds?: Record<string, string | number>;
295 + provenance: Provenance;
296 +}
297 +
298 +export interface NormalizedNewsEvent {
299 + entityType: "news_event";
300 + key: string; // url hash
301 + title: string;
302 + url: string;
303 + publishedAt?: string | null;
304 + summary?: string | null;
305 + pageType?: PageType;
306 + eventType?: EventType;
307 + mentions?: { operators?: string[]; countriesIso2?: string[]; cities?: string[]; mw?: number[]; facilities?: string[] };
308 + provenance: Provenance;
309 +}
310 +
311 +export interface NormalizedCountryStat {
312 + entityType: "country";
313 + key: string; // iso2
314 + iso2: string;
315 + population?: number | null;
316 + gdpUsd?: number | null;
317 + electricityTwh?: number | null;
318 + renewableShare?: number | null;
319 + year?: number | null;
320 + provenance: Provenance;
321 +}
322 +
323 +export type NormalizedEntity =
324 + | NormalizedFacility
325 + | NormalizedOperator
326 + | NormalizedCampus
327 + | NormalizedCloudRegion
328 + | NormalizedIxp
329 + | NormalizedProject
330 + | NormalizedNewsEvent
331 + | NormalizedCountryStat;
332 +
333 +export interface ValidationIssue {
334 + key: string;
335 + field?: string;
336 + level: "error" | "warn";
337 + message: string;
338 +}
339 +export interface ValidationReport {
340 + total: number;
341 + valid: number;
342 + rejected: number;
343 + issues: ValidationIssue[];
344 +}
345 +
346 +/** Structured change detected between two versions of the same entity/page. */
347 +export interface DetectedChange {
348 + field: string;
349 + oldValue: unknown;
350 + newValue: unknown;
351 + significance: number; // 0..100
352 + eventType: EventType;
353 + description: string;
354 +}
355 +
356 +export interface EventRecord {
357 + id: string;
358 + entityType: EntityType;
359 + entityId: string | null;
360 + eventType: EventType;
361 + detectedAt: string;
362 + effectiveDate?: string | null;
363 + oldValue?: unknown;
364 + newValue?: unknown;
365 + sourceId: string;
366 + documentId?: string | null;
367 + url: string;
368 + title: string;
369 + summary?: string | null;
370 + significance: number;
371 + confidence: ConfidenceLevel;
372 + reviewStatus: ReviewStatus;
373 + countryIso2?: string | null;
374 + operatorId?: string | null;
375 +}
added packages/core/tsconfig.json +1 −0
@@ -0,0 +1 @@
1 +{ "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true, "types": ["node"] }, "include": ["src"] }
added packages/db/drizzle.config.ts +2 −0
@@ -0,0 +1,2 @@
1 +import type { Config } from "drizzle-kit";
2 +export default { schema: "./src/schema.ts", out: "./migrations", dialect: "postgresql", dbCredentials: { url: process.env.DATABASE_URL ?? "postgres://dci:dci@127.0.0.1:5432/dci" }, casing: "snake_case" } satisfies Config;
added packages/db/migrations/0000_init.sql +532 −0
@@ -0,0 +1,532 @@
1 +CREATE TABLE "admin_sessions" (
2 + "id" text PRIMARY KEY NOT NULL,
3 + "created_at" timestamp with time zone DEFAULT now() NOT NULL,
4 + "expires_at" timestamp with time zone NOT NULL,
5 + "ip" text,
6 + "user_agent" text
7 +);
8 +--> statement-breakpoint
9 +CREATE TABLE "campuses" (
10 + "id" text PRIMARY KEY NOT NULL,
11 + "slug" text NOT NULL,
12 + "name" text NOT NULL,
13 + "operator_id" text,
14 + "metro_id" text,
15 + "country_iso2" text,
16 + "city" text,
17 + "lat" double precision,
18 + "lng" double precision,
19 + "external_ids" jsonb DEFAULT '{}'::jsonb NOT NULL,
20 + "created_at" timestamp with time zone DEFAULT now() NOT NULL,
21 + "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
22 + CONSTRAINT "campuses_slug_unique" UNIQUE("slug")
23 +);
24 +--> statement-breakpoint
25 +CREATE TABLE "cloud_regions" (
26 + "id" text PRIMARY KEY NOT NULL,
27 + "slug" text NOT NULL,
28 + "provider_id" text NOT NULL,
29 + "code" text NOT NULL,
30 + "name" text NOT NULL,
31 + "city" text,
32 + "region_name" text,
33 + "country_iso2" text,
34 + "metro_id" text,
35 + "lat" double precision,
36 + "lng" double precision,
37 + "geo_precision" text DEFAULT 'city' NOT NULL,
38 + "availability_zones" integer,
39 + "launched_on" text,
40 + "status" text DEFAULT 'operational' NOT NULL,
41 + "is_sovereign" boolean DEFAULT false NOT NULL,
42 + "source_url" text,
43 + "external_ids" jsonb DEFAULT '{}'::jsonb NOT NULL,
44 + "created_at" timestamp with time zone DEFAULT now() NOT NULL,
45 + "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
46 + CONSTRAINT "cloud_regions_slug_unique" UNIQUE("slug")
47 +);
48 +--> statement-breakpoint
49 +CREATE TABLE "connector_runs" (
50 + "id" text PRIMARY KEY NOT NULL,
51 + "connector_id" text NOT NULL,
52 + "task" text DEFAULT 'crawl' NOT NULL,
53 + "started_at" timestamp with time zone DEFAULT now() NOT NULL,
54 + "finished_at" timestamp with time zone,
55 + "status" text DEFAULT 'running' NOT NULL,
56 + "stats" jsonb DEFAULT '{}'::jsonb NOT NULL,
57 + "error" text,
58 + "log" jsonb DEFAULT '[]'::jsonb NOT NULL
59 +);
60 +--> statement-breakpoint
61 +CREATE TABLE "connectors" (
62 + "id" text PRIMARY KEY NOT NULL,
63 + "source_name" text NOT NULL,
64 + "domain" text NOT NULL,
65 + "kind" text NOT NULL,
66 + "mode" text NOT NULL,
67 + "enabled" boolean DEFAULT true NOT NULL,
68 + "paused" boolean DEFAULT false NOT NULL,
69 + "config" jsonb DEFAULT '{}'::jsonb NOT NULL,
70 + "parser_version" text DEFAULT 'v1' NOT NULL,
71 + "schedule" jsonb DEFAULT '{}'::jsonb NOT NULL,
72 + "health" text DEFAULT 'never_run' NOT NULL,
73 + "last_run_at" timestamp with time zone,
74 + "last_status" text,
75 + "last_error" text,
76 + "next_run_at" timestamp with time zone,
77 + "stats" jsonb DEFAULT '{}'::jsonb NOT NULL,
78 + "created_at" timestamp with time zone DEFAULT now() NOT NULL,
79 + "updated_at" timestamp with time zone DEFAULT now() NOT NULL
80 +);
81 +--> statement-breakpoint
82 +CREATE TABLE "countries" (
83 + "iso2" text PRIMARY KEY NOT NULL,
84 + "iso3" text,
85 + "slug" text NOT NULL,
86 + "name" text NOT NULL,
87 + "region" text,
88 + "subregion" text,
89 + "population" bigint,
90 + "gdp_usd" double precision,
91 + "electricity_twh" double precision,
92 + "renewable_share" double precision,
93 + "stats_year" integer,
94 + "lat" double precision,
95 + "lng" double precision,
96 + "stats" jsonb DEFAULT '{}'::jsonb NOT NULL,
97 + "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
98 + CONSTRAINT "countries_slug_unique" UNIQUE("slug")
99 +);
100 +--> statement-breakpoint
101 +CREATE TABLE "daily_metrics" (
102 + "day" date NOT NULL,
103 + "metric" text NOT NULL,
104 + "dim" text DEFAULT 'global' NOT NULL,
105 + "value" double precision NOT NULL,
106 + CONSTRAINT "daily_metrics_day_metric_dim_pk" PRIMARY KEY("day","metric","dim")
107 +);
108 +--> statement-breakpoint
109 +CREATE TABLE "document_versions" (
110 + "id" text PRIMARY KEY NOT NULL,
111 + "document_id" text NOT NULL,
112 + "content_hash" text NOT NULL,
113 + "fetched_at" timestamp with time zone DEFAULT now() NOT NULL,
114 + "fetch_level" integer DEFAULT 1 NOT NULL,
115 + "status_code" integer,
116 + "size_bytes" integer,
117 + "storage_key" text,
118 + "diff_summary" jsonb,
119 + "detected_changes" jsonb DEFAULT '[]'::jsonb NOT NULL,
120 + "significance" integer DEFAULT 0 NOT NULL
121 +);
122 +--> statement-breakpoint
123 +CREATE TABLE "documents" (
124 + "id" text PRIMARY KEY NOT NULL,
125 + "connector_id" text NOT NULL,
126 + "source_id" text NOT NULL,
127 + "url" text NOT NULL,
128 + "canonical_url" text NOT NULL,
129 + "url_fingerprint" text NOT NULL,
130 + "page_type" text DEFAULT 'unknown' NOT NULL,
131 + "classifier" text,
132 + "fetch_level" integer DEFAULT 1 NOT NULL,
133 + "priority" integer DEFAULT 50 NOT NULL,
134 + "content_hash" text,
135 + "etag" text,
136 + "last_modified" text,
137 + "status_code" integer,
138 + "content_type" text,
139 + "size_bytes" integer,
140 + "title" text,
141 + "storage_key" text,
142 + "extractor_version" text,
143 + "extract_ok" boolean,
144 + "extract_count" integer DEFAULT 0 NOT NULL,
145 + "error" text,
146 + "error_count" integer DEFAULT 0 NOT NULL,
147 + "first_seen" timestamp with time zone DEFAULT now() NOT NULL,
148 + "last_fetched" timestamp with time zone,
149 + "last_changed" timestamp with time zone,
150 + "last_checked" timestamp with time zone,
151 + "next_check" timestamp with time zone,
152 + "change_frequency_score" real DEFAULT 0.5 NOT NULL,
153 + "fetch_count" integer DEFAULT 0 NOT NULL,
154 + "change_count" integer DEFAULT 0 NOT NULL,
155 + "discovered_from" text,
156 + "quarantined" boolean DEFAULT false NOT NULL,
157 + "entity_refs" jsonb DEFAULT '[]'::jsonb NOT NULL,
158 + "updated_at" timestamp with time zone DEFAULT now() NOT NULL
159 +);
160 +--> statement-breakpoint
161 +CREATE TABLE "entity_keys" (
162 + "key" text PRIMARY KEY NOT NULL,
163 + "entity_type" text NOT NULL,
164 + "entity_id" text NOT NULL,
165 + "connector_id" text NOT NULL,
166 + "created_at" timestamp with time zone DEFAULT now() NOT NULL
167 +);
168 +--> statement-breakpoint
169 +CREATE TABLE "entity_matches" (
170 + "id" text PRIMARY KEY NOT NULL,
171 + "connector_id" text NOT NULL,
172 + "candidate_key" text NOT NULL,
173 + "candidate" jsonb NOT NULL,
174 + "matched_facility_id" text,
175 + "score" real NOT NULL,
176 + "reasons" text[] DEFAULT '{}'::text[] NOT NULL,
177 + "status" text DEFAULT 'pending' NOT NULL,
178 + "decided_by" text,
179 + "decided_at" timestamp with time zone,
180 + "created_at" timestamp with time zone DEFAULT now() NOT NULL
181 +);
182 +--> statement-breakpoint
183 +CREATE TABLE "events" (
184 + "id" text PRIMARY KEY NOT NULL,
185 + "entity_type" text NOT NULL,
186 + "entity_id" text,
187 + "event_type" text NOT NULL,
188 + "detected_at" timestamp with time zone DEFAULT now() NOT NULL,
189 + "effective_date" text,
190 + "old_value" jsonb,
191 + "new_value" jsonb,
192 + "source_id" text NOT NULL,
193 + "document_id" text,
194 + "url" text NOT NULL,
195 + "title" text NOT NULL,
196 + "summary" text,
197 + "significance" integer DEFAULT 30 NOT NULL,
198 + "confidence" text DEFAULT 'moderate' NOT NULL,
199 + "review_status" text DEFAULT 'auto' NOT NULL,
200 + "country_iso2" text,
201 + "operator_id" text,
202 + "metro_id" text,
203 + "project_id" text,
204 + "fingerprint" text NOT NULL
205 +);
206 +--> statement-breakpoint
207 +CREATE TABLE "facilities" (
208 + "id" text PRIMARY KEY NOT NULL,
209 + "slug" text NOT NULL,
210 + "name" text NOT NULL,
211 + "normalized_name" text NOT NULL,
212 + "operator_id" text,
213 + "owner_id" text,
214 + "campus_id" text,
215 + "metro_id" text,
216 + "country_iso2" text,
217 + "city" text,
218 + "region_name" text,
219 + "address" text,
220 + "postal_code" text,
221 + "lat" double precision,
222 + "lng" double precision,
223 + "geo_precision" text DEFAULT 'unknown' NOT NULL,
224 + "geo_source" text,
225 + "geohash" text,
226 + "status" text DEFAULT 'unknown' NOT NULL,
227 + "facility_type" text DEFAULT 'unknown' NOT NULL,
228 + "tier" text,
229 + "building_sqm" double precision,
230 + "site_area_ha" double precision,
231 + "it_capacity_mw" double precision,
232 + "total_power_mw" double precision,
233 + "planned_power_mw" double precision,
234 + "mw_is_estimate" boolean DEFAULT false NOT NULL,
235 + "rack_count" integer,
236 + "pue" real,
237 + "cooling_type" text,
238 + "renewable_claim" text,
239 + "opened_on" text,
240 + "construction_started_on" text,
241 + "announced_on" text,
242 + "website" text,
243 + "description" text,
244 + "is_ai" boolean DEFAULT false NOT NULL,
245 + "is_hyperscale" boolean DEFAULT false NOT NULL,
246 + "certifications" text[] DEFAULT '{}'::text[] NOT NULL,
247 + "carriers_count" integer,
248 + "ixp_count" integer,
249 + "networks_count" integer,
250 + "confidence" text DEFAULT 'unverified' NOT NULL,
251 + "completeness" integer DEFAULT 0 NOT NULL,
252 + "external_ids" jsonb DEFAULT '{}'::jsonb NOT NULL,
253 + "source_count" integer DEFAULT 0 NOT NULL,
254 + "first_seen" timestamp with time zone DEFAULT now() NOT NULL,
255 + "last_verified" timestamp with time zone,
256 + "merged_into" text,
257 + "created_at" timestamp with time zone DEFAULT now() NOT NULL,
258 + "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
259 + CONSTRAINT "facilities_slug_unique" UNIQUE("slug")
260 +);
261 +--> statement-breakpoint
262 +CREATE TABLE "facility_aliases" (
263 + "facility_id" text NOT NULL,
264 + "alias" text NOT NULL,
265 + "normalized" text NOT NULL,
266 + "source_id" text,
267 + CONSTRAINT "facility_aliases_facility_id_alias_pk" PRIMARY KEY("facility_id","alias")
268 +);
269 +--> statement-breakpoint
270 +CREATE TABLE "facility_ixps" (
271 + "facility_id" text NOT NULL,
272 + "ixp_id" text NOT NULL,
273 + "source_id" text,
274 + CONSTRAINT "facility_ixps_facility_id_ixp_id_pk" PRIMARY KEY("facility_id","ixp_id")
275 +);
276 +--> statement-breakpoint
277 +CREATE TABLE "facility_tenants" (
278 + "facility_id" text NOT NULL,
279 + "operator_id" text NOT NULL,
280 + "role" text DEFAULT 'carrier' NOT NULL,
281 + "asn" integer,
282 + "source_id" text,
283 + CONSTRAINT "facility_tenants_facility_id_operator_id_role_pk" PRIMARY KEY("facility_id","operator_id","role")
284 +);
285 +--> statement-breakpoint
286 +CREATE TABLE "ixps" (
287 + "id" text PRIMARY KEY NOT NULL,
288 + "slug" text NOT NULL,
289 + "name" text NOT NULL,
290 + "name_long" text,
291 + "city" text,
292 + "country_iso2" text,
293 + "metro_id" text,
294 + "region_continent" text,
295 + "website" text,
296 + "network_count" integer,
297 + "external_ids" jsonb DEFAULT '{}'::jsonb NOT NULL,
298 + "created_at" timestamp with time zone DEFAULT now() NOT NULL,
299 + "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
300 + CONSTRAINT "ixps_slug_unique" UNIQUE("slug")
301 +);
302 +--> statement-breakpoint
303 +CREATE TABLE "metros" (
304 + "id" text PRIMARY KEY NOT NULL,
305 + "slug" text NOT NULL,
306 + "name" text NOT NULL,
307 + "country_iso2" text NOT NULL,
308 + "region_name" text,
309 + "lat" double precision NOT NULL,
310 + "lng" double precision NOT NULL,
311 + "radius_km" real DEFAULT 60 NOT NULL,
312 + "aliases" text[] DEFAULT '{}'::text[] NOT NULL,
313 + "description" text,
314 + "stats" jsonb DEFAULT '{}'::jsonb NOT NULL,
315 + "created_at" timestamp with time zone DEFAULT now() NOT NULL,
316 + "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
317 + CONSTRAINT "metros_slug_unique" UNIQUE("slug")
318 +);
319 +--> statement-breakpoint
320 +CREATE TABLE "news_items" (
321 + "id" text PRIMARY KEY NOT NULL,
322 + "source_id" text NOT NULL,
323 + "connector_id" text NOT NULL,
324 + "url" text NOT NULL,
325 + "title" text NOT NULL,
326 + "published_at" timestamp with time zone,
327 + "summary" text,
328 + "page_type" text DEFAULT 'unknown' NOT NULL,
329 + "event_type" text,
330 + "mentions" jsonb DEFAULT '{}'::jsonb NOT NULL,
331 + "operator_ids" text[] DEFAULT '{}'::text[] NOT NULL,
332 + "country_iso2s" text[] DEFAULT '{}'::text[] NOT NULL,
333 + "facility_ids" text[] DEFAULT '{}'::text[] NOT NULL,
334 + "project_id" text,
335 + "mw" double precision,
336 + "significance" integer DEFAULT 20 NOT NULL,
337 + "created_at" timestamp with time zone DEFAULT now() NOT NULL,
338 + CONSTRAINT "news_items_url_unique" UNIQUE("url")
339 +);
340 +--> statement-breakpoint
341 +CREATE TABLE "operators" (
342 + "id" text PRIMARY KEY NOT NULL,
343 + "slug" text NOT NULL,
344 + "name" text NOT NULL,
345 + "normalized_name" text NOT NULL,
346 + "kind" text,
347 + "website" text,
348 + "hq_country_iso2" text,
349 + "hq_city" text,
350 + "parent_id" text,
351 + "description" text,
352 + "aliases" text[] DEFAULT '{}'::text[] NOT NULL,
353 + "external_ids" jsonb DEFAULT '{}'::jsonb NOT NULL,
354 + "is_cloud_provider" boolean DEFAULT false NOT NULL,
355 + "is_carrier" boolean DEFAULT false NOT NULL,
356 + "stats" jsonb DEFAULT '{}'::jsonb NOT NULL,
357 + "created_at" timestamp with time zone DEFAULT now() NOT NULL,
358 + "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
359 + CONSTRAINT "operators_slug_unique" UNIQUE("slug")
360 +);
361 +--> statement-breakpoint
362 +CREATE TABLE "project_timeline" (
363 + "id" text PRIMARY KEY NOT NULL,
364 + "project_id" text NOT NULL,
365 + "event_date" text NOT NULL,
366 + "event_type" text NOT NULL,
367 + "description" text NOT NULL,
368 + "source_id" text,
369 + "document_id" text,
370 + "url" text,
371 + "created_at" timestamp with time zone DEFAULT now() NOT NULL
372 +);
373 +--> statement-breakpoint
374 +CREATE TABLE "projects" (
375 + "id" text PRIMARY KEY NOT NULL,
376 + "slug" text NOT NULL,
377 + "name" text NOT NULL,
378 + "normalized_name" text NOT NULL,
379 + "operator_id" text,
380 + "facility_id" text,
381 + "metro_id" text,
382 + "country_iso2" text,
383 + "city" text,
384 + "region_name" text,
385 + "lat" double precision,
386 + "lng" double precision,
387 + "geo_precision" text DEFAULT 'unknown' NOT NULL,
388 + "status" text DEFAULT 'announced' NOT NULL,
389 + "announced_on" text,
390 + "expected_opening" text,
391 + "planned_mw" double precision,
392 + "investment_usd" double precision,
393 + "investment_currency" text,
394 + "investment_original" double precision,
395 + "acreage" double precision,
396 + "phase_count" integer,
397 + "is_ai" boolean DEFAULT false NOT NULL,
398 + "description" text,
399 + "source_url" text,
400 + "confidence" text DEFAULT 'moderate' NOT NULL,
401 + "external_ids" jsonb DEFAULT '{}'::jsonb NOT NULL,
402 + "last_update" timestamp with time zone DEFAULT now() NOT NULL,
403 + "created_at" timestamp with time zone DEFAULT now() NOT NULL,
404 + "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
405 + CONSTRAINT "projects_slug_unique" UNIQUE("slug")
406 +);
407 +--> statement-breakpoint
408 +CREATE TABLE "provenance" (
409 + "id" text PRIMARY KEY NOT NULL,
410 + "entity_type" text NOT NULL,
411 + "entity_id" text NOT NULL,
412 + "field" text NOT NULL,
413 + "value" jsonb,
414 + "source_id" text NOT NULL,
415 + "connector_id" text NOT NULL,
416 + "document_id" text,
417 + "url" text NOT NULL,
418 + "first_observed" timestamp with time zone DEFAULT now() NOT NULL,
419 + "last_observed" timestamp with time zone DEFAULT now() NOT NULL,
420 + "retrieved_at" timestamp with time zone DEFAULT now() NOT NULL,
421 + "confidence" text DEFAULT 'moderate' NOT NULL,
422 + "is_estimate" boolean DEFAULT false NOT NULL,
423 + "method" text,
424 + "extractor_version" text,
425 + "is_current" boolean DEFAULT true NOT NULL,
426 + "note" text
427 +);
428 +--> statement-breakpoint
429 +CREATE TABLE "rankings" (
430 + "id" text PRIMARY KEY NOT NULL,
431 + "key" text NOT NULL,
432 + "scope" text NOT NULL,
433 + "label" text NOT NULL,
434 + "unit" text NOT NULL,
435 + "methodology" text NOT NULL,
436 + "min_coverage" real,
437 + "rows" jsonb NOT NULL,
438 + "total" integer NOT NULL,
439 + "computed_at" timestamp with time zone DEFAULT now() NOT NULL,
440 + "is_current" boolean DEFAULT true NOT NULL
441 +);
442 +--> statement-breakpoint
443 +CREATE TABLE "sources" (
444 + "id" text PRIMARY KEY NOT NULL,
445 + "connector_id" text NOT NULL,
446 + "name" text NOT NULL,
447 + "domain" text NOT NULL,
448 + "kind" text NOT NULL,
449 + "priority" integer DEFAULT 3 NOT NULL,
450 + "url" text,
451 + "license" text,
452 + "attribution" text,
453 + "robots_allowed" boolean,
454 + "notes" text,
455 + "created_at" timestamp with time zone DEFAULT now() NOT NULL,
456 + "updated_at" timestamp with time zone DEFAULT now() NOT NULL
457 +);
458 +--> statement-breakpoint
459 +CREATE TABLE "system_alerts" (
460 + "id" text PRIMARY KEY NOT NULL,
461 + "level" text NOT NULL,
462 + "component" text NOT NULL,
463 + "message" text NOT NULL,
464 + "details" jsonb,
465 + "resolved_at" timestamp with time zone,
466 + "created_at" timestamp with time zone DEFAULT now() NOT NULL
467 +);
468 +--> statement-breakpoint
469 +ALTER TABLE "campuses" ADD CONSTRAINT "campuses_operator_id_operators_id_fk" FOREIGN KEY ("operator_id") REFERENCES "public"."operators"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
470 +ALTER TABLE "campuses" ADD CONSTRAINT "campuses_metro_id_metros_id_fk" FOREIGN KEY ("metro_id") REFERENCES "public"."metros"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
471 +ALTER TABLE "cloud_regions" ADD CONSTRAINT "cloud_regions_provider_id_operators_id_fk" FOREIGN KEY ("provider_id") REFERENCES "public"."operators"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
472 +ALTER TABLE "cloud_regions" ADD CONSTRAINT "cloud_regions_country_iso2_countries_iso2_fk" FOREIGN KEY ("country_iso2") REFERENCES "public"."countries"("iso2") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
473 +ALTER TABLE "cloud_regions" ADD CONSTRAINT "cloud_regions_metro_id_metros_id_fk" FOREIGN KEY ("metro_id") REFERENCES "public"."metros"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
474 +ALTER TABLE "document_versions" ADD CONSTRAINT "document_versions_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
475 +ALTER TABLE "facilities" ADD CONSTRAINT "facilities_operator_id_operators_id_fk" FOREIGN KEY ("operator_id") REFERENCES "public"."operators"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
476 +ALTER TABLE "facilities" ADD CONSTRAINT "facilities_owner_id_operators_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."operators"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
477 +ALTER TABLE "facilities" ADD CONSTRAINT "facilities_campus_id_campuses_id_fk" FOREIGN KEY ("campus_id") REFERENCES "public"."campuses"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
478 +ALTER TABLE "facilities" ADD CONSTRAINT "facilities_metro_id_metros_id_fk" FOREIGN KEY ("metro_id") REFERENCES "public"."metros"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
479 +ALTER TABLE "facilities" ADD CONSTRAINT "facilities_country_iso2_countries_iso2_fk" FOREIGN KEY ("country_iso2") REFERENCES "public"."countries"("iso2") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
480 +ALTER TABLE "facility_aliases" ADD CONSTRAINT "facility_aliases_facility_id_facilities_id_fk" FOREIGN KEY ("facility_id") REFERENCES "public"."facilities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
481 +ALTER TABLE "facility_ixps" ADD CONSTRAINT "facility_ixps_facility_id_facilities_id_fk" FOREIGN KEY ("facility_id") REFERENCES "public"."facilities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
482 +ALTER TABLE "facility_ixps" ADD CONSTRAINT "facility_ixps_ixp_id_ixps_id_fk" FOREIGN KEY ("ixp_id") REFERENCES "public"."ixps"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
483 +ALTER TABLE "facility_tenants" ADD CONSTRAINT "facility_tenants_facility_id_facilities_id_fk" FOREIGN KEY ("facility_id") REFERENCES "public"."facilities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
484 +ALTER TABLE "facility_tenants" ADD CONSTRAINT "facility_tenants_operator_id_operators_id_fk" FOREIGN KEY ("operator_id") REFERENCES "public"."operators"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
485 +ALTER TABLE "ixps" ADD CONSTRAINT "ixps_country_iso2_countries_iso2_fk" FOREIGN KEY ("country_iso2") REFERENCES "public"."countries"("iso2") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
486 +ALTER TABLE "ixps" ADD CONSTRAINT "ixps_metro_id_metros_id_fk" FOREIGN KEY ("metro_id") REFERENCES "public"."metros"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
487 +ALTER TABLE "metros" ADD CONSTRAINT "metros_country_iso2_countries_iso2_fk" FOREIGN KEY ("country_iso2") REFERENCES "public"."countries"("iso2") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
488 +ALTER TABLE "project_timeline" ADD CONSTRAINT "project_timeline_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
489 +ALTER TABLE "projects" ADD CONSTRAINT "projects_operator_id_operators_id_fk" FOREIGN KEY ("operator_id") REFERENCES "public"."operators"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
490 +ALTER TABLE "projects" ADD CONSTRAINT "projects_facility_id_facilities_id_fk" FOREIGN KEY ("facility_id") REFERENCES "public"."facilities"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
491 +ALTER TABLE "projects" ADD CONSTRAINT "projects_metro_id_metros_id_fk" FOREIGN KEY ("metro_id") REFERENCES "public"."metros"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
492 +ALTER TABLE "projects" ADD CONSTRAINT "projects_country_iso2_countries_iso2_fk" FOREIGN KEY ("country_iso2") REFERENCES "public"."countries"("iso2") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
493 +CREATE UNIQUE INDEX "cloud_regions_provider_code_uq" ON "cloud_regions" USING btree ("provider_id","code");--> statement-breakpoint
494 +CREATE INDEX "cloud_regions_country_idx" ON "cloud_regions" USING btree ("country_iso2");--> statement-breakpoint
495 +CREATE INDEX "connector_runs_connector_idx" ON "connector_runs" USING btree ("connector_id","started_at");--> statement-breakpoint
496 +CREATE INDEX "document_versions_doc_idx" ON "document_versions" USING btree ("document_id","fetched_at");--> statement-breakpoint
497 +CREATE UNIQUE INDEX "documents_fingerprint_uq" ON "documents" USING btree ("url_fingerprint");--> statement-breakpoint
498 +CREATE INDEX "documents_connector_idx" ON "documents" USING btree ("connector_id");--> statement-breakpoint
499 +CREATE INDEX "documents_next_check_idx" ON "documents" USING btree ("next_check");--> statement-breakpoint
500 +CREATE INDEX "documents_page_type_idx" ON "documents" USING btree ("page_type");--> statement-breakpoint
501 +CREATE INDEX "entity_keys_entity_idx" ON "entity_keys" USING btree ("entity_type","entity_id");--> statement-breakpoint
502 +CREATE INDEX "entity_matches_status_idx" ON "entity_matches" USING btree ("status");--> statement-breakpoint
503 +CREATE INDEX "entity_matches_key_idx" ON "entity_matches" USING btree ("candidate_key");--> statement-breakpoint
504 +CREATE UNIQUE INDEX "events_fingerprint_uq" ON "events" USING btree ("fingerprint");--> statement-breakpoint
505 +CREATE INDEX "events_detected_idx" ON "events" USING btree ("detected_at");--> statement-breakpoint
506 +CREATE INDEX "events_entity_idx" ON "events" USING btree ("entity_type","entity_id");--> statement-breakpoint
507 +CREATE INDEX "events_country_idx" ON "events" USING btree ("country_iso2");--> statement-breakpoint
508 +CREATE INDEX "events_operator_idx" ON "events" USING btree ("operator_id");--> statement-breakpoint
509 +CREATE INDEX "events_type_idx" ON "events" USING btree ("event_type");--> statement-breakpoint
510 +CREATE INDEX "facilities_country_idx" ON "facilities" USING btree ("country_iso2");--> statement-breakpoint
511 +CREATE INDEX "facilities_operator_idx" ON "facilities" USING btree ("operator_id");--> statement-breakpoint
512 +CREATE INDEX "facilities_metro_idx" ON "facilities" USING btree ("metro_id");--> statement-breakpoint
513 +CREATE INDEX "facilities_status_idx" ON "facilities" USING btree ("status");--> statement-breakpoint
514 +CREATE INDEX "facilities_type_idx" ON "facilities" USING btree ("facility_type");--> statement-breakpoint
515 +CREATE INDEX "facilities_geohash_idx" ON "facilities" USING btree ("geohash");--> statement-breakpoint
516 +CREATE INDEX "facilities_norm_idx" ON "facilities" USING btree ("normalized_name");--> statement-breakpoint
517 +CREATE INDEX "facilities_updated_idx" ON "facilities" USING btree ("updated_at");--> statement-breakpoint
518 +CREATE INDEX "facility_aliases_norm_idx" ON "facility_aliases" USING btree ("normalized");--> statement-breakpoint
519 +CREATE INDEX "facility_tenants_operator_idx" ON "facility_tenants" USING btree ("operator_id");--> statement-breakpoint
520 +CREATE INDEX "metros_country_idx" ON "metros" USING btree ("country_iso2");--> statement-breakpoint
521 +CREATE INDEX "news_items_published_idx" ON "news_items" USING btree ("published_at");--> statement-breakpoint
522 +CREATE INDEX "operators_norm_idx" ON "operators" USING btree ("normalized_name");--> statement-breakpoint
523 +CREATE INDEX "operators_kind_idx" ON "operators" USING btree ("kind");--> statement-breakpoint
524 +CREATE INDEX "project_timeline_project_idx" ON "project_timeline" USING btree ("project_id");--> statement-breakpoint
525 +CREATE INDEX "projects_country_idx" ON "projects" USING btree ("country_iso2");--> statement-breakpoint
526 +CREATE INDEX "projects_operator_idx" ON "projects" USING btree ("operator_id");--> statement-breakpoint
527 +CREATE INDEX "projects_status_idx" ON "projects" USING btree ("status");--> statement-breakpoint
528 +CREATE INDEX "projects_norm_idx" ON "projects" USING btree ("normalized_name");--> statement-breakpoint
529 +CREATE INDEX "provenance_entity_idx" ON "provenance" USING btree ("entity_type","entity_id");--> statement-breakpoint
530 +CREATE INDEX "provenance_source_idx" ON "provenance" USING btree ("source_id");--> statement-breakpoint
531 +CREATE UNIQUE INDEX "provenance_uq" ON "provenance" USING btree ("entity_type","entity_id","field","source_id","url");--> statement-breakpoint
532 +CREATE INDEX "rankings_key_idx" ON "rankings" USING btree ("key","is_current");
\ No newline at end of file
added packages/db/migrations/0001_search.sql +26 −0
@@ -0,0 +1,26 @@
1 +-- Extensions + search/geo indexes
2 +CREATE EXTENSION IF NOT EXISTS pg_trgm;
3 +CREATE EXTENSION IF NOT EXISTS unaccent;
4 +--> statement-breakpoint
5 +ALTER TABLE facilities ADD COLUMN IF NOT EXISTS search tsvector
6 + GENERATED ALWAYS AS (
7 + setweight(to_tsvector('simple'::regconfig, coalesce(name,'')), 'A') ||
8 + setweight(to_tsvector('simple'::regconfig, coalesce(city,'') || ' ' || coalesce(region_name,'') || ' ' || coalesce(address,'')), 'B') ||
9 + setweight(to_tsvector('simple'::regconfig, coalesce(description,'')), 'C')
10 + ) STORED;
11 +--> statement-breakpoint
12 +CREATE INDEX IF NOT EXISTS facilities_search_idx ON facilities USING gin (search);
13 +--> statement-breakpoint
14 +CREATE INDEX IF NOT EXISTS facilities_name_trgm_idx ON facilities USING gin (name gin_trgm_ops);
15 +--> statement-breakpoint
16 +CREATE INDEX IF NOT EXISTS facilities_city_trgm_idx ON facilities USING gin (city gin_trgm_ops);
17 +--> statement-breakpoint
18 +CREATE INDEX IF NOT EXISTS operators_name_trgm_idx ON operators USING gin (name gin_trgm_ops);
19 +--> statement-breakpoint
20 +CREATE INDEX IF NOT EXISTS metros_name_trgm_idx ON metros USING gin (name gin_trgm_ops);
21 +--> statement-breakpoint
22 +CREATE INDEX IF NOT EXISTS projects_name_trgm_idx ON projects USING gin (name gin_trgm_ops);
23 +--> statement-breakpoint
24 +CREATE INDEX IF NOT EXISTS facilities_latlng_idx ON facilities (lat, lng) WHERE lat IS NOT NULL;
25 +--> statement-breakpoint
26 +CREATE INDEX IF NOT EXISTS facilities_live_idx ON facilities (status, country_iso2) WHERE merged_into IS NULL;
added packages/db/migrations/meta/0000_snapshot.json +3840 −0
@@ -0,0 +1,3840 @@
1 +{
2 + "id": "299a3c4c-177d-48f1-8ad6-24538455fa02",
3 + "prevId": "00000000-0000-0000-0000-000000000000",
4 + "version": "7",
5 + "dialect": "postgresql",
6 + "tables": {
7 + "public.admin_sessions": {
8 + "name": "admin_sessions",
9 + "schema": "",
10 + "columns": {
11 + "id": {
12 + "name": "id",
13 + "type": "text",
14 + "primaryKey": true,
15 + "notNull": true
16 + },
17 + "created_at": {
18 + "name": "created_at",
19 + "type": "timestamp with time zone",
20 + "primaryKey": false,
21 + "notNull": true,
22 + "default": "now()"
23 + },
24 + "expires_at": {
25 + "name": "expires_at",
26 + "type": "timestamp with time zone",
27 + "primaryKey": false,
28 + "notNull": true
29 + },
30 + "ip": {
31 + "name": "ip",
32 + "type": "text",
33 + "primaryKey": false,
34 + "notNull": false
35 + },
36 + "user_agent": {
37 + "name": "user_agent",
38 + "type": "text",
39 + "primaryKey": false,
40 + "notNull": false
41 + }
42 + },
43 + "indexes": {},
44 + "foreignKeys": {},
45 + "compositePrimaryKeys": {},
46 + "uniqueConstraints": {},
47 + "policies": {},
48 + "checkConstraints": {},
49 + "isRLSEnabled": false
50 + },
51 + "public.campuses": {
52 + "name": "campuses",
53 + "schema": "",
54 + "columns": {
55 + "id": {
56 + "name": "id",
57 + "type": "text",
58 + "primaryKey": true,
59 + "notNull": true
60 + },
61 + "slug": {
62 + "name": "slug",
63 + "type": "text",
64 + "primaryKey": false,
65 + "notNull": true
66 + },
67 + "name": {
68 + "name": "name",
69 + "type": "text",
70 + "primaryKey": false,
71 + "notNull": true
72 + },
73 + "operator_id": {
74 + "name": "operator_id",
75 + "type": "text",
76 + "primaryKey": false,
77 + "notNull": false
78 + },
79 + "metro_id": {
80 + "name": "metro_id",
81 + "type": "text",
82 + "primaryKey": false,
83 + "notNull": false
84 + },
85 + "country_iso2": {
86 + "name": "country_iso2",
87 + "type": "text",
88 + "primaryKey": false,
89 + "notNull": false
90 + },
91 + "city": {
92 + "name": "city",
93 + "type": "text",
94 + "primaryKey": false,
95 + "notNull": false
96 + },
97 + "lat": {
98 + "name": "lat",
99 + "type": "double precision",
100 + "primaryKey": false,
101 + "notNull": false
102 + },
103 + "lng": {
104 + "name": "lng",
105 + "type": "double precision",
106 + "primaryKey": false,
107 + "notNull": false
108 + },
109 + "external_ids": {
110 + "name": "external_ids",
111 + "type": "jsonb",
112 + "primaryKey": false,
113 + "notNull": true,
114 + "default": "'{}'::jsonb"
115 + },
116 + "created_at": {
117 + "name": "created_at",
118 + "type": "timestamp with time zone",
119 + "primaryKey": false,
120 + "notNull": true,
121 + "default": "now()"
122 + },
123 + "updated_at": {
124 + "name": "updated_at",
125 + "type": "timestamp with time zone",
126 + "primaryKey": false,
127 + "notNull": true,
128 + "default": "now()"
129 + }
130 + },
131 + "indexes": {},
132 + "foreignKeys": {
133 + "campuses_operator_id_operators_id_fk": {
134 + "name": "campuses_operator_id_operators_id_fk",
135 + "tableFrom": "campuses",
136 + "tableTo": "operators",
137 + "columnsFrom": [
138 + "operator_id"
139 + ],
140 + "columnsTo": [
141 + "id"
142 + ],
143 + "onDelete": "no action",
144 + "onUpdate": "no action"
145 + },
146 + "campuses_metro_id_metros_id_fk": {
147 + "name": "campuses_metro_id_metros_id_fk",
148 + "tableFrom": "campuses",
149 + "tableTo": "metros",
150 + "columnsFrom": [
151 + "metro_id"
152 + ],
153 + "columnsTo": [
154 + "id"
155 + ],
156 + "onDelete": "no action",
157 + "onUpdate": "no action"
158 + }
159 + },
160 + "compositePrimaryKeys": {},
161 + "uniqueConstraints": {
162 + "campuses_slug_unique": {
163 + "name": "campuses_slug_unique",
164 + "nullsNotDistinct": false,
165 + "columns": [
166 + "slug"
167 + ]
168 + }
169 + },
170 + "policies": {},
171 + "checkConstraints": {},
172 + "isRLSEnabled": false
173 + },
174 + "public.cloud_regions": {
175 + "name": "cloud_regions",
176 + "schema": "",
177 + "columns": {
178 + "id": {
179 + "name": "id",
180 + "type": "text",
181 + "primaryKey": true,
182 + "notNull": true
183 + },
184 + "slug": {
185 + "name": "slug",
186 + "type": "text",
187 + "primaryKey": false,
188 + "notNull": true
189 + },
190 + "provider_id": {
191 + "name": "provider_id",
192 + "type": "text",
193 + "primaryKey": false,
194 + "notNull": true
195 + },
196 + "code": {
197 + "name": "code",
198 + "type": "text",
199 + "primaryKey": false,
200 + "notNull": true
201 + },
202 + "name": {
203 + "name": "name",
204 + "type": "text",
205 + "primaryKey": false,
206 + "notNull": true
207 + },
208 + "city": {
209 + "name": "city",
210 + "type": "text",
211 + "primaryKey": false,
212 + "notNull": false
213 + },
214 + "region_name": {
215 + "name": "region_name",
216 + "type": "text",
217 + "primaryKey": false,
218 + "notNull": false
219 + },
220 + "country_iso2": {
221 + "name": "country_iso2",
222 + "type": "text",
223 + "primaryKey": false,
224 + "notNull": false
225 + },
226 + "metro_id": {
227 + "name": "metro_id",
228 + "type": "text",
229 + "primaryKey": false,
230 + "notNull": false
231 + },
232 + "lat": {
233 + "name": "lat",
234 + "type": "double precision",
235 + "primaryKey": false,
236 + "notNull": false
237 + },
238 + "lng": {
239 + "name": "lng",
240 + "type": "double precision",
241 + "primaryKey": false,
242 + "notNull": false
243 + },
244 + "geo_precision": {
245 + "name": "geo_precision",
246 + "type": "text",
247 + "primaryKey": false,
248 + "notNull": true,
249 + "default": "'city'"
250 + },
251 + "availability_zones": {
252 + "name": "availability_zones",
253 + "type": "integer",
254 + "primaryKey": false,
255 + "notNull": false
256 + },
257 + "launched_on": {
258 + "name": "launched_on",
259 + "type": "text",
260 + "primaryKey": false,
261 + "notNull": false
262 + },
263 + "status": {
264 + "name": "status",
265 + "type": "text",
266 + "primaryKey": false,
267 + "notNull": true,
268 + "default": "'operational'"
269 + },
270 + "is_sovereign": {
271 + "name": "is_sovereign",
272 + "type": "boolean",
273 + "primaryKey": false,
274 + "notNull": true,
275 + "default": false
276 + },
277 + "source_url": {
278 + "name": "source_url",
279 + "type": "text",
280 + "primaryKey": false,
281 + "notNull": false
282 + },
283 + "external_ids": {
284 + "name": "external_ids",
285 + "type": "jsonb",
286 + "primaryKey": false,
287 + "notNull": true,
288 + "default": "'{}'::jsonb"
289 + },
290 + "created_at": {
291 + "name": "created_at",
292 + "type": "timestamp with time zone",
293 + "primaryKey": false,
294 + "notNull": true,
295 + "default": "now()"
296 + },
297 + "updated_at": {
298 + "name": "updated_at",
299 + "type": "timestamp with time zone",
300 + "primaryKey": false,
301 + "notNull": true,
302 + "default": "now()"
303 + }
304 + },
305 + "indexes": {
306 + "cloud_regions_provider_code_uq": {
307 + "name": "cloud_regions_provider_code_uq",
308 + "columns": [
309 + {
310 + "expression": "provider_id",
311 + "isExpression": false,
312 + "asc": true,
313 + "nulls": "last"
314 + },
315 + {
316 + "expression": "code",
317 + "isExpression": false,
318 + "asc": true,
319 + "nulls": "last"
320 + }
321 + ],
322 + "isUnique": true,
323 + "concurrently": false,
324 + "method": "btree",
325 + "with": {}
326 + },
327 + "cloud_regions_country_idx": {
328 + "name": "cloud_regions_country_idx",
329 + "columns": [
330 + {
331 + "expression": "country_iso2",
332 + "isExpression": false,
333 + "asc": true,
334 + "nulls": "last"
335 + }
336 + ],
337 + "isUnique": false,
338 + "concurrently": false,
339 + "method": "btree",
340 + "with": {}
341 + }
342 + },
343 + "foreignKeys": {
344 + "cloud_regions_provider_id_operators_id_fk": {
345 + "name": "cloud_regions_provider_id_operators_id_fk",
346 + "tableFrom": "cloud_regions",
347 + "tableTo": "operators",
348 + "columnsFrom": [
349 + "provider_id"
350 + ],
351 + "columnsTo": [
352 + "id"
353 + ],
354 + "onDelete": "no action",
355 + "onUpdate": "no action"
356 + },
357 + "cloud_regions_country_iso2_countries_iso2_fk": {
358 + "name": "cloud_regions_country_iso2_countries_iso2_fk",
359 + "tableFrom": "cloud_regions",
360 + "tableTo": "countries",
361 + "columnsFrom": [
362 + "country_iso2"
363 + ],
364 + "columnsTo": [
365 + "iso2"
366 + ],
367 + "onDelete": "no action",
368 + "onUpdate": "no action"
369 + },
370 + "cloud_regions_metro_id_metros_id_fk": {
371 + "name": "cloud_regions_metro_id_metros_id_fk",
372 + "tableFrom": "cloud_regions",
373 + "tableTo": "metros",
374 + "columnsFrom": [
375 + "metro_id"
376 + ],
377 + "columnsTo": [
378 + "id"
379 + ],
380 + "onDelete": "no action",
381 + "onUpdate": "no action"
382 + }
383 + },
384 + "compositePrimaryKeys": {},
385 + "uniqueConstraints": {
386 + "cloud_regions_slug_unique": {
387 + "name": "cloud_regions_slug_unique",
388 + "nullsNotDistinct": false,
389 + "columns": [
390 + "slug"
391 + ]
392 + }
393 + },
394 + "policies": {},
395 + "checkConstraints": {},
396 + "isRLSEnabled": false
397 + },
398 + "public.connector_runs": {
399 + "name": "connector_runs",
400 + "schema": "",
401 + "columns": {
402 + "id": {
403 + "name": "id",
404 + "type": "text",
405 + "primaryKey": true,
406 + "notNull": true
407 + },
408 + "connector_id": {
409 + "name": "connector_id",
410 + "type": "text",
411 + "primaryKey": false,
412 + "notNull": true
413 + },
414 + "task": {
415 + "name": "task",
416 + "type": "text",
417 + "primaryKey": false,
418 + "notNull": true,
419 + "default": "'crawl'"
420 + },
421 + "started_at": {
422 + "name": "started_at",
423 + "type": "timestamp with time zone",
424 + "primaryKey": false,
425 + "notNull": true,
426 + "default": "now()"
427 + },
428 + "finished_at": {
429 + "name": "finished_at",
430 + "type": "timestamp with time zone",
431 + "primaryKey": false,
432 + "notNull": false
433 + },
434 + "status": {
435 + "name": "status",
436 + "type": "text",
437 + "primaryKey": false,
438 + "notNull": true,
439 + "default": "'running'"
440 + },
441 + "stats": {
442 + "name": "stats",
443 + "type": "jsonb",
444 + "primaryKey": false,
445 + "notNull": true,
446 + "default": "'{}'::jsonb"
447 + },
448 + "error": {
449 + "name": "error",
450 + "type": "text",
451 + "primaryKey": false,
452 + "notNull": false
453 + },
454 + "log": {
455 + "name": "log",
456 + "type": "jsonb",
457 + "primaryKey": false,
458 + "notNull": true,
459 + "default": "'[]'::jsonb"
460 + }
461 + },
462 + "indexes": {
463 + "connector_runs_connector_idx": {
464 + "name": "connector_runs_connector_idx",
465 + "columns": [
466 + {
467 + "expression": "connector_id",
468 + "isExpression": false,
469 + "asc": true,
470 + "nulls": "last"
471 + },
472 + {
473 + "expression": "started_at",
474 + "isExpression": false,
475 + "asc": true,
476 + "nulls": "last"
477 + }
478 + ],
479 + "isUnique": false,
480 + "concurrently": false,
481 + "method": "btree",
482 + "with": {}
483 + }
484 + },
485 + "foreignKeys": {},
486 + "compositePrimaryKeys": {},
487 + "uniqueConstraints": {},
488 + "policies": {},
489 + "checkConstraints": {},
490 + "isRLSEnabled": false
491 + },
492 + "public.connectors": {
493 + "name": "connectors",
494 + "schema": "",
495 + "columns": {
496 + "id": {
497 + "name": "id",
498 + "type": "text",
499 + "primaryKey": true,
500 + "notNull": true
501 + },
502 + "source_name": {
503 + "name": "source_name",
504 + "type": "text",
505 + "primaryKey": false,
506 + "notNull": true
507 + },
508 + "domain": {
509 + "name": "domain",
510 + "type": "text",
511 + "primaryKey": false,
512 + "notNull": true
513 + },
514 + "kind": {
515 + "name": "kind",
516 + "type": "text",
517 + "primaryKey": false,
518 + "notNull": true
519 + },
520 + "mode": {
521 + "name": "mode",
522 + "type": "text",
523 + "primaryKey": false,
524 + "notNull": true
525 + },
526 + "enabled": {
527 + "name": "enabled",
528 + "type": "boolean",
529 + "primaryKey": false,
530 + "notNull": true,
531 + "default": true
532 + },
533 + "paused": {
534 + "name": "paused",
535 + "type": "boolean",
536 + "primaryKey": false,
537 + "notNull": true,
538 + "default": false
539 + },
540 + "config": {
541 + "name": "config",
542 + "type": "jsonb",
543 + "primaryKey": false,
544 + "notNull": true,
545 + "default": "'{}'::jsonb"
546 + },
547 + "parser_version": {
548 + "name": "parser_version",
549 + "type": "text",
550 + "primaryKey": false,
551 + "notNull": true,
552 + "default": "'v1'"
553 + },
554 + "schedule": {
555 + "name": "schedule",
556 + "type": "jsonb",
557 + "primaryKey": false,
558 + "notNull": true,
559 + "default": "'{}'::jsonb"
560 + },
561 + "health": {
562 + "name": "health",
563 + "type": "text",
564 + "primaryKey": false,
565 + "notNull": true,
566 + "default": "'never_run'"
567 + },
568 + "last_run_at": {
569 + "name": "last_run_at",
570 + "type": "timestamp with time zone",
571 + "primaryKey": false,
572 + "notNull": false
573 + },
574 + "last_status": {
575 + "name": "last_status",
576 + "type": "text",
577 + "primaryKey": false,
578 + "notNull": false
579 + },
580 + "last_error": {
581 + "name": "last_error",
582 + "type": "text",
583 + "primaryKey": false,
584 + "notNull": false
585 + },
586 + "next_run_at": {
587 + "name": "next_run_at",
588 + "type": "timestamp with time zone",
589 + "primaryKey": false,
590 + "notNull": false
591 + },
592 + "stats": {
593 + "name": "stats",
594 + "type": "jsonb",
595 + "primaryKey": false,
596 + "notNull": true,
597 + "default": "'{}'::jsonb"
598 + },
599 + "created_at": {
600 + "name": "created_at",
601 + "type": "timestamp with time zone",
602 + "primaryKey": false,
603 + "notNull": true,
604 + "default": "now()"
605 + },
606 + "updated_at": {
607 + "name": "updated_at",
608 + "type": "timestamp with time zone",
609 + "primaryKey": false,
610 + "notNull": true,
611 + "default": "now()"
612 + }
613 + },
614 + "indexes": {},
615 + "foreignKeys": {},
616 + "compositePrimaryKeys": {},
617 + "uniqueConstraints": {},
618 + "policies": {},
619 + "checkConstraints": {},
620 + "isRLSEnabled": false
621 + },
622 + "public.countries": {
623 + "name": "countries",
624 + "schema": "",
625 + "columns": {
626 + "iso2": {
627 + "name": "iso2",
628 + "type": "text",
629 + "primaryKey": true,
630 + "notNull": true
631 + },
632 + "iso3": {
633 + "name": "iso3",
634 + "type": "text",
635 + "primaryKey": false,
636 + "notNull": false
637 + },
638 + "slug": {
639 + "name": "slug",
640 + "type": "text",
641 + "primaryKey": false,
642 + "notNull": true
643 + },
644 + "name": {
645 + "name": "name",
646 + "type": "text",
647 + "primaryKey": false,
648 + "notNull": true
649 + },
650 + "region": {
651 + "name": "region",
652 + "type": "text",
653 + "primaryKey": false,
654 + "notNull": false
655 + },
656 + "subregion": {
657 + "name": "subregion",
658 + "type": "text",
659 + "primaryKey": false,
660 + "notNull": false
661 + },
662 + "population": {
663 + "name": "population",
664 + "type": "bigint",
665 + "primaryKey": false,
666 + "notNull": false
667 + },
668 + "gdp_usd": {
669 + "name": "gdp_usd",
670 + "type": "double precision",
671 + "primaryKey": false,
672 + "notNull": false
673 + },
674 + "electricity_twh": {
675 + "name": "electricity_twh",
676 + "type": "double precision",
677 + "primaryKey": false,
678 + "notNull": false
679 + },
680 + "renewable_share": {
681 + "name": "renewable_share",
682 + "type": "double precision",
683 + "primaryKey": false,
684 + "notNull": false
685 + },
686 + "stats_year": {
687 + "name": "stats_year",
688 + "type": "integer",
689 + "primaryKey": false,
690 + "notNull": false
691 + },
692 + "lat": {
693 + "name": "lat",
694 + "type": "double precision",
695 + "primaryKey": false,
696 + "notNull": false
697 + },
698 + "lng": {
699 + "name": "lng",
700 + "type": "double precision",
701 + "primaryKey": false,
702 + "notNull": false
703 + },
704 + "stats": {
705 + "name": "stats",
706 + "type": "jsonb",
707 + "primaryKey": false,
708 + "notNull": true,
709 + "default": "'{}'::jsonb"
710 + },
711 + "updated_at": {
712 + "name": "updated_at",
713 + "type": "timestamp with time zone",
714 + "primaryKey": false,
715 + "notNull": true,
716 + "default": "now()"
717 + }
718 + },
719 + "indexes": {},
720 + "foreignKeys": {},
721 + "compositePrimaryKeys": {},
722 + "uniqueConstraints": {
723 + "countries_slug_unique": {
724 + "name": "countries_slug_unique",
725 + "nullsNotDistinct": false,
726 + "columns": [
727 + "slug"
728 + ]
729 + }
730 + },
731 + "policies": {},
732 + "checkConstraints": {},
733 + "isRLSEnabled": false
734 + },
735 + "public.daily_metrics": {
736 + "name": "daily_metrics",
737 + "schema": "",
738 + "columns": {
739 + "day": {
740 + "name": "day",
741 + "type": "date",
742 + "primaryKey": false,
743 + "notNull": true
744 + },
745 + "metric": {
746 + "name": "metric",
747 + "type": "text",
748 + "primaryKey": false,
749 + "notNull": true
750 + },
751 + "dim": {
752 + "name": "dim",
753 + "type": "text",
754 + "primaryKey": false,
755 + "notNull": true,
756 + "default": "'global'"
757 + },
758 + "value": {
759 + "name": "value",
760 + "type": "double precision",
761 + "primaryKey": false,
762 + "notNull": true
763 + }
764 + },
765 + "indexes": {},
766 + "foreignKeys": {},
767 + "compositePrimaryKeys": {
768 + "daily_metrics_day_metric_dim_pk": {
769 + "name": "daily_metrics_day_metric_dim_pk",
770 + "columns": [
771 + "day",
772 + "metric",
773 + "dim"
774 + ]
775 + }
776 + },
777 + "uniqueConstraints": {},
778 + "policies": {},
779 + "checkConstraints": {},
780 + "isRLSEnabled": false
781 + },
782 + "public.document_versions": {
783 + "name": "document_versions",
784 + "schema": "",
785 + "columns": {
786 + "id": {
787 + "name": "id",
788 + "type": "text",
789 + "primaryKey": true,
790 + "notNull": true
791 + },
792 + "document_id": {
793 + "name": "document_id",
794 + "type": "text",
795 + "primaryKey": false,
796 + "notNull": true
797 + },
798 + "content_hash": {
799 + "name": "content_hash",
800 + "type": "text",
801 + "primaryKey": false,
802 + "notNull": true
803 + },
804 + "fetched_at": {
805 + "name": "fetched_at",
806 + "type": "timestamp with time zone",
807 + "primaryKey": false,
808 + "notNull": true,
809 + "default": "now()"
810 + },
811 + "fetch_level": {
812 + "name": "fetch_level",
813 + "type": "integer",
814 + "primaryKey": false,
815 + "notNull": true,
816 + "default": 1
817 + },
818 + "status_code": {
819 + "name": "status_code",
820 + "type": "integer",
821 + "primaryKey": false,
822 + "notNull": false
823 + },
824 + "size_bytes": {
825 + "name": "size_bytes",
826 + "type": "integer",
827 + "primaryKey": false,
828 + "notNull": false
829 + },
830 + "storage_key": {
831 + "name": "storage_key",
832 + "type": "text",
833 + "primaryKey": false,
834 + "notNull": false
835 + },
836 + "diff_summary": {
837 + "name": "diff_summary",
838 + "type": "jsonb",
839 + "primaryKey": false,
840 + "notNull": false
841 + },
842 + "detected_changes": {
843 + "name": "detected_changes",
844 + "type": "jsonb",
845 + "primaryKey": false,
846 + "notNull": true,
847 + "default": "'[]'::jsonb"
848 + },
849 + "significance": {
850 + "name": "significance",
851 + "type": "integer",
852 + "primaryKey": false,
853 + "notNull": true,
854 + "default": 0
855 + }
856 + },
857 + "indexes": {
858 + "document_versions_doc_idx": {
859 + "name": "document_versions_doc_idx",
860 + "columns": [
861 + {
862 + "expression": "document_id",
863 + "isExpression": false,
864 + "asc": true,
865 + "nulls": "last"
866 + },
867 + {
868 + "expression": "fetched_at",
869 + "isExpression": false,
870 + "asc": true,
871 + "nulls": "last"
872 + }
873 + ],
874 + "isUnique": false,
875 + "concurrently": false,
876 + "method": "btree",
877 + "with": {}
878 + }
879 + },
880 + "foreignKeys": {
881 + "document_versions_document_id_documents_id_fk": {
882 + "name": "document_versions_document_id_documents_id_fk",
883 + "tableFrom": "document_versions",
884 + "tableTo": "documents",
885 + "columnsFrom": [
886 + "document_id"
887 + ],
888 + "columnsTo": [
889 + "id"
890 + ],
891 + "onDelete": "cascade",
892 + "onUpdate": "no action"
893 + }
894 + },
895 + "compositePrimaryKeys": {},
896 + "uniqueConstraints": {},
897 + "policies": {},
898 + "checkConstraints": {},
899 + "isRLSEnabled": false
900 + },
901 + "public.documents": {
902 + "name": "documents",
903 + "schema": "",
904 + "columns": {
905 + "id": {
906 + "name": "id",
907 + "type": "text",
908 + "primaryKey": true,
909 + "notNull": true
910 + },
911 + "connector_id": {
912 + "name": "connector_id",
913 + "type": "text",
914 + "primaryKey": false,
915 + "notNull": true
916 + },
917 + "source_id": {
918 + "name": "source_id",
919 + "type": "text",
920 + "primaryKey": false,
921 + "notNull": true
922 + },
923 + "url": {
924 + "name": "url",
925 + "type": "text",
926 + "primaryKey": false,
927 + "notNull": true
928 + },
929 + "canonical_url": {
930 + "name": "canonical_url",
931 + "type": "text",
932 + "primaryKey": false,
933 + "notNull": true
934 + },
935 + "url_fingerprint": {
936 + "name": "url_fingerprint",
937 + "type": "text",
938 + "primaryKey": false,
939 + "notNull": true
940 + },
941 + "page_type": {
942 + "name": "page_type",
943 + "type": "text",
944 + "primaryKey": false,
945 + "notNull": true,
946 + "default": "'unknown'"
947 + },
948 + "classifier": {
949 + "name": "classifier",
950 + "type": "text",
951 + "primaryKey": false,
952 + "notNull": false
953 + },
954 + "fetch_level": {
955 + "name": "fetch_level",
956 + "type": "integer",
957 + "primaryKey": false,
958 + "notNull": true,
959 + "default": 1
960 + },
961 + "priority": {
962 + "name": "priority",
963 + "type": "integer",
964 + "primaryKey": false,
965 + "notNull": true,
966 + "default": 50
967 + },
968 + "content_hash": {
969 + "name": "content_hash",
970 + "type": "text",
971 + "primaryKey": false,
972 + "notNull": false
973 + },
974 + "etag": {
975 + "name": "etag",
976 + "type": "text",
977 + "primaryKey": false,
978 + "notNull": false
979 + },
980 + "last_modified": {
981 + "name": "last_modified",
982 + "type": "text",
983 + "primaryKey": false,
984 + "notNull": false
985 + },
986 + "status_code": {
987 + "name": "status_code",
988 + "type": "integer",
989 + "primaryKey": false,
990 + "notNull": false
991 + },
992 + "content_type": {
993 + "name": "content_type",
994 + "type": "text",
995 + "primaryKey": false,
996 + "notNull": false
997 + },
998 + "size_bytes": {
999 + "name": "size_bytes",
1000 + "type": "integer",
1001 + "primaryKey": false,
1002 + "notNull": false
1003 + },
1004 + "title": {
1005 + "name": "title",
1006 + "type": "text",
1007 + "primaryKey": false,
1008 + "notNull": false
1009 + },
1010 + "storage_key": {
1011 + "name": "storage_key",
1012 + "type": "text",
1013 + "primaryKey": false,
1014 + "notNull": false
1015 + },
1016 + "extractor_version": {
1017 + "name": "extractor_version",
1018 + "type": "text",
1019 + "primaryKey": false,
1020 + "notNull": false
1021 + },
1022 + "extract_ok": {
1023 + "name": "extract_ok",
1024 + "type": "boolean",
1025 + "primaryKey": false,
1026 + "notNull": false
1027 + },
1028 + "extract_count": {
1029 + "name": "extract_count",
1030 + "type": "integer",
1031 + "primaryKey": false,
1032 + "notNull": true,
1033 + "default": 0
1034 + },
1035 + "error": {
1036 + "name": "error",
1037 + "type": "text",
1038 + "primaryKey": false,
1039 + "notNull": false
1040 + },
1041 + "error_count": {
1042 + "name": "error_count",
1043 + "type": "integer",
1044 + "primaryKey": false,
1045 + "notNull": true,
1046 + "default": 0
1047 + },
1048 + "first_seen": {
1049 + "name": "first_seen",
1050 + "type": "timestamp with time zone",
1051 + "primaryKey": false,
1052 + "notNull": true,
1053 + "default": "now()"
1054 + },
1055 + "last_fetched": {
1056 + "name": "last_fetched",
1057 + "type": "timestamp with time zone",
1058 + "primaryKey": false,
1059 + "notNull": false
1060 + },
1061 + "last_changed": {
1062 + "name": "last_changed",
1063 + "type": "timestamp with time zone",
1064 + "primaryKey": false,
1065 + "notNull": false
1066 + },
1067 + "last_checked": {
1068 + "name": "last_checked",
1069 + "type": "timestamp with time zone",
1070 + "primaryKey": false,
1071 + "notNull": false
1072 + },
1073 + "next_check": {
1074 + "name": "next_check",
1075 + "type": "timestamp with time zone",
1076 + "primaryKey": false,
1077 + "notNull": false
1078 + },
1079 + "change_frequency_score": {
1080 + "name": "change_frequency_score",
1081 + "type": "real",
1082 + "primaryKey": false,
1083 + "notNull": true,
1084 + "default": 0.5
1085 + },
1086 + "fetch_count": {
1087 + "name": "fetch_count",
1088 + "type": "integer",
1089 + "primaryKey": false,
1090 + "notNull": true,
1091 + "default": 0
1092 + },
1093 + "change_count": {
1094 + "name": "change_count",
1095 + "type": "integer",
1096 + "primaryKey": false,
1097 + "notNull": true,
1098 + "default": 0
1099 + },
1100 + "discovered_from": {
1101 + "name": "discovered_from",
1102 + "type": "text",
1103 + "primaryKey": false,
1104 + "notNull": false
1105 + },
1106 + "quarantined": {
1107 + "name": "quarantined",
1108 + "type": "boolean",
1109 + "primaryKey": false,
1110 + "notNull": true,
1111 + "default": false
1112 + },
1113 + "entity_refs": {
1114 + "name": "entity_refs",
1115 + "type": "jsonb",
1116 + "primaryKey": false,
1117 + "notNull": true,
1118 + "default": "'[]'::jsonb"
1119 + },
1120 + "updated_at": {
1121 + "name": "updated_at",
1122 + "type": "timestamp with time zone",
1123 + "primaryKey": false,
1124 + "notNull": true,
1125 + "default": "now()"
1126 + }
1127 + },
1128 + "indexes": {
1129 + "documents_fingerprint_uq": {
1130 + "name": "documents_fingerprint_uq",
1131 + "columns": [
1132 + {
1133 + "expression": "url_fingerprint",
1134 + "isExpression": false,
1135 + "asc": true,
1136 + "nulls": "last"
1137 + }
1138 + ],
1139 + "isUnique": true,
1140 + "concurrently": false,
1141 + "method": "btree",
1142 + "with": {}
1143 + },
1144 + "documents_connector_idx": {
1145 + "name": "documents_connector_idx",
1146 + "columns": [
1147 + {
1148 + "expression": "connector_id",
1149 + "isExpression": false,
1150 + "asc": true,
1151 + "nulls": "last"
1152 + }
1153 + ],
1154 + "isUnique": false,
1155 + "concurrently": false,
1156 + "method": "btree",
1157 + "with": {}
1158 + },
1159 + "documents_next_check_idx": {
1160 + "name": "documents_next_check_idx",
1161 + "columns": [
1162 + {
1163 + "expression": "next_check",
1164 + "isExpression": false,
1165 + "asc": true,
1166 + "nulls": "last"
1167 + }
1168 + ],
1169 + "isUnique": false,
1170 + "concurrently": false,
1171 + "method": "btree",
1172 + "with": {}
1173 + },
1174 + "documents_page_type_idx": {
1175 + "name": "documents_page_type_idx",
1176 + "columns": [
1177 + {
1178 + "expression": "page_type",
1179 + "isExpression": false,
1180 + "asc": true,
1181 + "nulls": "last"
1182 + }
1183 + ],
1184 + "isUnique": false,
1185 + "concurrently": false,
1186 + "method": "btree",
1187 + "with": {}
1188 + }
1189 + },
1190 + "foreignKeys": {},
1191 + "compositePrimaryKeys": {},
1192 + "uniqueConstraints": {},
1193 + "policies": {},
1194 + "checkConstraints": {},
1195 + "isRLSEnabled": false
1196 + },
1197 + "public.entity_keys": {
1198 + "name": "entity_keys",
1199 + "schema": "",
1200 + "columns": {
1201 + "key": {
1202 + "name": "key",
1203 + "type": "text",
1204 + "primaryKey": true,
1205 + "notNull": true
1206 + },
1207 + "entity_type": {
1208 + "name": "entity_type",
1209 + "type": "text",
1210 + "primaryKey": false,
1211 + "notNull": true
1212 + },
1213 + "entity_id": {
1214 + "name": "entity_id",
1215 + "type": "text",
1216 + "primaryKey": false,
1217 + "notNull": true
1218 + },
1219 + "connector_id": {
1220 + "name": "connector_id",
1221 + "type": "text",
1222 + "primaryKey": false,
1223 + "notNull": true
1224 + },
1225 + "created_at": {
1226 + "name": "created_at",
1227 + "type": "timestamp with time zone",
1228 + "primaryKey": false,
1229 + "notNull": true,
1230 + "default": "now()"
1231 + }
1232 + },
1233 + "indexes": {
1234 + "entity_keys_entity_idx": {
1235 + "name": "entity_keys_entity_idx",
1236 + "columns": [
1237 + {
1238 + "expression": "entity_type",
1239 + "isExpression": false,
1240 + "asc": true,
1241 + "nulls": "last"
1242 + },
1243 + {
1244 + "expression": "entity_id",
1245 + "isExpression": false,
1246 + "asc": true,
1247 + "nulls": "last"
1248 + }
1249 + ],
1250 + "isUnique": false,
1251 + "concurrently": false,
1252 + "method": "btree",
1253 + "with": {}
1254 + }
1255 + },
1256 + "foreignKeys": {},
1257 + "compositePrimaryKeys": {},
1258 + "uniqueConstraints": {},
1259 + "policies": {},
1260 + "checkConstraints": {},
1261 + "isRLSEnabled": false
1262 + },
1263 + "public.entity_matches": {
1264 + "name": "entity_matches",
1265 + "schema": "",
1266 + "columns": {
1267 + "id": {
1268 + "name": "id",
1269 + "type": "text",
1270 + "primaryKey": true,
1271 + "notNull": true
1272 + },
1273 + "connector_id": {
1274 + "name": "connector_id",
1275 + "type": "text",
1276 + "primaryKey": false,
1277 + "notNull": true
1278 + },
1279 + "candidate_key": {
1280 + "name": "candidate_key",
1281 + "type": "text",
1282 + "primaryKey": false,
1283 + "notNull": true
1284 + },
1285 + "candidate": {
1286 + "name": "candidate",
1287 + "type": "jsonb",
1288 + "primaryKey": false,
1289 + "notNull": true
1290 + },
1291 + "matched_facility_id": {
1292 + "name": "matched_facility_id",
1293 + "type": "text",
1294 + "primaryKey": false,
1295 + "notNull": false
1296 + },
1297 + "score": {
1298 + "name": "score",
1299 + "type": "real",
1300 + "primaryKey": false,
1301 + "notNull": true
1302 + },
1303 + "reasons": {
1304 + "name": "reasons",
1305 + "type": "text[]",
1306 + "primaryKey": false,
1307 + "notNull": true,
1308 + "default": "'{}'::text[]"
1309 + },
1310 + "status": {
1311 + "name": "status",
1312 + "type": "text",
1313 + "primaryKey": false,
1314 + "notNull": true,
1315 + "default": "'pending'"
1316 + },
1317 + "decided_by": {
1318 + "name": "decided_by",
1319 + "type": "text",
1320 + "primaryKey": false,
1321 + "notNull": false
1322 + },
1323 + "decided_at": {
1324 + "name": "decided_at",
1325 + "type": "timestamp with time zone",
1326 + "primaryKey": false,
1327 + "notNull": false
1328 + },
1329 + "created_at": {
1330 + "name": "created_at",
1331 + "type": "timestamp with time zone",
1332 + "primaryKey": false,
1333 + "notNull": true,
1334 + "default": "now()"
1335 + }
1336 + },
1337 + "indexes": {
1338 + "entity_matches_status_idx": {
1339 + "name": "entity_matches_status_idx",
1340 + "columns": [
1341 + {
1342 + "expression": "status",
1343 + "isExpression": false,
1344 + "asc": true,
1345 + "nulls": "last"
1346 + }
1347 + ],
1348 + "isUnique": false,
1349 + "concurrently": false,
1350 + "method": "btree",
1351 + "with": {}
1352 + },
1353 + "entity_matches_key_idx": {
1354 + "name": "entity_matches_key_idx",
1355 + "columns": [
1356 + {
1357 + "expression": "candidate_key",
1358 + "isExpression": false,
1359 + "asc": true,
1360 + "nulls": "last"
1361 + }
1362 + ],
1363 + "isUnique": false,
1364 + "concurrently": false,
1365 + "method": "btree",
1366 + "with": {}
1367 + }
1368 + },
1369 + "foreignKeys": {},
1370 + "compositePrimaryKeys": {},
1371 + "uniqueConstraints": {},
1372 + "policies": {},
1373 + "checkConstraints": {},
1374 + "isRLSEnabled": false
1375 + },
1376 + "public.events": {
1377 + "name": "events",
1378 + "schema": "",
1379 + "columns": {
1380 + "id": {
1381 + "name": "id",
1382 + "type": "text",
1383 + "primaryKey": true,
1384 + "notNull": true
1385 + },
1386 + "entity_type": {
1387 + "name": "entity_type",
1388 + "type": "text",
1389 + "primaryKey": false,
1390 + "notNull": true
1391 + },
1392 + "entity_id": {
1393 + "name": "entity_id",
1394 + "type": "text",
1395 + "primaryKey": false,
1396 + "notNull": false
1397 + },
1398 + "event_type": {
1399 + "name": "event_type",
1400 + "type": "text",
1401 + "primaryKey": false,
1402 + "notNull": true
1403 + },
1404 + "detected_at": {
1405 + "name": "detected_at",
1406 + "type": "timestamp with time zone",
1407 + "primaryKey": false,
1408 + "notNull": true,
1409 + "default": "now()"
1410 + },
1411 + "effective_date": {
1412 + "name": "effective_date",
1413 + "type": "text",
1414 + "primaryKey": false,
1415 + "notNull": false
1416 + },
1417 + "old_value": {
1418 + "name": "old_value",
1419 + "type": "jsonb",
1420 + "primaryKey": false,
1421 + "notNull": false
1422 + },
1423 + "new_value": {
1424 + "name": "new_value",
1425 + "type": "jsonb",
1426 + "primaryKey": false,
1427 + "notNull": false
1428 + },
1429 + "source_id": {
1430 + "name": "source_id",
1431 + "type": "text",
1432 + "primaryKey": false,
1433 + "notNull": true
1434 + },
1435 + "document_id": {
1436 + "name": "document_id",
1437 + "type": "text",
1438 + "primaryKey": false,
1439 + "notNull": false
1440 + },
1441 + "url": {
1442 + "name": "url",
1443 + "type": "text",
1444 + "primaryKey": false,
1445 + "notNull": true
1446 + },
1447 + "title": {
1448 + "name": "title",
1449 + "type": "text",
1450 + "primaryKey": false,
1451 + "notNull": true
1452 + },
1453 + "summary": {
1454 + "name": "summary",
1455 + "type": "text",
1456 + "primaryKey": false,
1457 + "notNull": false
1458 + },
1459 + "significance": {
1460 + "name": "significance",
1461 + "type": "integer",
1462 + "primaryKey": false,
1463 + "notNull": true,
1464 + "default": 30
1465 + },
1466 + "confidence": {
1467 + "name": "confidence",
1468 + "type": "text",
1469 + "primaryKey": false,
1470 + "notNull": true,
1471 + "default": "'moderate'"
1472 + },
1473 + "review_status": {
1474 + "name": "review_status",
1475 + "type": "text",
1476 + "primaryKey": false,
1477 + "notNull": true,
1478 + "default": "'auto'"
1479 + },
1480 + "country_iso2": {
1481 + "name": "country_iso2",
1482 + "type": "text",
1483 + "primaryKey": false,
1484 + "notNull": false
1485 + },
1486 + "operator_id": {
1487 + "name": "operator_id",
1488 + "type": "text",
1489 + "primaryKey": false,
1490 + "notNull": false
1491 + },
1492 + "metro_id": {
1493 + "name": "metro_id",
1494 + "type": "text",
1495 + "primaryKey": false,
1496 + "notNull": false
1497 + },
1498 + "project_id": {
1499 + "name": "project_id",
1500 + "type": "text",
1501 + "primaryKey": false,
1502 + "notNull": false
1503 + },
1504 + "fingerprint": {
1505 + "name": "fingerprint",
1506 + "type": "text",
1507 + "primaryKey": false,
1508 + "notNull": true
1509 + }
1510 + },
1511 + "indexes": {
1512 + "events_fingerprint_uq": {
1513 + "name": "events_fingerprint_uq",
1514 + "columns": [
1515 + {
1516 + "expression": "fingerprint",
1517 + "isExpression": false,
1518 + "asc": true,
1519 + "nulls": "last"
1520 + }
1521 + ],
1522 + "isUnique": true,
1523 + "concurrently": false,
1524 + "method": "btree",
1525 + "with": {}
1526 + },
1527 + "events_detected_idx": {
1528 + "name": "events_detected_idx",
1529 + "columns": [
1530 + {
1531 + "expression": "detected_at",
1532 + "isExpression": false,
1533 + "asc": true,
1534 + "nulls": "last"
1535 + }
1536 + ],
1537 + "isUnique": false,
1538 + "concurrently": false,
1539 + "method": "btree",
1540 + "with": {}
1541 + },
1542 + "events_entity_idx": {
1543 + "name": "events_entity_idx",
1544 + "columns": [
1545 + {
1546 + "expression": "entity_type",
1547 + "isExpression": false,
1548 + "asc": true,
1549 + "nulls": "last"
1550 + },
1551 + {
1552 + "expression": "entity_id",
1553 + "isExpression": false,
1554 + "asc": true,
1555 + "nulls": "last"
1556 + }
1557 + ],
1558 + "isUnique": false,
1559 + "concurrently": false,
1560 + "method": "btree",
1561 + "with": {}
1562 + },
1563 + "events_country_idx": {
1564 + "name": "events_country_idx",
1565 + "columns": [
1566 + {
1567 + "expression": "country_iso2",
1568 + "isExpression": false,
1569 + "asc": true,
1570 + "nulls": "last"
1571 + }
1572 + ],
1573 + "isUnique": false,
1574 + "concurrently": false,
1575 + "method": "btree",
1576 + "with": {}
1577 + },
1578 + "events_operator_idx": {
1579 + "name": "events_operator_idx",
1580 + "columns": [
1581 + {
1582 + "expression": "operator_id",
1583 + "isExpression": false,
1584 + "asc": true,
1585 + "nulls": "last"
1586 + }
1587 + ],
1588 + "isUnique": false,
1589 + "concurrently": false,
1590 + "method": "btree",
1591 + "with": {}
1592 + },
1593 + "events_type_idx": {
1594 + "name": "events_type_idx",
1595 + "columns": [
1596 + {
1597 + "expression": "event_type",
1598 + "isExpression": false,
1599 + "asc": true,
1600 + "nulls": "last"
1601 + }
1602 + ],
1603 + "isUnique": false,
1604 + "concurrently": false,
1605 + "method": "btree",
1606 + "with": {}
1607 + }
1608 + },
1609 + "foreignKeys": {},
1610 + "compositePrimaryKeys": {},
1611 + "uniqueConstraints": {},
1612 + "policies": {},
1613 + "checkConstraints": {},
1614 + "isRLSEnabled": false
1615 + },
1616 + "public.facilities": {
1617 + "name": "facilities",
1618 + "schema": "",
1619 + "columns": {
1620 + "id": {
1621 + "name": "id",
1622 + "type": "text",
1623 + "primaryKey": true,
1624 + "notNull": true
1625 + },
1626 + "slug": {
1627 + "name": "slug",
1628 + "type": "text",
1629 + "primaryKey": false,
1630 + "notNull": true
1631 + },
1632 + "name": {
1633 + "name": "name",
1634 + "type": "text",
1635 + "primaryKey": false,
1636 + "notNull": true
1637 + },
1638 + "normalized_name": {
1639 + "name": "normalized_name",
1640 + "type": "text",
1641 + "primaryKey": false,
1642 + "notNull": true
1643 + },
1644 + "operator_id": {
1645 + "name": "operator_id",
1646 + "type": "text",
1647 + "primaryKey": false,
1648 + "notNull": false
1649 + },
1650 + "owner_id": {
1651 + "name": "owner_id",
1652 + "type": "text",
1653 + "primaryKey": false,
1654 + "notNull": false
1655 + },
1656 + "campus_id": {
1657 + "name": "campus_id",
1658 + "type": "text",
1659 + "primaryKey": false,
1660 + "notNull": false
1661 + },
1662 + "metro_id": {
1663 + "name": "metro_id",
1664 + "type": "text",
1665 + "primaryKey": false,
1666 + "notNull": false
1667 + },
1668 + "country_iso2": {
1669 + "name": "country_iso2",
1670 + "type": "text",
1671 + "primaryKey": false,
1672 + "notNull": false
1673 + },
1674 + "city": {
1675 + "name": "city",
1676 + "type": "text",
1677 + "primaryKey": false,
1678 + "notNull": false
1679 + },
1680 + "region_name": {
1681 + "name": "region_name",
1682 + "type": "text",
1683 + "primaryKey": false,
1684 + "notNull": false
1685 + },
1686 + "address": {
1687 + "name": "address",
1688 + "type": "text",
1689 + "primaryKey": false,
1690 + "notNull": false
1691 + },
1692 + "postal_code": {
1693 + "name": "postal_code",
1694 + "type": "text",
1695 + "primaryKey": false,
1696 + "notNull": false
1697 + },
1698 + "lat": {
1699 + "name": "lat",
1700 + "type": "double precision",
1701 + "primaryKey": false,
1702 + "notNull": false
1703 + },
1704 + "lng": {
1705 + "name": "lng",
1706 + "type": "double precision",
1707 + "primaryKey": false,
1708 + "notNull": false
1709 + },
1710 + "geo_precision": {
1711 + "name": "geo_precision",
1712 + "type": "text",
1713 + "primaryKey": false,
1714 + "notNull": true,
1715 + "default": "'unknown'"
1716 + },
1717 + "geo_source": {
1718 + "name": "geo_source",
1719 + "type": "text",
1720 + "primaryKey": false,
1721 + "notNull": false
1722 + },
1723 + "geohash": {
1724 + "name": "geohash",
1725 + "type": "text",
1726 + "primaryKey": false,
1727 + "notNull": false
1728 + },
1729 + "status": {
1730 + "name": "status",
1731 + "type": "text",
1732 + "primaryKey": false,
1733 + "notNull": true,
1734 + "default": "'unknown'"
1735 + },
1736 + "facility_type": {
1737 + "name": "facility_type",
1738 + "type": "text",
1739 + "primaryKey": false,
1740 + "notNull": true,
1741 + "default": "'unknown'"
1742 + },
1743 + "tier": {
1744 + "name": "tier",
1745 + "type": "text",
1746 + "primaryKey": false,
1747 + "notNull": false
1748 + },
1749 + "building_sqm": {
1750 + "name": "building_sqm",
1751 + "type": "double precision",
1752 + "primaryKey": false,
1753 + "notNull": false
1754 + },
1755 + "site_area_ha": {
1756 + "name": "site_area_ha",
1757 + "type": "double precision",
1758 + "primaryKey": false,
1759 + "notNull": false
1760 + },
1761 + "it_capacity_mw": {
1762 + "name": "it_capacity_mw",
1763 + "type": "double precision",
1764 + "primaryKey": false,
1765 + "notNull": false
1766 + },
1767 + "total_power_mw": {
1768 + "name": "total_power_mw",
1769 + "type": "double precision",
1770 + "primaryKey": false,
1771 + "notNull": false
1772 + },
1773 + "planned_power_mw": {
1774 + "name": "planned_power_mw",
1775 + "type": "double precision",
1776 + "primaryKey": false,
1777 + "notNull": false
1778 + },
1779 + "mw_is_estimate": {
1780 + "name": "mw_is_estimate",
1781 + "type": "boolean",
1782 + "primaryKey": false,
1783 + "notNull": true,
1784 + "default": false
1785 + },
1786 + "rack_count": {
1787 + "name": "rack_count",
1788 + "type": "integer",
1789 + "primaryKey": false,
1790 + "notNull": false
1791 + },
1792 + "pue": {
1793 + "name": "pue",
1794 + "type": "real",
1795 + "primaryKey": false,
1796 + "notNull": false
1797 + },
1798 + "cooling_type": {
1799 + "name": "cooling_type",
1800 + "type": "text",
1801 + "primaryKey": false,
1802 + "notNull": false
1803 + },
1804 + "renewable_claim": {
1805 + "name": "renewable_claim",
1806 + "type": "text",
1807 + "primaryKey": false,
1808 + "notNull": false
1809 + },
1810 + "opened_on": {
1811 + "name": "opened_on",
1812 + "type": "text",
1813 + "primaryKey": false,
1814 + "notNull": false
1815 + },
1816 + "construction_started_on": {
1817 + "name": "construction_started_on",
1818 + "type": "text",
1819 + "primaryKey": false,
1820 + "notNull": false
1821 + },
1822 + "announced_on": {
1823 + "name": "announced_on",
1824 + "type": "text",
1825 + "primaryKey": false,
1826 + "notNull": false
1827 + },
1828 + "website": {
1829 + "name": "website",
1830 + "type": "text",
1831 + "primaryKey": false,
1832 + "notNull": false
1833 + },
1834 + "description": {
1835 + "name": "description",
1836 + "type": "text",
1837 + "primaryKey": false,
1838 + "notNull": false
1839 + },
1840 + "is_ai": {
1841 + "name": "is_ai",
1842 + "type": "boolean",
1843 + "primaryKey": false,
1844 + "notNull": true,
1845 + "default": false
1846 + },
1847 + "is_hyperscale": {
1848 + "name": "is_hyperscale",
1849 + "type": "boolean",
1850 + "primaryKey": false,
1851 + "notNull": true,
1852 + "default": false
1853 + },
1854 + "certifications": {
1855 + "name": "certifications",
1856 + "type": "text[]",
1857 + "primaryKey": false,
1858 + "notNull": true,
1859 + "default": "'{}'::text[]"
1860 + },
1861 + "carriers_count": {
1862 + "name": "carriers_count",
1863 + "type": "integer",
1864 + "primaryKey": false,
1865 + "notNull": false
1866 + },
1867 + "ixp_count": {
1868 + "name": "ixp_count",
1869 + "type": "integer",
1870 + "primaryKey": false,
1871 + "notNull": false
1872 + },
1873 + "networks_count": {
1874 + "name": "networks_count",
1875 + "type": "integer",
1876 + "primaryKey": false,
1877 + "notNull": false
1878 + },
1879 + "confidence": {
1880 + "name": "confidence",
1881 + "type": "text",
1882 + "primaryKey": false,
1883 + "notNull": true,
1884 + "default": "'unverified'"
1885 + },
1886 + "completeness": {
1887 + "name": "completeness",
1888 + "type": "integer",
1889 + "primaryKey": false,
1890 + "notNull": true,
1891 + "default": 0
1892 + },
1893 + "external_ids": {
1894 + "name": "external_ids",
1895 + "type": "jsonb",
1896 + "primaryKey": false,
1897 + "notNull": true,
1898 + "default": "'{}'::jsonb"
1899 + },
1900 + "source_count": {
1901 + "name": "source_count",
1902 + "type": "integer",
1903 + "primaryKey": false,
1904 + "notNull": true,
1905 + "default": 0
1906 + },
1907 + "first_seen": {
1908 + "name": "first_seen",
1909 + "type": "timestamp with time zone",
1910 + "primaryKey": false,
1911 + "notNull": true,
1912 + "default": "now()"
1913 + },
1914 + "last_verified": {
1915 + "name": "last_verified",
1916 + "type": "timestamp with time zone",
1917 + "primaryKey": false,
1918 + "notNull": false
1919 + },
1920 + "merged_into": {
1921 + "name": "merged_into",
1922 + "type": "text",
1923 + "primaryKey": false,
1924 + "notNull": false
1925 + },
1926 + "created_at": {
1927 + "name": "created_at",
1928 + "type": "timestamp with time zone",
1929 + "primaryKey": false,
1930 + "notNull": true,
1931 + "default": "now()"
1932 + },
1933 + "updated_at": {
1934 + "name": "updated_at",
1935 + "type": "timestamp with time zone",
1936 + "primaryKey": false,
1937 + "notNull": true,
1938 + "default": "now()"
1939 + }
1940 + },
1941 + "indexes": {
1942 + "facilities_country_idx": {
1943 + "name": "facilities_country_idx",
1944 + "columns": [
1945 + {
1946 + "expression": "country_iso2",
1947 + "isExpression": false,
1948 + "asc": true,
1949 + "nulls": "last"
1950 + }
1951 + ],
1952 + "isUnique": false,
1953 + "concurrently": false,
1954 + "method": "btree",
1955 + "with": {}
1956 + },
1957 + "facilities_operator_idx": {
1958 + "name": "facilities_operator_idx",
1959 + "columns": [
1960 + {
1961 + "expression": "operator_id",
1962 + "isExpression": false,
1963 + "asc": true,
1964 + "nulls": "last"
1965 + }
1966 + ],
1967 + "isUnique": false,
1968 + "concurrently": false,
1969 + "method": "btree",
1970 + "with": {}
1971 + },
1972 + "facilities_metro_idx": {
1973 + "name": "facilities_metro_idx",
1974 + "columns": [
1975 + {
1976 + "expression": "metro_id",
1977 + "isExpression": false,
1978 + "asc": true,
1979 + "nulls": "last"
1980 + }
1981 + ],
1982 + "isUnique": false,
1983 + "concurrently": false,
1984 + "method": "btree",
1985 + "with": {}
1986 + },
1987 + "facilities_status_idx": {
1988 + "name": "facilities_status_idx",
1989 + "columns": [
1990 + {
1991 + "expression": "status",
1992 + "isExpression": false,
1993 + "asc": true,
1994 + "nulls": "last"
1995 + }
1996 + ],
1997 + "isUnique": false,
1998 + "concurrently": false,
1999 + "method": "btree",
2000 + "with": {}
2001 + },
2002 + "facilities_type_idx": {
2003 + "name": "facilities_type_idx",
2004 + "columns": [
2005 + {
2006 + "expression": "facility_type",
2007 + "isExpression": false,
2008 + "asc": true,
2009 + "nulls": "last"
2010 + }
2011 + ],
2012 + "isUnique": false,
2013 + "concurrently": false,
2014 + "method": "btree",
2015 + "with": {}
2016 + },
2017 + "facilities_geohash_idx": {
2018 + "name": "facilities_geohash_idx",
2019 + "columns": [
2020 + {
2021 + "expression": "geohash",
2022 + "isExpression": false,
2023 + "asc": true,
2024 + "nulls": "last"
2025 + }
2026 + ],
2027 + "isUnique": false,
2028 + "concurrently": false,
2029 + "method": "btree",
2030 + "with": {}
2031 + },
2032 + "facilities_norm_idx": {
2033 + "name": "facilities_norm_idx",
2034 + "columns": [
2035 + {
2036 + "expression": "normalized_name",
2037 + "isExpression": false,
2038 + "asc": true,
2039 + "nulls": "last"
2040 + }
2041 + ],
2042 + "isUnique": false,
2043 + "concurrently": false,
2044 + "method": "btree",
2045 + "with": {}
2046 + },
2047 + "facilities_updated_idx": {
2048 + "name": "facilities_updated_idx",
2049 + "columns": [
2050 + {
2051 + "expression": "updated_at",
2052 + "isExpression": false,
2053 + "asc": true,
2054 + "nulls": "last"
2055 + }
2056 + ],
2057 + "isUnique": false,
2058 + "concurrently": false,
2059 + "method": "btree",
2060 + "with": {}
2061 + }
2062 + },
2063 + "foreignKeys": {
2064 + "facilities_operator_id_operators_id_fk": {
2065 + "name": "facilities_operator_id_operators_id_fk",
2066 + "tableFrom": "facilities",
2067 + "tableTo": "operators",
2068 + "columnsFrom": [
2069 + "operator_id"
2070 + ],
2071 + "columnsTo": [
2072 + "id"
2073 + ],
2074 + "onDelete": "no action",
2075 + "onUpdate": "no action"
2076 + },
2077 + "facilities_owner_id_operators_id_fk": {
2078 + "name": "facilities_owner_id_operators_id_fk",
2079 + "tableFrom": "facilities",
2080 + "tableTo": "operators",
2081 + "columnsFrom": [
2082 + "owner_id"
2083 + ],
2084 + "columnsTo": [
2085 + "id"
2086 + ],
2087 + "onDelete": "no action",
2088 + "onUpdate": "no action"
2089 + },
2090 + "facilities_campus_id_campuses_id_fk": {
2091 + "name": "facilities_campus_id_campuses_id_fk",
2092 + "tableFrom": "facilities",
2093 + "tableTo": "campuses",
2094 + "columnsFrom": [
2095 + "campus_id"
2096 + ],
2097 + "columnsTo": [
2098 + "id"
2099 + ],
2100 + "onDelete": "no action",
2101 + "onUpdate": "no action"
2102 + },
2103 + "facilities_metro_id_metros_id_fk": {
2104 + "name": "facilities_metro_id_metros_id_fk",
2105 + "tableFrom": "facilities",
2106 + "tableTo": "metros",
2107 + "columnsFrom": [
2108 + "metro_id"
2109 + ],
2110 + "columnsTo": [
2111 + "id"
2112 + ],
2113 + "onDelete": "no action",
2114 + "onUpdate": "no action"
2115 + },
2116 + "facilities_country_iso2_countries_iso2_fk": {
2117 + "name": "facilities_country_iso2_countries_iso2_fk",
2118 + "tableFrom": "facilities",
2119 + "tableTo": "countries",
2120 + "columnsFrom": [
2121 + "country_iso2"
2122 + ],
2123 + "columnsTo": [
2124 + "iso2"
2125 + ],
2126 + "onDelete": "no action",
2127 + "onUpdate": "no action"
2128 + }
2129 + },
2130 + "compositePrimaryKeys": {},
2131 + "uniqueConstraints": {
2132 + "facilities_slug_unique": {
2133 + "name": "facilities_slug_unique",
2134 + "nullsNotDistinct": false,
2135 + "columns": [
2136 + "slug"
2137 + ]
2138 + }
2139 + },
2140 + "policies": {},
2141 + "checkConstraints": {},
2142 + "isRLSEnabled": false
2143 + },
2144 + "public.facility_aliases": {
2145 + "name": "facility_aliases",
2146 + "schema": "",
2147 + "columns": {
2148 + "facility_id": {
2149 + "name": "facility_id",
2150 + "type": "text",
2151 + "primaryKey": false,
2152 + "notNull": true
2153 + },
2154 + "alias": {
2155 + "name": "alias",
2156 + "type": "text",
2157 + "primaryKey": false,
2158 + "notNull": true
2159 + },
2160 + "normalized": {
2161 + "name": "normalized",
2162 + "type": "text",
2163 + "primaryKey": false,
2164 + "notNull": true
2165 + },
2166 + "source_id": {
2167 + "name": "source_id",
2168 + "type": "text",
2169 + "primaryKey": false,
2170 + "notNull": false
2171 + }
2172 + },
2173 + "indexes": {
2174 + "facility_aliases_norm_idx": {
2175 + "name": "facility_aliases_norm_idx",
2176 + "columns": [
2177 + {
2178 + "expression": "normalized",
2179 + "isExpression": false,
2180 + "asc": true,
2181 + "nulls": "last"
2182 + }
2183 + ],
2184 + "isUnique": false,
2185 + "concurrently": false,
2186 + "method": "btree",
2187 + "with": {}
2188 + }
2189 + },
2190 + "foreignKeys": {
2191 + "facility_aliases_facility_id_facilities_id_fk": {
2192 + "name": "facility_aliases_facility_id_facilities_id_fk",
2193 + "tableFrom": "facility_aliases",
2194 + "tableTo": "facilities",
2195 + "columnsFrom": [
2196 + "facility_id"
2197 + ],
2198 + "columnsTo": [
2199 + "id"
2200 + ],
2201 + "onDelete": "cascade",
2202 + "onUpdate": "no action"
2203 + }
2204 + },
2205 + "compositePrimaryKeys": {
2206 + "facility_aliases_facility_id_alias_pk": {
2207 + "name": "facility_aliases_facility_id_alias_pk",
2208 + "columns": [
2209 + "facility_id",
2210 + "alias"
2211 + ]
2212 + }
2213 + },
2214 + "uniqueConstraints": {},
2215 + "policies": {},
2216 + "checkConstraints": {},
2217 + "isRLSEnabled": false
2218 + },
2219 + "public.facility_ixps": {
2220 + "name": "facility_ixps",
2221 + "schema": "",
2222 + "columns": {
2223 + "facility_id": {
2224 + "name": "facility_id",
2225 + "type": "text",
2226 + "primaryKey": false,
2227 + "notNull": true
2228 + },
2229 + "ixp_id": {
2230 + "name": "ixp_id",
2231 + "type": "text",
2232 + "primaryKey": false,
2233 + "notNull": true
2234 + },
2235 + "source_id": {
2236 + "name": "source_id",
2237 + "type": "text",
2238 + "primaryKey": false,
2239 + "notNull": false
2240 + }
2241 + },
2242 + "indexes": {},
2243 + "foreignKeys": {
2244 + "facility_ixps_facility_id_facilities_id_fk": {
2245 + "name": "facility_ixps_facility_id_facilities_id_fk",
2246 + "tableFrom": "facility_ixps",
2247 + "tableTo": "facilities",
2248 + "columnsFrom": [
2249 + "facility_id"
2250 + ],
2251 + "columnsTo": [
2252 + "id"
2253 + ],
2254 + "onDelete": "cascade",
2255 + "onUpdate": "no action"
2256 + },
2257 + "facility_ixps_ixp_id_ixps_id_fk": {
2258 + "name": "facility_ixps_ixp_id_ixps_id_fk",
2259 + "tableFrom": "facility_ixps",
2260 + "tableTo": "ixps",
2261 + "columnsFrom": [
2262 + "ixp_id"
2263 + ],
2264 + "columnsTo": [
2265 + "id"
2266 + ],
2267 + "onDelete": "cascade",
2268 + "onUpdate": "no action"
2269 + }
2270 + },
2271 + "compositePrimaryKeys": {
2272 + "facility_ixps_facility_id_ixp_id_pk": {
2273 + "name": "facility_ixps_facility_id_ixp_id_pk",
2274 + "columns": [
2275 + "facility_id",
2276 + "ixp_id"
2277 + ]
2278 + }
2279 + },
2280 + "uniqueConstraints": {},
2281 + "policies": {},
2282 + "checkConstraints": {},
2283 + "isRLSEnabled": false
2284 + },
2285 + "public.facility_tenants": {
2286 + "name": "facility_tenants",
2287 + "schema": "",
2288 + "columns": {
2289 + "facility_id": {
2290 + "name": "facility_id",
2291 + "type": "text",
2292 + "primaryKey": false,
2293 + "notNull": true
2294 + },
2295 + "operator_id": {
2296 + "name": "operator_id",
2297 + "type": "text",
2298 + "primaryKey": false,
2299 + "notNull": true
2300 + },
2301 + "role": {
2302 + "name": "role",
2303 + "type": "text",
2304 + "primaryKey": false,
2305 + "notNull": true,
2306 + "default": "'carrier'"
2307 + },
2308 + "asn": {
2309 + "name": "asn",
2310 + "type": "integer",
2311 + "primaryKey": false,
2312 + "notNull": false
2313 + },
2314 + "source_id": {
2315 + "name": "source_id",
2316 + "type": "text",
2317 + "primaryKey": false,
2318 + "notNull": false
2319 + }
2320 + },
2321 + "indexes": {
2322 + "facility_tenants_operator_idx": {
2323 + "name": "facility_tenants_operator_idx",
2324 + "columns": [
2325 + {
2326 + "expression": "operator_id",
2327 + "isExpression": false,
2328 + "asc": true,
2329 + "nulls": "last"
2330 + }
2331 + ],
2332 + "isUnique": false,
2333 + "concurrently": false,
2334 + "method": "btree",
2335 + "with": {}
2336 + }
2337 + },
2338 + "foreignKeys": {
2339 + "facility_tenants_facility_id_facilities_id_fk": {
2340 + "name": "facility_tenants_facility_id_facilities_id_fk",
2341 + "tableFrom": "facility_tenants",
2342 + "tableTo": "facilities",
2343 + "columnsFrom": [
2344 + "facility_id"
2345 + ],
2346 + "columnsTo": [
2347 + "id"
2348 + ],
2349 + "onDelete": "cascade",
2350 + "onUpdate": "no action"
2351 + },
2352 + "facility_tenants_operator_id_operators_id_fk": {
2353 + "name": "facility_tenants_operator_id_operators_id_fk",
2354 + "tableFrom": "facility_tenants",
2355 + "tableTo": "operators",
2356 + "columnsFrom": [
2357 + "operator_id"
2358 + ],
2359 + "columnsTo": [
2360 + "id"
2361 + ],
2362 + "onDelete": "cascade",
2363 + "onUpdate": "no action"
2364 + }
2365 + },
2366 + "compositePrimaryKeys": {
2367 + "facility_tenants_facility_id_operator_id_role_pk": {
2368 + "name": "facility_tenants_facility_id_operator_id_role_pk",
2369 + "columns": [
2370 + "facility_id",
2371 + "operator_id",
2372 + "role"
2373 + ]
2374 + }
2375 + },
2376 + "uniqueConstraints": {},
2377 + "policies": {},
2378 + "checkConstraints": {},
2379 + "isRLSEnabled": false
2380 + },
2381 + "public.ixps": {
2382 + "name": "ixps",
2383 + "schema": "",
2384 + "columns": {
2385 + "id": {
2386 + "name": "id",
2387 + "type": "text",
2388 + "primaryKey": true,
2389 + "notNull": true
2390 + },
2391 + "slug": {
2392 + "name": "slug",
2393 + "type": "text",
2394 + "primaryKey": false,
2395 + "notNull": true
2396 + },
2397 + "name": {
2398 + "name": "name",
2399 + "type": "text",
2400 + "primaryKey": false,
2401 + "notNull": true
2402 + },
2403 + "name_long": {
2404 + "name": "name_long",
2405 + "type": "text",
2406 + "primaryKey": false,
2407 + "notNull": false
2408 + },
2409 + "city": {
2410 + "name": "city",
2411 + "type": "text",
2412 + "primaryKey": false,
2413 + "notNull": false
2414 + },
2415 + "country_iso2": {
2416 + "name": "country_iso2",
2417 + "type": "text",
2418 + "primaryKey": false,
2419 + "notNull": false
2420 + },
2421 + "metro_id": {
2422 + "name": "metro_id",
2423 + "type": "text",
2424 + "primaryKey": false,
2425 + "notNull": false
2426 + },
2427 + "region_continent": {
2428 + "name": "region_continent",
2429 + "type": "text",
2430 + "primaryKey": false,
2431 + "notNull": false
2432 + },
2433 + "website": {
2434 + "name": "website",
2435 + "type": "text",
2436 + "primaryKey": false,
2437 + "notNull": false
2438 + },
2439 + "network_count": {
2440 + "name": "network_count",
2441 + "type": "integer",
2442 + "primaryKey": false,
2443 + "notNull": false
2444 + },
2445 + "external_ids": {
2446 + "name": "external_ids",
2447 + "type": "jsonb",
2448 + "primaryKey": false,
2449 + "notNull": true,
2450 + "default": "'{}'::jsonb"
2451 + },
2452 + "created_at": {
2453 + "name": "created_at",
2454 + "type": "timestamp with time zone",
2455 + "primaryKey": false,
2456 + "notNull": true,
2457 + "default": "now()"
2458 + },
2459 + "updated_at": {
2460 + "name": "updated_at",
2461 + "type": "timestamp with time zone",
2462 + "primaryKey": false,
2463 + "notNull": true,
2464 + "default": "now()"
2465 + }
2466 + },
2467 + "indexes": {},
2468 + "foreignKeys": {
2469 + "ixps_country_iso2_countries_iso2_fk": {
2470 + "name": "ixps_country_iso2_countries_iso2_fk",
2471 + "tableFrom": "ixps",
2472 + "tableTo": "countries",
2473 + "columnsFrom": [
2474 + "country_iso2"
2475 + ],
2476 + "columnsTo": [
2477 + "iso2"
2478 + ],
2479 + "onDelete": "no action",
2480 + "onUpdate": "no action"
2481 + },
2482 + "ixps_metro_id_metros_id_fk": {
2483 + "name": "ixps_metro_id_metros_id_fk",
2484 + "tableFrom": "ixps",
2485 + "tableTo": "metros",
2486 + "columnsFrom": [
2487 + "metro_id"
2488 + ],
2489 + "columnsTo": [
2490 + "id"
2491 + ],
2492 + "onDelete": "no action",
2493 + "onUpdate": "no action"
2494 + }
2495 + },
2496 + "compositePrimaryKeys": {},
2497 + "uniqueConstraints": {
2498 + "ixps_slug_unique": {
2499 + "name": "ixps_slug_unique",
2500 + "nullsNotDistinct": false,
2501 + "columns": [
2502 + "slug"
2503 + ]
2504 + }
2505 + },
2506 + "policies": {},
2507 + "checkConstraints": {},
2508 + "isRLSEnabled": false
2509 + },
2510 + "public.metros": {
2511 + "name": "metros",
2512 + "schema": "",
2513 + "columns": {
2514 + "id": {
2515 + "name": "id",
2516 + "type": "text",
2517 + "primaryKey": true,
2518 + "notNull": true
2519 + },
2520 + "slug": {
2521 + "name": "slug",
2522 + "type": "text",
2523 + "primaryKey": false,
2524 + "notNull": true
2525 + },
2526 + "name": {
2527 + "name": "name",
2528 + "type": "text",
2529 + "primaryKey": false,
2530 + "notNull": true
2531 + },
2532 + "country_iso2": {
2533 + "name": "country_iso2",
2534 + "type": "text",
2535 + "primaryKey": false,
2536 + "notNull": true
2537 + },
2538 + "region_name": {
2539 + "name": "region_name",
2540 + "type": "text",
2541 + "primaryKey": false,
2542 + "notNull": false
2543 + },
2544 + "lat": {
2545 + "name": "lat",
2546 + "type": "double precision",
2547 + "primaryKey": false,
2548 + "notNull": true
2549 + },
2550 + "lng": {
2551 + "name": "lng",
2552 + "type": "double precision",
2553 + "primaryKey": false,
2554 + "notNull": true
2555 + },
2556 + "radius_km": {
2557 + "name": "radius_km",
2558 + "type": "real",
2559 + "primaryKey": false,
2560 + "notNull": true,
2561 + "default": 60
2562 + },
2563 + "aliases": {
2564 + "name": "aliases",
2565 + "type": "text[]",
2566 + "primaryKey": false,
2567 + "notNull": true,
2568 + "default": "'{}'::text[]"
2569 + },
2570 + "description": {
2571 + "name": "description",
2572 + "type": "text",
2573 + "primaryKey": false,
2574 + "notNull": false
2575 + },
2576 + "stats": {
2577 + "name": "stats",
2578 + "type": "jsonb",
2579 + "primaryKey": false,
2580 + "notNull": true,
2581 + "default": "'{}'::jsonb"
2582 + },
2583 + "created_at": {
2584 + "name": "created_at",
2585 + "type": "timestamp with time zone",
2586 + "primaryKey": false,
2587 + "notNull": true,
2588 + "default": "now()"
2589 + },
2590 + "updated_at": {
2591 + "name": "updated_at",
2592 + "type": "timestamp with time zone",
2593 + "primaryKey": false,
2594 + "notNull": true,
2595 + "default": "now()"
2596 + }
2597 + },
2598 + "indexes": {
2599 + "metros_country_idx": {
2600 + "name": "metros_country_idx",
2601 + "columns": [
2602 + {
2603 + "expression": "country_iso2",
2604 + "isExpression": false,
2605 + "asc": true,
2606 + "nulls": "last"
2607 + }
2608 + ],
2609 + "isUnique": false,
2610 + "concurrently": false,
2611 + "method": "btree",
2612 + "with": {}
2613 + }
2614 + },
2615 + "foreignKeys": {
2616 + "metros_country_iso2_countries_iso2_fk": {
2617 + "name": "metros_country_iso2_countries_iso2_fk",
2618 + "tableFrom": "metros",
2619 + "tableTo": "countries",
2620 + "columnsFrom": [
2621 + "country_iso2"
2622 + ],
2623 + "columnsTo": [
2624 + "iso2"
2625 + ],
2626 + "onDelete": "no action",
2627 + "onUpdate": "no action"
2628 + }
2629 + },
2630 + "compositePrimaryKeys": {},
2631 + "uniqueConstraints": {
2632 + "metros_slug_unique": {
2633 + "name": "metros_slug_unique",
2634 + "nullsNotDistinct": false,
2635 + "columns": [
2636 + "slug"
2637 + ]
2638 + }
2639 + },
2640 + "policies": {},
2641 + "checkConstraints": {},
2642 + "isRLSEnabled": false
2643 + },
2644 + "public.news_items": {
2645 + "name": "news_items",
2646 + "schema": "",
2647 + "columns": {
2648 + "id": {
2649 + "name": "id",
2650 + "type": "text",
2651 + "primaryKey": true,
2652 + "notNull": true
2653 + },
2654 + "source_id": {
2655 + "name": "source_id",
2656 + "type": "text",
2657 + "primaryKey": false,
2658 + "notNull": true
2659 + },
2660 + "connector_id": {
2661 + "name": "connector_id",
2662 + "type": "text",
2663 + "primaryKey": false,
2664 + "notNull": true
2665 + },
2666 + "url": {
2667 + "name": "url",
2668 + "type": "text",
2669 + "primaryKey": false,
2670 + "notNull": true
2671 + },
2672 + "title": {
2673 + "name": "title",
2674 + "type": "text",
2675 + "primaryKey": false,
2676 + "notNull": true
2677 + },
2678 + "published_at": {
2679 + "name": "published_at",
2680 + "type": "timestamp with time zone",
2681 + "primaryKey": false,
2682 + "notNull": false
2683 + },
2684 + "summary": {
2685 + "name": "summary",
2686 + "type": "text",
2687 + "primaryKey": false,
2688 + "notNull": false
2689 + },
2690 + "page_type": {
2691 + "name": "page_type",
2692 + "type": "text",
2693 + "primaryKey": false,
2694 + "notNull": true,
2695 + "default": "'unknown'"
2696 + },
2697 + "event_type": {
2698 + "name": "event_type",
2699 + "type": "text",
2700 + "primaryKey": false,
2701 + "notNull": false
2702 + },
2703 + "mentions": {
2704 + "name": "mentions",
2705 + "type": "jsonb",
2706 + "primaryKey": false,
2707 + "notNull": true,
2708 + "default": "'{}'::jsonb"
2709 + },
2710 + "operator_ids": {
2711 + "name": "operator_ids",
2712 + "type": "text[]",
2713 + "primaryKey": false,
2714 + "notNull": true,
2715 + "default": "'{}'::text[]"
2716 + },
2717 + "country_iso2s": {
2718 + "name": "country_iso2s",
2719 + "type": "text[]",
2720 + "primaryKey": false,
2721 + "notNull": true,
2722 + "default": "'{}'::text[]"
2723 + },
2724 + "facility_ids": {
2725 + "name": "facility_ids",
2726 + "type": "text[]",
2727 + "primaryKey": false,
2728 + "notNull": true,
2729 + "default": "'{}'::text[]"
2730 + },
2731 + "project_id": {
2732 + "name": "project_id",
2733 + "type": "text",
2734 + "primaryKey": false,
2735 + "notNull": false
2736 + },
2737 + "mw": {
2738 + "name": "mw",
2739 + "type": "double precision",
2740 + "primaryKey": false,
2741 + "notNull": false
2742 + },
2743 + "significance": {
2744 + "name": "significance",
2745 + "type": "integer",
2746 + "primaryKey": false,
2747 + "notNull": true,
2748 + "default": 20
2749 + },
2750 + "created_at": {
2751 + "name": "created_at",
2752 + "type": "timestamp with time zone",
2753 + "primaryKey": false,
2754 + "notNull": true,
2755 + "default": "now()"
2756 + }
2757 + },
2758 + "indexes": {
2759 + "news_items_published_idx": {
2760 + "name": "news_items_published_idx",
2761 + "columns": [
2762 + {
2763 + "expression": "published_at",
2764 + "isExpression": false,
2765 + "asc": true,
2766 + "nulls": "last"
2767 + }
2768 + ],
2769 + "isUnique": false,
2770 + "concurrently": false,
2771 + "method": "btree",
2772 + "with": {}
2773 + }
2774 + },
2775 + "foreignKeys": {},
2776 + "compositePrimaryKeys": {},
2777 + "uniqueConstraints": {
2778 + "news_items_url_unique": {
2779 + "name": "news_items_url_unique",
2780 + "nullsNotDistinct": false,
2781 + "columns": [
2782 + "url"
2783 + ]
2784 + }
2785 + },
2786 + "policies": {},
2787 + "checkConstraints": {},
2788 + "isRLSEnabled": false
2789 + },
2790 + "public.operators": {
2791 + "name": "operators",
2792 + "schema": "",
2793 + "columns": {
2794 + "id": {
2795 + "name": "id",
2796 + "type": "text",
2797 + "primaryKey": true,
2798 + "notNull": true
2799 + },
2800 + "slug": {
2801 + "name": "slug",
2802 + "type": "text",
2803 + "primaryKey": false,
2804 + "notNull": true
2805 + },
2806 + "name": {
2807 + "name": "name",
2808 + "type": "text",
2809 + "primaryKey": false,
2810 + "notNull": true
2811 + },
2812 + "normalized_name": {
2813 + "name": "normalized_name",
2814 + "type": "text",
2815 + "primaryKey": false,
2816 + "notNull": true
2817 + },
2818 + "kind": {
2819 + "name": "kind",
2820 + "type": "text",
2821 + "primaryKey": false,
2822 + "notNull": false
2823 + },
2824 + "website": {
2825 + "name": "website",
2826 + "type": "text",
2827 + "primaryKey": false,
2828 + "notNull": false
2829 + },
2830 + "hq_country_iso2": {
2831 + "name": "hq_country_iso2",
2832 + "type": "text",
2833 + "primaryKey": false,
2834 + "notNull": false
2835 + },
2836 + "hq_city": {
2837 + "name": "hq_city",
2838 + "type": "text",
2839 + "primaryKey": false,
2840 + "notNull": false
2841 + },
2842 + "parent_id": {
2843 + "name": "parent_id",
2844 + "type": "text",
2845 + "primaryKey": false,
2846 + "notNull": false
2847 + },
2848 + "description": {
2849 + "name": "description",
2850 + "type": "text",
2851 + "primaryKey": false,
2852 + "notNull": false
2853 + },
2854 + "aliases": {
2855 + "name": "aliases",
2856 + "type": "text[]",
2857 + "primaryKey": false,
2858 + "notNull": true,
2859 + "default": "'{}'::text[]"
2860 + },
2861 + "external_ids": {
2862 + "name": "external_ids",
2863 + "type": "jsonb",
2864 + "primaryKey": false,
2865 + "notNull": true,
2866 + "default": "'{}'::jsonb"
2867 + },
2868 + "is_cloud_provider": {
2869 + "name": "is_cloud_provider",
2870 + "type": "boolean",
2871 + "primaryKey": false,
2872 + "notNull": true,
2873 + "default": false
2874 + },
2875 + "is_carrier": {
2876 + "name": "is_carrier",
2877 + "type": "boolean",
2878 + "primaryKey": false,
2879 + "notNull": true,
2880 + "default": false
2881 + },
2882 + "stats": {
2883 + "name": "stats",
2884 + "type": "jsonb",
2885 + "primaryKey": false,
2886 + "notNull": true,
2887 + "default": "'{}'::jsonb"
2888 + },
2889 + "created_at": {
2890 + "name": "created_at",
2891 + "type": "timestamp with time zone",
2892 + "primaryKey": false,
2893 + "notNull": true,
2894 + "default": "now()"
2895 + },
2896 + "updated_at": {
2897 + "name": "updated_at",
2898 + "type": "timestamp with time zone",
2899 + "primaryKey": false,
2900 + "notNull": true,
2901 + "default": "now()"
2902 + }
2903 + },
2904 + "indexes": {
2905 + "operators_norm_idx": {
2906 + "name": "operators_norm_idx",
2907 + "columns": [
2908 + {
2909 + "expression": "normalized_name",
2910 + "isExpression": false,
2911 + "asc": true,
2912 + "nulls": "last"
2913 + }
2914 + ],
2915 + "isUnique": false,
2916 + "concurrently": false,
2917 + "method": "btree",
2918 + "with": {}
2919 + },
2920 + "operators_kind_idx": {
2921 + "name": "operators_kind_idx",
2922 + "columns": [
2923 + {
2924 + "expression": "kind",
2925 + "isExpression": false,
2926 + "asc": true,
2927 + "nulls": "last"
2928 + }
2929 + ],
2930 + "isUnique": false,
2931 + "concurrently": false,
2932 + "method": "btree",
2933 + "with": {}
2934 + }
2935 + },
2936 + "foreignKeys": {},
2937 + "compositePrimaryKeys": {},
2938 + "uniqueConstraints": {
2939 + "operators_slug_unique": {
2940 + "name": "operators_slug_unique",
2941 + "nullsNotDistinct": false,
2942 + "columns": [
2943 + "slug"
2944 + ]
2945 + }
2946 + },
2947 + "policies": {},
2948 + "checkConstraints": {},
2949 + "isRLSEnabled": false
2950 + },
2951 + "public.project_timeline": {
2952 + "name": "project_timeline",
2953 + "schema": "",
2954 + "columns": {
2955 + "id": {
2956 + "name": "id",
2957 + "type": "text",
2958 + "primaryKey": true,
2959 + "notNull": true
2960 + },
2961 + "project_id": {
2962 + "name": "project_id",
2963 + "type": "text",
2964 + "primaryKey": false,
2965 + "notNull": true
2966 + },
2967 + "event_date": {
2968 + "name": "event_date",
2969 + "type": "text",
2970 + "primaryKey": false,
2971 + "notNull": true
2972 + },
2973 + "event_type": {
2974 + "name": "event_type",
2975 + "type": "text",
2976 + "primaryKey": false,
2977 + "notNull": true
2978 + },
2979 + "description": {
2980 + "name": "description",
2981 + "type": "text",
2982 + "primaryKey": false,
2983 + "notNull": true
2984 + },
2985 + "source_id": {
2986 + "name": "source_id",
2987 + "type": "text",
2988 + "primaryKey": false,
2989 + "notNull": false
2990 + },
2991 + "document_id": {
2992 + "name": "document_id",
2993 + "type": "text",
2994 + "primaryKey": false,
2995 + "notNull": false
2996 + },
2997 + "url": {
2998 + "name": "url",
2999 + "type": "text",
3000 + "primaryKey": false,
3001 + "notNull": false
3002 + },
3003 + "created_at": {
3004 + "name": "created_at",
3005 + "type": "timestamp with time zone",
3006 + "primaryKey": false,
3007 + "notNull": true,
3008 + "default": "now()"
3009 + }
3010 + },
3011 + "indexes": {
3012 + "project_timeline_project_idx": {
3013 + "name": "project_timeline_project_idx",
3014 + "columns": [
3015 + {
3016 + "expression": "project_id",
3017 + "isExpression": false,
3018 + "asc": true,
3019 + "nulls": "last"
3020 + }
3021 + ],
3022 + "isUnique": false,
3023 + "concurrently": false,
3024 + "method": "btree",
3025 + "with": {}
3026 + }
3027 + },
3028 + "foreignKeys": {
3029 + "project_timeline_project_id_projects_id_fk": {
3030 + "name": "project_timeline_project_id_projects_id_fk",
3031 + "tableFrom": "project_timeline",
3032 + "tableTo": "projects",
3033 + "columnsFrom": [
3034 + "project_id"
3035 + ],
3036 + "columnsTo": [
3037 + "id"
3038 + ],
3039 + "onDelete": "cascade",
3040 + "onUpdate": "no action"
3041 + }
3042 + },
3043 + "compositePrimaryKeys": {},
3044 + "uniqueConstraints": {},
3045 + "policies": {},
3046 + "checkConstraints": {},
3047 + "isRLSEnabled": false
3048 + },
3049 + "public.projects": {
3050 + "name": "projects",
3051 + "schema": "",
3052 + "columns": {
3053 + "id": {
3054 + "name": "id",
3055 + "type": "text",
3056 + "primaryKey": true,
3057 + "notNull": true
3058 + },
3059 + "slug": {
3060 + "name": "slug",
3061 + "type": "text",
3062 + "primaryKey": false,
3063 + "notNull": true
3064 + },
3065 + "name": {
3066 + "name": "name",
3067 + "type": "text",
3068 + "primaryKey": false,
3069 + "notNull": true
3070 + },
3071 + "normalized_name": {
3072 + "name": "normalized_name",
3073 + "type": "text",
3074 + "primaryKey": false,
3075 + "notNull": true
3076 + },
3077 + "operator_id": {
3078 + "name": "operator_id",
3079 + "type": "text",
3080 + "primaryKey": false,
3081 + "notNull": false
3082 + },
3083 + "facility_id": {
3084 + "name": "facility_id",
3085 + "type": "text",
3086 + "primaryKey": false,
3087 + "notNull": false
3088 + },
3089 + "metro_id": {
3090 + "name": "metro_id",
3091 + "type": "text",
3092 + "primaryKey": false,
3093 + "notNull": false
3094 + },
3095 + "country_iso2": {
3096 + "name": "country_iso2",
3097 + "type": "text",
3098 + "primaryKey": false,
3099 + "notNull": false
3100 + },
3101 + "city": {
3102 + "name": "city",
3103 + "type": "text",
3104 + "primaryKey": false,
3105 + "notNull": false
3106 + },
3107 + "region_name": {
3108 + "name": "region_name",
3109 + "type": "text",
3110 + "primaryKey": false,
3111 + "notNull": false
3112 + },
3113 + "lat": {
3114 + "name": "lat",
3115 + "type": "double precision",
3116 + "primaryKey": false,
3117 + "notNull": false
3118 + },
3119 + "lng": {
3120 + "name": "lng",
3121 + "type": "double precision",
3122 + "primaryKey": false,
3123 + "notNull": false
3124 + },
3125 + "geo_precision": {
3126 + "name": "geo_precision",
3127 + "type": "text",
3128 + "primaryKey": false,
3129 + "notNull": true,
3130 + "default": "'unknown'"
3131 + },
3132 + "status": {
3133 + "name": "status",
3134 + "type": "text",
3135 + "primaryKey": false,
3136 + "notNull": true,
3137 + "default": "'announced'"
3138 + },
3139 + "announced_on": {
3140 + "name": "announced_on",
3141 + "type": "text",
3142 + "primaryKey": false,
3143 + "notNull": false
3144 + },
3145 + "expected_opening": {
3146 + "name": "expected_opening",
3147 + "type": "text",
3148 + "primaryKey": false,
3149 + "notNull": false
3150 + },
3151 + "planned_mw": {
3152 + "name": "planned_mw",
3153 + "type": "double precision",
3154 + "primaryKey": false,
3155 + "notNull": false
3156 + },
3157 + "investment_usd": {
3158 + "name": "investment_usd",
3159 + "type": "double precision",
3160 + "primaryKey": false,
3161 + "notNull": false
3162 + },
3163 + "investment_currency": {
3164 + "name": "investment_currency",
3165 + "type": "text",
3166 + "primaryKey": false,
3167 + "notNull": false
3168 + },
3169 + "investment_original": {
3170 + "name": "investment_original",
3171 + "type": "double precision",
3172 + "primaryKey": false,
3173 + "notNull": false
3174 + },
3175 + "acreage": {
3176 + "name": "acreage",
3177 + "type": "double precision",
3178 + "primaryKey": false,
3179 + "notNull": false
3180 + },
3181 + "phase_count": {
3182 + "name": "phase_count",
3183 + "type": "integer",
3184 + "primaryKey": false,
3185 + "notNull": false
3186 + },
3187 + "is_ai": {
3188 + "name": "is_ai",
3189 + "type": "boolean",
3190 + "primaryKey": false,
3191 + "notNull": true,
3192 + "default": false
3193 + },
3194 + "description": {
3195 + "name": "description",
3196 + "type": "text",
3197 + "primaryKey": false,
3198 + "notNull": false
3199 + },
3200 + "source_url": {
3201 + "name": "source_url",
3202 + "type": "text",
3203 + "primaryKey": false,
3204 + "notNull": false
3205 + },
3206 + "confidence": {
3207 + "name": "confidence",
3208 + "type": "text",
3209 + "primaryKey": false,
3210 + "notNull": true,
3211 + "default": "'moderate'"
3212 + },
3213 + "external_ids": {
3214 + "name": "external_ids",
3215 + "type": "jsonb",
3216 + "primaryKey": false,
3217 + "notNull": true,
3218 + "default": "'{}'::jsonb"
3219 + },
3220 + "last_update": {
3221 + "name": "last_update",
3222 + "type": "timestamp with time zone",
3223 + "primaryKey": false,
3224 + "notNull": true,
3225 + "default": "now()"
3226 + },
3227 + "created_at": {
3228 + "name": "created_at",
3229 + "type": "timestamp with time zone",
3230 + "primaryKey": false,
3231 + "notNull": true,
3232 + "default": "now()"
3233 + },
3234 + "updated_at": {
3235 + "name": "updated_at",
3236 + "type": "timestamp with time zone",
3237 + "primaryKey": false,
3238 + "notNull": true,
3239 + "default": "now()"
3240 + }
3241 + },
3242 + "indexes": {
3243 + "projects_country_idx": {
3244 + "name": "projects_country_idx",
3245 + "columns": [
3246 + {
3247 + "expression": "country_iso2",
3248 + "isExpression": false,
3249 + "asc": true,
3250 + "nulls": "last"
3251 + }
3252 + ],
3253 + "isUnique": false,
3254 + "concurrently": false,
3255 + "method": "btree",
3256 + "with": {}
3257 + },
3258 + "projects_operator_idx": {
3259 + "name": "projects_operator_idx",
3260 + "columns": [
3261 + {
3262 + "expression": "operator_id",
3263 + "isExpression": false,
3264 + "asc": true,
3265 + "nulls": "last"
3266 + }
3267 + ],
3268 + "isUnique": false,
3269 + "concurrently": false,
3270 + "method": "btree",
3271 + "with": {}
3272 + },
3273 + "projects_status_idx": {
3274 + "name": "projects_status_idx",
3275 + "columns": [
3276 + {
3277 + "expression": "status",
3278 + "isExpression": false,
3279 + "asc": true,
3280 + "nulls": "last"
3281 + }
3282 + ],
3283 + "isUnique": false,
3284 + "concurrently": false,
3285 + "method": "btree",
3286 + "with": {}
3287 + },
3288 + "projects_norm_idx": {
3289 + "name": "projects_norm_idx",
3290 + "columns": [
3291 + {
3292 + "expression": "normalized_name",
3293 + "isExpression": false,
3294 + "asc": true,
3295 + "nulls": "last"
3296 + }
3297 + ],
3298 + "isUnique": false,
3299 + "concurrently": false,
3300 + "method": "btree",
3301 + "with": {}
3302 + }
3303 + },
3304 + "foreignKeys": {
3305 + "projects_operator_id_operators_id_fk": {
3306 + "name": "projects_operator_id_operators_id_fk",
3307 + "tableFrom": "projects",
3308 + "tableTo": "operators",
3309 + "columnsFrom": [
3310 + "operator_id"
3311 + ],
3312 + "columnsTo": [
3313 + "id"
3314 + ],
3315 + "onDelete": "no action",
3316 + "onUpdate": "no action"
3317 + },
3318 + "projects_facility_id_facilities_id_fk": {
3319 + "name": "projects_facility_id_facilities_id_fk",
3320 + "tableFrom": "projects",
3321 + "tableTo": "facilities",
3322 + "columnsFrom": [
3323 + "facility_id"
3324 + ],
3325 + "columnsTo": [
3326 + "id"
3327 + ],
3328 + "onDelete": "no action",
3329 + "onUpdate": "no action"
3330 + },
3331 + "projects_metro_id_metros_id_fk": {
3332 + "name": "projects_metro_id_metros_id_fk",
3333 + "tableFrom": "projects",
3334 + "tableTo": "metros",
3335 + "columnsFrom": [
3336 + "metro_id"
3337 + ],
3338 + "columnsTo": [
3339 + "id"
3340 + ],
3341 + "onDelete": "no action",
3342 + "onUpdate": "no action"
3343 + },
3344 + "projects_country_iso2_countries_iso2_fk": {
3345 + "name": "projects_country_iso2_countries_iso2_fk",
3346 + "tableFrom": "projects",
3347 + "tableTo": "countries",
3348 + "columnsFrom": [
3349 + "country_iso2"
3350 + ],
3351 + "columnsTo": [
3352 + "iso2"
3353 + ],
3354 + "onDelete": "no action",
3355 + "onUpdate": "no action"
3356 + }
3357 + },
3358 + "compositePrimaryKeys": {},
3359 + "uniqueConstraints": {
3360 + "projects_slug_unique": {
3361 + "name": "projects_slug_unique",
3362 + "nullsNotDistinct": false,
3363 + "columns": [
3364 + "slug"
3365 + ]
3366 + }
3367 + },
3368 + "policies": {},
3369 + "checkConstraints": {},
3370 + "isRLSEnabled": false
3371 + },
3372 + "public.provenance": {
3373 + "name": "provenance",
3374 + "schema": "",
3375 + "columns": {
3376 + "id": {
3377 + "name": "id",
3378 + "type": "text",
3379 + "primaryKey": true,
3380 + "notNull": true
3381 + },
3382 + "entity_type": {
3383 + "name": "entity_type",
3384 + "type": "text",
3385 + "primaryKey": false,
3386 + "notNull": true
3387 + },
3388 + "entity_id": {
3389 + "name": "entity_id",
3390 + "type": "text",
3391 + "primaryKey": false,
3392 + "notNull": true
3393 + },
3394 + "field": {
3395 + "name": "field",
3396 + "type": "text",
3397 + "primaryKey": false,
3398 + "notNull": true
3399 + },
3400 + "value": {
3401 + "name": "value",
3402 + "type": "jsonb",
3403 + "primaryKey": false,
3404 + "notNull": false
3405 + },
3406 + "source_id": {
3407 + "name": "source_id",
3408 + "type": "text",
3409 + "primaryKey": false,
3410 + "notNull": true
3411 + },
3412 + "connector_id": {
3413 + "name": "connector_id",
3414 + "type": "text",
3415 + "primaryKey": false,
3416 + "notNull": true
3417 + },
3418 + "document_id": {
3419 + "name": "document_id",
3420 + "type": "text",
3421 + "primaryKey": false,
3422 + "notNull": false
3423 + },
3424 + "url": {
3425 + "name": "url",
3426 + "type": "text",
3427 + "primaryKey": false,
3428 + "notNull": true
3429 + },
3430 + "first_observed": {
3431 + "name": "first_observed",
3432 + "type": "timestamp with time zone",
3433 + "primaryKey": false,
3434 + "notNull": true,
3435 + "default": "now()"
3436 + },
3437 + "last_observed": {
3438 + "name": "last_observed",
3439 + "type": "timestamp with time zone",
3440 + "primaryKey": false,
3441 + "notNull": true,
3442 + "default": "now()"
3443 + },
3444 + "retrieved_at": {
3445 + "name": "retrieved_at",
3446 + "type": "timestamp with time zone",
3447 + "primaryKey": false,
3448 + "notNull": true,
3449 + "default": "now()"
3450 + },
3451 + "confidence": {
3452 + "name": "confidence",
3453 + "type": "text",
3454 + "primaryKey": false,
3455 + "notNull": true,
3456 + "default": "'moderate'"
3457 + },
3458 + "is_estimate": {
3459 + "name": "is_estimate",
3460 + "type": "boolean",
3461 + "primaryKey": false,
3462 + "notNull": true,
3463 + "default": false
3464 + },
3465 + "method": {
3466 + "name": "method",
3467 + "type": "text",
3468 + "primaryKey": false,
3469 + "notNull": false
3470 + },
3471 + "extractor_version": {
3472 + "name": "extractor_version",
3473 + "type": "text",
3474 + "primaryKey": false,
3475 + "notNull": false
3476 + },
3477 + "is_current": {
3478 + "name": "is_current",
3479 + "type": "boolean",
3480 + "primaryKey": false,
3481 + "notNull": true,
3482 + "default": true
3483 + },
3484 + "note": {
3485 + "name": "note",
3486 + "type": "text",
3487 + "primaryKey": false,
3488 + "notNull": false
3489 + }
3490 + },
3491 + "indexes": {
3492 + "provenance_entity_idx": {
3493 + "name": "provenance_entity_idx",
3494 + "columns": [
3495 + {
3496 + "expression": "entity_type",
3497 + "isExpression": false,
3498 + "asc": true,
3499 + "nulls": "last"
3500 + },
3501 + {
3502 + "expression": "entity_id",
3503 + "isExpression": false,
3504 + "asc": true,
3505 + "nulls": "last"
3506 + }
3507 + ],
3508 + "isUnique": false,
3509 + "concurrently": false,
3510 + "method": "btree",
3511 + "with": {}
3512 + },
3513 + "provenance_source_idx": {
3514 + "name": "provenance_source_idx",
3515 + "columns": [
3516 + {
3517 + "expression": "source_id",
3518 + "isExpression": false,
3519 + "asc": true,
3520 + "nulls": "last"
3521 + }
3522 + ],
3523 + "isUnique": false,
3524 + "concurrently": false,
3525 + "method": "btree",
3526 + "with": {}
3527 + },
3528 + "provenance_uq": {
3529 + "name": "provenance_uq",
3530 + "columns": [
3531 + {
3532 + "expression": "entity_type",
3533 + "isExpression": false,
3534 + "asc": true,
3535 + "nulls": "last"
3536 + },
3537 + {
3538 + "expression": "entity_id",
3539 + "isExpression": false,
3540 + "asc": true,
3541 + "nulls": "last"
3542 + },
3543 + {
3544 + "expression": "field",
3545 + "isExpression": false,
3546 + "asc": true,
3547 + "nulls": "last"
3548 + },
3549 + {
3550 + "expression": "source_id",
3551 + "isExpression": false,
3552 + "asc": true,
3553 + "nulls": "last"
3554 + },
3555 + {
3556 + "expression": "url",
3557 + "isExpression": false,
3558 + "asc": true,
3559 + "nulls": "last"
3560 + }
3561 + ],
3562 + "isUnique": true,
3563 + "concurrently": false,
3564 + "method": "btree",
3565 + "with": {}
3566 + }
3567 + },
3568 + "foreignKeys": {},
3569 + "compositePrimaryKeys": {},
3570 + "uniqueConstraints": {},
3571 + "policies": {},
3572 + "checkConstraints": {},
3573 + "isRLSEnabled": false
3574 + },
3575 + "public.rankings": {
3576 + "name": "rankings",
3577 + "schema": "",
3578 + "columns": {
3579 + "id": {
3580 + "name": "id",
3581 + "type": "text",
3582 + "primaryKey": true,
3583 + "notNull": true
3584 + },
3585 + "key": {
3586 + "name": "key",
3587 + "type": "text",
3588 + "primaryKey": false,
3589 + "notNull": true
3590 + },
3591 + "scope": {
3592 + "name": "scope",
3593 + "type": "text",
3594 + "primaryKey": false,
3595 + "notNull": true
3596 + },
3597 + "label": {
3598 + "name": "label",
3599 + "type": "text",
3600 + "primaryKey": false,
3601 + "notNull": true
3602 + },
3603 + "unit": {
3604 + "name": "unit",
3605 + "type": "text",
3606 + "primaryKey": false,
3607 + "notNull": true
3608 + },
3609 + "methodology": {
3610 + "name": "methodology",
3611 + "type": "text",
3612 + "primaryKey": false,
3613 + "notNull": true
3614 + },
3615 + "min_coverage": {
3616 + "name": "min_coverage",
3617 + "type": "real",
3618 + "primaryKey": false,
3619 + "notNull": false
3620 + },
3621 + "rows": {
3622 + "name": "rows",
3623 + "type": "jsonb",
3624 + "primaryKey": false,
3625 + "notNull": true
3626 + },
3627 + "total": {
3628 + "name": "total",
3629 + "type": "integer",
3630 + "primaryKey": false,
3631 + "notNull": true
3632 + },
3633 + "computed_at": {
3634 + "name": "computed_at",
3635 + "type": "timestamp with time zone",
3636 + "primaryKey": false,
3637 + "notNull": true,
3638 + "default": "now()"
3639 + },
3640 + "is_current": {
3641 + "name": "is_current",
3642 + "type": "boolean",
3643 + "primaryKey": false,
3644 + "notNull": true,
3645 + "default": true
3646 + }
3647 + },
3648 + "indexes": {
3649 + "rankings_key_idx": {
3650 + "name": "rankings_key_idx",
3651 + "columns": [
3652 + {
3653 + "expression": "key",
3654 + "isExpression": false,
3655 + "asc": true,
3656 + "nulls": "last"
3657 + },
3658 + {
3659 + "expression": "is_current",
3660 + "isExpression": false,
3661 + "asc": true,
3662 + "nulls": "last"
3663 + }
3664 + ],
3665 + "isUnique": false,
3666 + "concurrently": false,
3667 + "method": "btree",
3668 + "with": {}
3669 + }
3670 + },
3671 + "foreignKeys": {},
3672 + "compositePrimaryKeys": {},
3673 + "uniqueConstraints": {},
3674 + "policies": {},
3675 + "checkConstraints": {},
3676 + "isRLSEnabled": false
3677 + },
3678 + "public.sources": {
3679 + "name": "sources",
3680 + "schema": "",
3681 + "columns": {
3682 + "id": {
3683 + "name": "id",
3684 + "type": "text",
3685 + "primaryKey": true,
3686 + "notNull": true
3687 + },
3688 + "connector_id": {
3689 + "name": "connector_id",
3690 + "type": "text",
3691 + "primaryKey": false,
3692 + "notNull": true
3693 + },
3694 + "name": {
3695 + "name": "name",
3696 + "type": "text",
3697 + "primaryKey": false,
3698 + "notNull": true
3699 + },
3700 + "domain": {
3701 + "name": "domain",
3702 + "type": "text",
3703 + "primaryKey": false,
3704 + "notNull": true
3705 + },
3706 + "kind": {
3707 + "name": "kind",
3708 + "type": "text",
3709 + "primaryKey": false,
3710 + "notNull": true
3711 + },
3712 + "priority": {
3713 + "name": "priority",
3714 + "type": "integer",
3715 + "primaryKey": false,
3716 + "notNull": true,
3717 + "default": 3
3718 + },
3719 + "url": {
3720 + "name": "url",
3721 + "type": "text",
3722 + "primaryKey": false,
3723 + "notNull": false
3724 + },
3725 + "license": {
3726 + "name": "license",
3727 + "type": "text",
3728 + "primaryKey": false,
3729 + "notNull": false
3730 + },
3731 + "attribution": {
3732 + "name": "attribution",
3733 + "type": "text",
3734 + "primaryKey": false,
3735 + "notNull": false
3736 + },
3737 + "robots_allowed": {
3738 + "name": "robots_allowed",
3739 + "type": "boolean",
3740 + "primaryKey": false,
3741 + "notNull": false
3742 + },
3743 + "notes": {
3744 + "name": "notes",
3745 + "type": "text",
3746 + "primaryKey": false,
3747 + "notNull": false
3748 + },
3749 + "created_at": {
3750 + "name": "created_at",
3751 + "type": "timestamp with time zone",
3752 + "primaryKey": false,
3753 + "notNull": true,
3754 + "default": "now()"
3755 + },
3756 + "updated_at": {
3757 + "name": "updated_at",
3758 + "type": "timestamp with time zone",
3759 + "primaryKey": false,
3760 + "notNull": true,
3761 + "default": "now()"
3762 + }
3763 + },
3764 + "indexes": {},
3765 + "foreignKeys": {},
3766 + "compositePrimaryKeys": {},
3767 + "uniqueConstraints": {},
3768 + "policies": {},
3769 + "checkConstraints": {},
3770 + "isRLSEnabled": false
3771 + },
3772 + "public.system_alerts": {
3773 + "name": "system_alerts",
3774 + "schema": "",
3775 + "columns": {
3776 + "id": {
3777 + "name": "id",
3778 + "type": "text",
3779 + "primaryKey": true,
3780 + "notNull": true
3781 + },
3782 + "level": {
3783 + "name": "level",
3784 + "type": "text",
3785 + "primaryKey": false,
3786 + "notNull": true
3787 + },
3788 + "component": {
3789 + "name": "component",
3790 + "type": "text",
3791 + "primaryKey": false,
3792 + "notNull": true
3793 + },
3794 + "message": {
3795 + "name": "message",
3796 + "type": "text",
3797 + "primaryKey": false,
3798 + "notNull": true
3799 + },
3800 + "details": {
3801 + "name": "details",
3802 + "type": "jsonb",
3803 + "primaryKey": false,
3804 + "notNull": false
3805 + },
3806 + "resolved_at": {
3807 + "name": "resolved_at",
3808 + "type": "timestamp with time zone",
3809 + "primaryKey": false,
3810 + "notNull": false
3811 + },
3812 + "created_at": {
3813 + "name": "created_at",
3814 + "type": "timestamp with time zone",
3815 + "primaryKey": false,
3816 + "notNull": true,
3817 + "default": "now()"
3818 + }
3819 + },
3820 + "indexes": {},
3821 + "foreignKeys": {},
3822 + "compositePrimaryKeys": {},
3823 + "uniqueConstraints": {},
3824 + "policies": {},
3825 + "checkConstraints": {},
3826 + "isRLSEnabled": false
3827 + }
3828 + },
3829 + "enums": {},
3830 + "schemas": {},
3831 + "sequences": {},
3832 + "roles": {},
3833 + "policies": {},
3834 + "views": {},
3835 + "_meta": {
3836 + "columns": {},
3837 + "schemas": {},
3838 + "tables": {}
3839 + }
3840 +}
\ No newline at end of file
added packages/db/migrations/meta/_journal.json +13 −0
@@ -0,0 +1,13 @@
1 +{
2 + "version": "7",
3 + "dialect": "postgresql",
4 + "entries": [
5 + {
6 + "idx": 0,
7 + "version": "7",
8 + "when": 1789108278691,
9 + "tag": "0000_init",
10 + "breakpoints": true
11 + }
12 + ]
13 +}
\ No newline at end of file
added packages/db/package.json +23 −0
@@ -0,0 +1,23 @@
1 +{
2 + "name": "@dci/db",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "main": "./src/index.ts",
7 + "types": "./src/index.ts",
8 + "exports": { ".": "./src/index.ts", "./schema": "./src/schema.ts", "./clickhouse": "./src/clickhouse.ts" },
9 + "scripts": {
10 + "typecheck": "tsc --noEmit",
11 + "test": "vitest run --passWithNoTests",
12 + "generate": "drizzle-kit generate",
13 + "migrate": "node ../../node_modules/tsx/dist/cli.mjs src/migrate.ts",
14 + "seed": "node ../../node_modules/tsx/dist/cli.mjs src/seed.ts"
15 + },
16 + "dependencies": {
17 + "@clickhouse/client": "^1.11.0",
18 + "@dci/core": "workspace:*",
19 + "drizzle-orm": "^0.44.0",
20 + "postgres": "^3.4.7"
21 + },
22 + "devDependencies": { "@types/node": "^24.0.0", "drizzle-kit": "^0.31.0", "typescript": "^5.9.3", "vitest": "^3.2.0" }
23 +}
added packages/db/src/clickhouse.ts +114 −0
@@ -0,0 +1,114 @@
1 +import { createClient, type ClickHouseClient } from "@clickhouse/client";
2 +
3 +/**
4 + * ClickHouse: append-only analytics store — crawl history, extracted observations, page changes,
5 + * connector metrics and daily time series. Postgres remains the transactional source of truth.
6 + */
7 +let _ch: ClickHouseClient | null = null;
8 +export function getClickHouse(): ClickHouseClient {
9 + if (!_ch) {
10 + _ch = createClient({
11 + url: process.env.CLICKHOUSE_URL ?? "http://127.0.0.1:8123",
12 + database: process.env.CLICKHOUSE_DB ?? "dci",
13 + username: process.env.CLICKHOUSE_USER ?? "default",
14 + password: process.env.CLICKHOUSE_PASSWORD ?? "",
15 + request_timeout: 30_000,
16 + clickhouse_settings: { async_insert: 1, wait_for_async_insert: 0 },
17 + });
18 + }
19 + return _ch;
20 +}
21 +
22 +export const CLICKHOUSE_DDL: string[] = [
23 + `CREATE TABLE IF NOT EXISTS crawl_log (
24 + ts DateTime64(3) DEFAULT now64(3),
25 + connector_id LowCardinality(String),
26 + source_id LowCardinality(String),
27 + document_id String,
28 + url String,
29 + fetch_level UInt8,
30 + fetcher LowCardinality(String),
31 + status_code UInt16,
32 + duration_ms UInt32,
33 + bytes UInt32,
34 + changed UInt8,
35 + not_modified UInt8,
36 + error LowCardinality(String),
37 + credits Float32,
38 + run_id String
39 + ) ENGINE = MergeTree ORDER BY (connector_id, ts) TTL toDateTime(ts) + INTERVAL 400 DAY`,
40 + `CREATE TABLE IF NOT EXISTS observations (
41 + ts DateTime64(3) DEFAULT now64(3),
42 + connector_id LowCardinality(String),
43 + source_id LowCardinality(String),
44 + document_id String,
45 + entity_type LowCardinality(String),
46 + entity_key String,
47 + entity_id String,
48 + field LowCardinality(String),
49 + value String,
50 + value_num Nullable(Float64),
51 + confidence LowCardinality(String),
52 + is_estimate UInt8,
53 + method LowCardinality(String),
54 + extractor_version LowCardinality(String),
55 + run_id String
56 + ) ENGINE = MergeTree ORDER BY (entity_type, entity_key, field, ts)`,
57 + `CREATE TABLE IF NOT EXISTS page_changes (
58 + ts DateTime64(3) DEFAULT now64(3),
59 + connector_id LowCardinality(String),
60 + document_id String,
61 + url String,
62 + old_hash String,
63 + new_hash String,
64 + added_lines UInt32,
65 + removed_lines UInt32,
66 + ratio Float32,
67 + significance UInt8,
68 + fields Array(String),
69 + event_types Array(String)
70 + ) ENGINE = MergeTree ORDER BY (connector_id, ts)`,
71 + `CREATE TABLE IF NOT EXISTS metrics (
72 + ts DateTime DEFAULT now(),
73 + metric LowCardinality(String),
74 + dim LowCardinality(String),
75 + value Float64
76 + ) ENGINE = MergeTree ORDER BY (metric, dim, ts) TTL ts + INTERVAL 400 DAY`,
77 + `CREATE TABLE IF NOT EXISTS entity_daily (
78 + day Date,
79 + metric LowCardinality(String),
80 + dim LowCardinality(String),
81 + value Float64
82 + ) ENGINE = ReplacingMergeTree ORDER BY (metric, dim, day)`,
83 + `CREATE TABLE IF NOT EXISTS api_requests (
84 + ts DateTime DEFAULT now(),
85 + route LowCardinality(String),
86 + status UInt16,
87 + duration_ms UInt32,
88 + cached UInt8
89 + ) ENGINE = MergeTree ORDER BY (route, ts) TTL ts + INTERVAL 90 DAY`,
90 +];
91 +
92 +export async function ensureClickHouse(): Promise<void> {
93 + const db = process.env.CLICKHOUSE_DB ?? "dci";
94 + const admin = createClient({ url: process.env.CLICKHOUSE_URL ?? "http://127.0.0.1:8123", username: process.env.CLICKHOUSE_USER ?? "default", password: process.env.CLICKHOUSE_PASSWORD ?? "" });
95 + await admin.command({ query: `CREATE DATABASE IF NOT EXISTS ${db}` });
96 + await admin.close();
97 + const ch = getClickHouse();
98 + for (const ddl of CLICKHOUSE_DDL) await ch.command({ query: ddl });
99 +}
100 +
101 +export async function chInsert(table: string, rows: Record<string, unknown>[]): Promise<void> {
102 + if (!rows.length) return;
103 + try {
104 + await getClickHouse().insert({ table, values: rows, format: "JSONEachRow" });
105 + } catch (e) {
106 + if (process.env.DCI_CLICKHOUSE_OPTIONAL !== "0") console.warn(`[clickhouse] insert ${table} failed: ${(e as Error).message}`);
107 + else throw e;
108 + }
109 +}
110 +
111 +export async function chQuery<T = Record<string, unknown>>(query: string, params: Record<string, unknown> = {}): Promise<T[]> {
112 + const rs = await getClickHouse().query({ query, query_params: params, format: "JSONEachRow" });
113 + return (await rs.json()) as T[];
114 +}
added packages/db/src/index.ts +36 −0
@@ -0,0 +1,36 @@
1 +import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js";
2 +import postgres from "postgres";
3 +import * as schema from "./schema.js";
4 +
5 +export * from "./schema.js";
6 +export { schema };
7 +export { sql, eq, and, or, desc, asc, inArray, isNull, isNotNull, gte, lte, lt, gt, ilike, like, ne, count, sum, avg, max, min, notInArray } from "drizzle-orm";
8 +
9 +export type Db = PostgresJsDatabase<typeof schema>;
10 +
11 +let _client: ReturnType<typeof postgres> | null = null;
12 +let _db: Db | null = null;
13 +
14 +export function getSql(): ReturnType<typeof postgres> {
15 + if (!_client) {
16 + const url = process.env.DATABASE_URL ?? "postgres://dci:dci@127.0.0.1:5432/dci";
17 + _client = postgres(url, { max: Number(process.env.PG_POOL_MAX ?? 16), idle_timeout: 30, connect_timeout: 15, prepare: true, onnotice: () => {} });
18 + }
19 + return _client;
20 +}
21 +
22 +export function getDb(): Db {
23 + if (!_db) _db = drizzle(getSql(), { schema, casing: "snake_case" });
24 + return _db;
25 +}
26 +
27 +export async function closeDb(): Promise<void> {
28 + if (_client) await _client.end({ timeout: 5 });
29 + _client = null;
30 + _db = null;
31 +}
32 +
33 +/** Postgres `text[]` literal helper — drizzle `sql\`${arr}\`` would spread arrays into tuples. */
34 +export function textArray(values: string[]): string {
35 + return `{${values.map((v) => `"${v.replace(/(["\\])/g, "\\$1")}"`).join(",")}}`;
36 +}
added packages/db/src/migrate.ts +26 −0
@@ -0,0 +1,26 @@
1 +import { readdirSync, readFileSync } from "node:fs";
2 +import { join, dirname } from "node:path";
3 +import { fileURLToPath } from "node:url";
4 +import { getSql, closeDb } from "./index.js";
5 +
6 +/** Plain-SQL migrations in ./migrations, applied in lexical order, tracked in _dci_migrations. Idempotent. */
7 +async function main() {
8 + const sql = getSql();
9 + const dir = join(dirname(fileURLToPath(import.meta.url)), "..", "migrations");
10 + await sql`create table if not exists _dci_migrations (name text primary key, applied_at timestamptz not null default now())`;
11 + const done = new Set((await sql`select name from _dci_migrations`).map((r) => r.name as string));
12 + const files = readdirSync(dir).filter((f) => f.endsWith(".sql")).sort();
13 + for (const f of files) {
14 + if (done.has(f)) continue;
15 + const body = readFileSync(join(dir, f), "utf8");
16 + process.stdout.write(`applying ${f} … `);
17 + await sql.begin(async (tx) => {
18 + await tx.unsafe(body);
19 + await tx`insert into _dci_migrations (name) values (${f})`;
20 + });
21 + console.log("ok");
22 + }
23 + console.log(`migrations: ${files.length} total, ${files.length - done.size} applied now`);
24 + await closeDb();
25 +}
26 +main().catch((e) => { console.error(e); process.exit(1); });
added packages/db/src/schema.ts +575 −0
@@ -0,0 +1,575 @@
1 +/**
2 + * PostgreSQL schema (source of truth for entities, provenance, events, connector state).
3 + * ClickHouse (see clickhouse.ts) holds crawl history, observations, page changes and time series.
4 + * Column naming: snake_case via drizzle `casing`.
5 + */
6 +import { boolean, doublePrecision, index, integer, jsonb, pgTable, primaryKey, real, text, timestamp, uniqueIndex, date, bigint } from "drizzle-orm/pg-core";
7 +import { sql } from "drizzle-orm";
8 +
9 +const ts = (name: string) => timestamp(name, { withTimezone: true, mode: "string" });
10 +const createdAt = () => ts("created_at").notNull().defaultNow();
11 +const updatedAt = () => ts("updated_at").notNull().defaultNow();
12 +
13 +export const countries = pgTable("countries", {
14 + iso2: text("iso2").primaryKey(),
15 + iso3: text("iso3"),
16 + slug: text("slug").notNull().unique(),
17 + name: text("name").notNull(),
18 + region: text("region"),
19 + subregion: text("subregion"),
20 + population: bigint("population", { mode: "number" }),
21 + gdpUsd: doublePrecision("gdp_usd"),
22 + electricityTwh: doublePrecision("electricity_twh"),
23 + renewableShare: doublePrecision("renewable_share"),
24 + statsYear: integer("stats_year"),
25 + lat: doublePrecision("lat"),
26 + lng: doublePrecision("lng"),
27 + stats: jsonb("stats").$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
28 + updatedAt: updatedAt(),
29 +});
30 +
31 +export const metros = pgTable(
32 + "metros",
33 + {
34 + id: text("id").primaryKey(),
35 + slug: text("slug").notNull().unique(),
36 + name: text("name").notNull(),
37 + countryIso2: text("country_iso2").notNull().references(() => countries.iso2),
38 + regionName: text("region_name"),
39 + lat: doublePrecision("lat").notNull(),
40 + lng: doublePrecision("lng").notNull(),
41 + radiusKm: real("radius_km").notNull().default(60),
42 + aliases: text("aliases").array().notNull().default(sql`'{}'::text[]`),
43 + description: text("description"),
44 + stats: jsonb("stats").$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
45 + createdAt: createdAt(),
46 + updatedAt: updatedAt(),
47 + },
48 + (t) => [index("metros_country_idx").on(t.countryIso2)],
49 +);
50 +
51 +export const operators = pgTable(
52 + "operators",
53 + {
54 + id: text("id").primaryKey(),
55 + slug: text("slug").notNull().unique(),
56 + name: text("name").notNull(),
57 + normalizedName: text("normalized_name").notNull(),
58 + kind: text("kind"), // OperatorKind
59 + website: text("website"),
60 + hqCountryIso2: text("hq_country_iso2"),
61 + hqCity: text("hq_city"),
62 + parentId: text("parent_id"),
63 + description: text("description"),
64 + aliases: text("aliases").array().notNull().default(sql`'{}'::text[]`),
65 + externalIds: jsonb("external_ids").$type<Record<string, string | number>>().notNull().default(sql`'{}'::jsonb`),
66 + isCloudProvider: boolean("is_cloud_provider").notNull().default(false),
67 + isCarrier: boolean("is_carrier").notNull().default(false),
68 + stats: jsonb("stats").$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
69 + createdAt: createdAt(),
70 + updatedAt: updatedAt(),
71 + },
72 + (t) => [index("operators_norm_idx").on(t.normalizedName), index("operators_kind_idx").on(t.kind)],
73 +);
74 +
75 +export const campuses = pgTable("campuses", {
76 + id: text("id").primaryKey(),
77 + slug: text("slug").notNull().unique(),
78 + name: text("name").notNull(),
79 + operatorId: text("operator_id").references(() => operators.id),
80 + metroId: text("metro_id").references(() => metros.id),
81 + countryIso2: text("country_iso2"),
82 + city: text("city"),
83 + lat: doublePrecision("lat"),
84 + lng: doublePrecision("lng"),
85 + externalIds: jsonb("external_ids").$type<Record<string, string | number>>().notNull().default(sql`'{}'::jsonb`),
86 + createdAt: createdAt(),
87 + updatedAt: updatedAt(),
88 +});
89 +
90 +export const facilities = pgTable(
91 + "facilities",
92 + {
93 + id: text("id").primaryKey(),
94 + slug: text("slug").notNull().unique(),
95 + name: text("name").notNull(),
96 + normalizedName: text("normalized_name").notNull(),
97 + operatorId: text("operator_id").references(() => operators.id),
98 + ownerId: text("owner_id").references(() => operators.id),
99 + campusId: text("campus_id").references(() => campuses.id),
100 + metroId: text("metro_id").references(() => metros.id),
101 + countryIso2: text("country_iso2").references(() => countries.iso2),
102 + city: text("city"),
103 + regionName: text("region_name"),
104 + address: text("address"),
105 + postalCode: text("postal_code"),
106 + lat: doublePrecision("lat"),
107 + lng: doublePrecision("lng"),
108 + geoPrecision: text("geo_precision").notNull().default("unknown"),
109 + geoSource: text("geo_source"),
110 + geohash: text("geohash"),
111 + status: text("status").notNull().default("unknown"),
112 + facilityType: text("facility_type").notNull().default("unknown"),
113 + tier: text("tier"),
114 + buildingSqm: doublePrecision("building_sqm"),
115 + siteAreaHa: doublePrecision("site_area_ha"),
116 + itCapacityMw: doublePrecision("it_capacity_mw"),
117 + totalPowerMw: doublePrecision("total_power_mw"),
118 + plannedPowerMw: doublePrecision("planned_power_mw"),
119 + mwIsEstimate: boolean("mw_is_estimate").notNull().default(false),
120 + rackCount: integer("rack_count"),
121 + pue: real("pue"),
122 + coolingType: text("cooling_type"),
123 + renewableClaim: text("renewable_claim"),
124 + openedOn: text("opened_on"), // partial date
125 + constructionStartedOn: text("construction_started_on"),
126 + announcedOn: text("announced_on"),
127 + website: text("website"),
128 + description: text("description"),
129 + isAi: boolean("is_ai").notNull().default(false),
130 + isHyperscale: boolean("is_hyperscale").notNull().default(false),
131 + certifications: text("certifications").array().notNull().default(sql`'{}'::text[]`),
132 + carriersCount: integer("carriers_count"),
133 + ixpCount: integer("ixp_count"),
134 + networksCount: integer("networks_count"),
135 + confidence: text("confidence").notNull().default("unverified"),
136 + completeness: integer("completeness").notNull().default(0),
137 + externalIds: jsonb("external_ids").$type<Record<string, string | number>>().notNull().default(sql`'{}'::jsonb`),
138 + sourceCount: integer("source_count").notNull().default(0),
139 + firstSeen: ts("first_seen").notNull().defaultNow(),
140 + lastVerified: ts("last_verified"),
141 + mergedInto: text("merged_into"),
142 + createdAt: createdAt(),
143 + updatedAt: updatedAt(),
144 + },
145 + (t) => [
146 + index("facilities_country_idx").on(t.countryIso2),
147 + index("facilities_operator_idx").on(t.operatorId),
148 + index("facilities_metro_idx").on(t.metroId),
149 + index("facilities_status_idx").on(t.status),
150 + index("facilities_type_idx").on(t.facilityType),
151 + index("facilities_geohash_idx").on(t.geohash),
152 + index("facilities_norm_idx").on(t.normalizedName),
153 + index("facilities_updated_idx").on(t.updatedAt),
154 + ],
155 +);
156 +
157 +export const facilityAliases = pgTable(
158 + "facility_aliases",
159 + {
160 + facilityId: text("facility_id").notNull().references(() => facilities.id, { onDelete: "cascade" }),
161 + alias: text("alias").notNull(),
162 + normalized: text("normalized").notNull(),
163 + sourceId: text("source_id"),
164 + },
165 + (t) => [primaryKey({ columns: [t.facilityId, t.alias] }), index("facility_aliases_norm_idx").on(t.normalized)],
166 +);
167 +
168 +/** connector-scoped key → facility id (idempotent re-ingestion, e.g. "peeringdb:fac:1"). */
169 +export const entityKeys = pgTable(
170 + "entity_keys",
171 + {
172 + key: text("key").primaryKey(),
173 + entityType: text("entity_type").notNull(),
174 + entityId: text("entity_id").notNull(),
175 + connectorId: text("connector_id").notNull(),
176 + createdAt: createdAt(),
177 + },
178 + (t) => [index("entity_keys_entity_idx").on(t.entityType, t.entityId)],
179 +);
180 +
181 +export const cloudRegions = pgTable(
182 + "cloud_regions",
183 + {
184 + id: text("id").primaryKey(),
185 + slug: text("slug").notNull().unique(),
186 + providerId: text("provider_id").notNull().references(() => operators.id),
187 + code: text("code").notNull(),
188 + name: text("name").notNull(),
189 + city: text("city"),
190 + regionName: text("region_name"),
191 + countryIso2: text("country_iso2").references(() => countries.iso2),
192 + metroId: text("metro_id").references(() => metros.id),
193 + lat: doublePrecision("lat"),
194 + lng: doublePrecision("lng"),
195 + geoPrecision: text("geo_precision").notNull().default("city"),
196 + availabilityZones: integer("availability_zones"),
197 + launchedOn: text("launched_on"),
198 + status: text("status").notNull().default("operational"),
199 + isSovereign: boolean("is_sovereign").notNull().default(false),
200 + sourceUrl: text("source_url"),
201 + externalIds: jsonb("external_ids").$type<Record<string, string | number>>().notNull().default(sql`'{}'::jsonb`),
202 + createdAt: createdAt(),
203 + updatedAt: updatedAt(),
204 + },
205 + (t) => [uniqueIndex("cloud_regions_provider_code_uq").on(t.providerId, t.code), index("cloud_regions_country_idx").on(t.countryIso2)],
206 +);
207 +
208 +export const ixps = pgTable("ixps", {
209 + id: text("id").primaryKey(),
210 + slug: text("slug").notNull().unique(),
211 + name: text("name").notNull(),
212 + nameLong: text("name_long"),
213 + city: text("city"),
214 + countryIso2: text("country_iso2").references(() => countries.iso2),
215 + metroId: text("metro_id").references(() => metros.id),
216 + regionContinent: text("region_continent"),
217 + website: text("website"),
218 + networkCount: integer("network_count"),
219 + externalIds: jsonb("external_ids").$type<Record<string, string | number>>().notNull().default(sql`'{}'::jsonb`),
220 + createdAt: createdAt(),
221 + updatedAt: updatedAt(),
222 +});
223 +
224 +export const facilityIxps = pgTable(
225 + "facility_ixps",
226 + {
227 + facilityId: text("facility_id").notNull().references(() => facilities.id, { onDelete: "cascade" }),
228 + ixpId: text("ixp_id").notNull().references(() => ixps.id, { onDelete: "cascade" }),
229 + sourceId: text("source_id"),
230 + },
231 + (t) => [primaryKey({ columns: [t.facilityId, t.ixpId] })],
232 +);
233 +
234 +/** Carriers / networks / cloud providers present at a facility (operators with kind carrier|cloud|hyperscaler). */
235 +export const facilityTenants = pgTable(
236 + "facility_tenants",
237 + {
238 + facilityId: text("facility_id").notNull().references(() => facilities.id, { onDelete: "cascade" }),
239 + operatorId: text("operator_id").notNull().references(() => operators.id, { onDelete: "cascade" }),
240 + role: text("role").notNull().default("carrier"), // carrier | cloud | network | tenant
241 + asn: integer("asn"),
242 + sourceId: text("source_id"),
243 + },
244 + (t) => [primaryKey({ columns: [t.facilityId, t.operatorId, t.role] }), index("facility_tenants_operator_idx").on(t.operatorId)],
245 +);
246 +
247 +export const projects = pgTable(
248 + "projects",
249 + {
250 + id: text("id").primaryKey(),
251 + slug: text("slug").notNull().unique(),
252 + name: text("name").notNull(),
253 + normalizedName: text("normalized_name").notNull(),
254 + operatorId: text("operator_id").references(() => operators.id),
255 + facilityId: text("facility_id").references(() => facilities.id),
256 + metroId: text("metro_id").references(() => metros.id),
257 + countryIso2: text("country_iso2").references(() => countries.iso2),
258 + city: text("city"),
259 + regionName: text("region_name"),
260 + lat: doublePrecision("lat"),
261 + lng: doublePrecision("lng"),
262 + geoPrecision: text("geo_precision").notNull().default("unknown"),
263 + status: text("status").notNull().default("announced"),
264 + announcedOn: text("announced_on"),
265 + expectedOpening: text("expected_opening"),
266 + plannedMw: doublePrecision("planned_mw"),
267 + investmentUsd: doublePrecision("investment_usd"),
268 + investmentCurrency: text("investment_currency"),
269 + investmentOriginal: doublePrecision("investment_original"),
270 + acreage: doublePrecision("acreage"),
271 + phaseCount: integer("phase_count"),
272 + isAi: boolean("is_ai").notNull().default(false),
273 + description: text("description"),
274 + sourceUrl: text("source_url"),
275 + confidence: text("confidence").notNull().default("moderate"),
276 + externalIds: jsonb("external_ids").$type<Record<string, string | number>>().notNull().default(sql`'{}'::jsonb`),
277 + lastUpdate: ts("last_update").notNull().defaultNow(),
278 + createdAt: createdAt(),
279 + updatedAt: updatedAt(),
280 + },
281 + (t) => [index("projects_country_idx").on(t.countryIso2), index("projects_operator_idx").on(t.operatorId), index("projects_status_idx").on(t.status), index("projects_norm_idx").on(t.normalizedName)],
282 +);
283 +
284 +export const projectTimeline = pgTable(
285 + "project_timeline",
286 + {
287 + id: text("id").primaryKey(),
288 + projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
289 + eventDate: text("event_date").notNull(), // partial date
290 + eventType: text("event_type").notNull(),
291 + description: text("description").notNull(),
292 + sourceId: text("source_id"),
293 + documentId: text("document_id"),
294 + url: text("url"),
295 + createdAt: createdAt(),
296 + },
297 + (t) => [index("project_timeline_project_idx").on(t.projectId)],
298 +);
299 +
300 +/** Sources = websites / datasets a connector reads from. */
301 +export const sources = pgTable("sources", {
302 + id: text("id").primaryKey(),
303 + connectorId: text("connector_id").notNull(),
304 + name: text("name").notNull(),
305 + domain: text("domain").notNull(),
306 + kind: text("kind").notNull(), // SourceKind
307 + priority: integer("priority").notNull().default(3),
308 + url: text("url"),
309 + license: text("license"),
310 + attribution: text("attribution"),
311 + robotsAllowed: boolean("robots_allowed"),
312 + notes: text("notes"),
313 + createdAt: createdAt(),
314 + updatedAt: updatedAt(),
315 +});
316 +
317 +/** Connector runtime state (config is loaded from YAML and mirrored here for the admin UI). */
318 +export const connectors = pgTable("connectors", {
319 + id: text("id").primaryKey(),
320 + sourceName: text("source_name").notNull(),
321 + domain: text("domain").notNull(),
322 + kind: text("kind").notNull(),
323 + mode: text("mode").notNull(),
324 + enabled: boolean("enabled").notNull().default(true),
325 + paused: boolean("paused").notNull().default(false),
326 + config: jsonb("config").$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
327 + parserVersion: text("parser_version").notNull().default("v1"),
328 + schedule: jsonb("schedule").$type<Record<string, string>>().notNull().default(sql`'{}'::jsonb`),
329 + health: text("health").notNull().default("never_run"),
330 + lastRunAt: ts("last_run_at"),
331 + lastStatus: text("last_status"),
332 + lastError: text("last_error"),
333 + nextRunAt: ts("next_run_at"),
334 + stats: jsonb("stats").$type<Record<string, number>>().notNull().default(sql`'{}'::jsonb`),
335 + createdAt: createdAt(),
336 + updatedAt: updatedAt(),
337 +});
338 +
339 +export const connectorRuns = pgTable(
340 + "connector_runs",
341 + {
342 + id: text("id").primaryKey(),
343 + connectorId: text("connector_id").notNull(),
344 + task: text("task").notNull().default("crawl"), // crawl | discover | reprocess | manual
345 + startedAt: ts("started_at").notNull().defaultNow(),
346 + finishedAt: ts("finished_at"),
347 + status: text("status").notNull().default("running"), // running | ok | partial | failed | aborted
348 + stats: jsonb("stats").$type<Record<string, number>>().notNull().default(sql`'{}'::jsonb`),
349 + error: text("error"),
350 + log: jsonb("log").$type<Array<{ t: string; level: string; msg: string }>>().notNull().default(sql`'[]'::jsonb`),
351 + },
352 + (t) => [index("connector_runs_connector_idx").on(t.connectorId, t.startedAt)],
353 +);
354 +
355 +/** Crawled documents (URL registry with scheduling + change-tracking state). Raw bodies live in object storage. */
356 +export const documents = pgTable(
357 + "documents",
358 + {
359 + id: text("id").primaryKey(), // stableId("document", canonicalUrl)
360 + connectorId: text("connector_id").notNull(),
361 + sourceId: text("source_id").notNull(),
362 + url: text("url").notNull(),
363 + canonicalUrl: text("canonical_url").notNull(),
364 + urlFingerprint: text("url_fingerprint").notNull(),
365 + pageType: text("page_type").notNull().default("unknown"),
366 + classifier: text("classifier"), // rule id that classified it
367 + fetchLevel: integer("fetch_level").notNull().default(1),
368 + priority: integer("priority").notNull().default(50),
369 + contentHash: text("content_hash"),
370 + etag: text("etag"),
371 + lastModified: text("last_modified"),
372 + statusCode: integer("status_code"),
373 + contentType: text("content_type"),
374 + sizeBytes: integer("size_bytes"),
375 + title: text("title"),
376 + storageKey: text("storage_key"),
377 + extractorVersion: text("extractor_version"),
378 + extractOk: boolean("extract_ok"),
379 + extractCount: integer("extract_count").notNull().default(0),
380 + error: text("error"),
381 + errorCount: integer("error_count").notNull().default(0),
382 + firstSeen: ts("first_seen").notNull().defaultNow(),
383 + lastFetched: ts("last_fetched"),
384 + lastChanged: ts("last_changed"),
385 + lastChecked: ts("last_checked"),
386 + nextCheck: ts("next_check"),
387 + changeFrequencyScore: real("change_frequency_score").notNull().default(0.5),
388 + fetchCount: integer("fetch_count").notNull().default(0),
389 + changeCount: integer("change_count").notNull().default(0),
390 + discoveredFrom: text("discovered_from"),
391 + quarantined: boolean("quarantined").notNull().default(false),
392 + entityRefs: jsonb("entity_refs").$type<Array<{ type: string; id: string }>>().notNull().default(sql`'[]'::jsonb`),
393 + updatedAt: updatedAt(),
394 + },
395 + (t) => [
396 + uniqueIndex("documents_fingerprint_uq").on(t.urlFingerprint),
397 + index("documents_connector_idx").on(t.connectorId),
398 + index("documents_next_check_idx").on(t.nextCheck),
399 + index("documents_page_type_idx").on(t.pageType),
400 + ],
401 +);
402 +
403 +export const documentVersions = pgTable(
404 + "document_versions",
405 + {
406 + id: text("id").primaryKey(),
407 + documentId: text("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
408 + contentHash: text("content_hash").notNull(),
409 + fetchedAt: ts("fetched_at").notNull().defaultNow(),
410 + fetchLevel: integer("fetch_level").notNull().default(1),
411 + statusCode: integer("status_code"),
412 + sizeBytes: integer("size_bytes"),
413 + storageKey: text("storage_key"),
414 + diffSummary: jsonb("diff_summary").$type<{ addedCount: number; removedCount: number; ratio: number; added: string[]; removed: string[] }>(),
415 + detectedChanges: jsonb("detected_changes").$type<Array<Record<string, unknown>>>().notNull().default(sql`'[]'::jsonb`),
416 + significance: integer("significance").notNull().default(0),
417 + },
418 + (t) => [index("document_versions_doc_idx").on(t.documentId, t.fetchedAt)],
419 +);
420 +
421 +/** Per-field provenance for every entity value. */
422 +export const provenance = pgTable(
423 + "provenance",
424 + {
425 + id: text("id").primaryKey(),
426 + entityType: text("entity_type").notNull(),
427 + entityId: text("entity_id").notNull(),
428 + field: text("field").notNull(),
429 + value: jsonb("value"),
430 + sourceId: text("source_id").notNull(),
431 + connectorId: text("connector_id").notNull(),
432 + documentId: text("document_id"),
433 + url: text("url").notNull(),
434 + firstObserved: ts("first_observed").notNull().defaultNow(),
435 + lastObserved: ts("last_observed").notNull().defaultNow(),
436 + retrievedAt: ts("retrieved_at").notNull().defaultNow(),
437 + confidence: text("confidence").notNull().default("moderate"),
438 + isEstimate: boolean("is_estimate").notNull().default(false),
439 + method: text("method"),
440 + extractorVersion: text("extractor_version"),
441 + isCurrent: boolean("is_current").notNull().default(true),
442 + note: text("note"),
443 + },
444 + (t) => [
445 + index("provenance_entity_idx").on(t.entityType, t.entityId),
446 + index("provenance_source_idx").on(t.sourceId),
447 + uniqueIndex("provenance_uq").on(t.entityType, t.entityId, t.field, t.sourceId, t.url),
448 + ],
449 +);
450 +
451 +export const events = pgTable(
452 + "events",
453 + {
454 + id: text("id").primaryKey(),
455 + entityType: text("entity_type").notNull(),
456 + entityId: text("entity_id"),
457 + eventType: text("event_type").notNull(),
458 + detectedAt: ts("detected_at").notNull().defaultNow(),
459 + effectiveDate: text("effective_date"),
460 + oldValue: jsonb("old_value"),
461 + newValue: jsonb("new_value"),
462 + sourceId: text("source_id").notNull(),
463 + documentId: text("document_id"),
464 + url: text("url").notNull(),
465 + title: text("title").notNull(),
466 + summary: text("summary"),
467 + significance: integer("significance").notNull().default(30),
468 + confidence: text("confidence").notNull().default("moderate"),
469 + reviewStatus: text("review_status").notNull().default("auto"),
470 + countryIso2: text("country_iso2"),
471 + operatorId: text("operator_id"),
472 + metroId: text("metro_id"),
473 + projectId: text("project_id"),
474 + fingerprint: text("fingerprint").notNull(),
475 + },
476 + (t) => [
477 + uniqueIndex("events_fingerprint_uq").on(t.fingerprint),
478 + index("events_detected_idx").on(t.detectedAt),
479 + index("events_entity_idx").on(t.entityType, t.entityId),
480 + index("events_country_idx").on(t.countryIso2),
481 + index("events_operator_idx").on(t.operatorId),
482 + index("events_type_idx").on(t.eventType),
483 + ],
484 +);
485 +
486 +export const newsItems = pgTable(
487 + "news_items",
488 + {
489 + id: text("id").primaryKey(),
490 + sourceId: text("source_id").notNull(),
491 + connectorId: text("connector_id").notNull(),
492 + url: text("url").notNull().unique(),
493 + title: text("title").notNull(),
494 + publishedAt: ts("published_at"),
495 + summary: text("summary"),
496 + pageType: text("page_type").notNull().default("unknown"),
497 + eventType: text("event_type"),
498 + mentions: jsonb("mentions").$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
499 + operatorIds: text("operator_ids").array().notNull().default(sql`'{}'::text[]`),
500 + countryIso2s: text("country_iso2s").array().notNull().default(sql`'{}'::text[]`),
501 + facilityIds: text("facility_ids").array().notNull().default(sql`'{}'::text[]`),
502 + projectId: text("project_id"),
503 + mw: doublePrecision("mw"),
504 + significance: integer("significance").notNull().default(20),
505 + createdAt: createdAt(),
506 + },
507 + (t) => [index("news_items_published_idx").on(t.publishedAt)],
508 +);
509 +
510 +/** Reconciliation queue: candidate records whose match to an existing facility was ambiguous. */
511 +export const entityMatches = pgTable(
512 + "entity_matches",
513 + {
514 + id: text("id").primaryKey(),
515 + connectorId: text("connector_id").notNull(),
516 + candidateKey: text("candidate_key").notNull(),
517 + candidate: jsonb("candidate").$type<Record<string, unknown>>().notNull(),
518 + matchedFacilityId: text("matched_facility_id"),
519 + score: real("score").notNull(),
520 + reasons: text("reasons").array().notNull().default(sql`'{}'::text[]`),
521 + status: text("status").notNull().default("pending"), // pending | approved | rejected | auto_merged | auto_created
522 + decidedBy: text("decided_by"),
523 + decidedAt: ts("decided_at"),
524 + createdAt: createdAt(),
525 + },
526 + (t) => [index("entity_matches_status_idx").on(t.status), index("entity_matches_key_idx").on(t.candidateKey)],
527 +);
528 +
529 +export const rankings = pgTable(
530 + "rankings",
531 + {
532 + id: text("id").primaryKey(),
533 + key: text("key").notNull(),
534 + scope: text("scope").notNull(),
535 + label: text("label").notNull(),
536 + unit: text("unit").notNull(),
537 + methodology: text("methodology").notNull(),
538 + minCoverage: real("min_coverage"),
539 + rows: jsonb("rows").$type<Array<Record<string, unknown>>>().notNull(),
540 + total: integer("total").notNull(),
541 + computedAt: ts("computed_at").notNull().defaultNow(),
542 + isCurrent: boolean("is_current").notNull().default(true),
543 + },
544 + (t) => [index("rankings_key_idx").on(t.key, t.isCurrent)],
545 +);
546 +
547 +/** Daily aggregate snapshots (also mirrored to ClickHouse) for growth charts. */
548 +export const dailyMetrics = pgTable(
549 + "daily_metrics",
550 + {
551 + day: date("day").notNull(),
552 + metric: text("metric").notNull(), // facilities_total, known_mw, construction_mw, planned_mw, events, …
553 + dim: text("dim").notNull().default("global"), // "global" | "country:US" | "operator:op_x" | "metro:met_x"
554 + value: doublePrecision("value").notNull(),
555 + },
556 + (t) => [primaryKey({ columns: [t.day, t.metric, t.dim] })],
557 +);
558 +
559 +export const adminSessions = pgTable("admin_sessions", {
560 + id: text("id").primaryKey(),
561 + createdAt: createdAt(),
562 + expiresAt: ts("expires_at").notNull(),
563 + ip: text("ip"),
564 + userAgent: text("user_agent"),
565 +});
566 +
567 +export const systemAlerts = pgTable("system_alerts", {
568 + id: text("id").primaryKey(),
569 + level: text("level").notNull(), // info | warn | error
570 + component: text("component").notNull(),
571 + message: text("message").notNull(),
572 + details: jsonb("details").$type<Record<string, unknown>>(),
573 + resolvedAt: ts("resolved_at"),
574 + createdAt: createdAt(),
575 +});
added packages/db/tsconfig.json +1 −0
@@ -0,0 +1 @@
1 +{ "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true, "types": ["node"] }, "include": ["src"] }
added pnpm-lock.yaml +2058 −0
@@ -0,0 +1,2058 @@
1 +lockfileVersion: '9.0'
2 +
3 +settings:
4 + autoInstallPeers: true
5 + excludeLinksFromLockfile: false
6 +
7 +importers:
8 +
9 + .:
10 + devDependencies:
11 + '@types/node':
12 + specifier: ^24.0.0
13 + version: 24.13.4
14 + tsx:
15 + specifier: ^4.20.0
16 + version: 4.23.13
17 + typescript:
18 + specifier: ^5.9.3
19 + version: 5.9.3
20 + vitest:
21 + specifier: ^3.2.0
22 + version: 3.2.7(@types/node@24.13.4)(tsx@4.23.13)(yaml@2.9.0)
23 +
24 + packages/connectors:
25 + dependencies:
26 + '@dci/core':
27 + specifier: workspace:*
28 + version: link:../core
29 + cheerio:
30 + specifier: ^1.1.0
31 + version: 1.2.0
32 + fast-xml-parser:
33 + specifier: ^5.2.0
34 + version: 5.11.1
35 + undici:
36 + specifier: ^7.10.0
37 + version: 7.29.1
38 + unpdf:
39 + specifier: ^1.0.0
40 + version: 1.8.1
41 + yaml:
42 + specifier: ^2.8.0
43 + version: 2.9.0
44 + zod:
45 + specifier: ^4.1.0
46 + version: 4.6.1
47 + devDependencies:
48 + '@types/node':
49 + specifier: ^24.0.0
50 + version: 24.13.4
51 + typescript:
52 + specifier: ^5.9.3
53 + version: 5.9.3
54 + vitest:
55 + specifier: ^3.2.0
56 + version: 3.2.7(@types/node@24.13.4)(tsx@4.23.13)(yaml@2.9.0)
57 +
58 + packages/core:
59 + dependencies:
60 + zod:
61 + specifier: ^4.1.0
62 + version: 4.6.1
63 + devDependencies:
64 + '@types/node':
65 + specifier: ^24.0.0
66 + version: 24.13.4
67 + typescript:
68 + specifier: ^5.9.3
69 + version: 5.9.3
70 + vitest:
71 + specifier: ^3.2.0
72 + version: 3.2.7(@types/node@24.13.4)(tsx@4.23.13)(yaml@2.9.0)
73 +
74 + packages/db:
75 + dependencies:
76 + '@clickhouse/client':
77 + specifier: ^1.11.0
78 + version: 1.23.1
79 + '@dci/core':
80 + specifier: workspace:*
81 + version: link:../core
82 + drizzle-orm:
83 + specifier: ^0.44.0
84 + version: 0.44.7(postgres@3.4.9)
85 + postgres:
86 + specifier: ^3.4.7
87 + version: 3.4.9
88 + devDependencies:
89 + '@types/node':
90 + specifier: ^24.0.0
91 + version: 24.13.4
92 + drizzle-kit:
93 + specifier: ^0.31.0
94 + version: 0.31.10
95 + typescript:
96 + specifier: ^5.9.3
97 + version: 5.9.3
98 + vitest:
99 + specifier: ^3.2.0
100 + version: 3.2.7(@types/node@24.13.4)(tsx@4.23.13)(yaml@2.9.0)
101 +
102 +packages:
103 +
104 + '@clickhouse/client@1.23.1':
105 + resolution: {integrity: sha512-vs3/Zc1dHvT171btW5nMoPsPCJ6QVJ5pp7obxzO5sjqwFx/jjz9wwCAqcFOdc2DhprugDBaVn+4dVY8hG3A9nw==}
106 + engines: {node: '>=20'}
107 +
108 + '@drizzle-team/brocli@0.10.2':
109 + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
110 +
111 + '@esbuild-kit/core-utils@3.3.2':
112 + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
113 + deprecated: 'Merged into tsx: https://tsx.hirok.io'
114 +
115 + '@esbuild-kit/esm-loader@2.6.5':
116 + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==}
117 + deprecated: 'Merged into tsx: https://tsx.hirok.io'
118 +
119 + '@esbuild/aix-ppc64@0.25.12':
120 + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
121 + engines: {node: '>=18'}
122 + cpu: [ppc64]
123 + os: [aix]
124 +
125 + '@esbuild/aix-ppc64@0.28.2':
126 + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
127 + engines: {node: '>=18'}
128 + cpu: [ppc64]
129 + os: [aix]
130 +
131 + '@esbuild/android-arm64@0.18.20':
132 + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==}
133 + engines: {node: '>=12'}
134 + cpu: [arm64]
135 + os: [android]
136 +
137 + '@esbuild/android-arm64@0.25.12':
138 + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
139 + engines: {node: '>=18'}
140 + cpu: [arm64]
141 + os: [android]
142 +
143 + '@esbuild/android-arm64@0.28.2':
144 + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
145 + engines: {node: '>=18'}
146 + cpu: [arm64]
147 + os: [android]
148 +
149 + '@esbuild/android-arm@0.18.20':
150 + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==}
151 + engines: {node: '>=12'}
152 + cpu: [arm]
153 + os: [android]
154 +
155 + '@esbuild/android-arm@0.25.12':
156 + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
157 + engines: {node: '>=18'}
158 + cpu: [arm]
159 + os: [android]
160 +
161 + '@esbuild/android-arm@0.28.2':
162 + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
163 + engines: {node: '>=18'}
164 + cpu: [arm]
165 + os: [android]
166 +
167 + '@esbuild/android-x64@0.18.20':
168 + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==}
169 + engines: {node: '>=12'}
170 + cpu: [x64]
171 + os: [android]
172 +
173 + '@esbuild/android-x64@0.25.12':
174 + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
175 + engines: {node: '>=18'}
176 + cpu: [x64]
177 + os: [android]
178 +
179 + '@esbuild/android-x64@0.28.2':
180 + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
181 + engines: {node: '>=18'}
182 + cpu: [x64]
183 + os: [android]
184 +
185 + '@esbuild/darwin-arm64@0.18.20':
186 + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==}
187 + engines: {node: '>=12'}
188 + cpu: [arm64]
189 + os: [darwin]
190 +
191 + '@esbuild/darwin-arm64@0.25.12':
192 + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
193 + engines: {node: '>=18'}
194 + cpu: [arm64]
195 + os: [darwin]
196 +
197 + '@esbuild/darwin-arm64@0.28.2':
198 + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
199 + engines: {node: '>=18'}
200 + cpu: [arm64]
201 + os: [darwin]
202 +
203 + '@esbuild/darwin-x64@0.18.20':
204 + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==}
205 + engines: {node: '>=12'}
206 + cpu: [x64]
207 + os: [darwin]
208 +
209 + '@esbuild/darwin-x64@0.25.12':
210 + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
211 + engines: {node: '>=18'}
212 + cpu: [x64]
213 + os: [darwin]
214 +
215 + '@esbuild/darwin-x64@0.28.2':
216 + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
217 + engines: {node: '>=18'}
218 + cpu: [x64]
219 + os: [darwin]
220 +
221 + '@esbuild/freebsd-arm64@0.18.20':
222 + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==}
223 + engines: {node: '>=12'}
224 + cpu: [arm64]
225 + os: [freebsd]
226 +
227 + '@esbuild/freebsd-arm64@0.25.12':
228 + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
229 + engines: {node: '>=18'}
230 + cpu: [arm64]
231 + os: [freebsd]
232 +
233 + '@esbuild/freebsd-arm64@0.28.2':
234 + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
235 + engines: {node: '>=18'}
236 + cpu: [arm64]
237 + os: [freebsd]
238 +
239 + '@esbuild/freebsd-x64@0.18.20':
240 + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==}
241 + engines: {node: '>=12'}
242 + cpu: [x64]
243 + os: [freebsd]
244 +
245 + '@esbuild/freebsd-x64@0.25.12':
246 + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
247 + engines: {node: '>=18'}
248 + cpu: [x64]
249 + os: [freebsd]
250 +
251 + '@esbuild/freebsd-x64@0.28.2':
252 + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
253 + engines: {node: '>=18'}
254 + cpu: [x64]
255 + os: [freebsd]
256 +
257 + '@esbuild/linux-arm64@0.18.20':
258 + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==}
259 + engines: {node: '>=12'}
260 + cpu: [arm64]
261 + os: [linux]
262 +
263 + '@esbuild/linux-arm64@0.25.12':
264 + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
265 + engines: {node: '>=18'}
266 + cpu: [arm64]
267 + os: [linux]
268 +
269 + '@esbuild/linux-arm64@0.28.2':
270 + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
271 + engines: {node: '>=18'}
272 + cpu: [arm64]
273 + os: [linux]
274 +
275 + '@esbuild/linux-arm@0.18.20':
276 + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==}
277 + engines: {node: '>=12'}
278 + cpu: [arm]
279 + os: [linux]
280 +
281 + '@esbuild/linux-arm@0.25.12':
282 + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
283 + engines: {node: '>=18'}
284 + cpu: [arm]
285 + os: [linux]
286 +
287 + '@esbuild/linux-arm@0.28.2':
288 + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
289 + engines: {node: '>=18'}
290 + cpu: [arm]
291 + os: [linux]
292 +
293 + '@esbuild/linux-ia32@0.18.20':
294 + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==}
295 + engines: {node: '>=12'}
296 + cpu: [ia32]
297 + os: [linux]
298 +
299 + '@esbuild/linux-ia32@0.25.12':
300 + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
301 + engines: {node: '>=18'}
302 + cpu: [ia32]
303 + os: [linux]
304 +
305 + '@esbuild/linux-ia32@0.28.2':
306 + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
307 + engines: {node: '>=18'}
308 + cpu: [ia32]
309 + os: [linux]
310 +
311 + '@esbuild/linux-loong64@0.18.20':
312 + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==}
313 + engines: {node: '>=12'}
314 + cpu: [loong64]
315 + os: [linux]
316 +
317 + '@esbuild/linux-loong64@0.25.12':
318 + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
319 + engines: {node: '>=18'}
320 + cpu: [loong64]
321 + os: [linux]
322 +
323 + '@esbuild/linux-loong64@0.28.2':
324 + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
325 + engines: {node: '>=18'}
326 + cpu: [loong64]
327 + os: [linux]
328 +
329 + '@esbuild/linux-mips64el@0.18.20':
330 + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==}
331 + engines: {node: '>=12'}
332 + cpu: [mips64el]
333 + os: [linux]
334 +
335 + '@esbuild/linux-mips64el@0.25.12':
336 + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
337 + engines: {node: '>=18'}
338 + cpu: [mips64el]
339 + os: [linux]
340 +
341 + '@esbuild/linux-mips64el@0.28.2':
342 + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
343 + engines: {node: '>=18'}
344 + cpu: [mips64el]
345 + os: [linux]
346 +
347 + '@esbuild/linux-ppc64@0.18.20':
348 + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==}
349 + engines: {node: '>=12'}
350 + cpu: [ppc64]
351 + os: [linux]
352 +
353 + '@esbuild/linux-ppc64@0.25.12':
354 + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
355 + engines: {node: '>=18'}
356 + cpu: [ppc64]
357 + os: [linux]
358 +
359 + '@esbuild/linux-ppc64@0.28.2':
360 + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
361 + engines: {node: '>=18'}
362 + cpu: [ppc64]
363 + os: [linux]
364 +
365 + '@esbuild/linux-riscv64@0.18.20':
366 + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==}
367 + engines: {node: '>=12'}
368 + cpu: [riscv64]
369 + os: [linux]
370 +
371 + '@esbuild/linux-riscv64@0.25.12':
372 + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
373 + engines: {node: '>=18'}
374 + cpu: [riscv64]
375 + os: [linux]
376 +
377 + '@esbuild/linux-riscv64@0.28.2':
378 + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
379 + engines: {node: '>=18'}
380 + cpu: [riscv64]
381 + os: [linux]
382 +
383 + '@esbuild/linux-s390x@0.18.20':
384 + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==}
385 + engines: {node: '>=12'}
386 + cpu: [s390x]
387 + os: [linux]
388 +
389 + '@esbuild/linux-s390x@0.25.12':
390 + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
391 + engines: {node: '>=18'}
392 + cpu: [s390x]
393 + os: [linux]
394 +
395 + '@esbuild/linux-s390x@0.28.2':
396 + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
397 + engines: {node: '>=18'}
398 + cpu: [s390x]
399 + os: [linux]
400 +
401 + '@esbuild/linux-x64@0.18.20':
402 + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==}
403 + engines: {node: '>=12'}
404 + cpu: [x64]
405 + os: [linux]
406 +
407 + '@esbuild/linux-x64@0.25.12':
408 + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
409 + engines: {node: '>=18'}
410 + cpu: [x64]
411 + os: [linux]
412 +
413 + '@esbuild/linux-x64@0.28.2':
414 + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
415 + engines: {node: '>=18'}
416 + cpu: [x64]
417 + os: [linux]
418 +
419 + '@esbuild/netbsd-arm64@0.25.12':
420 + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
421 + engines: {node: '>=18'}
422 + cpu: [arm64]
423 + os: [netbsd]
424 +
425 + '@esbuild/netbsd-arm64@0.28.2':
426 + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
427 + engines: {node: '>=18'}
428 + cpu: [arm64]
429 + os: [netbsd]
430 +
431 + '@esbuild/netbsd-x64@0.18.20':
432 + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==}
433 + engines: {node: '>=12'}
434 + cpu: [x64]
435 + os: [netbsd]
436 +
437 + '@esbuild/netbsd-x64@0.25.12':
438 + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
439 + engines: {node: '>=18'}
440 + cpu: [x64]
441 + os: [netbsd]
442 +
443 + '@esbuild/netbsd-x64@0.28.2':
444 + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
445 + engines: {node: '>=18'}
446 + cpu: [x64]
447 + os: [netbsd]
448 +
449 + '@esbuild/openbsd-arm64@0.25.12':
450 + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
451 + engines: {node: '>=18'}
452 + cpu: [arm64]
453 + os: [openbsd]
454 +
455 + '@esbuild/openbsd-arm64@0.28.2':
456 + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
457 + engines: {node: '>=18'}
458 + cpu: [arm64]
459 + os: [openbsd]
460 +
461 + '@esbuild/openbsd-x64@0.18.20':
462 + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==}
463 + engines: {node: '>=12'}
464 + cpu: [x64]
465 + os: [openbsd]
466 +
467 + '@esbuild/openbsd-x64@0.25.12':
468 + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
469 + engines: {node: '>=18'}
470 + cpu: [x64]
471 + os: [openbsd]
472 +
473 + '@esbuild/openbsd-x64@0.28.2':
474 + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
475 + engines: {node: '>=18'}
476 + cpu: [x64]
477 + os: [openbsd]
478 +
479 + '@esbuild/openharmony-arm64@0.25.12':
480 + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
481 + engines: {node: '>=18'}
482 + cpu: [arm64]
483 + os: [openharmony]
484 +
485 + '@esbuild/openharmony-arm64@0.28.2':
486 + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
487 + engines: {node: '>=18'}
488 + cpu: [arm64]
489 + os: [openharmony]
490 +
491 + '@esbuild/sunos-x64@0.18.20':
492 + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==}
493 + engines: {node: '>=12'}
494 + cpu: [x64]
495 + os: [sunos]
496 +
497 + '@esbuild/sunos-x64@0.25.12':
498 + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
499 + engines: {node: '>=18'}
500 + cpu: [x64]
501 + os: [sunos]
502 +
503 + '@esbuild/sunos-x64@0.28.2':
504 + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
505 + engines: {node: '>=18'}
506 + cpu: [x64]
507 + os: [sunos]
508 +
509 + '@esbuild/win32-arm64@0.18.20':
510 + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==}
511 + engines: {node: '>=12'}
512 + cpu: [arm64]
513 + os: [win32]
514 +
515 + '@esbuild/win32-arm64@0.25.12':
516 + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
517 + engines: {node: '>=18'}
518 + cpu: [arm64]
519 + os: [win32]
520 +
521 + '@esbuild/win32-arm64@0.28.2':
522 + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
523 + engines: {node: '>=18'}
524 + cpu: [arm64]
525 + os: [win32]
526 +
527 + '@esbuild/win32-ia32@0.18.20':
528 + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==}
529 + engines: {node: '>=12'}
530 + cpu: [ia32]
531 + os: [win32]
532 +
533 + '@esbuild/win32-ia32@0.25.12':
534 + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
535 + engines: {node: '>=18'}
536 + cpu: [ia32]
537 + os: [win32]
538 +
539 + '@esbuild/win32-ia32@0.28.2':
540 + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
541 + engines: {node: '>=18'}
542 + cpu: [ia32]
543 + os: [win32]
544 +
545 + '@esbuild/win32-x64@0.18.20':
546 + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==}
547 + engines: {node: '>=12'}
548 + cpu: [x64]
549 + os: [win32]
550 +
551 + '@esbuild/win32-x64@0.25.12':
552 + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
553 + engines: {node: '>=18'}
554 + cpu: [x64]
555 + os: [win32]
556 +
557 + '@esbuild/win32-x64@0.28.2':
558 + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
559 + engines: {node: '>=18'}
560 + cpu: [x64]
561 + os: [win32]
562 +
563 + '@jridgewell/sourcemap-codec@1.6.0':
564 + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
565 +
566 + '@napi-rs/lzma-linux-x64-gnu@1.5.1':
567 + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
568 + engines: {node: ^22.20 || ^24.12 || >=25}
569 + cpu: [x64]
570 + os: [linux]
571 + libc: [glibc]
572 +
573 + '@nodable/entities@3.0.0':
574 + resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==}
575 +
576 + '@rollup/rollup-android-arm-eabi@4.63.1':
577 + resolution: {integrity: sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==}
578 + cpu: [arm]
579 + os: [android]
580 +
581 + '@rollup/rollup-android-arm64@4.63.1':
582 + resolution: {integrity: sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==}
583 + cpu: [arm64]
584 + os: [android]
585 +
586 + '@rollup/rollup-darwin-arm64@4.63.1':
587 + resolution: {integrity: sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==}
588 + cpu: [arm64]
589 + os: [darwin]
590 +
591 + '@rollup/rollup-darwin-x64@4.63.1':
592 + resolution: {integrity: sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==}
593 + cpu: [x64]
594 + os: [darwin]
595 +
596 + '@rollup/rollup-freebsd-arm64@4.63.1':
597 + resolution: {integrity: sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==}
598 + cpu: [arm64]
599 + os: [freebsd]
600 +
601 + '@rollup/rollup-freebsd-x64@4.63.1':
602 + resolution: {integrity: sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==}
603 + cpu: [x64]
604 + os: [freebsd]
605 +
606 + '@rollup/rollup-linux-arm-gnueabihf@4.63.1':
607 + resolution: {integrity: sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==}
608 + cpu: [arm]
609 + os: [linux]
610 + libc: [glibc]
611 +
612 + '@rollup/rollup-linux-arm-musleabihf@4.63.1':
613 + resolution: {integrity: sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==}
614 + cpu: [arm]
615 + os: [linux]
616 + libc: [musl]
617 +
618 + '@rollup/rollup-linux-arm64-gnu@4.63.1':
619 + resolution: {integrity: sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==}
620 + cpu: [arm64]
621 + os: [linux]
622 + libc: [glibc]
623 +
624 + '@rollup/rollup-linux-arm64-musl@4.63.1':
625 + resolution: {integrity: sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==}
626 + cpu: [arm64]
627 + os: [linux]
628 + libc: [musl]
629 +
630 + '@rollup/rollup-linux-loong64-gnu@4.63.1':
631 + resolution: {integrity: sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==}
632 + cpu: [loong64]
633 + os: [linux]
634 + libc: [glibc]
635 +
636 + '@rollup/rollup-linux-loong64-musl@4.63.1':
637 + resolution: {integrity: sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==}
638 + cpu: [loong64]
639 + os: [linux]
640 + libc: [musl]
641 +
642 + '@rollup/rollup-linux-ppc64-gnu@4.63.1':
643 + resolution: {integrity: sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==}
644 + cpu: [ppc64]
645 + os: [linux]
646 + libc: [glibc]
647 +
648 + '@rollup/rollup-linux-ppc64-musl@4.63.1':
649 + resolution: {integrity: sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==}
650 + cpu: [ppc64]
651 + os: [linux]
652 + libc: [musl]
653 +
654 + '@rollup/rollup-linux-riscv64-gnu@4.63.1':
655 + resolution: {integrity: sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==}
656 + cpu: [riscv64]
657 + os: [linux]
658 + libc: [glibc]
659 +
660 + '@rollup/rollup-linux-riscv64-musl@4.63.1':
661 + resolution: {integrity: sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==}
662 + cpu: [riscv64]
663 + os: [linux]
664 + libc: [musl]
665 +
666 + '@rollup/rollup-linux-s390x-gnu@4.63.1':
667 + resolution: {integrity: sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==}
668 + cpu: [s390x]
669 + os: [linux]
670 + libc: [glibc]
671 +
672 + '@rollup/rollup-linux-x64-gnu@4.63.1':
673 + resolution: {integrity: sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==}
674 + cpu: [x64]
675 + os: [linux]
676 + libc: [glibc]
677 +
678 + '@rollup/rollup-linux-x64-musl@4.63.1':
679 + resolution: {integrity: sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==}
680 + cpu: [x64]
681 + os: [linux]
682 + libc: [musl]
683 +
684 + '@rollup/rollup-openbsd-x64@4.63.1':
685 + resolution: {integrity: sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==}
686 + cpu: [x64]
687 + os: [openbsd]
688 +
689 + '@rollup/rollup-openharmony-arm64@4.63.1':
690 + resolution: {integrity: sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==}
691 + cpu: [arm64]
692 + os: [openharmony]
693 +
694 + '@rollup/rollup-win32-arm64-msvc@4.63.1':
695 + resolution: {integrity: sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==}
696 + cpu: [arm64]
697 + os: [win32]
698 +
699 + '@rollup/rollup-win32-ia32-msvc@4.63.1':
700 + resolution: {integrity: sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==}
701 + cpu: [ia32]
702 + os: [win32]
703 +
704 + '@rollup/rollup-win32-x64-gnu@4.63.1':
705 + resolution: {integrity: sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==}
706 + cpu: [x64]
707 + os: [win32]
708 +
709 + '@rollup/rollup-win32-x64-msvc@4.63.1':
710 + resolution: {integrity: sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==}
711 + cpu: [x64]
712 + os: [win32]
713 +
714 + '@types/chai@5.2.3':
715 + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
716 +
717 + '@types/deep-eql@4.0.2':
718 + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
719 +
720 + '@types/estree@1.0.9':
721 + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
722 +
723 + '@types/node@24.13.4':
724 + resolution: {integrity: sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==}
725 +
726 + '@vitest/expect@3.2.7':
727 + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==}
728 +
729 + '@vitest/mocker@3.2.7':
730 + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==}
731 + peerDependencies:
732 + msw: ^2.4.9
733 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
734 + peerDependenciesMeta:
735 + msw:
736 + optional: true
737 + vite:
738 + optional: true
739 +
740 + '@vitest/pretty-format@3.2.7':
741 + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==}
742 +
743 + '@vitest/runner@3.2.7':
744 + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==}
745 +
746 + '@vitest/snapshot@3.2.7':
747 + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==}
748 +
749 + '@vitest/spy@3.2.7':
750 + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==}
751 +
752 + '@vitest/utils@3.2.7':
753 + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==}
754 +
755 + anynum@1.0.1:
756 + resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==}
757 +
758 + assertion-error@2.0.1:
759 + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
760 + engines: {node: '>=12'}
761 +
762 + boolbase@1.0.0:
763 + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
764 +
765 + buffer-from@1.1.2:
766 + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
767 +
768 + cac@6.7.14:
769 + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
770 + engines: {node: '>=8'}
771 +
772 + chai@5.3.3:
773 + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
774 + engines: {node: '>=18'}
775 +
776 + check-error@2.1.3:
777 + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
778 + engines: {node: '>= 16'}
779 +
780 + cheerio-select@2.1.0:
781 + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==}
782 +
783 + cheerio@1.2.0:
784 + resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==}
785 + engines: {node: '>=20.18.1'}
786 +
787 + css-select@5.2.2:
788 + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
789 +
790 + css-what@6.2.2:
791 + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==}
792 + engines: {node: '>= 6'}
793 +
794 + debug@4.4.3:
795 + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
796 + engines: {node: '>=6.0'}
797 + peerDependencies:
798 + supports-color: '*'
799 + peerDependenciesMeta:
800 + supports-color:
801 + optional: true
802 +
803 + deep-eql@5.0.2:
804 + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
805 + engines: {node: '>=6'}
806 +
807 + dom-serializer@2.0.0:
808 + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
809 +
810 + domelementtype@2.3.0:
811 + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
812 +
813 + domhandler@5.0.3:
814 + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
815 + engines: {node: '>= 4'}
816 +
817 + domutils@3.2.2:
818 + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
819 +
820 + drizzle-kit@0.31.10:
821 + resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==}
822 + hasBin: true
823 +
824 + drizzle-orm@0.44.7:
825 + resolution: {integrity: sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ==}
826 + peerDependencies:
827 + '@aws-sdk/client-rds-data': '>=3'
828 + '@cloudflare/workers-types': '>=4'
829 + '@electric-sql/pglite': '>=0.2.0'
830 + '@libsql/client': '>=0.10.0'
831 + '@libsql/client-wasm': '>=0.10.0'
832 + '@neondatabase/serverless': '>=0.10.0'
833 + '@op-engineering/op-sqlite': '>=2'
834 + '@opentelemetry/api': ^1.4.1
835 + '@planetscale/database': '>=1.13'
836 + '@prisma/client': '*'
837 + '@tidbcloud/serverless': '*'
838 + '@types/better-sqlite3': '*'
839 + '@types/pg': '*'
840 + '@types/sql.js': '*'
841 + '@upstash/redis': '>=1.34.7'
842 + '@vercel/postgres': '>=0.8.0'
843 + '@xata.io/client': '*'
844 + better-sqlite3: '>=7'
845 + bun-types: '*'
846 + expo-sqlite: '>=14.0.0'
847 + gel: '>=2'
848 + knex: '*'
849 + kysely: '*'
850 + mysql2: '>=2'
851 + pg: '>=8'
852 + postgres: '>=3'
853 + prisma: '*'
854 + sql.js: '>=1'
855 + sqlite3: '>=5'
856 + peerDependenciesMeta:
857 + '@aws-sdk/client-rds-data':
858 + optional: true
859 + '@cloudflare/workers-types':
860 + optional: true
861 + '@electric-sql/pglite':
862 + optional: true
863 + '@libsql/client':
864 + optional: true
865 + '@libsql/client-wasm':
866 + optional: true
867 + '@neondatabase/serverless':
868 + optional: true
869 + '@op-engineering/op-sqlite':
870 + optional: true
871 + '@opentelemetry/api':
872 + optional: true
873 + '@planetscale/database':
874 + optional: true
875 + '@prisma/client':
876 + optional: true
877 + '@tidbcloud/serverless':
878 + optional: true
879 + '@types/better-sqlite3':
880 + optional: true
881 + '@types/pg':
882 + optional: true
883 + '@types/sql.js':
884 + optional: true
885 + '@upstash/redis':
886 + optional: true
887 + '@vercel/postgres':
888 + optional: true
889 + '@xata.io/client':
890 + optional: true
891 + better-sqlite3:
892 + optional: true
893 + bun-types:
894 + optional: true
895 + expo-sqlite:
896 + optional: true
897 + gel:
898 + optional: true
899 + knex:
900 + optional: true
901 + kysely:
902 + optional: true
903 + mysql2:
904 + optional: true
905 + pg:
906 + optional: true
907 + postgres:
908 + optional: true
909 + prisma:
910 + optional: true
911 + sql.js:
912 + optional: true
913 + sqlite3:
914 + optional: true
915 +
916 + encoding-sniffer@0.2.1:
917 + resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==}
918 +
919 + entities@4.5.0:
920 + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
921 + engines: {node: '>=0.12'}
922 +
923 + entities@6.0.1:
924 + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
925 + engines: {node: '>=0.12'}
926 +
927 + entities@7.0.1:
928 + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
929 + engines: {node: '>=0.12'}
930 +
931 + es-module-lexer@1.7.0:
932 + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
933 +
934 + esbuild@0.18.20:
935 + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==}
936 + engines: {node: '>=12'}
937 + hasBin: true
938 +
939 + esbuild@0.25.12:
940 + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
941 + engines: {node: '>=18'}
942 + hasBin: true
943 +
944 + esbuild@0.28.2:
945 + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
946 + engines: {node: '>=18'}
947 + hasBin: true
948 +
949 + estree-walker@3.0.3:
950 + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
951 +
952 + expect-type@1.4.0:
953 + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
954 + engines: {node: '>=12.0.0'}
955 +
956 + fast-xml-builder@1.3.1:
957 + resolution: {integrity: sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==}
958 +
959 + fast-xml-parser@5.11.1:
960 + resolution: {integrity: sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==}
961 + hasBin: true
962 +
963 + fdir@6.5.0:
964 + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
965 + engines: {node: '>=12.0.0'}
966 + peerDependencies:
967 + picomatch: ^3 || ^4
968 + peerDependenciesMeta:
969 + picomatch:
970 + optional: true
971 +
972 + fsevents@2.3.3:
973 + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
974 + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
975 + os: [darwin]
976 +
977 + get-tsconfig@4.14.3:
978 + resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==}
979 +
980 + htmlparser2@10.1.0:
981 + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==}
982 +
983 + iconv-lite@0.6.3:
984 + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
985 + engines: {node: '>=0.10.0'}
986 +
987 + is-unsafe@2.0.2:
988 + resolution: {integrity: sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==}
989 +
990 + js-tokens@9.0.1:
991 + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
992 +
993 + loupe@3.2.1:
994 + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
995 +
996 + magic-string@0.30.21:
997 + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
998 +
999 + ms@2.1.3:
1000 + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
1001 +
1002 + nanoid@3.3.18:
1003 + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
1004 + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
1005 + hasBin: true
1006 +
1007 + nth-check@2.1.1:
1008 + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
1009 +
1010 + parse5-htmlparser2-tree-adapter@7.1.0:
1011 + resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==}
1012 +
1013 + parse5-parser-stream@7.1.2:
1014 + resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==}
1015 +
1016 + parse5@7.3.0:
1017 + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
1018 +
1019 + path-expression-matcher@1.6.2:
1020 + resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==}
1021 + engines: {node: '>=14.0.0'}
1022 +
1023 + pathe@2.0.3:
1024 + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
1025 +
1026 + pathval@2.0.1:
1027 + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
1028 + engines: {node: '>= 14.16'}
1029 +
1030 + picocolors@1.1.1:
1031 + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
1032 +
1033 + picomatch@4.0.7:
1034 + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
1035 + engines: {node: '>=12'}
1036 +
1037 + postcss@8.5.28:
1038 + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
1039 + engines: {node: ^10 || ^12 || >=14}
1040 +
1041 + postgres@3.4.9:
1042 + resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==}
1043 + engines: {node: '>=12'}
1044 +
1045 + resolve-pkg-maps@1.0.0:
1046 + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
1047 +
1048 + rollup@4.63.1:
1049 + resolution: {integrity: sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==}
1050 + engines: {node: '>=18.0.0', npm: '>=8.0.0'}
1051 + hasBin: true
1052 +
1053 + safer-buffer@2.1.2:
1054 + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
1055 +
1056 + siginfo@2.0.0:
1057 + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
1058 +
1059 + source-map-js@1.2.1:
1060 + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
1061 + engines: {node: '>=0.10.0'}
1062 +
1063 + source-map-support@0.5.21:
1064 + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
1065 +
1066 + source-map@0.6.1:
1067 + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
1068 + engines: {node: '>=0.10.0'}
1069 +
1070 + stackback@0.0.2:
1071 + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
1072 +
1073 + std-env@3.10.0:
1074 + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
1075 +
1076 + strip-literal@3.1.0:
1077 + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
1078 +
1079 + strnum@2.4.2:
1080 + resolution: {integrity: sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==}
1081 +
1082 + tinybench@2.9.0:
1083 + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
1084 +
1085 + tinyexec@0.3.2:
1086 + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
1087 +
1088 + tinyglobby@0.2.17:
1089 + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
1090 + engines: {node: '>=12.0.0'}
1091 +
1092 + tinypool@1.1.1:
1093 + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
1094 + engines: {node: ^18.0.0 || >=20.0.0}
1095 +
1096 + tinyrainbow@2.0.0:
1097 + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
1098 + engines: {node: '>=14.0.0'}
1099 +
1100 + tinyspy@4.0.6:
1101 + resolution: {integrity: sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==}
1102 + engines: {node: '>=14.0.0'}
1103 +
1104 + tsx@4.23.13:
1105 + resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==}
1106 + engines: {node: '>=18.0.0'}
1107 + hasBin: true
1108 +
1109 + typescript@5.9.3:
1110 + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
1111 + engines: {node: '>=14.17'}
1112 + hasBin: true
1113 +
1114 + undici-types@7.18.2:
1115 + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
1116 +
1117 + undici@7.29.1:
1118 + resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==}
1119 + engines: {node: '>=20.18.1'}
1120 +
1121 + unpdf@1.8.1:
1122 + resolution: {integrity: sha512-xkURhy2SoGpOIH0a1gLHNkASPIQYonadDJs2AQwPEfUakafeD9EA1WTWWsaR++gfTCXJpV27W7tU1nXuk82UKQ==}
1123 + engines: {node: '>=22'}
1124 + peerDependencies:
1125 + '@napi-rs/canvas': ^0.1.69 || ^1.0.0
1126 + peerDependenciesMeta:
1127 + '@napi-rs/canvas':
1128 + optional: true
1129 +
1130 + vite-node@3.2.4:
1131 + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
1132 + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
1133 + hasBin: true
1134 +
1135 + vite@7.3.6:
1136 + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==}
1137 + engines: {node: ^20.19.0 || >=22.12.0}
1138 + hasBin: true
1139 + peerDependencies:
1140 + '@types/node': ^20.19.0 || >=22.12.0
1141 + jiti: '>=1.21.0'
1142 + less: ^4.0.0
1143 + lightningcss: ^1.21.0
1144 + sass: ^1.70.0
1145 + sass-embedded: ^1.70.0
1146 + stylus: '>=0.54.8'
1147 + sugarss: ^5.0.0
1148 + terser: ^5.16.0
1149 + tsx: ^4.8.1
1150 + yaml: ^2.4.2
1151 + peerDependenciesMeta:
1152 + '@types/node':
1153 + optional: true
1154 + jiti:
1155 + optional: true
1156 + less:
1157 + optional: true
1158 + lightningcss:
1159 + optional: true
1160 + sass:
1161 + optional: true
1162 + sass-embedded:
1163 + optional: true
1164 + stylus:
1165 + optional: true
1166 + sugarss:
1167 + optional: true
1168 + terser:
1169 + optional: true
1170 + tsx:
1171 + optional: true
1172 + yaml:
1173 + optional: true
1174 +
1175 + vitest@3.2.7:
1176 + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==}
1177 + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
1178 + hasBin: true
1179 + peerDependencies:
1180 + '@edge-runtime/vm': '*'
1181 + '@types/debug': ^4.1.12
1182 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
1183 + '@vitest/browser': 3.2.7
1184 + '@vitest/ui': 3.2.7
1185 + happy-dom: '*'
1186 + jsdom: '*'
1187 + peerDependenciesMeta:
1188 + '@edge-runtime/vm':
1189 + optional: true
1190 + '@types/debug':
1191 + optional: true
1192 + '@types/node':
1193 + optional: true
1194 + '@vitest/browser':
1195 + optional: true
1196 + '@vitest/ui':
1197 + optional: true
1198 + happy-dom:
1199 + optional: true
1200 + jsdom:
1201 + optional: true
1202 +
1203 + whatwg-encoding@3.1.1:
1204 + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
1205 + engines: {node: '>=18'}
1206 + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
1207 +
1208 + whatwg-mimetype@4.0.0:
1209 + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
1210 + engines: {node: '>=18'}
1211 +
1212 + why-is-node-running@2.3.0:
1213 + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
1214 + engines: {node: '>=8'}
1215 + hasBin: true
1216 +
1217 + xml-naming@0.3.0:
1218 + resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==}
1219 + engines: {node: '>=16.0.0'}
1220 +
1221 + yaml@2.9.0:
1222 + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
1223 + engines: {node: '>= 14.6'}
1224 + hasBin: true
1225 +
1226 + zod@4.6.1:
1227 + resolution: {integrity: sha512-341aRWQsve0rvronKNTqZpjmzdbUDlFuzHaI/XLg/Ej82qffDJRRfBTCuv7+9q/rMjB6LSLyEBnW4InJeMtt/Q==}
1228 +
1229 +snapshots:
1230 +
1231 + '@clickhouse/client@1.23.1': {}
1232 +
1233 + '@drizzle-team/brocli@0.10.2': {}
1234 +
1235 + '@esbuild-kit/core-utils@3.3.2':
1236 + dependencies:
1237 + esbuild: 0.18.20
1238 + source-map-support: 0.5.21
1239 +
1240 + '@esbuild-kit/esm-loader@2.6.5':
1241 + dependencies:
1242 + '@esbuild-kit/core-utils': 3.3.2
1243 + get-tsconfig: 4.14.3
1244 +
1245 + '@esbuild/aix-ppc64@0.25.12':
1246 + optional: true
1247 +
1248 + '@esbuild/aix-ppc64@0.28.2':
1249 + optional: true
1250 +
1251 + '@esbuild/android-arm64@0.18.20':
1252 + optional: true
1253 +
1254 + '@esbuild/android-arm64@0.25.12':
1255 + optional: true
1256 +
1257 + '@esbuild/android-arm64@0.28.2':
1258 + optional: true
1259 +
1260 + '@esbuild/android-arm@0.18.20':
1261 + optional: true
1262 +
1263 + '@esbuild/android-arm@0.25.12':
1264 + optional: true
1265 +
1266 + '@esbuild/android-arm@0.28.2':
1267 + optional: true
1268 +
1269 + '@esbuild/android-x64@0.18.20':
1270 + optional: true
1271 +
1272 + '@esbuild/android-x64@0.25.12':
1273 + optional: true
1274 +
1275 + '@esbuild/android-x64@0.28.2':
1276 + optional: true
1277 +
1278 + '@esbuild/darwin-arm64@0.18.20':
1279 + optional: true
1280 +
1281 + '@esbuild/darwin-arm64@0.25.12':
1282 + optional: true
1283 +
1284 + '@esbuild/darwin-arm64@0.28.2':
1285 + optional: true
1286 +
1287 + '@esbuild/darwin-x64@0.18.20':
1288 + optional: true
1289 +
1290 + '@esbuild/darwin-x64@0.25.12':
1291 + optional: true
1292 +
1293 + '@esbuild/darwin-x64@0.28.2':
1294 + optional: true
1295 +
1296 + '@esbuild/freebsd-arm64@0.18.20':
1297 + optional: true
1298 +
1299 + '@esbuild/freebsd-arm64@0.25.12':
1300 + optional: true
1301 +
1302 + '@esbuild/freebsd-arm64@0.28.2':
1303 + optional: true
1304 +
1305 + '@esbuild/freebsd-x64@0.18.20':
1306 + optional: true
1307 +
1308 + '@esbuild/freebsd-x64@0.25.12':
1309 + optional: true
1310 +
1311 + '@esbuild/freebsd-x64@0.28.2':
1312 + optional: true
1313 +
1314 + '@esbuild/linux-arm64@0.18.20':
1315 + optional: true
1316 +
1317 + '@esbuild/linux-arm64@0.25.12':
1318 + optional: true
1319 +
1320 + '@esbuild/linux-arm64@0.28.2':
1321 + optional: true
1322 +
1323 + '@esbuild/linux-arm@0.18.20':
1324 + optional: true
1325 +
1326 + '@esbuild/linux-arm@0.25.12':
1327 + optional: true
1328 +
1329 + '@esbuild/linux-arm@0.28.2':
1330 + optional: true
1331 +
1332 + '@esbuild/linux-ia32@0.18.20':
1333 + optional: true
1334 +
1335 + '@esbuild/linux-ia32@0.25.12':
1336 + optional: true
1337 +
1338 + '@esbuild/linux-ia32@0.28.2':
1339 + optional: true
1340 +
1341 + '@esbuild/linux-loong64@0.18.20':
1342 + optional: true
1343 +
1344 + '@esbuild/linux-loong64@0.25.12':
1345 + optional: true
1346 +
1347 + '@esbuild/linux-loong64@0.28.2':
1348 + optional: true
1349 +
1350 + '@esbuild/linux-mips64el@0.18.20':
1351 + optional: true
1352 +
1353 + '@esbuild/linux-mips64el@0.25.12':
1354 + optional: true
1355 +
1356 + '@esbuild/linux-mips64el@0.28.2':
1357 + optional: true
1358 +
1359 + '@esbuild/linux-ppc64@0.18.20':
1360 + optional: true
1361 +
1362 + '@esbuild/linux-ppc64@0.25.12':
1363 + optional: true
1364 +
1365 + '@esbuild/linux-ppc64@0.28.2':
1366 + optional: true
1367 +
1368 + '@esbuild/linux-riscv64@0.18.20':
1369 + optional: true
1370 +
1371 + '@esbuild/linux-riscv64@0.25.12':
1372 + optional: true
1373 +
1374 + '@esbuild/linux-riscv64@0.28.2':
1375 + optional: true
1376 +
1377 + '@esbuild/linux-s390x@0.18.20':
1378 + optional: true
1379 +
1380 + '@esbuild/linux-s390x@0.25.12':
1381 + optional: true
1382 +
1383 + '@esbuild/linux-s390x@0.28.2':
1384 + optional: true
1385 +
1386 + '@esbuild/linux-x64@0.18.20':
1387 + optional: true
1388 +
1389 + '@esbuild/linux-x64@0.25.12':
1390 + optional: true
1391 +
1392 + '@esbuild/linux-x64@0.28.2':
1393 + optional: true
1394 +
1395 + '@esbuild/netbsd-arm64@0.25.12':
1396 + optional: true
1397 +
1398 + '@esbuild/netbsd-arm64@0.28.2':
1399 + optional: true
1400 +
1401 + '@esbuild/netbsd-x64@0.18.20':
1402 + optional: true
1403 +
1404 + '@esbuild/netbsd-x64@0.25.12':
1405 + optional: true
1406 +
1407 + '@esbuild/netbsd-x64@0.28.2':
1408 + optional: true
1409 +
1410 + '@esbuild/openbsd-arm64@0.25.12':
1411 + optional: true
1412 +
1413 + '@esbuild/openbsd-arm64@0.28.2':
1414 + optional: true
1415 +
1416 + '@esbuild/openbsd-x64@0.18.20':
1417 + optional: true
1418 +
1419 + '@esbuild/openbsd-x64@0.25.12':
1420 + optional: true
1421 +
1422 + '@esbuild/openbsd-x64@0.28.2':
1423 + optional: true
1424 +
1425 + '@esbuild/openharmony-arm64@0.25.12':
1426 + optional: true
1427 +
1428 + '@esbuild/openharmony-arm64@0.28.2':
1429 + optional: true
1430 +
1431 + '@esbuild/sunos-x64@0.18.20':
1432 + optional: true
1433 +
1434 + '@esbuild/sunos-x64@0.25.12':
1435 + optional: true
1436 +
1437 + '@esbuild/sunos-x64@0.28.2':
1438 + optional: true
1439 +
1440 + '@esbuild/win32-arm64@0.18.20':
1441 + optional: true
1442 +
1443 + '@esbuild/win32-arm64@0.25.12':
1444 + optional: true
1445 +
1446 + '@esbuild/win32-arm64@0.28.2':
1447 + optional: true
1448 +
1449 + '@esbuild/win32-ia32@0.18.20':
1450 + optional: true
1451 +
1452 + '@esbuild/win32-ia32@0.25.12':
1453 + optional: true
1454 +
1455 + '@esbuild/win32-ia32@0.28.2':
1456 + optional: true
1457 +
1458 + '@esbuild/win32-x64@0.18.20':
1459 + optional: true
1460 +
1461 + '@esbuild/win32-x64@0.25.12':
1462 + optional: true
1463 +
1464 + '@esbuild/win32-x64@0.28.2':
1465 + optional: true
1466 +
1467 + '@jridgewell/sourcemap-codec@1.6.0': {}
1468 +
1469 + '@napi-rs/lzma-linux-x64-gnu@1.5.1':
1470 + optional: true
1471 +
1472 + '@nodable/entities@3.0.0': {}
1473 +
1474 + '@rollup/rollup-android-arm-eabi@4.63.1':
1475 + optional: true
1476 +
1477 + '@rollup/rollup-android-arm64@4.63.1':
1478 + optional: true
1479 +
1480 + '@rollup/rollup-darwin-arm64@4.63.1':
1481 + optional: true
1482 +
1483 + '@rollup/rollup-darwin-x64@4.63.1':
1484 + optional: true
1485 +
1486 + '@rollup/rollup-freebsd-arm64@4.63.1':
1487 + optional: true
1488 +
1489 + '@rollup/rollup-freebsd-x64@4.63.1':
1490 + optional: true
1491 +
1492 + '@rollup/rollup-linux-arm-gnueabihf@4.63.1':
1493 + optional: true
1494 +
1495 + '@rollup/rollup-linux-arm-musleabihf@4.63.1':
1496 + optional: true
1497 +
1498 + '@rollup/rollup-linux-arm64-gnu@4.63.1':
1499 + optional: true
1500 +
1501 + '@rollup/rollup-linux-arm64-musl@4.63.1':
1502 + optional: true
1503 +
1504 + '@rollup/rollup-linux-loong64-gnu@4.63.1':
1505 + optional: true
1506 +
1507 + '@rollup/rollup-linux-loong64-musl@4.63.1':
1508 + optional: true
1509 +
1510 + '@rollup/rollup-linux-ppc64-gnu@4.63.1':
1511 + optional: true
1512 +
1513 + '@rollup/rollup-linux-ppc64-musl@4.63.1':
1514 + optional: true
1515 +
1516 + '@rollup/rollup-linux-riscv64-gnu@4.63.1':
1517 + optional: true
1518 +
1519 + '@rollup/rollup-linux-riscv64-musl@4.63.1':
1520 + optional: true
1521 +
1522 + '@rollup/rollup-linux-s390x-gnu@4.63.1':
1523 + optional: true
1524 +
1525 + '@rollup/rollup-linux-x64-gnu@4.63.1':
1526 + optional: true
1527 +
1528 + '@rollup/rollup-linux-x64-musl@4.63.1':
1529 + optional: true
1530 +
1531 + '@rollup/rollup-openbsd-x64@4.63.1':
1532 + optional: true
1533 +
1534 + '@rollup/rollup-openharmony-arm64@4.63.1':
1535 + optional: true
1536 +
1537 + '@rollup/rollup-win32-arm64-msvc@4.63.1':
1538 + optional: true
1539 +
1540 + '@rollup/rollup-win32-ia32-msvc@4.63.1':
1541 + optional: true
1542 +
1543 + '@rollup/rollup-win32-x64-gnu@4.63.1':
1544 + optional: true
1545 +
1546 + '@rollup/rollup-win32-x64-msvc@4.63.1':
1547 + optional: true
1548 +
1549 + '@types/chai@5.2.3':
1550 + dependencies:
1551 + '@types/deep-eql': 4.0.2
1552 + assertion-error: 2.0.1
1553 +
1554 + '@types/deep-eql@4.0.2': {}
1555 +
1556 + '@types/estree@1.0.9': {}
1557 +
1558 + '@types/node@24.13.4':
1559 + dependencies:
1560 + undici-types: 7.18.2
1561 +
1562 + '@vitest/expect@3.2.7':
1563 + dependencies:
1564 + '@types/chai': 5.2.3
1565 + '@vitest/spy': 3.2.7
1566 + '@vitest/utils': 3.2.7
1567 + chai: 5.3.3
1568 + tinyrainbow: 2.0.0
1569 +
1570 + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@24.13.4)(tsx@4.23.13)(yaml@2.9.0))':
1571 + dependencies:
1572 + '@vitest/spy': 3.2.7
1573 + estree-walker: 3.0.3
1574 + magic-string: 0.30.21
1575 + optionalDependencies:
1576 + vite: 7.3.6(@types/node@24.13.4)(tsx@4.23.13)(yaml@2.9.0)
1577 +
1578 + '@vitest/pretty-format@3.2.7':
1579 + dependencies:
1580 + tinyrainbow: 2.0.0
1581 +
1582 + '@vitest/runner@3.2.7':
1583 + dependencies:
1584 + '@vitest/utils': 3.2.7
1585 + pathe: 2.0.3
1586 + strip-literal: 3.1.0
1587 +
1588 + '@vitest/snapshot@3.2.7':
1589 + dependencies:
1590 + '@vitest/pretty-format': 3.2.7
1591 + magic-string: 0.30.21
1592 + pathe: 2.0.3
1593 +
1594 + '@vitest/spy@3.2.7':
1595 + dependencies:
1596 + tinyspy: 4.0.6
1597 +
1598 + '@vitest/utils@3.2.7':
1599 + dependencies:
1600 + '@vitest/pretty-format': 3.2.7
1601 + loupe: 3.2.1
1602 + tinyrainbow: 2.0.0
1603 +
1604 + anynum@1.0.1: {}
1605 +
1606 + assertion-error@2.0.1: {}
1607 +
1608 + boolbase@1.0.0: {}
1609 +
1610 + buffer-from@1.1.2: {}
1611 +
1612 + cac@6.7.14: {}
1613 +
1614 + chai@5.3.3:
1615 + dependencies:
1616 + assertion-error: 2.0.1
1617 + check-error: 2.1.3
1618 + deep-eql: 5.0.2
1619 + loupe: 3.2.1
1620 + pathval: 2.0.1
1621 +
1622 + check-error@2.1.3: {}
1623 +
1624 + cheerio-select@2.1.0:
1625 + dependencies:
1626 + boolbase: 1.0.0
1627 + css-select: 5.2.2
1628 + css-what: 6.2.2
1629 + domelementtype: 2.3.0
1630 + domhandler: 5.0.3
1631 + domutils: 3.2.2
1632 +
1633 + cheerio@1.2.0:
1634 + dependencies:
1635 + cheerio-select: 2.1.0
1636 + dom-serializer: 2.0.0
1637 + domhandler: 5.0.3
1638 + domutils: 3.2.2
1639 + encoding-sniffer: 0.2.1
1640 + htmlparser2: 10.1.0
1641 + parse5: 7.3.0
1642 + parse5-htmlparser2-tree-adapter: 7.1.0
1643 + parse5-parser-stream: 7.1.2
1644 + undici: 7.29.1
1645 + whatwg-mimetype: 4.0.0
1646 +
1647 + css-select@5.2.2:
1648 + dependencies:
1649 + boolbase: 1.0.0
1650 + css-what: 6.2.2
1651 + domhandler: 5.0.3
1652 + domutils: 3.2.2
1653 + nth-check: 2.1.1
1654 +
1655 + css-what@6.2.2: {}
1656 +
1657 + debug@4.4.3:
1658 + dependencies:
1659 + ms: 2.1.3
1660 +
1661 + deep-eql@5.0.2: {}
1662 +
1663 + dom-serializer@2.0.0:
1664 + dependencies:
1665 + domelementtype: 2.3.0
1666 + domhandler: 5.0.3
1667 + entities: 4.5.0
1668 +
1669 + domelementtype@2.3.0: {}
1670 +
1671 + domhandler@5.0.3:
1672 + dependencies:
1673 + domelementtype: 2.3.0
1674 +
1675 + domutils@3.2.2:
1676 + dependencies:
1677 + dom-serializer: 2.0.0
1678 + domelementtype: 2.3.0
1679 + domhandler: 5.0.3
1680 +
1681 + drizzle-kit@0.31.10:
1682 + dependencies:
1683 + '@drizzle-team/brocli': 0.10.2
1684 + '@esbuild-kit/esm-loader': 2.6.5
1685 + esbuild: 0.25.12
1686 + tsx: 4.23.13
1687 +
1688 + drizzle-orm@0.44.7(postgres@3.4.9):
1689 + optionalDependencies:
1690 + postgres: 3.4.9
1691 +
1692 + encoding-sniffer@0.2.1:
1693 + dependencies:
1694 + iconv-lite: 0.6.3
1695 + whatwg-encoding: 3.1.1
1696 +
1697 + entities@4.5.0: {}
1698 +
1699 + entities@6.0.1: {}
1700 +
1701 + entities@7.0.1: {}
1702 +
1703 + es-module-lexer@1.7.0: {}
1704 +
1705 + esbuild@0.18.20:
1706 + optionalDependencies:
1707 + '@esbuild/android-arm': 0.18.20
1708 + '@esbuild/android-arm64': 0.18.20
1709 + '@esbuild/android-x64': 0.18.20
1710 + '@esbuild/darwin-arm64': 0.18.20
1711 + '@esbuild/darwin-x64': 0.18.20
1712 + '@esbuild/freebsd-arm64': 0.18.20
1713 + '@esbuild/freebsd-x64': 0.18.20
1714 + '@esbuild/linux-arm': 0.18.20
1715 + '@esbuild/linux-arm64': 0.18.20
1716 + '@esbuild/linux-ia32': 0.18.20
1717 + '@esbuild/linux-loong64': 0.18.20
1718 + '@esbuild/linux-mips64el': 0.18.20
1719 + '@esbuild/linux-ppc64': 0.18.20
1720 + '@esbuild/linux-riscv64': 0.18.20
1721 + '@esbuild/linux-s390x': 0.18.20
1722 + '@esbuild/linux-x64': 0.18.20
1723 + '@esbuild/netbsd-x64': 0.18.20
1724 + '@esbuild/openbsd-x64': 0.18.20
1725 + '@esbuild/sunos-x64': 0.18.20
1726 + '@esbuild/win32-arm64': 0.18.20
1727 + '@esbuild/win32-ia32': 0.18.20
1728 + '@esbuild/win32-x64': 0.18.20
1729 +
1730 + esbuild@0.25.12:
1731 + optionalDependencies:
1732 + '@esbuild/aix-ppc64': 0.25.12
1733 + '@esbuild/android-arm': 0.25.12
1734 + '@esbuild/android-arm64': 0.25.12
1735 + '@esbuild/android-x64': 0.25.12
1736 + '@esbuild/darwin-arm64': 0.25.12
1737 + '@esbuild/darwin-x64': 0.25.12
1738 + '@esbuild/freebsd-arm64': 0.25.12
1739 + '@esbuild/freebsd-x64': 0.25.12
1740 + '@esbuild/linux-arm': 0.25.12
1741 + '@esbuild/linux-arm64': 0.25.12
1742 + '@esbuild/linux-ia32': 0.25.12
1743 + '@esbuild/linux-loong64': 0.25.12
1744 + '@esbuild/linux-mips64el': 0.25.12
1745 + '@esbuild/linux-ppc64': 0.25.12
1746 + '@esbuild/linux-riscv64': 0.25.12
1747 + '@esbuild/linux-s390x': 0.25.12
1748 + '@esbuild/linux-x64': 0.25.12
1749 + '@esbuild/netbsd-arm64': 0.25.12
1750 + '@esbuild/netbsd-x64': 0.25.12
1751 + '@esbuild/openbsd-arm64': 0.25.12
1752 + '@esbuild/openbsd-x64': 0.25.12
1753 + '@esbuild/openharmony-arm64': 0.25.12
1754 + '@esbuild/sunos-x64': 0.25.12
1755 + '@esbuild/win32-arm64': 0.25.12
1756 + '@esbuild/win32-ia32': 0.25.12
1757 + '@esbuild/win32-x64': 0.25.12
1758 +
1759 + esbuild@0.28.2:
1760 + optionalDependencies:
1761 + '@esbuild/aix-ppc64': 0.28.2
1762 + '@esbuild/android-arm': 0.28.2
1763 + '@esbuild/android-arm64': 0.28.2
1764 + '@esbuild/android-x64': 0.28.2
1765 + '@esbuild/darwin-arm64': 0.28.2
1766 + '@esbuild/darwin-x64': 0.28.2
1767 + '@esbuild/freebsd-arm64': 0.28.2
1768 + '@esbuild/freebsd-x64': 0.28.2
1769 + '@esbuild/linux-arm': 0.28.2
1770 + '@esbuild/linux-arm64': 0.28.2
1771 + '@esbuild/linux-ia32': 0.28.2
1772 + '@esbuild/linux-loong64': 0.28.2
1773 + '@esbuild/linux-mips64el': 0.28.2
1774 + '@esbuild/linux-ppc64': 0.28.2
1775 + '@esbuild/linux-riscv64': 0.28.2
1776 + '@esbuild/linux-s390x': 0.28.2
1777 + '@esbuild/linux-x64': 0.28.2
1778 + '@esbuild/netbsd-arm64': 0.28.2
1779 + '@esbuild/netbsd-x64': 0.28.2
1780 + '@esbuild/openbsd-arm64': 0.28.2
1781 + '@esbuild/openbsd-x64': 0.28.2
1782 + '@esbuild/openharmony-arm64': 0.28.2
1783 + '@esbuild/sunos-x64': 0.28.2
1784 + '@esbuild/win32-arm64': 0.28.2
1785 + '@esbuild/win32-ia32': 0.28.2
1786 + '@esbuild/win32-x64': 0.28.2
1787 +
1788 + estree-walker@3.0.3:
1789 + dependencies:
1790 + '@types/estree': 1.0.9
1791 +
1792 + expect-type@1.4.0: {}
1793 +
1794 + fast-xml-builder@1.3.1:
1795 + dependencies:
1796 + path-expression-matcher: 1.6.2
1797 + xml-naming: 0.3.0
1798 +
1799 + fast-xml-parser@5.11.1:
1800 + dependencies:
1801 + '@nodable/entities': 3.0.0
1802 + fast-xml-builder: 1.3.1
1803 + is-unsafe: 2.0.2
1804 + path-expression-matcher: 1.6.2
1805 + strnum: 2.4.2
1806 + xml-naming: 0.3.0
1807 +
1808 + fdir@6.5.0(picomatch@4.0.7):
1809 + optionalDependencies:
1810 + picomatch: 4.0.7
1811 +
1812 + fsevents@2.3.3:
1813 + optional: true
1814 +
1815 + get-tsconfig@4.14.3:
1816 + dependencies:
1817 + resolve-pkg-maps: 1.0.0
1818 +
1819 + htmlparser2@10.1.0:
1820 + dependencies:
1821 + domelementtype: 2.3.0
1822 + domhandler: 5.0.3
1823 + domutils: 3.2.2
1824 + entities: 7.0.1
1825 +
1826 + iconv-lite@0.6.3:
1827 + dependencies:
1828 + safer-buffer: 2.1.2
1829 +
1830 + is-unsafe@2.0.2: {}
1831 +
1832 + js-tokens@9.0.1: {}
1833 +
1834 + loupe@3.2.1: {}
1835 +
1836 + magic-string@0.30.21:
1837 + dependencies:
1838 + '@jridgewell/sourcemap-codec': 1.6.0
1839 +
1840 + ms@2.1.3: {}
1841 +
1842 + nanoid@3.3.18: {}
1843 +
1844 + nth-check@2.1.1:
1845 + dependencies:
1846 + boolbase: 1.0.0
1847 +
1848 + parse5-htmlparser2-tree-adapter@7.1.0:
1849 + dependencies:
1850 + domhandler: 5.0.3
1851 + parse5: 7.3.0
1852 +
1853 + parse5-parser-stream@7.1.2:
1854 + dependencies:
1855 + parse5: 7.3.0
1856 +
1857 + parse5@7.3.0:
1858 + dependencies:
1859 + entities: 6.0.1
1860 +
1861 + path-expression-matcher@1.6.2: {}
1862 +
1863 + pathe@2.0.3: {}
1864 +
1865 + pathval@2.0.1: {}
1866 +
1867 + picocolors@1.1.1: {}
1868 +
1869 + picomatch@4.0.7: {}
1870 +
1871 + postcss@8.5.28:
1872 + dependencies:
1873 + nanoid: 3.3.18
1874 + picocolors: 1.1.1
1875 + source-map-js: 1.2.1
1876 +
1877 + postgres@3.4.9: {}
1878 +
1879 + resolve-pkg-maps@1.0.0: {}
1880 +
1881 + rollup@4.63.1:
1882 + dependencies:
1883 + '@types/estree': 1.0.9
1884 + optionalDependencies:
1885 + '@napi-rs/lzma-linux-x64-gnu': 1.5.1
1886 + '@rollup/rollup-android-arm-eabi': 4.63.1
1887 + '@rollup/rollup-android-arm64': 4.63.1
1888 + '@rollup/rollup-darwin-arm64': 4.63.1
1889 + '@rollup/rollup-darwin-x64': 4.63.1
1890 + '@rollup/rollup-freebsd-arm64': 4.63.1
1891 + '@rollup/rollup-freebsd-x64': 4.63.1
1892 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.1
1893 + '@rollup/rollup-linux-arm-musleabihf': 4.63.1
1894 + '@rollup/rollup-linux-arm64-gnu': 4.63.1
1895 + '@rollup/rollup-linux-arm64-musl': 4.63.1
1896 + '@rollup/rollup-linux-loong64-gnu': 4.63.1
1897 + '@rollup/rollup-linux-loong64-musl': 4.63.1
1898 + '@rollup/rollup-linux-ppc64-gnu': 4.63.1
1899 + '@rollup/rollup-linux-ppc64-musl': 4.63.1
1900 + '@rollup/rollup-linux-riscv64-gnu': 4.63.1
1901 + '@rollup/rollup-linux-riscv64-musl': 4.63.1
1902 + '@rollup/rollup-linux-s390x-gnu': 4.63.1
1903 + '@rollup/rollup-linux-x64-gnu': 4.63.1
1904 + '@rollup/rollup-linux-x64-musl': 4.63.1
1905 + '@rollup/rollup-openbsd-x64': 4.63.1
1906 + '@rollup/rollup-openharmony-arm64': 4.63.1
1907 + '@rollup/rollup-win32-arm64-msvc': 4.63.1
1908 + '@rollup/rollup-win32-ia32-msvc': 4.63.1
1909 + '@rollup/rollup-win32-x64-gnu': 4.63.1
1910 + '@rollup/rollup-win32-x64-msvc': 4.63.1
1911 + fsevents: 2.3.3
1912 +
1913 + safer-buffer@2.1.2: {}
1914 +
1915 + siginfo@2.0.0: {}
1916 +
1917 + source-map-js@1.2.1: {}
1918 +
1919 + source-map-support@0.5.21:
1920 + dependencies:
1921 + buffer-from: 1.1.2
1922 + source-map: 0.6.1
1923 +
1924 + source-map@0.6.1: {}
1925 +
1926 + stackback@0.0.2: {}
1927 +
1928 + std-env@3.10.0: {}
1929 +
1930 + strip-literal@3.1.0:
1931 + dependencies:
1932 + js-tokens: 9.0.1
1933 +
1934 + strnum@2.4.2:
1935 + dependencies:
1936 + anynum: 1.0.1
1937 +
1938 + tinybench@2.9.0: {}
1939 +
1940 + tinyexec@0.3.2: {}
1941 +
1942 + tinyglobby@0.2.17:
1943 + dependencies:
1944 + fdir: 6.5.0(picomatch@4.0.7)
1945 + picomatch: 4.0.7
1946 +
1947 + tinypool@1.1.1: {}
1948 +
1949 + tinyrainbow@2.0.0: {}
1950 +
1951 + tinyspy@4.0.6: {}
1952 +
1953 + tsx@4.23.13:
1954 + dependencies:
1955 + esbuild: 0.28.2
1956 + optionalDependencies:
1957 + fsevents: 2.3.3
1958 +
1959 + typescript@5.9.3: {}
1960 +
1961 + undici-types@7.18.2: {}
1962 +
1963 + undici@7.29.1: {}
1964 +
1965 + unpdf@1.8.1: {}
1966 +
1967 + vite-node@3.2.4(@types/node@24.13.4)(tsx@4.23.13)(yaml@2.9.0):
1968 + dependencies:
1969 + cac: 6.7.14
1970 + debug: 4.4.3
1971 + es-module-lexer: 1.7.0
1972 + pathe: 2.0.3
1973 + vite: 7.3.6(@types/node@24.13.4)(tsx@4.23.13)(yaml@2.9.0)
1974 + transitivePeerDependencies:
1975 + - '@types/node'
1976 + - jiti
1977 + - less
1978 + - lightningcss
1979 + - sass
1980 + - sass-embedded
1981 + - stylus
1982 + - sugarss
1983 + - supports-color
1984 + - terser
1985 + - tsx
1986 + - yaml
1987 +
1988 + vite@7.3.6(@types/node@24.13.4)(tsx@4.23.13)(yaml@2.9.0):
1989 + dependencies:
1990 + esbuild: 0.28.2
1991 + fdir: 6.5.0(picomatch@4.0.7)
1992 + picomatch: 4.0.7
1993 + postcss: 8.5.28
1994 + rollup: 4.63.1
1995 + tinyglobby: 0.2.17
1996 + optionalDependencies:
1997 + '@types/node': 24.13.4
1998 + fsevents: 2.3.3
1999 + tsx: 4.23.13
2000 + yaml: 2.9.0
2001 +
2002 + vitest@3.2.7(@types/node@24.13.4)(tsx@4.23.13)(yaml@2.9.0):
2003 + dependencies:
2004 + '@types/chai': 5.2.3
2005 + '@vitest/expect': 3.2.7
2006 + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@24.13.4)(tsx@4.23.13)(yaml@2.9.0))
2007 + '@vitest/pretty-format': 3.2.7
2008 + '@vitest/runner': 3.2.7
2009 + '@vitest/snapshot': 3.2.7
2010 + '@vitest/spy': 3.2.7
2011 + '@vitest/utils': 3.2.7
2012 + chai: 5.3.3
2013 + debug: 4.4.3
2014 + expect-type: 1.4.0
2015 + magic-string: 0.30.21
2016 + pathe: 2.0.3
2017 + picomatch: 4.0.7
2018 + std-env: 3.10.0
2019 + tinybench: 2.9.0
2020 + tinyexec: 0.3.2
2021 + tinyglobby: 0.2.17
2022 + tinypool: 1.1.1
2023 + tinyrainbow: 2.0.0
2024 + vite: 7.3.6(@types/node@24.13.4)(tsx@4.23.13)(yaml@2.9.0)
2025 + vite-node: 3.2.4(@types/node@24.13.4)(tsx@4.23.13)(yaml@2.9.0)
2026 + why-is-node-running: 2.3.0
2027 + optionalDependencies:
2028 + '@types/node': 24.13.4
2029 + transitivePeerDependencies:
2030 + - jiti
2031 + - less
2032 + - lightningcss
2033 + - msw
2034 + - sass
2035 + - sass-embedded
2036 + - stylus
2037 + - sugarss
2038 + - supports-color
2039 + - terser
2040 + - tsx
2041 + - yaml
2042 +
2043 + whatwg-encoding@3.1.1:
2044 + dependencies:
2045 + iconv-lite: 0.6.3
2046 +
2047 + whatwg-mimetype@4.0.0: {}
2048 +
2049 + why-is-node-running@2.3.0:
2050 + dependencies:
2051 + siginfo: 2.0.0
2052 + stackback: 0.0.2
2053 +
2054 + xml-naming@0.3.0: {}
2055 +
2056 + yaml@2.9.0: {}
2057 +
2058 + zod@4.6.1: {}
added pnpm-workspace.yaml +10 −0
@@ -0,0 +1,10 @@
1 +packages:
2 + - "apps/*"
3 + - "packages/*"
4 +onlyBuiltDependencies:
5 + - esbuild
6 + - sharp
7 + - '@tailwindcss/oxide'
8 + - unrs-resolver
9 + - bufferutil
10 + - utf-8-validate
added tsconfig.base.json +19 −0
@@ -0,0 +1,19 @@
1 +{
2 + "compilerOptions": {
3 + "target": "ES2023",
4 + "module": "NodeNext",
5 + "moduleResolution": "NodeNext",
6 + "lib": ["ES2023"],
7 + "strict": true,
8 + "noUncheckedIndexedAccess": true,
9 + "exactOptionalPropertyTypes": false,
10 + "esModuleInterop": true,
11 + "skipLibCheck": true,
12 + "resolveJsonModule": true,
13 + "declaration": false,
14 + "sourceMap": true,
15 + "forceConsistentCasingInFileNames": true,
16 + "isolatedModules": true,
17 + "verbatimModuleSyntax": true
18 + }
19 +}
20