Please enter the commit message for your changes. Lines starting

with '#' will be ignored, and an empty message aborts the commit.

On branch main

Initial commit

Changes to be committed:
	new file:   .DS_Store
	new file:   .env
	new file:   .gitignore
	new file:   ai-worker/Dockerfile
	new file:   ai-worker/requirements.txt
	new file:   ai-worker/worker.py
	new file:   background-worker/Dockerfile
	new file:   background-worker/go.mod
	new file:   background-worker/go.sum
	new file:   background-worker/main.go
	new file:   background-worker/market.go
	new file:   background-worker/rmv.go
	new file:   background-worker/rss.go
	new file:   background-worker/sql_work.go
	new file:   db/Dockerfile
	new file:   db/init.sql
	new file:   docker-compose.yml
	new file:   server-app/dockerfile
	new file:   server-app/go.mod
	new file:   server-app/go.sum
	new file:   server-app/main.go
	new file:   volumes/.DS_Store
	new file:   volumes/db-init/.DS_Store
	new file:   volumes/db-init/data/news_rss_feeds.csv
	new file:   volumes/web/.DS_Store
	new file:   volumes/web/static/css/blog.css
	new file:   volumes/web/static/css/index-lite.css
	new file:   volumes/web/static/css/index.css
	new file:   volumes/web/static/css/mandelbrot.css
	new file:   volumes/web/static/img/minecraft.png
	new file:   volumes/web/static/js/blog.js
	new file:   volumes/web/static/js/index-lite.js
	new file:   volumes/web/static/js/index.js
	new file:   volumes/web/static/js/mandelbrot.js
	new file:   volumes/web/static/media/cantina.mp3
	new file:   volumes/web/static/media/countdowns.json
	new file:   volumes/web/static/media/gong.mp4
	new file:   volumes/web/template/blog.html
	new file:   volumes/web/template/index-lite.html
	new file:   volumes/web/template/index.html
	new file:   volumes/web/template/mandelbrot.html
This commit is contained in:
hubble_dubble
2026-01-26 00:19:54 +01:00
commit 3667c678e4
41 changed files with 3556 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
FROM golang:1.24
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
CMD ["go", "run", "."]

23
background-worker/go.mod Normal file
View File

@@ -0,0 +1,23 @@
module background-worker
go 1.24.0
toolchain go1.24.12
require (
github.com/jackc/pgx/v5 v5.8.0
github.com/mmcdole/gofeed v1.3.0
)
require (
github.com/PuerkitoBio/goquery v1.8.0 // indirect
github.com/andybalholm/cascadia v1.3.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
golang.org/x/net v0.4.0 // indirect
golang.org/x/text v0.29.0 // indirect
)

50
background-worker/go.sum Normal file
View File

@@ -0,0 +1,50 @@
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk=
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU=
golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

33
background-worker/main.go Normal file
View File

@@ -0,0 +1,33 @@
package main
import (
"log"
"time"
)
func main() {
log.Println("[background-worker] started")
tickerRMV := time.NewTicker(5 * time.Minute)
tickerRSS := time.NewTicker(1 * time.Minute)
tickerMarket := time.NewTicker(10 * time.Second)
defer tickerRMV.Stop()
defer tickerRSS.Stop()
defer tickerMarket.Stop()
rmv_request()
fetchMarketData()
fetchRSSFeeds()
for {
select {
case <-tickerRMV.C:
go rmv_request()
case <-tickerRSS.C:
go fetchRSSFeeds()
case <-tickerMarket.C:
go fetchMarketData()
}
}
}

View File

@@ -0,0 +1,87 @@
package main
import (
"context"
"encoding/json"
"log"
"net/http"
"time"
)
type marketQuote struct {
Instrument string
Bid float64
QuotedAt time.Time
}
type sqResponse struct {
SpreadProfilePrices []struct {
Bid float64 `json:"bid"`
} `json:"spreadProfilePrices"`
Timestamp int64 `json:"timestamp"`
}
func fetchMarketData() {
ctx := context.Background()
client := http.Client{Timeout: 10 * time.Second}
type endpoint struct {
Instrument string
URL string
}
endpoints := []endpoint{
{Instrument: "USD/JPY", URL: "https://forex-data-feed.swissquote.com/public-quotes/bboquotes/instrument/USD/JPY"},
{Instrument: "OIL/USD", URL: "https://forex-data-feed.swissquote.com/public-quotes/bboquotes/instrument/OIL/USD"},
{Instrument: "XAU/USD", URL: "https://forex-data-feed.swissquote.com/public-quotes/bboquotes/instrument/XAU/USD"},
{Instrument: "EUR/USD", URL: "https://forex-data-feed.swissquote.com/public-quotes/bboquotes/instrument/EUR/USD"},
{Instrument: "USD/CHF", URL: "https://forex-data-feed.swissquote.com/public-quotes/bboquotes/instrument/USD/CHF"},
{Instrument: "GBP/USD", URL: "https://forex-data-feed.swissquote.com/public-quotes/bboquotes/instrument/GBP/USD"},
}
var quotes []MarketQuote
for _, ep := range endpoints {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, ep.URL, nil)
resp, err := client.Do(req)
if err != nil {
log.Printf("market fetch %s failed: %v", ep.Instrument, err)
continue
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
log.Printf("market fetch %s bad status: %s", ep.Instrument, resp.Status)
continue
}
var payload []sqResponse
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
resp.Body.Close()
log.Printf("decode %s failed: %v", ep.Instrument, err)
continue
}
resp.Body.Close()
if len(payload) == 0 || len(payload[0].SpreadProfilePrices) == 0 {
log.Printf("market fetch %s: empty payload", ep.Instrument)
continue
}
bid := payload[0].SpreadProfilePrices[0].Bid
quotedAt := time.UnixMilli(payload[0].Timestamp)
quotes = append(quotes, MarketQuote{
Instrument: ep.Instrument,
Bid: bid,
QuotedAt: quotedAt,
})
}
if len(quotes) == 0 {
return
}
if err := saveMarketQuotes(ctx, quotes); err != nil {
log.Printf("saveMarketQuotes failed: %v", err)
}
log.Printf("market quotes updated: %d instruments", len(quotes))
}

108
background-worker/rmv.go Normal file
View File

@@ -0,0 +1,108 @@
package main
import (
"encoding/json"
"log"
"net/http"
"net/url"
"os"
)
type RMVResponse struct {
Trip []Trip `json:"Trip"`
}
type Trip struct {
LegList LegList `json:"LegList"`
}
type LegList struct {
Leg []Leg `json:"Leg"`
}
type Leg struct {
Type string `json:"type"`
Name string `json:"name"`
Origin Stop `json:"Origin"`
Destination Stop `json:"Destination"`
}
type Stop struct {
Name string `json:"name"`
Time string `json:"time"`
RtTime string `json:"rtTime"`
}
type Verbindung struct {
Linie string
Abfahrt string
Ankunft string
Von string
Nach string
}
func rmv_request() {
u, _ := url.Parse("https://www.rmv.de/hapi/trip")
q := u.Query()
q.Set("originId", "3018009")
q.Set("destId", "3029164")
q.Set("format", "json")
q.Set("accessId", os.Getenv("RMV_API_KEY"))
u.RawQuery = q.Encode()
resp, err := http.Get(u.String())
if err != nil {
panic(err)
}
defer resp.Body.Close()
var respData RMVResponse
dec := json.NewDecoder(resp.Body)
if err := dec.Decode(&respData); err != nil {
log.Fatal(err)
}
var verbindungen [][]Verbindung
for _, trip := range respData.Trip {
var tripInfo []Verbindung
for _, leg := range trip.LegList.Leg {
if leg.Type == "WALK" {
continue
}
abfahrt := leg.Origin.RtTime
if abfahrt == "" {
abfahrt = leg.Origin.Time
}
ankunft := leg.Destination.RtTime
if ankunft == "" {
ankunft = leg.Destination.Time
}
tripInfo = append(tripInfo, Verbindung{
Linie: leg.Name,
Abfahrt: abfahrt,
Ankunft: ankunft,
Von: leg.Origin.Name,
Nach: leg.Destination.Name,
})
}
if len(tripInfo) > 0 {
verbindungen = append(verbindungen, tripInfo)
}
}
writeVerbindungToSQL(verbindungen)
log.Println("RMV data updated")
if err := writeVerbindungToSQL(verbindungen); err != nil {
log.Printf("failed to persist RMV data: %v", err)
}
}

66
background-worker/rss.go Normal file
View File

@@ -0,0 +1,66 @@
package main
import (
"context"
"log"
"time"
"github.com/mmcdole/gofeed"
)
func fetchRSSFeeds() {
ctx := context.Background()
feeds, err := fetchPendingFeeds(ctx)
if err != nil {
log.Printf("fetchPendingFeeds failed: %v", err)
return
}
if len(feeds) == 0 {
log.Println("no feeds due for refresh")
return
}
parser := gofeed.NewParser()
for _, feed := range feeds {
fp, err := parser.ParseURL(feed.URL)
if err != nil {
log.Printf("parse feed %s: %v", feed.URL, err)
if err := setFeedAccess(ctx, feed.ID, false); err != nil {
log.Printf("disable feed %d failed: %v", feed.ID, err)
}
continue
}
var articles []Article
for _, item := range fp.Items {
var published *time.Time
if item.PublishedParsed != nil {
published = item.PublishedParsed
}
summary := item.Description
if summary == "" {
summary = item.Content
}
articles = append(articles, Article{
ArticleID: articleIDFromLink(item.Link),
FeedID: feed.ID,
Title: item.Title,
Link: item.Link,
Summary: summary,
PublishedAt: published,
})
}
if err := saveArticles(ctx, feed.ID, articles); err != nil {
log.Printf("save articles for feed %d: %v", feed.ID, err)
continue
}
log.Printf("processed feed %d (%s) with %d items", feed.ID, feed.URL, len(articles))
}
}

View File

@@ -0,0 +1,217 @@
package main
import (
"context"
"crypto/sha1"
"encoding/hex"
"fmt"
"os"
"time"
"github.com/jackc/pgx/v5"
)
func getenv(k, fallback string) string {
if v := os.Getenv(k); v != "" {
return v
}
return fallback
}
func buildDSN() string {
if url := os.Getenv("DATABASE_URL"); url != "" {
return url
}
host := getenv("POSTGRES_HOST", "database")
port := getenv("POSTGRES_PORT", "5432")
user := getenv("POSTGRES_USER", "app")
pass := getenv("POSTGRES_PASSWORD", "appsecret")
name := getenv("POSTGRES_DB", "appdb")
return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", user, pass, host, port, name)
}
type FeedMeta struct {
ID int
URL string
}
type Article struct {
ArticleID string
FeedID int
Title string
Link string
Summary string
PublishedAt *time.Time
}
type MarketQuote struct {
Instrument string
Bid float64
QuotedAt time.Time
}
func writeVerbindungToSQL(verbindungen [][]Verbindung) error {
ctx := context.Background()
dsn := buildDSN()
conn, err := pgx.Connect(ctx, dsn)
if err != nil {
return fmt.Errorf("connect db: %w", err)
}
defer conn.Close(ctx)
tx, err := conn.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, "DELETE FROM rmv_data"); err != nil {
return fmt.Errorf("clear rmv_data: %w", err)
}
stmt := `INSERT INTO rmv_data (trip_index, leg_index, linie, abfahrt, ankunft, von, nach) VALUES ($1,$2,$3,$4,$5,$6,$7)`
for tripIdx, trip := range verbindungen {
for legIdx, leg := range trip {
if _, err := tx.Exec(ctx, stmt, tripIdx, legIdx, leg.Linie, leg.Abfahrt, leg.Ankunft, leg.Von, leg.Nach); err != nil {
return fmt.Errorf("insert trip %d leg %d: %w", tripIdx, legIdx, err)
}
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit tx: %w", err)
}
return nil
}
func fetchPendingFeeds(ctx context.Context) ([]FeedMeta, error) {
dsn := buildDSN()
conn, err := pgx.Connect(ctx, dsn)
if err != nil {
return nil, fmt.Errorf("connect db: %w", err)
}
defer conn.Close(ctx)
rows, err := conn.Query(ctx, `
SELECT id, url
FROM feeds
WHERE access = TRUE
AND (last_checked IS NULL OR last_checked < now() - interval '5 minutes')
`)
if err != nil {
return nil, fmt.Errorf("query feeds: %w", err)
}
defer rows.Close()
var feeds []FeedMeta
for rows.Next() {
var f FeedMeta
if err := rows.Scan(&f.ID, &f.URL); err != nil {
return nil, fmt.Errorf("scan feed: %w", err)
}
feeds = append(feeds, f)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate feeds: %w", err)
}
return feeds, nil
}
func articleIDFromLink(link string) string {
h := sha1.Sum([]byte(link))
return hex.EncodeToString(h[:])
}
func saveArticles(ctx context.Context, feedID int, articles []Article) error {
dsn := buildDSN()
conn, err := pgx.Connect(ctx, dsn)
if err != nil {
return fmt.Errorf("connect db: %w", err)
}
defer conn.Close(ctx)
tx, err := conn.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback(ctx)
stmt := `
INSERT INTO articles (article_id, feed_id, title, link, summary, published_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (article_id) DO NOTHING
`
for _, a := range articles {
aid := a.ArticleID
if aid == "" {
aid = articleIDFromLink(a.Link)
}
if _, err := tx.Exec(ctx, stmt, aid, feedID, a.Title, a.Link, a.Summary, a.PublishedAt); err != nil {
return fmt.Errorf("insert article for feed %d: %w", feedID, err)
}
}
if _, err := tx.Exec(ctx, `UPDATE feeds SET last_checked = now() WHERE id = $1`, feedID); err != nil {
return fmt.Errorf("update feed timestamp: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit tx: %w", err)
}
return nil
}
func setFeedAccess(ctx context.Context, feedID int, access bool) error {
dsn := buildDSN()
conn, err := pgx.Connect(ctx, dsn)
if err != nil {
return fmt.Errorf("connect db: %w", err)
}
defer conn.Close(ctx)
if _, err := conn.Exec(ctx, `UPDATE feeds SET access = $1 WHERE id = $2`, access, feedID); err != nil {
return fmt.Errorf("update feed access: %w", err)
}
return nil
}
func saveMarketQuotes(ctx context.Context, quotes []MarketQuote) error {
dsn := buildDSN()
conn, err := pgx.Connect(ctx, dsn)
if err != nil {
return fmt.Errorf("connect db: %w", err)
}
defer conn.Close(ctx)
batch := &pgx.Batch{}
for _, q := range quotes {
batch.Queue(
`INSERT INTO market_quotes (instrument, bid, quoted_at)
VALUES ($1, $2, $3)
ON CONFLICT (instrument) DO UPDATE
SET bid = EXCLUDED.bid, quoted_at = EXCLUDED.quoted_at`,
q.Instrument, q.Bid, q.QuotedAt,
)
}
br := conn.SendBatch(ctx, batch)
defer br.Close()
for range quotes {
if _, err := br.Exec(); err != nil {
return fmt.Errorf("insert market quote: %w", err)
}
}
return nil
}