From 9f45655fc70f6960a3e603b8f2e901a81e5138d5 Mon Sep 17 00:00:00 2001 From: planadecu Date: Tue, 29 Sep 2026 18:10:46 +0200 Subject: [PATCH 01/14] Remove the deprecated MetricTier, MetricDataType and FetchFn exports Remove MetricTierSchema, MetricTier, MetricDataTypeSchema, MetricDataType and FetchFn, deprecated with a promise of removal in 1.0. None is used by the client. Drop the FetchFn assertions in test/config.spec.ts (keeping a check that typeof fetch fits GlassnodeFetch) and the MetricTier mention in the tier field comment. CHANGELOG entry with migration notes under 1.0.0 (unreleased); no version bump (release/1.0 flow). Closes #23 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 15 +++++++++++++++ src/types/config.ts | 10 ---------- src/types/metadata.ts | 40 +--------------------------------------- test/config.spec.ts | 6 ++---- 4 files changed, 18 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cccac9a..969194a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 1.0.0 (unreleased) + +### Breaking changes + +- Removed the deprecated exports `MetricTierSchema`, `MetricTier`, `MetricDataTypeSchema`, + `MetricDataType` and `FetchFn` (#23). None of them was used by the client; they were deprecated + with a promise of removal in 1.0. Migration: + - `MetricTier` / `MetricTierSchema`: use `MetricMetadata['tier']`, a `number` (e.g. `2`). The + string enum (`'free' | 'tier1' | …`) never matched the API's `tier` field. + - `MetricDataType` / `MetricDataTypeSchema`: define your own enum if you need these values + (`'average' | 'sum' | 'count' | 'percentage' | 'ratio'`); no response was ever validated + against them. + - `FetchFn`: use `GlassnodeFetch`, the type of the `fetch` config option, or `typeof fetch` + (which `FetchFn` was an alias of; a standard `fetch` still fits the option). + ## 0.29.4 - CI: `ci.yml` now also runs on pull requests into `release/**` branches (e.g. `release/1.0`), not diff --git a/src/types/config.ts b/src/types/config.ts index e167ca1..249fa0a 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -7,16 +7,6 @@ import type { GlassnodeHooks } from './hooks.js'; */ export type Logger = (message: string, ...args: unknown[]) => void; -/** - * The standard `fetch` type (`typeof fetch`). - * - * @deprecated The `fetch` config option is typed as {@link GlassnodeFetch}, the call the client - * actually makes, which also accepts string-only custom fetches. `FetchFn` still means - * `typeof fetch` (and a `FetchFn` value is still accepted as the option); use `GlassnodeFetch` to - * type a custom fetch for the client. - */ -export type FetchFn = typeof fetch; - /** * Type of the `fetch` config option: the call the client makes. It is only ever called with a * string URL, as `fetch(url)` or `fetch(url, init)`, and must resolve to a standard `Response`. diff --git a/src/types/metadata.ts b/src/types/metadata.ts index 3784e22..a0931f7 100644 --- a/src/types/metadata.ts +++ b/src/types/metadata.ts @@ -146,43 +146,6 @@ export const AssetMetadataResponseSchema = z.array(AssetMetadataSchema); */ export type AssetMetadataResponse = z.infer; -/** - * Metric tier schema - * - * @deprecated Not used by any schema or client method, and it does not match the API: - * the `tier` field on metric metadata is a number (e.g. `2`), not one of these strings. - * Use `MetricMetadata['tier']` (a `number`) to type it instead. This export will be - * removed in the next major release (1.0). - */ -export const MetricTierSchema = z.enum(['free', 'tier1', 'tier2', 'tier3', 'tier4', 'tier5']); - -/** - * Metric tier type - * - * @deprecated Does not match the API: the `tier` field on metric metadata is a number - * (e.g. `2`), not one of these strings. Use `MetricMetadata['tier']` (a `number`) instead. - * This export will be removed in the next major release (1.0). - */ -export type MetricTier = z.infer; - -/** - * Metric data type schema - * - * @deprecated Not used by any schema or client method and not validated against any - * API response. If you need these values, define your own enum. This export will be - * removed in the next major release (1.0). - */ -export const MetricDataTypeSchema = z.enum(['average', 'sum', 'count', 'percentage', 'ratio']); - -/** - * Metric data type - * - * @deprecated Not used by any schema or client method and not validated against any - * API response. If you need these values, define your own type. This export will be - * removed in the next major release (1.0). - */ -export type MetricDataType = z.infer; - /** * Metric descriptors schema (human-readable names, tags, descriptions) */ @@ -211,8 +174,7 @@ export const MetricMetadataSchema = z.object({ /** * Access tier required for this metric, as a non-negative integer (e.g. `2`). - * Type it as `MetricMetadata['tier']`; the deprecated string-based `MetricTier` - * does not describe this field. + * Type it as `MetricMetadata['tier']`. */ tier: z.number().int().nonnegative(), diff --git a/test/config.spec.ts b/test/config.spec.ts index 6fa8269..638fa8f 100644 --- a/test/config.spec.ts +++ b/test/config.spec.ts @@ -3,7 +3,6 @@ import { GlassnodeConfigSchema, DEFAULT_API_URL, X402_API_URL, - type FetchFn, type GlassnodeConfig, type GlassnodeFetch, type Logger, @@ -84,9 +83,8 @@ describe('logger and fetch options', () => { expectTypeOf().toEqualTypeOf< (input: string, init?: RequestInit) => Promise >(); - // The deprecated `FetchFn` keeps its meaning (`typeof fetch`) and still fits the option. - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toExtend(); + // The standard `fetch` type still fits the option. + expectTypeOf().toExtend(); }); it('contextually types inline logger and fetch callbacks', () => { From 080f952f1bcc47f238d09355c0b6f731c65d4a30 Mon Sep 17 00:00:00 2001 From: planadecu Date: Tue, 29 Sep 2026 18:12:44 +0200 Subject: [PATCH 02/14] Make MetricMetadata refs, queried and parameters optional Aligns MetricMetadataSchema with the lenient response policy: a getMetricMetadata() response that omits refs, queried or parameters now parses instead of rejecting the whole call. Only path and tier stay required. An absent field is reported as undefined, with no {} default. Breaking (1.0): the three field types become `... | undefined`, so callers read them with `?.`. Examples and contract tests updated; the CHANGELOG 1.0.0 (unreleased) section carries the migration note. Closes #25 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 +++++++++ examples/ex.metadata.validation.ts | 10 ++++---- examples/ex.metric.dump.ts | 2 +- src/types/metadata.ts | 39 ++++++++++++++++++------------ test/contract.spec.ts | 22 +++++++++++------ test/glassnode-api.spec.ts | 35 ++++++++++++++++++++++++++- 6 files changed, 88 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 969194a..63259ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,17 @@ against them. - `FetchFn`: use `GlassnodeFetch`, the type of the `fetch` config option, or `typeof fetch` (which `FetchFn` was an alias of; a standard `fetch` still fits the option). +- `MetricMetadata` (from `getMetricMetadata()` / `MetricMetadataSchema`): `refs`, `queried` and + `parameters` are now optional, in line with the lenient response policy (0.12.0). A response + that omits any of them now parses instead of rejecting the whole call; only `path` and `tier` + stay required. An absent field is reported as sent, `undefined`, with no `{}` default. Their + types become `… | undefined`, which breaks code that reads them directly (e.g. + `meta.refs.docs` or `Object.keys(meta.parameters)`) under `strictNullChecks`. The recorded API + responses still carry all three, so runtime values do not change today. + + **Migration:** read these fields with `?.` and a fallback where you need one, e.g. + `meta.refs?.docs`, `meta.queried?.a`, `meta.parameters?.a ?? []`, + `Object.entries(meta.parameters ?? {})`. ## 0.29.4 diff --git a/examples/ex.metadata.validation.ts b/examples/ex.metadata.validation.ts index 72ee7ba..6cd292a 100644 --- a/examples/ex.metadata.validation.ts +++ b/examples/ex.metadata.validation.ts @@ -97,18 +97,18 @@ async function fetchExchangeBalanceMetadata() { // Display available parameters console.log('\nAvailable parameters:'); // Only print the first 3 values per parameter - Object.entries(metadata.parameters).forEach(([key, values]) => { + Object.entries(metadata.parameters ?? {}).forEach(([key, values]) => { console.log(` ${key}: ${values.slice(0, 3).join(', ')}`); }); // Display documentation links and metric variants - if (metadata.refs.docs) { + if (metadata.refs?.docs) { console.log(`\nDocumentation: ${metadata.refs.docs}`); } - if (metadata.refs.studio) { + if (metadata.refs?.studio) { console.log(`Studio: ${metadata.refs.studio}`); } - if (metadata.refs.metric_variant) { + if (metadata.refs?.metric_variant) { console.log('Metric variants:'); Object.entries(metadata.refs.metric_variant).forEach(([key, value]) => { if (value) console.log(` ${key}: ${value}`); @@ -134,7 +134,7 @@ async function fetchExchangeBalanceWithParams() { // Display queried parameters console.log('\nQueried parameters:'); - Object.entries(metadata.queried).forEach(([key, value]) => { + Object.entries(metadata.queried ?? {}).forEach(([key, value]) => { console.log(` ${key}: ${value}`); }); } catch (error) { diff --git a/examples/ex.metric.dump.ts b/examples/ex.metric.dump.ts index fca95a7..38c7a14 100644 --- a/examples/ex.metric.dump.ts +++ b/examples/ex.metric.dump.ts @@ -12,7 +12,7 @@ async function runExamples() { const metricList = await api.getMetricList(); for (const metric of metricList.slice(0, 10)) { const metricMetadata = await api.getMetricMetadata(metric); - const params = metricMetadata.parameters; + const params = metricMetadata.parameters ?? {}; // create params object from params array const paramsObject: Record = {}; for (const param of Object.keys(params)) { diff --git a/src/types/metadata.ts b/src/types/metadata.ts index a0931f7..fcb250a 100644 --- a/src/types/metadata.ts +++ b/src/types/metadata.ts @@ -219,29 +219,36 @@ export const MetricMetadataSchema = z.object({ .optional(), /** - * Reference links for this metric + * Reference links for this metric (docs, Studio, variant paths). Optional: `undefined` when the + * API omits it, so read it with `?.` (e.g. `meta.refs?.docs`). */ - refs: z.object({ - docs: z.string().optional(), - studio: z.string().optional(), - metric_variant: z - .object({ - base: z.string().optional(), - bulk: z.string().optional(), - pit: z.string().optional(), - }) - .optional(), - }), + refs: z + .object({ + docs: z.string().optional(), + studio: z.string().optional(), + metric_variant: z + .object({ + base: z.string().optional(), + bulk: z.string().optional(), + pit: z.string().optional(), + }) + .optional(), + }) + .optional(), /** - * Queried parameters for the metric + * The request's parameters as the API echoes them back (e.g. `{ path: '/market/price_usd_close' }`, + * plus `a` when an asset was passed). Optional: `undefined` when the API omits it, so read it + * with `?.`. */ - queried: z.record(z.string(), z.any()), + queried: z.record(z.string(), z.any()).optional(), /** - * List of all allowed parameters and their values for the metric + * All allowed parameters of the metric and their values, keyed by parameter name (e.g. + * `{ a: ['BTC', 'ETH', …], i: ['24h', …] }`). Optional, with no `{}` default: `undefined` when + * the API omits it, so read it with `?.` (e.g. `meta.parameters?.a ?? []`). */ - parameters: z.record(z.string(), z.array(z.string())), + parameters: z.record(z.string(), z.array(z.string())).optional(), /** * Default values of the parameters that have one, keyed by parameter name (e.g. diff --git a/test/contract.spec.ts b/test/contract.spec.ts index 99a78fa..76121bf 100644 --- a/test/contract.spec.ts +++ b/test/contract.spec.ts @@ -164,15 +164,21 @@ async function metricMetadataCase( expect(typeof result.timerange!.min).toBe('number'); expect(result.timerange!.min).toBeLessThan(result.timerange!.max); expect(result.bulk_supported).toBe(body.bulk_supported); + // `refs`, `queried` and `parameters` are optional in the schema, but the recorded responses + // all carry them. Checked before any dereference below, so a re-recorded response without them + // fails with a clear assertion: that is a change in the recorded API response, not a schema bug. + expect(result.refs).toBeDefined(); + expect(result.queried).toBeDefined(); + expect(result.parameters).toBeDefined(); expect(result.parameters).toEqual(body.parameters); // Only sent for a metric with a defaulted parameter; `undefined` (not `{}`) otherwise. expect(result.parameters_defaults).toEqual(body.parameters_defaults); expect(result.queried).toEqual(body.queried); - expect(result.queried.path).toBe(metadataPath(entry)); + expect(result.queried!.path).toBe(metadataPath(entry)); expect(result.refs).toEqual(body.refs); expect(result.descriptors).toEqual(body.descriptors); expect(result.descriptors?.name).toEqual(expect.any(String)); - expect(result.parameters.f).toContain('json'); + expect(result.parameters!.f).toContain('json'); return result; } @@ -279,8 +285,8 @@ const CASES: Record = { 'metric-metadata-price-usd-close': async (api, entry, raw) => { const result = await metricMetadataCase(api, entry, raw); expect(result.tier).toBe(1); - expect(result.parameters.a).toEqual(expect.arrayContaining(['BTC', 'ETH', 'SOL'])); - expect(result.parameters.a.length).toBeGreaterThan(100); + expect(result.parameters!.a).toEqual(expect.arrayContaining(['BTC', 'ETH', 'SOL'])); + expect(result.parameters!.a.length).toBeGreaterThan(100); expect(result.queried).toEqual({ path: '/market/price_usd_close' }); expect(result.parameters_defaults).toBeUndefined(); return result; @@ -290,8 +296,8 @@ const CASES: Record = { 'metric-metadata-balance-exchanges-btc': async (api, entry, raw) => { const result = await metricMetadataCase(api, entry, raw); expect(result.tier).toBe(2); - expect(result.parameters.a).toEqual(['BTC']); - expect(result.parameters.e).toEqual(expect.arrayContaining(['aggregated', 'binance'])); + expect(result.parameters!.a).toEqual(['BTC']); + expect(result.parameters!.e).toEqual(expect.arrayContaining(['aggregated', 'binance'])); expect(result.parameters_defaults).toEqual({ e: ['aggregated'] }); expectTypeOf(result.parameters_defaults).toEqualTypeOf | undefined>(); expect(result.queried).toEqual({ a: 'BTC', path: '/distribution/balance_exchanges' }); @@ -302,8 +308,8 @@ const CASES: Record = { 'metric-metadata-us-spot-etf-balances-all': async (api, entry, raw) => { const result = await metricMetadataCase(api, entry, raw); expect(result.tier).toBe(1); - expect(result.parameters.a).toEqual(expect.arrayContaining(['BTC', 'ETH'])); - expect(result.parameters.i).toEqual(['24h', '1w', '1month']); + expect(result.parameters!.a).toEqual(expect.arrayContaining(['BTC', 'ETH'])); + expect(result.parameters!.i).toEqual(['24h', '1w', '1month']); expect(result.queried).toEqual({ path: '/institutions/us_spot_etf_balances_all' }); return result; }, diff --git a/test/glassnode-api.spec.ts b/test/glassnode-api.spec.ts index 3960a91..2b072e3 100644 --- a/test/glassnode-api.spec.ts +++ b/test/glassnode-api.spec.ts @@ -1,6 +1,7 @@ -import { describe, it, expect, vi, type Mock } from 'vitest'; +import { describe, it, expect, expectTypeOf, vi, type Mock } from 'vitest'; import { GlassnodeAPI } from '../src/glassnode-api'; import { GlassnodeApiError } from '../src/errors'; +import { MetricMetadataSchema, type MetricMetadata } from '../src/types/metadata'; import { API_KEY, DEFAULT_API_URL, @@ -453,6 +454,38 @@ describe('GlassnodeAPI', () => { } }); + it('requires only path and tier: refs, queried and parameters are optional', () => { + const result = MetricMetadataSchema.safeParse({ path: '/x', tier: 1 }); + + expect(result.success).toBe(true); + expect(result.data).toEqual({ path: '/x', tier: 1 }); + // The fields' types admit `undefined` (read them with `?.`). + expectTypeOf().toExtend(); + expectTypeOf().toExtend(); + expectTypeOf().toEqualTypeOf< + Record | undefined + >(); + + const missing = MetricMetadataSchema.safeParse({}); + expect(missing.success).toBe(false); + expect(missing.error!.issues.map((i) => i.path.join('.')).sort()).toEqual(['path', 'tier']); + }); + + it('leaves refs, queried and parameters undefined (not {}) when the API omits them', async () => { + const raw: Record = { ...mockRawMetricMetadataResponse }; + delete raw.refs; + delete raw.queried; + delete raw.parameters; + const fetchFn = createMockFetch({ ok: true, json: vi.fn().mockResolvedValue(raw) }); + + const result = await createApi(fetchFn).getMetricMetadata('/distribution/balance_exchanges'); + + expect(result.path).toBe('/distribution/balance_exchanges'); + expect(result).not.toHaveProperty('refs'); + expect(result).not.toHaveProperty('queried'); + expect(result).not.toHaveProperty('parameters'); + }); + it('rejects parameters_defaults whose values are not string arrays', async () => { const fetchFn = createMockFetch({ ok: true, From 3ca18fcdfcc7e3efecea4b7d3cfdfb427bd037c6 Mon Sep 17 00:00:00 2001 From: planadecu Date: Tue, 29 Sep 2026 18:11:43 +0200 Subject: [PATCH 03/14] Keep MetricMetadata.modified as unix seconds (number) MetricMetadata.modified was the only time field the schemas converted to a Date (and it turned 0 into undefined). It now passes through as the API sends it, a number of unix seconds, like timerange and every `t`. No toDate helper: new Date(t * 1000) already covers every field. Breaking change for 1.0 (#22); no version bump on release/1.0. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 17 +++++++++++++++++ README.md | 24 +++++++++++------------- examples/ex.metadata.validation.ts | 4 +++- src/types/metadata.ts | 17 +++++------------ test/contract.spec.ts | 8 ++++---- test/glassnode-api.spec.ts | 3 +-- test/timestamps.spec.ts | 13 ++++++------- 7 files changed, 47 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63259ad..dcb1c83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,23 @@ `meta.refs?.docs`, `meta.queried?.a`, `meta.parameters?.a ?? []`, `Object.entries(meta.parameters ?? {})`. +- `MetricMetadata.modified` is now unix **seconds** (`number | undefined`), passed through as the + API sends it, like every other time field (`timerange.min`/`max`, the time series and bulk `t`, + `callMetric()` results). It used to be the one field converted to a `Date`. The schema is now + `z.number().optional()`; README "Timestamps", the schema comments and + `examples/ex.metadata.validation.ts` are updated. No `toDate` helper is added: + `new Date(t * 1000)` already covers every field (#22). + - **Behaviour change:** `modified: 0` now passes through as `0`; it used to become `undefined` + ("no modification time recorded"). Absent is still `undefined`. + - **Migration:** convert explicitly where you need a `Date`: + + ```ts + const modified = meta.modified !== undefined ? new Date(meta.modified * 1000) : undefined; + ``` + + To keep treating `0` as "not recorded", use `meta.modified || undefined` (or check for `0`) + before converting. + ## 0.29.4 - CI: `ci.yml` now also runs on pull requests into `release/**` branches (e.g. `release/1.0`), not diff --git a/README.md b/README.md index 0723963..84d535b 100644 --- a/README.md +++ b/README.md @@ -302,19 +302,17 @@ Metrics with other shapes (e.g. an array `v`) can use any Zod schema of your own ## Timestamps The API sends every point in time (timestamp) as unix **seconds**, and the client passes them -through as plain `number`s — with **one exception**, `MetricMetadata.modified`, which is converted -to a `Date`: - -| Field | Type | -| ------------------------------------------------------ | -------------------- | -| `MetricMetadata.modified` | `Date \| undefined` | -| `MetricMetadata.timerange.min` / `.max` | `number` (unix secs) | -| `BulkResponse[number].t` | `number` (unix secs) | -| `t` in `callMetric()` results (raw JSON, typed by you) | `number` (unix secs) | -| `TimeSeriesPoint.t` / `TimeSeriesObjectPoint.t` | `number` (unix secs) | - -`modified` is `undefined` when the API omits it **or sends `0`** (treated as "not recorded", not as -1970-01-01). Convert any unix-second value with `new Date(t * 1000)`: +through as plain `number`s: + +| Field | Type | +| ------------------------------------------------------ | --------------------------------- | +| `MetricMetadata.modified` | `number \| undefined` (unix secs) | +| `MetricMetadata.timerange.min` / `.max` | `number` (unix secs) | +| `BulkResponse[number].t` | `number` (unix secs) | +| `t` in `callMetric()` results (raw JSON, typed by you) | `number` (unix secs) | +| `TimeSeriesPoint.t` / `TimeSeriesObjectPoint.t` | `number` (unix secs) | + +Convert any unix-second value with `new Date(t * 1000)`: ```typescript const [latest] = (await api.callBulkMetric('/market/marketcap_usd')).slice(-1); diff --git a/examples/ex.metadata.validation.ts b/examples/ex.metadata.validation.ts index 6cd292a..0073377 100644 --- a/examples/ex.metadata.validation.ts +++ b/examples/ex.metadata.validation.ts @@ -84,7 +84,9 @@ async function fetchExchangeBalanceMetadata() { console.log('✅ Exchange balance metadata:'); console.log(` Path: ${metadata.path}`); console.log(` Tier: ${metadata.tier}`); - console.log(` Modified: ${metadata.modified?.toISOString()}`); + const modified = + metadata.modified !== undefined ? new Date(metadata.modified * 1000).toISOString() : 'N/A'; + console.log(` Modified: ${modified}`); console.log(` Bulk supported: ${metadata.bulk_supported ?? 'N/A'}`); // Display time range diff --git a/src/types/metadata.ts b/src/types/metadata.ts index fcb250a..9b00d3c 100644 --- a/src/types/metadata.ts +++ b/src/types/metadata.ts @@ -2,8 +2,7 @@ * Metadata response types * * Timestamps: the API sends every point in time as unix **seconds** (a number). The schemas - * pass them through unchanged — with one exception, `MetricMetadata.modified`, which is - * converted to a JS `Date`. Convert the others yourself with `new Date(seconds * 1000)`. + * pass them all through unchanged; convert one yourself with `new Date(seconds * 1000)`. * Durations are not timestamps: the lag percentiles of {@link MetricStatsResponse} are lengths of * time in their entry's `unit` (e.g. seconds) and must not be passed to `new Date()`. */ @@ -179,17 +178,11 @@ export const MetricMetadataSchema = z.object({ tier: z.number().int().nonnegative(), /** - * When the metric's metadata was last updated, as a JS `Date`. - * - * The API sends unix seconds; the schema converts them to a `Date` (the only time field - * that is converted — see `timerange` and the time series/bulk `t`, which stay numbers). - * `undefined` when the field is absent **or `0`**: `0` is treated as "no modification time - * recorded", not as 1970-01-01. + * When the metric's metadata was last updated, in unix seconds (as sent by the API, like + * every other time field). `undefined` when the API omits it. Convert with + * `new Date(modified * 1000)`. */ - modified: z - .number() - .optional() - .transform((val) => (val ? new Date(val * 1000) : undefined)), + modified: z.number().optional(), /** * Whether this is a point-in-time metric diff --git a/test/contract.spec.ts b/test/contract.spec.ts index 76121bf..9df6896 100644 --- a/test/contract.spec.ts +++ b/test/contract.spec.ts @@ -5,7 +5,7 @@ * * - the call succeeds, i.e. the recorded response passes the library's Zod schema; * - the result has the documented shape and the recorded values (timestamps stay unix seconds, - * except `MetricMetadata.modified`, which becomes a `Date`); + * `MetricMetadata.modified` included); * - the request URL the client builds matches the recorded endpoint and query, plus the `f=json` * the client adds to metric data calls and the (query-string) API key; * - the fields the schemas strip from the real response are exactly the known, unmodelled ones @@ -156,9 +156,9 @@ async function metricMetadataCase( expect(result.path).toBe(metadataPath(entry)); expect(Number.isInteger(result.tier)).toBe(true); expect(result.tier).toBe(body.tier); - // The one converted time field: unix seconds → Date. - expect(result.modified).toBeInstanceOf(Date); - expect(result.modified!.getTime()).toBe(body.modified * 1000); + // `modified` stays unix seconds (a number), like every other time field. + expect(result.modified).toBe(body.modified); + expect(typeof result.modified).toBe('number'); // `timerange` stays unix seconds (numbers). expect(result.timerange).toEqual(body.timerange); expect(typeof result.timerange!.min).toBe('number'); diff --git a/test/glassnode-api.spec.ts b/test/glassnode-api.spec.ts index 2b072e3..1cb190d 100644 --- a/test/glassnode-api.spec.ts +++ b/test/glassnode-api.spec.ts @@ -420,8 +420,7 @@ describe('GlassnodeAPI', () => { expect(result).toEqual(mockMetricMetadataResponse); expect(result.path).toBe('/distribution/balance_exchanges'); expect(result.tier).toBe(2); - expect(result.modified).toBeInstanceOf(Date); - expect(result.modified!.getTime()).toBe(mockRawMetricMetadataResponse.modified! * 1000); + expect(result.modified).toBe(mockRawMetricMetadataResponse.modified); }); it('leaves parameters_defaults undefined when the API omits it', async () => { diff --git a/test/timestamps.spec.ts b/test/timestamps.spec.ts index 08e522a..dfb98fd 100644 --- a/test/timestamps.spec.ts +++ b/test/timestamps.spec.ts @@ -4,18 +4,17 @@ import { mockRawMetricMetadataResponse } from './mocks/metadata.mock'; /** * Pins the documented timestamp representation (see README "Timestamps"): - * `MetricMetadata.modified` is a `Date`; every other time field stays unix seconds. + * every time field, `MetricMetadata.modified` included, stays unix seconds (a number). */ describe('timestamp representation', () => { describe('MetricMetadata.modified', () => { - it('converts unix seconds into a Date', () => { + it('keeps unix seconds as a number', () => { const result = MetricMetadataSchema.parse({ ...mockRawMetricMetadataResponse, modified: 1733829848, }); - expect(result.modified).toBeInstanceOf(Date); - expect(result.modified!.getTime()).toBe(1733829848 * 1000); - expect(result.modified!.toISOString()).toBe('2024-12-10T11:24:08.000Z'); + expect(result.modified).toBe(1733829848); + expect(typeof result.modified).toBe('number'); }); it('is undefined when the field is absent', () => { @@ -25,9 +24,9 @@ describe('timestamp representation', () => { expect(result.modified).toBeUndefined(); }); - it('is undefined (not the 1970 epoch) when the API sends 0', () => { + it('passes 0 through as 0', () => { const result = MetricMetadataSchema.parse({ ...mockRawMetricMetadataResponse, modified: 0 }); - expect(result.modified).toBeUndefined(); + expect(result.modified).toBe(0); }); }); From db0ba0d8a0ca04fca1548673b0bf22c8e69d0442 Mon Sep 17 00:00:00 2001 From: planadecu Date: Tue, 29 Sep 2026 22:42:20 +0200 Subject: [PATCH 04/14] Declare the CDN entry with unpkg/jsdelivr; move browser build to bundler resolution #37: drop the top-level `browser` field in favour of `unpkg` and `jsdelivr` fields pointing at the UMD bundle. `exports` gets no `browser` condition, so bundlers keep the tree-shakeable ESM build. publint no longer suggests the `browser` condition. #35: tsconfig.browser.json uses `module: ESNext` + `moduleResolution: bundler` instead of the deprecated node10 resolution, removing the TS5107 warning from build:browser. Both browser bundles are byte-identical before and after. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 19 +++++++++++++++++++ CLAUDE.md | 10 +++++++++- README.md | 6 ++++++ package.json | 3 ++- tsconfig.browser.json | 4 ++-- 5 files changed, 38 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03b20c0..2df3797 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,25 @@ To keep treating `0` as "not recorded", use `meta.modified || undefined` (or check for `0`) before converting. +- `package.json` no longer has a top-level `browser` field; `unpkg` and `jsdelivr` fields point at + `dist/glassnode-api.umd.min.js` instead (#37). `exports` gets no `browser` condition, so + bundlers keep resolving the tree-shakeable ESM build (`import`) or CommonJS (`require`) exactly + as before. Both bundles still ship at the same paths, and the bare unpkg/jsDelivr URL still + serves the UMD bundle. + - **Who is affected:** only tools that read `browser` and ignore `exports` (e.g. webpack 4, + Browserify, or a bundler with `exports` resolution turned off). They now fall back to `module` + (`dist/esm/index.js`) or `main` (`dist/index.js`), which import `zod` as a dependency instead + of the pre-minified bundle with `zod` inlined. The API is the same. + - **Migration:** nothing for most users. To keep loading the UMD bundle, reference it by path: + `glassnode-api/dist/glassnode-api.umd.min.js` in a ` ``` +The bare package URL (`https://unpkg.com/glassnode-api`, `https://cdn.jsdelivr.net/npm/glassnode-api`) +serves the UMD bundle, via the package's `unpkg` and `jsdelivr` fields. Pin a version in production +(e.g. `glassnode-api@1.0.0`). Bundlers (webpack, Vite, esbuild, Rollup) do not use these bundles: +they resolve `glassnode-api` through `exports` to the tree-shakeable ESM build, which shares `zod` +with your app. + The bundles also work unchanged where CORS does not apply, such as browser extensions with host permissions for `api.glassnode.com` (from the background script), or Deno, Bun and other non-browser runtimes. If you do call Glassnode directly from a browser context, keep the default diff --git a/package.json b/package.json index 7846f4b..2f31264 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "description": "TypeScript client for the Glassnode API (Node.js and browsers)", "main": "dist/index.js", "types": "./dist/index.d.ts", - "browser": "dist/glassnode-api.umd.min.js", + "unpkg": "dist/glassnode-api.umd.min.js", + "jsdelivr": "dist/glassnode-api.umd.min.js", "module": "./dist/esm/index.js", "files": [ "dist/**/*.js", diff --git a/tsconfig.browser.json b/tsconfig.browser.json index a602afe..ab02fae 100644 --- a/tsconfig.browser.json +++ b/tsconfig.browser.json @@ -2,8 +2,8 @@ "extends": "./tsconfig.json", "compilerOptions": { "target": "ES2015", - "module": "ES2015", - "moduleResolution": "node", + "module": "ESNext", + "moduleResolution": "bundler", "lib": ["ES2022", "DOM"], "declaration": false }, From 1a98f0dc5888bdae9d1576ee2560fb43e6cee7f6 Mon Sep 17 00:00:00 2001 From: planadecu Date: Tue, 29 Sep 2026 22:49:26 +0200 Subject: [PATCH 05/14] Correct the CDN, affected-tools and browser-field claims (#37) - unpkg ignores `browser` and served the CJS `main` for the bare URL; it now serves the UMD bundle for the first time. jsDelivr is unchanged. - Browserify is the tool affected: it now bundles dist/index.js plus zod's CJS (about 186 KB gzipped instead of 31 KB). Migration: require the UMD file by path, which Browserify resolves because it ignores `exports` (verified). webpack 4 is removed from the list: it failed before and after (optional chaining in the UMD bundle, ES2022 in the ESM build). - CLAUDE.md: only a `browser` condition inside `exports` would steer webpack 5/Vite/esbuild; the top-level field is ignored when `exports` exists. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 29 +++++++++++++++++++---------- CLAUDE.md | 11 +++++++---- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2df3797..454fbb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,16 +45,25 @@ - `package.json` no longer has a top-level `browser` field; `unpkg` and `jsdelivr` fields point at `dist/glassnode-api.umd.min.js` instead (#37). `exports` gets no `browser` condition, so - bundlers keep resolving the tree-shakeable ESM build (`import`) or CommonJS (`require`) exactly - as before. Both bundles still ship at the same paths, and the bare unpkg/jsDelivr URL still - serves the UMD bundle. - - **Who is affected:** only tools that read `browser` and ignore `exports` (e.g. webpack 4, - Browserify, or a bundler with `exports` resolution turned off). They now fall back to `module` - (`dist/esm/index.js`) or `main` (`dist/index.js`), which import `zod` as a dependency instead - of the pre-minified bundle with `zod` inlined. The API is the same. - - **Migration:** nothing for most users. To keep loading the UMD bundle, reference it by path: - `glassnode-api/dist/glassnode-api.umd.min.js` in a `