diff --git a/README.md b/README.md index fc13190..6177f9f 100644 --- a/README.md +++ b/README.md @@ -34,3 +34,16 @@ console.log(deliveries.deliveries); Webhook mutations are not retried automatically because the API does not yet accept webhook idempotency keys; this avoids duplicate endpoint creation or secret rotation after an ambiguous timeout. Webhook destination reachability and DNS safety are checked by the API. A client-side HTTPS check is not an authorization to deliver to a destination. The client retries network errors, 429, and transient 5xx statuses at most twice by default. It enforces a 10 second request timeout and returns `TidelightApiError` with status, code, and request ID. + +## Run the asynchronous research example + +From a mother-repository checkout: + +```sh +npm ci --prefix sdk/typescript +npm test --prefix sdk/typescript +export TIDELIGHT_API_KEY='tl_test_…' +node sdk/typescript/examples/research.mjs NVDA +``` + +The example calls the SDK company snapshot job flow and prints a result only after the API reports `complete`. It preserves job and request IDs and never prints the key. CI injects a mock transport and makes no network calls. A live run requires `research:run` and consumes project quota (10 jobs per rolling hour). This package is not available from npm; run from the repository source. diff --git a/examples/research-flow.mjs b/examples/research-flow.mjs new file mode 100644 index 0000000..939b619 --- /dev/null +++ b/examples/research-flow.mjs @@ -0,0 +1,5 @@ +export async function runCompanyResearch(client, { ticker, idempotencyKey, intervalMs = 2_000, timeoutMs = 120_000 }) { + const accepted = await client.startCompanyResearch(ticker, idempotencyKey); + const job = await client.waitForResearchJob(accepted.job_id, { intervalMs, timeoutMs }); + return { accepted, job }; +} diff --git a/examples/research.mjs b/examples/research.mjs new file mode 100644 index 0000000..fdfa41e --- /dev/null +++ b/examples/research.mjs @@ -0,0 +1,28 @@ +import { randomUUID } from "node:crypto"; +import { TidelightApiError, TidelightClient } from "../dist/index.js"; +import { runCompanyResearch } from "./research-flow.mjs"; + +const apiKey = process.env.TIDELIGHT_API_KEY; +if (!apiKey) { + console.error("Set TIDELIGHT_API_KEY in this server-side shell."); + process.exit(2); +} +const ticker = process.argv[2] ?? process.env.TIDELIGHT_TICKER ?? "NVDA"; +try { + const { accepted, job } = await runCompanyResearch(new TidelightClient({ apiKey }), { ticker, idempotencyKey: randomUUID() }); + const output = { + accepted: { job_id: accepted.job_id, status: accepted.status, duplicate: accepted.duplicate, request_id: accepted.request_id }, + job: { job_id: job.job_id, ticker: job.ticker, status: job.status, request_id: job.request_id }, + ...(job.status === "complete" ? { result: job.result } : {}), + ...(job.error ? { error: { code: job.error.code } } : {}), + }; + process.stdout.write(JSON.stringify(output, null, 2) + "\n"); + if (job.status !== "complete") process.exitCode = 1; +} catch (error) { + if (error instanceof TidelightApiError) { + console.error(JSON.stringify({ error: { status: error.status, code: error.code, request_id: error.requestId } })); + } else { + console.error("Research request failed. Inspect the server's safe error telemetry."); + } + process.exitCode = 1; +} diff --git a/tests/research-example.test.mjs b/tests/research-example.test.mjs new file mode 100644 index 0000000..db5fa47 --- /dev/null +++ b/tests/research-example.test.mjs @@ -0,0 +1,36 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { TidelightClient } from "../dist/index.js"; +import { runCompanyResearch } from "../examples/research-flow.mjs"; +const key = `tl_test_${"a".repeat(43)}`; +const jobId = "01234567-89ab-4cde-8fab-0123456789ab"; +const job = (status, result = null, error = null) => ({ + job_id: jobId, ticker: "NVDA", status, attempts: 1, max_attempts: 3, result, error, + created_at: "2026-10-16T00:00:00Z", started_at: null, completed_at: null, retry_at: null, request_id: "research-result", +}); +test("source example exercises the actual SDK idempotent research flow", async () => { + const requests = []; + const client = new TidelightClient({ apiKey: key, fetch: async (url, init) => { + requests.push({ url: String(url), init }); + if (init.method === "POST") return Response.json({ job_id: jobId, status: "queued", duplicate: false, request_id: "research-accepted" }, { status: 202 }); + return Response.json(job("complete", { source: "contract-shaped test fixture" })); + } }); + const result = await runCompanyResearch(client, { ticker: "NVDA", idempotencyKey: "example-run-0001", intervalMs: 250, timeoutMs: 1_000 }); + assert.equal(result.accepted.status, "queued"); + assert.equal(result.job.status, "complete"); + assert.deepEqual(result.job.result, { source: "contract-shaped test fixture" }); + assert.equal(requests.length, 2); + assert.equal(requests[0].init.headers["Idempotency-Key"], "example-run-0001"); + assert.equal(requests[0].init.headers.Authorization, `Bearer ${key}`); + assert.match(requests[0].url, /\/v1\/research\/jobs$/); + assert.match(requests[1].url, new RegExp(jobId)); +}); +test("source example preserves failed terminal state without inventing a result", async () => { + const client = new TidelightClient({ apiKey: key, fetch: async (_url, init) => init.method === "POST" + ? Response.json({ job_id: jobId, status: "queued", duplicate: false, request_id: "accepted" }, { status: 202 }) + : Response.json(job("failed", null, { code: "provider_unavailable", message: "Unavailable" })) }); + const result = await runCompanyResearch(client, { ticker: "NVDA", idempotencyKey: "example-run-0002", intervalMs: 250, timeoutMs: 1_000 }); + assert.equal(result.job.status, "failed"); + assert.equal(result.job.result, null); + assert.equal(result.job.error.code, "provider_unavailable"); +});