sheeply 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.env.example +15 -0
  2. package/LICENSE +21 -0
  3. package/README.md +155 -2
  4. package/dist/analysis/analyse.js +28 -0
  5. package/dist/analysis/black-sheep-calibration.js +7 -0
  6. package/dist/analysis/black-sheep.js +167 -0
  7. package/dist/analysis/data-quality.js +90 -0
  8. package/dist/analysis/dataset.js +126 -0
  9. package/dist/analysis/entry-maturity.js +72 -0
  10. package/dist/analysis/interpretation.js +105 -0
  11. package/dist/analysis/investments.js +107 -0
  12. package/dist/analysis/leadership.js +42 -0
  13. package/dist/analysis/partial-date.js +17 -0
  14. package/dist/analysis/ratio.js +28 -0
  15. package/dist/analysis/sector-exposure.js +64 -0
  16. package/dist/analysis/stages.js +30 -0
  17. package/dist/analysis/syndicates.js +70 -0
  18. package/dist/analysis/types.js +1 -0
  19. package/dist/cli/analyse-command.js +106 -0
  20. package/dist/cli/db-command.js +28 -0
  21. package/dist/cli/index.js +39 -0
  22. package/dist/cli/program.js +38 -0
  23. package/dist/config.js +53 -0
  24. package/dist/db/database.js +53 -0
  25. package/dist/db/schema.js +159 -0
  26. package/dist/dealroom/client.js +208 -0
  27. package/dist/dealroom/endpoints.js +17 -0
  28. package/dist/dealroom/pagination.js +25 -0
  29. package/dist/dealroom/schemas.js +121 -0
  30. package/dist/ingest/extract.js +137 -0
  31. package/dist/ingest/fetch.js +80 -0
  32. package/dist/ingest/resolve.js +74 -0
  33. package/dist/reports/json.js +97 -0
  34. package/dist/reports/sheep.js +63 -0
  35. package/dist/util.js +19 -0
  36. package/dist/version.js +9 -0
  37. package/docs/api-limitations.md +36 -0
  38. package/docs/dealroom-field-mapping.md +90 -0
  39. package/docs/methodology.md +203 -0
  40. package/package.json +52 -4
@@ -0,0 +1,28 @@
1
+ import { rebuildDerivedTables } from "../ingest/extract.js";
2
+ import { openDatabase } from "../db/database.js";
3
+ export function runRebuild(dbPath, io) {
4
+ const db = openDatabase(dbPath);
5
+ try {
6
+ const runs = rebuildDerivedTables(db);
7
+ io.stderr(`Rebuilt derived tables from ${runs.length} stored ingestion run(s)\n`);
8
+ }
9
+ finally {
10
+ db.close();
11
+ }
12
+ }
13
+ export function runStatus(dbPath, io) {
14
+ const db = openDatabase(dbPath);
15
+ try {
16
+ const investors = db
17
+ .prepare(`SELECT r.investor_id AS id, i.name, max(CASE WHEN r.status = 'complete' THEN r.id END) AS latest_run,
18
+ max(CASE WHEN r.status = 'complete' THEN r.finished_at END) AS retrieved_at,
19
+ sum(r.status = 'complete') AS complete_runs, sum(r.status = 'failed') AS failed_runs
20
+ FROM ingestion_runs r LEFT JOIN investors i ON i.uuid = r.investor_id
21
+ GROUP BY r.investor_id ORDER BY i.name, r.investor_id`)
22
+ .all();
23
+ io.stdout(`${JSON.stringify({ database: dbPath, investors }, null, 2)}\n`);
24
+ }
25
+ finally {
26
+ db.close();
27
+ }
28
+ }
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ import { CommanderError } from "commander";
3
+ import { ConfigError, loadDotEnv } from "../config.js";
4
+ import { DealroomApiError, DealroomSchemaError } from "../dealroom/client.js";
5
+ import { InvestorResolutionError } from "../ingest/resolve.js";
6
+ import { NoStoredDataError } from "../analysis/dataset.js";
7
+ import { UsageError, describeCandidate } from "./analyse-command.js";
8
+ import { buildProgram } from "./program.js";
9
+ loadDotEnv();
10
+ const io = {
11
+ env: process.env,
12
+ stdout: (text) => process.stdout.write(text),
13
+ stderr: (text) => process.stderr.write(text),
14
+ isInteractive: Boolean(process.stdin.isTTY && process.stderr.isTTY),
15
+ };
16
+ try {
17
+ await buildProgram(io).parseAsync(process.argv);
18
+ }
19
+ catch (error) {
20
+ process.exitCode = exitCodeFor(error);
21
+ }
22
+ function exitCodeFor(error) {
23
+ if (error instanceof CommanderError)
24
+ return error.exitCode;
25
+ const message = error instanceof Error ? error.message : String(error);
26
+ io.stderr(`sheeply: ${message}\n`);
27
+ if (error instanceof InvestorResolutionError) {
28
+ for (const candidate of error.candidates)
29
+ io.stderr(` ${describeCandidate(candidate)}\n`);
30
+ return 2;
31
+ }
32
+ if (error instanceof UsageError || error instanceof ConfigError || error instanceof NoStoredDataError)
33
+ return 2;
34
+ if (error instanceof DealroomApiError || error instanceof DealroomSchemaError)
35
+ return 3;
36
+ if (error instanceof Error && error.stack && process.env.SHEEPLY_DEBUG)
37
+ io.stderr(`${error.stack}\n`);
38
+ return 1;
39
+ }
@@ -0,0 +1,38 @@
1
+ import { Command, Option } from "commander";
2
+ import { DEFAULT_DB_PATH } from "../config.js";
3
+ import { TOOL_VERSION } from "../version.js";
4
+ import { SUPPORTED_FORMATS, runAnalyse } from "./analyse-command.js";
5
+ import { runRebuild, runStatus } from "./db-command.js";
6
+ export function buildProgram(io) {
7
+ const dbDefault = io.env.SHEEPLY_DB || DEFAULT_DB_PATH;
8
+ const dbOption = () => new Option("--db <path>", "SQLite cache file, created if missing").default(dbDefault);
9
+ const program = new Command()
10
+ .name("sheeply")
11
+ .description("Profile how a venture firm invests (entry stage, lead behaviour, syndicates) from Dealroom data.")
12
+ .version(TOOL_VERSION)
13
+ .enablePositionalOptions()
14
+ .argument("[investor]", "investor name to resolve on Dealroom")
15
+ .option("--investor-id <uuid>", "Dealroom investor UUID, skips name resolution")
16
+ .addOption(dbOption())
17
+ .option("--refresh", "fetch from Dealroom again even if data is stored", false)
18
+ .option("--offline", "use only stored data, never call Dealroom", false)
19
+ .addOption(new Option("--format <format>", "output format").choices(SUPPORTED_FORMATS).default("sheep"))
20
+ .option("--output <file>", "write the report to a file instead of stdout")
21
+ .option("--verbose", "include the per-company investments behind every metric (json format)", false)
22
+ .option("--no-interactive", "fail instead of prompting when a name is ambiguous")
23
+ .configureOutput({ writeOut: io.stdout, writeErr: io.stderr })
24
+ .exitOverride()
25
+ .action(async (investor, options) => {
26
+ await runAnalyse({ ...options, investor }, io);
27
+ });
28
+ const db = program.command("db").description("Manage the local SQLite cache");
29
+ db.command("rebuild")
30
+ .description("Drop and re-derive every table from the stored Dealroom responses (no API calls)")
31
+ .addOption(dbOption())
32
+ .action((options) => runRebuild(options.db, io));
33
+ db.command("status")
34
+ .description("List stored investors and ingestion runs")
35
+ .addOption(dbOption())
36
+ .action((options) => runStatus(options.db, io));
37
+ return program;
38
+ }
package/dist/config.js ADDED
@@ -0,0 +1,53 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { parseEnv } from "node:util";
4
+ export const DEALROOM_DEFAULTS = {
5
+ baseUrl: "https://api.dealroom.app",
6
+ tokenUrl: "https://accounts.dealroom.co/oauth/token",
7
+ apiVersion: "2026-10-22",
8
+ requestsPerSecond: 4,
9
+ timeoutMs: 60_000,
10
+ maxRetries: 5,
11
+ };
12
+ export const DEFAULT_DB_PATH = "sheeply.sqlite";
13
+ export class ConfigError extends Error {
14
+ name = "ConfigError";
15
+ }
16
+ /** Fills unset variables from `.env` in the working directory; real environment values always win. */
17
+ export function loadDotEnv(cwd = process.cwd(), env = process.env) {
18
+ const file = resolve(cwd, ".env");
19
+ if (!existsSync(file))
20
+ return;
21
+ for (const [key, value] of Object.entries(parseEnv(readFileSync(file, "utf8")))) {
22
+ if (env[key] === undefined || env[key] === "")
23
+ env[key] = value;
24
+ }
25
+ }
26
+ const positiveNumber = (raw, fallback, name) => {
27
+ if (raw === undefined || raw === "")
28
+ return fallback;
29
+ const value = Number(raw);
30
+ if (!Number.isFinite(value) || value <= 0)
31
+ throw new ConfigError(`${name} must be a positive number, got "${raw}"`);
32
+ return value;
33
+ };
34
+ export function loadDealroomConfig(env = process.env) {
35
+ const clientId = env.DEALROOM_CLIENT_ID ?? "";
36
+ const clientSecret = env.DEALROOM_CLIENT_SECRET ?? "";
37
+ if (!clientId || !clientSecret) {
38
+ throw new ConfigError("DEALROOM_CLIENT_ID and DEALROOM_CLIENT_SECRET are required to fetch from Dealroom. " +
39
+ "Copy .env.example to .env and fill them in, or rerun with --offline to use stored data.");
40
+ }
41
+ const baseUrl = (env.DEALROOM_API_BASE_URL || DEALROOM_DEFAULTS.baseUrl).replace(/\/+$/, "");
42
+ return {
43
+ clientId,
44
+ clientSecret,
45
+ baseUrl,
46
+ audience: env.DEALROOM_AUDIENCE || baseUrl,
47
+ tokenUrl: env.DEALROOM_TOKEN_URL || DEALROOM_DEFAULTS.tokenUrl,
48
+ apiVersion: env.DEALROOM_API_VERSION || DEALROOM_DEFAULTS.apiVersion,
49
+ requestsPerSecond: positiveNumber(env.DEALROOM_REQUESTS_PER_SECOND, DEALROOM_DEFAULTS.requestsPerSecond, "DEALROOM_REQUESTS_PER_SECOND"),
50
+ timeoutMs: positiveNumber(env.DEALROOM_TIMEOUT_MS, DEALROOM_DEFAULTS.timeoutMs, "DEALROOM_TIMEOUT_MS"),
51
+ maxRetries: DEALROOM_DEFAULTS.maxRetries,
52
+ };
53
+ }
@@ -0,0 +1,53 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { DatabaseSync } from "node:sqlite";
4
+ import { EXTRACTION_VERSION } from "../version.js";
5
+ import { DERIVED_SCHEMA, DERIVED_TABLES, MIGRATIONS } from "./schema.js";
6
+ export function openDatabase(path) {
7
+ if (path !== ":memory:")
8
+ mkdirSync(dirname(path), { recursive: true });
9
+ const db = new DatabaseSync(path);
10
+ db.exec("PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;");
11
+ migrate(db);
12
+ return db;
13
+ }
14
+ function migrate(db) {
15
+ db.exec(`
16
+ CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL);
17
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
18
+ `);
19
+ const applied = new Set(db.prepare("SELECT version FROM schema_migrations").all().map((row) => Number(row.version)));
20
+ for (const migration of MIGRATIONS) {
21
+ if (applied.has(migration.version))
22
+ continue;
23
+ transaction(db, () => {
24
+ db.exec(migration.sql);
25
+ db.prepare("INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)").run(migration.version, new Date().toISOString());
26
+ });
27
+ }
28
+ db.exec(DERIVED_SCHEMA);
29
+ }
30
+ /** True when derived tables were produced by an older extraction and need a rebuild. */
31
+ export function extractionIsStale(db) {
32
+ const row = db.prepare("SELECT value FROM meta WHERE key = 'extraction_version'").get();
33
+ const hasRuns = db.prepare("SELECT 1 FROM ingestion_runs WHERE status = 'complete' LIMIT 1").get() !== undefined;
34
+ return hasRuns && row?.value !== EXTRACTION_VERSION;
35
+ }
36
+ export function resetDerivedTables(db) {
37
+ for (const table of DERIVED_TABLES)
38
+ db.exec(`DROP TABLE IF EXISTS ${table}`);
39
+ db.exec(DERIVED_SCHEMA);
40
+ db.prepare("INSERT INTO meta (key, value) VALUES ('extraction_version', ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value").run(EXTRACTION_VERSION);
41
+ }
42
+ export function transaction(db, fn) {
43
+ db.exec("BEGIN");
44
+ try {
45
+ const result = fn();
46
+ db.exec("COMMIT");
47
+ return result;
48
+ }
49
+ catch (error) {
50
+ db.exec("ROLLBACK");
51
+ throw error;
52
+ }
53
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Two layers. `ingestion_runs` and `raw_responses` record exactly what Dealroom returned and when.
3
+ * Every other table is derived from them by src/ingest/extract.ts and can be dropped and rebuilt
4
+ * at any time (`sheeply db rebuild`), so it is never the source of truth.
5
+ */
6
+ export const MIGRATIONS = [
7
+ {
8
+ version: 1,
9
+ sql: `
10
+ CREATE TABLE ingestion_runs (
11
+ id INTEGER PRIMARY KEY,
12
+ investor_id TEXT NOT NULL,
13
+ status TEXT NOT NULL CHECK (status IN ('running', 'complete', 'failed')),
14
+ started_at TEXT NOT NULL,
15
+ finished_at TEXT,
16
+ api_base_url TEXT NOT NULL,
17
+ api_version TEXT NOT NULL,
18
+ rounds_strategy TEXT,
19
+ tool_version TEXT NOT NULL,
20
+ requests INTEGER,
21
+ credits_used INTEGER,
22
+ error TEXT
23
+ );
24
+ CREATE INDEX ingestion_runs_investor ON ingestion_runs (investor_id, status, id);
25
+
26
+ CREATE TABLE raw_responses (
27
+ id INTEGER PRIMARY KEY,
28
+ run_id INTEGER NOT NULL REFERENCES ingestion_runs (id) ON DELETE CASCADE,
29
+ kind TEXT NOT NULL CHECK (kind IN ('investor', 'portfolio', 'transactions', 'company_rounds')),
30
+ subject_id TEXT NOT NULL,
31
+ page_index INTEGER NOT NULL,
32
+ endpoint TEXT NOT NULL,
33
+ params_json TEXT NOT NULL,
34
+ retrieved_at TEXT NOT NULL,
35
+ api_version TEXT,
36
+ credits_used INTEGER,
37
+ body_sha256 TEXT NOT NULL,
38
+ body_json TEXT NOT NULL
39
+ );
40
+ CREATE INDEX raw_responses_run ON raw_responses (run_id, kind, subject_id, page_index);
41
+ `,
42
+ },
43
+ ];
44
+ export const DERIVED_TABLES = [
45
+ "round_participants",
46
+ "investor_round_scope",
47
+ "funding_rounds",
48
+ "company_taxonomy",
49
+ "taxonomy_terms",
50
+ "portfolio_relationships",
51
+ "companies",
52
+ "investors",
53
+ "entities",
54
+ "extraction_state",
55
+ ];
56
+ export const DERIVED_SCHEMA = `
57
+ CREATE TABLE IF NOT EXISTS entities (
58
+ uuid TEXT PRIMARY KEY,
59
+ name TEXT NOT NULL,
60
+ type TEXT NOT NULL,
61
+ subtype TEXT,
62
+ dealroom_url TEXT,
63
+ source_response_id INTEGER NOT NULL
64
+ );
65
+
66
+ CREATE TABLE IF NOT EXISTS investors (
67
+ uuid TEXT PRIMARY KEY,
68
+ name TEXT NOT NULL,
69
+ name_key TEXT NOT NULL,
70
+ type TEXT NOT NULL,
71
+ subtype TEXT,
72
+ firm_types_json TEXT NOT NULL,
73
+ launch_date TEXT,
74
+ domain TEXT,
75
+ hq_country TEXT,
76
+ dealroom_url TEXT,
77
+ reported_portfolio_total INTEGER,
78
+ reported_rounds_count INTEGER,
79
+ source_response_id INTEGER NOT NULL,
80
+ retrieved_at TEXT NOT NULL
81
+ );
82
+ CREATE INDEX IF NOT EXISTS investors_name_key ON investors (name_key);
83
+
84
+ CREATE TABLE IF NOT EXISTS companies (
85
+ uuid TEXT PRIMARY KEY,
86
+ name TEXT NOT NULL,
87
+ subtype TEXT,
88
+ launch_date TEXT,
89
+ dealroom_url TEXT,
90
+ taxonomy_known INTEGER NOT NULL,
91
+ source_response_id INTEGER NOT NULL,
92
+ retrieved_at TEXT NOT NULL
93
+ );
94
+
95
+ CREATE TABLE IF NOT EXISTS taxonomy_terms (
96
+ id INTEGER PRIMARY KEY,
97
+ name TEXT NOT NULL,
98
+ type TEXT NOT NULL
99
+ );
100
+
101
+ CREATE TABLE IF NOT EXISTS company_taxonomy (
102
+ company_id TEXT NOT NULL,
103
+ term_id INTEGER NOT NULL,
104
+ source_response_id INTEGER NOT NULL,
105
+ PRIMARY KEY (company_id, term_id)
106
+ );
107
+
108
+ CREATE TABLE IF NOT EXISTS portfolio_relationships (
109
+ investor_id TEXT NOT NULL,
110
+ company_id TEXT NOT NULL,
111
+ company_subtype TEXT,
112
+ is_lead_any INTEGER NOT NULL,
113
+ rounds_count INTEGER NOT NULL,
114
+ last_round_date TEXT,
115
+ source_response_id INTEGER NOT NULL,
116
+ retrieved_at TEXT NOT NULL,
117
+ PRIMARY KEY (investor_id, company_id)
118
+ );
119
+
120
+ CREATE TABLE IF NOT EXISTS funding_rounds (
121
+ id INTEGER PRIMARY KEY,
122
+ company_id TEXT NOT NULL,
123
+ announced_date TEXT,
124
+ round_type TEXT,
125
+ standardized_round TEXT,
126
+ round_label TEXT,
127
+ amount REAL,
128
+ valuation REAL,
129
+ currency TEXT,
130
+ is_vc_round INTEGER,
131
+ is_exit INTEGER,
132
+ investors_disclosed INTEGER NOT NULL,
133
+ source_url TEXT,
134
+ source_response_id INTEGER NOT NULL,
135
+ retrieved_at TEXT NOT NULL
136
+ );
137
+ CREATE INDEX IF NOT EXISTS funding_rounds_company ON funding_rounds (company_id);
138
+
139
+ CREATE TABLE IF NOT EXISTS round_participants (
140
+ round_id INTEGER NOT NULL,
141
+ entity_id TEXT NOT NULL,
142
+ is_lead INTEGER,
143
+ position INTEGER NOT NULL,
144
+ PRIMARY KEY (round_id, entity_id)
145
+ );
146
+ CREATE INDEX IF NOT EXISTS round_participants_entity ON round_participants (entity_id);
147
+
148
+ CREATE TABLE IF NOT EXISTS investor_round_scope (
149
+ investor_id TEXT NOT NULL,
150
+ round_id INTEGER NOT NULL,
151
+ PRIMARY KEY (investor_id, round_id)
152
+ );
153
+
154
+ CREATE TABLE IF NOT EXISTS extraction_state (
155
+ investor_id TEXT PRIMARY KEY,
156
+ run_id INTEGER NOT NULL,
157
+ extraction_version TEXT NOT NULL
158
+ );
159
+ `;
@@ -0,0 +1,208 @@
1
+ import { TOOL_NAME, TOOL_VERSION } from "../version.js";
2
+ import { errorEnvelope } from "./schemas.js";
3
+ export class DealroomApiError extends Error {
4
+ name = "DealroomApiError";
5
+ status;
6
+ code;
7
+ endpoint;
8
+ constructor(message, status, code, endpoint) {
9
+ super(message);
10
+ this.status = status;
11
+ this.code = code;
12
+ this.endpoint = endpoint;
13
+ }
14
+ }
15
+ export class DealroomSchemaError extends Error {
16
+ name = "DealroomSchemaError";
17
+ endpoint;
18
+ issues;
19
+ constructor(endpoint, issues) {
20
+ super(`Unexpected response shape from ${endpoint}: ${issues.slice(0, 5).join("; ")}`);
21
+ this.endpoint = endpoint;
22
+ this.issues = issues;
23
+ }
24
+ }
25
+ const TOKEN_EXPIRY_SKEW_MS = 60_000;
26
+ const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
27
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
28
+ export class DealroomClient {
29
+ config;
30
+ #fetch;
31
+ #sleep;
32
+ #now;
33
+ #log;
34
+ #token = null;
35
+ #nextSlot = 0;
36
+ #requests = 0;
37
+ #credits = 0;
38
+ #warnedVersion = false;
39
+ constructor(config, options = {}) {
40
+ this.config = config;
41
+ this.#fetch = options.fetch ?? fetch;
42
+ this.#sleep = options.sleep ?? defaultSleep;
43
+ this.#now = options.now ?? Date.now;
44
+ this.#log = options.log ?? (() => { });
45
+ }
46
+ get usage() {
47
+ return { requests: this.#requests, credits: this.#credits };
48
+ }
49
+ async getValidated(endpoint, params, schema) {
50
+ const { body, meta } = await this.get(endpoint, params);
51
+ const parsed = schema.safeParse(body);
52
+ if (!parsed.success) {
53
+ throw new DealroomSchemaError(endpoint, parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`));
54
+ }
55
+ return { body: parsed.data, meta, raw: body };
56
+ }
57
+ async get(endpoint, params = {}) {
58
+ const query = new URLSearchParams();
59
+ for (const [key, value] of Object.entries(params)) {
60
+ if (value !== undefined)
61
+ query.set(key, String(value));
62
+ }
63
+ const url = `${this.config.baseUrl}${endpoint}${query.size ? `?${query}` : ""}`;
64
+ for (let attempt = 0;; attempt++) {
65
+ await this.#takeSlot();
66
+ const token = await this.#getToken();
67
+ let response;
68
+ try {
69
+ this.#requests++;
70
+ response = await this.#fetch(url, {
71
+ headers: {
72
+ Authorization: `Bearer ${token}`,
73
+ "X-Client-Id": this.config.clientId,
74
+ "API-Version": this.config.apiVersion,
75
+ "User-Agent": `${TOOL_NAME}/${TOOL_VERSION}`,
76
+ Accept: "application/json",
77
+ },
78
+ signal: AbortSignal.timeout(this.config.timeoutMs),
79
+ });
80
+ }
81
+ catch (cause) {
82
+ if (attempt < this.config.maxRetries) {
83
+ const wait = this.#backoff(attempt);
84
+ this.#log(`network error on ${endpoint} (${describe(cause)}), retrying in ${wait}ms`);
85
+ await this.#sleep(wait);
86
+ continue;
87
+ }
88
+ throw new DealroomApiError(`Network failure on ${endpoint}: ${describe(cause)}`, 0, "NETWORK_ERROR", endpoint);
89
+ }
90
+ const credits = Number(response.headers.get("X-Credits-Used"));
91
+ if (Number.isFinite(credits))
92
+ this.#credits += credits;
93
+ if (response.ok) {
94
+ const text = await response.text();
95
+ let body;
96
+ try {
97
+ body = JSON.parse(text);
98
+ }
99
+ catch {
100
+ if (attempt < this.config.maxRetries) {
101
+ await this.#sleep(this.#backoff(attempt));
102
+ continue;
103
+ }
104
+ throw new DealroomApiError(`Non-JSON 200 response from ${endpoint}`, response.status, "INVALID_BODY", endpoint);
105
+ }
106
+ const meta = {
107
+ endpoint,
108
+ params: Object.fromEntries(query.entries()),
109
+ status: response.status,
110
+ apiVersion: response.headers.get("API-Version"),
111
+ creditsUsed: Number.isFinite(credits) ? credits : null,
112
+ retrievedAt: new Date(this.#now()).toISOString(),
113
+ deprecation: response.headers.get("Deprecation"),
114
+ sunset: response.headers.get("Sunset"),
115
+ };
116
+ if (meta.sunset && !this.#warnedVersion) {
117
+ this.#warnedVersion = true;
118
+ this.#log(`API version ${this.config.apiVersion} is deprecated and sunsets on ${meta.sunset}`);
119
+ }
120
+ return { body, meta };
121
+ }
122
+ const text = await response.text();
123
+ const code = parseErrorCode(text);
124
+ if (response.status === 401 && attempt === 0) {
125
+ this.#token = null;
126
+ continue;
127
+ }
128
+ if (RETRYABLE_STATUS.has(response.status) && attempt < this.config.maxRetries) {
129
+ const retryAfter = Number(response.headers.get("Retry-After"));
130
+ const wait = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : this.#backoff(attempt);
131
+ this.#log(`${response.status} ${code} on ${endpoint}, retrying in ${wait}ms`);
132
+ await this.#sleep(wait);
133
+ continue;
134
+ }
135
+ throw new DealroomApiError(explain(response.status, code, endpoint, text), response.status, code, endpoint);
136
+ }
137
+ }
138
+ #backoff(attempt) {
139
+ return Math.min(30_000, 500 * 2 ** attempt);
140
+ }
141
+ async #takeSlot() {
142
+ const interval = Math.ceil(1000 / this.config.requestsPerSecond);
143
+ const now = this.#now();
144
+ const slot = Math.max(now, this.#nextSlot);
145
+ this.#nextSlot = slot + interval;
146
+ if (slot > now)
147
+ await this.#sleep(slot - now);
148
+ }
149
+ async #getToken() {
150
+ if (this.#token && this.#token.expiresAt > this.#now())
151
+ return this.#token.value;
152
+ let response;
153
+ try {
154
+ response = await this.#fetch(this.config.tokenUrl, {
155
+ method: "POST",
156
+ headers: { "Content-Type": "application/json", "User-Agent": `${TOOL_NAME}/${TOOL_VERSION}` },
157
+ body: JSON.stringify({
158
+ client_id: this.config.clientId,
159
+ client_secret: this.config.clientSecret,
160
+ audience: this.config.audience,
161
+ grant_type: "client_credentials",
162
+ }),
163
+ signal: AbortSignal.timeout(this.config.timeoutMs),
164
+ });
165
+ }
166
+ catch (cause) {
167
+ throw new DealroomApiError(`Token exchange failed: ${describe(cause)}`, 0, "NETWORK_ERROR", "oauth/token");
168
+ }
169
+ if (!response.ok) {
170
+ const hint = response.status === 403
171
+ ? ` The key was refused for audience ${this.config.audience}. Keys issued for the beta host need DEALROOM_API_BASE_URL=https://api.beta.dealroom.app.`
172
+ : "";
173
+ throw new DealroomApiError(`Token exchange failed with ${response.status}.${hint}`, response.status, "AUTH_FAILED", "oauth/token");
174
+ }
175
+ const body = (await response.json());
176
+ if (!body.access_token)
177
+ throw new DealroomApiError("Token exchange returned no access_token", 200, "AUTH_FAILED", "oauth/token");
178
+ this.#token = {
179
+ value: body.access_token,
180
+ expiresAt: this.#now() + (body.expires_in ?? 3600) * 1000 - TOKEN_EXPIRY_SKEW_MS,
181
+ };
182
+ return body.access_token;
183
+ }
184
+ }
185
+ function parseErrorCode(text) {
186
+ try {
187
+ const parsed = errorEnvelope.safeParse(JSON.parse(text));
188
+ return parsed.success ? parsed.data.error.code : "UNKNOWN";
189
+ }
190
+ catch {
191
+ return "UNKNOWN";
192
+ }
193
+ }
194
+ function explain(status, code, endpoint, text) {
195
+ const detail = text.trimStart().startsWith("<") ? "(HTML error page)" : text.slice(0, 300);
196
+ if (status === 402)
197
+ return `Dealroom credits are exhausted (402 ${code}) on ${endpoint}. Add credits before retrying.`;
198
+ if (status === 403)
199
+ return `Dealroom refused ${endpoint} (403 ${code}): the API key lacks access. ${detail}`;
200
+ if (status === 404)
201
+ return `Not found on Dealroom: ${endpoint} (404 ${code})`;
202
+ return `${status} ${code} on ${endpoint}: ${detail}`;
203
+ }
204
+ function describe(cause) {
205
+ if (cause instanceof Error)
206
+ return cause.name === "TimeoutError" ? "timed out" : cause.message;
207
+ return String(cause);
208
+ }
@@ -0,0 +1,17 @@
1
+ import { walkPages } from "./pagination.js";
2
+ import { companyRoundsResponse, investorDetailResponse, portfolioResponse, searchResponse, transactionsResponse, } from "./schemas.js";
3
+ export const PORTFOLIO_PAGE_SIZE = 500;
4
+ export const TRANSACTIONS_PAGE_SIZE = 500;
5
+ export const COMPANY_ROUNDS_PAGE_SIZE = 500;
6
+ export const SEARCH_LIMIT = 20;
7
+ export const searchInvestors = (client, query) => client.getValidated("/data/search", { q: query, types: "investor", limit: SEARCH_LIMIT }, searchResponse);
8
+ export const getInvestor = (client, id) => client.getValidated(`/data/investors/${encodeURIComponent(id)}`, {}, investorDetailResponse);
9
+ export const portfolioPages = (client, id) => walkPages(client, `/data/investors/${encodeURIComponent(id)}/portfolio`, { limit: PORTFOLIO_PAGE_SIZE }, portfolioResponse);
10
+ /** Every round of every company the investor has backed, including rounds it did not join. */
11
+ export const portfolioTransactionPages = (client, id) => walkPages(client, "/data/transactions", {
12
+ filter: `company_investor_id[in_any]:${id}`,
13
+ sort: "date",
14
+ limit: TRANSACTIONS_PAGE_SIZE,
15
+ include_total: true,
16
+ }, transactionsResponse);
17
+ export const companyRoundPages = (client, companyId) => walkPages(client, `/data/companies/${encodeURIComponent(companyId)}/funding-rounds`, { limit: COMPANY_ROUNDS_PAGE_SIZE }, companyRoundsResponse);
@@ -0,0 +1,25 @@
1
+ import { DealroomApiError } from "./client.js";
2
+ const MAX_PAGES = 10_000;
3
+ /**
4
+ * Walks a `/data` list by cursor. The first request carries `limit`; later ones send the cursor
5
+ * back with the same `filter` and `sort`, which the API requires to match the cursor's origin.
6
+ */
7
+ export async function* walkPages(client, endpoint, params, schema) {
8
+ const carried = { filter: params.filter, sort: params.sort, currency: params.currency };
9
+ const seen = new Set();
10
+ let cursor = null;
11
+ for (let pageIndex = 0; pageIndex < MAX_PAGES; pageIndex++) {
12
+ const request = cursor === null ? params : { ...carried, cursor };
13
+ const response = await client.getValidated(endpoint, request, schema);
14
+ yield { ...response, pageIndex };
15
+ const next = response.body.page.next_cursor ?? null;
16
+ if (next === null || response.body.data.length === 0)
17
+ return;
18
+ if (seen.has(next)) {
19
+ throw new DealroomApiError(`Pagination on ${endpoint} returned a repeated cursor`, 200, "PAGINATION_LOOP", endpoint);
20
+ }
21
+ seen.add(next);
22
+ cursor = next;
23
+ }
24
+ throw new DealroomApiError(`Pagination on ${endpoint} exceeded ${MAX_PAGES} pages`, 200, "PAGINATION_LIMIT", endpoint);
25
+ }