Skip to content

Commit f6277c1

Browse files
easeevclaude
andcommitted
viewer: level grid cards and spell the product name consistently; query: run project SQL in UTC
Panels in a grid row stretch to equal height and KPI values sit at the bottom of the card, so a KPI with a description no longer breaks the row. The dashboard footer credits Chainplot by name and links to this repository. Both footer links carry the same underline and differ only in weight, so a release published `results_only`, which has no fork link, does not present its one remaining link as plain text. Capitalized is how the docs and the page title already spell the product, so the templates and the usdc-supply example now match; lowercase stays for the command, the config file, the image tag and the repository path. Dated plan and spec documents are left as written. The query worker sets TimeZone='UTC' before any project SQL. rindexer exports block_timestamp as TIMESTAMP WITH TIME ZONE and DuckDB renders, casts and buckets that type in the session zone, so a fork built outside UTC published different hourly and daily figures than the producer. The setting comes from DuckDB's ICU extension, which the bundled binaries link statically; a build without it fails before project SQL runs. The contract is documented in docs/capabilities.md and covered by a test against a parquet TIMESTAMPTZ column. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 126a207 commit f6277c1

11 files changed

Lines changed: 125 additions & 16 deletions

File tree

‎docs/capabilities.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,15 @@ INSERT, COPY, ATTACH, PRAGMA and friends outright, so the rule is the parser's,
4444
not a keyword denylist. Filesystem and network access are disabled before any
4545
project SQL executes, models included: a forked recipe is a stranger's code.
4646

47+
The session runs in UTC. rindexer exports `block_timestamp` as `TIMESTAMP WITH
48+
TIME ZONE`, and DuckDB renders, casts and buckets that type in the session
49+
`TimeZone`, which otherwise follows the machine. The worker pins it, so
50+
`strftime`, `date_trunc`, `hour()` and casts to `TIMESTAMP` give the same
51+
answer on the producer, in a fork in another zone, and in `query` on a laptop.
52+
This is a worker-wide contract, not a per-query option; it needs DuckDB's ICU
53+
extension, which the bundled binaries link statically, and the worker refuses
54+
to run project SQL on a build without it.
55+
4756
`cp_sortkey(v)` is available in every query. It maps a decimal-string amount to
4857
a fixed-width key whose lexicographic order is signed-numeric order, so
4958
`ORDER BY cp_sortkey(value)` sorts uint256 correctly without projecting a

‎examples/usdc-supply/.env.example‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ RPC_URL=
77
# nothing to install or point at yourself.
88
DATABASE_URL=postgresql://chainplot:chainplot@postgres:5432/chainplot
99

10-
# Producer image tag. Build it once from a chainplot checkout:
10+
# Producer image tag. Build it once from a Chainplot checkout:
1111
# docker build --platform linux/amd64 -t chainplot:local \
1212
# -f docker/producer.Dockerfile .
1313
# CHAINPLOT_IMAGE=chainplot:local

‎examples/usdc-supply/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ indexed filters, a model, and time-series charts.
2727

2828
## Run it
2929

30-
Build the producer image once from a chainplot checkout, then:
30+
Build the producer image once from a Chainplot checkout, then:
3131

3232
```bash
3333
cp .env.example .env # fill in RPC_URL

‎examples/usdc-supply/compose.yaml‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@ services:
1616
retries: 30
1717

1818
producer:
19-
# The producer image is the chainplot CLI plus the pinned rindexer binary.
20-
# It is built from the chainplot repo, not from this project — a scaffolded
21-
# project has no CLI sources to build from. Once, from a chainplot checkout:
19+
# The producer image is the Chainplot CLI plus the pinned rindexer binary.
20+
# It is built from the Chainplot repo, not from this project — a scaffolded
21+
# project has no CLI sources to build from. Once, from a Chainplot checkout:
2222
#
2323
# docker build --platform linux/amd64 \
2424
# -t chainplot:local -f docker/producer.Dockerfile .

‎src/query/workerMain.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,24 @@ async function execute(req: WorkerRequest): Promise<{
160160
// trusted than the query itself, so they must land on this side of it.
161161
// DuckDB does not allow re-enabling external access in a session.
162162
await conn.run("SET enable_external_access=false");
163+
// rindexer exports block_timestamp as TIMESTAMP WITH TIME ZONE, and
164+
// DuckDB renders, casts and buckets that type in the session's
165+
// TimeZone, which defaults to the machine's. Pinning UTC is what makes
166+
// an hourly or daily figure the same on the producer, in a fork on a
167+
// laptop in another zone, and in the published results. The setting
168+
// belongs to DuckDB's ICU extension, which the bundled binaries link
169+
// statically; a build without it must fail here, before any project
170+
// SQL, rather than quietly compute in local time.
171+
try {
172+
await conn.run("SET TimeZone='UTC'");
173+
} catch (err) {
174+
throw issueError({
175+
code: "internal",
176+
message:
177+
"DuckDB build lacks ICU time-zone support; the query worker requires it " +
178+
`so timestamps compute in UTC on every machine (${err instanceof Error ? err.message : String(err)})`,
179+
});
180+
}
163181
await conn.run(SORT_KEY_MACRO);
164182

165183
for (const model of req.models ?? []) {

‎templates/ingest-transfers/.env.example‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ RPC_URL=
77
# nothing to install or point at yourself.
88
DATABASE_URL=postgresql://chainplot:chainplot@postgres:5432/chainplot
99

10-
# Producer image tag. Build it once from a chainplot checkout:
10+
# Producer image tag. Build it once from a Chainplot checkout:
1111
# docker build --platform linux/amd64 -t chainplot:local \
1212
# -f docker/producer.Dockerfile .
1313
# CHAINPLOT_IMAGE=chainplot:local

‎templates/ingest-transfers/README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ thing you supply is an archive-capable RPC endpoint.
77

88
## One-time: build the producer image
99

10-
The producer image is the chainplot CLI plus rindexer. It is built from a
11-
chainplot checkout, not from this project — a scaffolded project has no CLI
10+
The producer image is the Chainplot CLI plus rindexer. It is built from a
11+
Chainplot checkout, not from this project — a scaffolded project has no CLI
1212
sources. rindexer ships linux/amd64 only, so build for that platform:
1313

1414
```bash

‎templates/ingest-transfers/compose.yaml‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@ services:
1616
retries: 30
1717

1818
producer:
19-
# The producer image is the chainplot CLI plus the pinned rindexer binary.
20-
# It is built from the chainplot repo, not from this project — a scaffolded
21-
# project has no CLI sources to build from. Once, from a chainplot checkout:
19+
# The producer image is the Chainplot CLI plus the pinned rindexer binary.
20+
# It is built from the Chainplot repo, not from this project — a scaffolded
21+
# project has no CLI sources to build from. Once, from a Chainplot checkout:
2222
#
2323
# docker build --platform linux/amd64 \
2424
# -t chainplot:local -f docker/producer.Dockerfile .

‎tests/query/timezone.test.ts‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe, expect, it } from "vitest";
2+
import fs from "node:fs";
3+
import os from "node:os";
4+
import path from "node:path";
5+
import { DuckDBInstance } from "@duckdb/node-api";
6+
import { runQuery } from "../../src/query/runQuery.js";
7+
8+
// A snapshot shaped like rindexer's export: block_timestamp is TIMESTAMP WITH
9+
// TIME ZONE. Written by this test process, not the worker, which has external
10+
// access switched off before any project SQL runs.
11+
async function timestampFixture(): Promise<string> {
12+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "chainplot-tz-"));
13+
const parquet = path.join(dir, "events.parquet");
14+
const instance = await DuckDBInstance.create(":memory:");
15+
const conn = await instance.connect();
16+
await conn.run(
17+
`COPY (
18+
SELECT * FROM (VALUES
19+
(1, TIMESTAMPTZ '2026-09-16 00:26:20+00'),
20+
(2, TIMESTAMPTZ '2026-09-16 23:59:59+00')
21+
) AS t(rindexer_id, block_timestamp)
22+
) TO '${parquet.replaceAll("'", "''")}' (FORMAT PARQUET)`,
23+
);
24+
return parquet;
25+
}
26+
27+
// DuckDB formats, casts and buckets TIMESTAMPTZ in the session TimeZone, which
28+
// follows the machine unless pinned: the same query would publish "08:00" from
29+
// a laptop in Singapore and "00:00" from the producer container. The worker
30+
// pins UTC so a fork reproduces the published figures wherever it runs.
31+
describe("query session timezone", () => {
32+
// TimeZone is a setting DuckDB's ICU extension registers, so reading it
33+
// back also proves the bundled build links ICU: without it the worker's
34+
// SET would have failed before this query ran. (duckdb_extensions() is not
35+
// usable here: it scans the extension directory, which the worker's
36+
// external-access lockdown forbids.)
37+
it("is pinned to UTC by the ICU-backed setting", async () => {
38+
const parquet = await timestampFixture();
39+
const result = await runQuery({
40+
sql: "SELECT current_setting('TimeZone') AS tz FROM (SELECT 1)",
41+
tables: { events: parquet },
42+
rawAmountColumns: [],
43+
rowLimit: 10,
44+
});
45+
expect(result.rows).toEqual([["UTC"]]);
46+
});
47+
48+
it("renders, buckets and casts a TIMESTAMPTZ column in UTC", async () => {
49+
const parquet = await timestampFixture();
50+
const result = await runQuery({
51+
sql: `SELECT typeof(block_timestamp) AS t,
52+
strftime(block_timestamp, '%Y-%m-%d %H:%M:%S') AS rendered,
53+
strftime(date_trunc('day', block_timestamp), '%Y-%m-%d') AS day,
54+
hour(block_timestamp) AS h,
55+
strftime(block_timestamp::TIMESTAMP, '%H:%M') AS cast_naive
56+
FROM events ORDER BY rindexer_id`,
57+
tables: { events: parquet },
58+
rawAmountColumns: [],
59+
rowLimit: 10,
60+
});
61+
expect(result.rows).toEqual([
62+
// hour() is BIGINT, which the worker serializes as a decimal string.
63+
["TIMESTAMP WITH TIME ZONE", "2026-09-16 00:26:20", "2026-09-16", "0", "00:26"],
64+
["TIMESTAMP WITH TIME ZONE", "2026-09-16 23:59:59", "2026-09-16", "23", "23:59"],
65+
]);
66+
});
67+
});

‎viewer/src/App.tsx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ import {
1818
} from "./format.js";
1919

2020
/** Where a reader learns how to rebuild a release from its dataset. */
21-
const FORK_HOWTO_URL = "https://github.com/chainstacklabs/chainplot#fork-a-published-release";
21+
const CHAINPLOT_REPO_URL = "https://github.com/chainstacklabs/chainplot";
22+
const FORK_HOWTO_URL = `${CHAINPLOT_REPO_URL}#fork-a-published-release`;
2223

2324
/** Columns the panel asked to compute but not show, e.g. an explicit sort key. */
2425
function visibleColumns(
@@ -528,7 +529,11 @@ export function App() {
528529
))}
529530

530531
<footer className="colophon">
531-
Built by <span>chainplot</span> · {release.mode.replace(/_/g, " ")} ·{" "}
532+
Built by{" "}
533+
<a className="brand" href={CHAINPLOT_REPO_URL} rel="noopener">
534+
Chainplot
535+
</a>{" "}
536+
· {release.mode.replace(/_/g, " ")} ·{" "}
532537
<time dateTime={release.generated_at}>{release.generated_at}</time>
533538
{" · "}
534539
{release.mode === "results_only" ? (

0 commit comments

Comments
 (0)