initial commit
This commit is contained in:
+19
@@ -0,0 +1,19 @@
|
||||
FROM golang:1.24-alpine AS build
|
||||
|
||||
WORKDIR /src
|
||||
COPY go.mod ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN go mod tidy
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/inventory-cdc-demo ./cmd/server
|
||||
|
||||
FROM alpine:3.22
|
||||
|
||||
RUN adduser -D -H app
|
||||
USER app
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/inventory-cdc-demo /app/inventory-cdc-demo
|
||||
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/app/inventory-cdc-demo"]
|
||||
@@ -0,0 +1,173 @@
|
||||
# Live Inventory CDC Demo
|
||||
|
||||
Small POC website showing browser CRUD actions flowing through Postgres, Debezium Server, NATS JetStream, a Go SSE backend, and back into the browser.
|
||||
|
||||
The important demo behavior is that mutation endpoints only return `command accepted`. The visible inventory cards are refreshed when the matching CDC event arrives over `/events`.
|
||||
|
||||
## Development Run
|
||||
|
||||
Development uses `compose.yml` for Postgres, NATS JetStream, Debezium, and helper containers. The Go web app runs as a native host process.
|
||||
|
||||
Install the host tools:
|
||||
|
||||
```bash
|
||||
sudo dnf install golang podman
|
||||
```
|
||||
|
||||
Use Taskfile as the main interface:
|
||||
|
||||
```bash
|
||||
task --list
|
||||
```
|
||||
|
||||
Terminal 1, start containerized Postgres, NATS, and Debezium:
|
||||
|
||||
```bash
|
||||
task infra
|
||||
```
|
||||
|
||||
Terminal 2, run the Go backend/frontend:
|
||||
|
||||
```bash
|
||||
task backend
|
||||
```
|
||||
|
||||
Then open:
|
||||
|
||||
```text
|
||||
http://localhost:8080
|
||||
```
|
||||
|
||||
Postgres is published to the host on:
|
||||
|
||||
```text
|
||||
localhost:5453
|
||||
```
|
||||
|
||||
NATS is published to the host on:
|
||||
|
||||
```text
|
||||
localhost:4222
|
||||
```
|
||||
|
||||
Development Debezium uses the internal compose addresses `postgres:5432` and `nats:4222`.
|
||||
|
||||
The Go backend defaults to:
|
||||
|
||||
```text
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5453/postgres?sslmode=disable
|
||||
NATS_URL=nats://localhost:4222
|
||||
```
|
||||
|
||||
## Production Run
|
||||
|
||||
Production uses user-level Podman quadlets for containers and a user systemd service for the Go backend.
|
||||
|
||||
Install the production units:
|
||||
|
||||
```bash
|
||||
task prod:install
|
||||
```
|
||||
|
||||
Start everything:
|
||||
|
||||
```bash
|
||||
task prod:start
|
||||
```
|
||||
|
||||
Stop everything:
|
||||
|
||||
```bash
|
||||
task prod:down
|
||||
```
|
||||
|
||||
Follow production logs:
|
||||
|
||||
```bash
|
||||
task prod:logs
|
||||
```
|
||||
|
||||
Production Debezium uses the internal quadlet network addresses `cdc-postgres:5432` and `cdc-nats:4222`.
|
||||
|
||||
## Production User Units
|
||||
|
||||
Quadlet files are installed to:
|
||||
|
||||
```text
|
||||
~/.config/containers/systemd/
|
||||
```
|
||||
|
||||
The Go backend service is installed to:
|
||||
|
||||
```text
|
||||
~/.config/systemd/user/cdc-inventory.service
|
||||
```
|
||||
|
||||
The service files are:
|
||||
|
||||
| Unit | Purpose |
|
||||
|---|---|
|
||||
| `cdc-postgres.service` | Postgres with logical replication enabled |
|
||||
| `cdc-nats.service` | NATS JetStream |
|
||||
| `cdc-debezium.service` | Debezium Server |
|
||||
| `cdc-inventory.service` | Native Go backend/frontend |
|
||||
| `cdc-seed.service` | One-shot seed helper |
|
||||
| `cdc-inspect.service` | One-shot NATS inspect helper |
|
||||
|
||||
## Useful Checks
|
||||
|
||||
Inspect stream subjects and recent events:
|
||||
|
||||
```bash
|
||||
task inspect
|
||||
```
|
||||
|
||||
For production:
|
||||
|
||||
```bash
|
||||
task prod:inspect
|
||||
```
|
||||
|
||||
Generate a fresh burst of create/update/delete events:
|
||||
|
||||
```bash
|
||||
task seed
|
||||
```
|
||||
|
||||
For production:
|
||||
|
||||
```bash
|
||||
task prod:seed
|
||||
```
|
||||
|
||||
Stop development infrastructure:
|
||||
|
||||
```bash
|
||||
task down
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| `GET` | `/` | Demo page |
|
||||
| `GET` | `/health` | Health check |
|
||||
| `GET` | `/api/inventory` | Current database snapshot |
|
||||
| `POST` | `/api/inventory` | Create inventory item |
|
||||
| `PATCH` | `/api/inventory/{id}/sell` | Decrement quantity by 1 |
|
||||
| `PATCH` | `/api/inventory/{id}/restock` | Increment quantity |
|
||||
| `PATCH` | `/api/inventory/{id}` | Rename, move, or set quantity |
|
||||
| `DELETE` | `/api/inventory/{id}` | Delete item |
|
||||
| `GET` | `/events` | Server-Sent Events from CDC stream |
|
||||
|
||||
## Pipeline
|
||||
|
||||
```text
|
||||
Browser action
|
||||
-> Go backend writes to Postgres
|
||||
-> Debezium captures the DB change
|
||||
-> Debezium publishes to NATS JetStream
|
||||
-> Go backend consumes the event
|
||||
-> Go backend streams it to browser with SSE
|
||||
-> Browser updates inventory and audit trail
|
||||
```
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
version: '3'
|
||||
|
||||
vars:
|
||||
DATABASE_URL: postgres://postgres:postgres@localhost:5453/postgres?sslmode=disable
|
||||
NATS_URL: nats://localhost:4222
|
||||
NATS_SUBJECT: postgres.public.inventory
|
||||
APP_NAME: inventory-cdc-demo
|
||||
APP_SHARE: '{{.HOME}}/.local/share/live-inventory-cdc-demo'
|
||||
APP_BIN: '{{.HOME}}/.local/bin/inventory-cdc-demo'
|
||||
QUADLET_DIR: '{{.HOME}}/.config/containers/systemd'
|
||||
USER_UNIT_DIR: '{{.HOME}}/.config/systemd/user'
|
||||
|
||||
tasks:
|
||||
default:
|
||||
desc: list available tasks
|
||||
cmds:
|
||||
- go tool task --list
|
||||
|
||||
run:
|
||||
desc: show the common development and production commands
|
||||
cmds:
|
||||
- echo "Development - go tool task infra, then go tool task backend"
|
||||
- echo "Production - go tool task prod-install, then go tool task prod-start"
|
||||
- echo "Open - http://localhost:8080"
|
||||
|
||||
infra:
|
||||
desc: development - start Postgres, NATS, and Debezium with compose
|
||||
cmds:
|
||||
- podman compose -f compose.yml up
|
||||
|
||||
backend:
|
||||
desc: development - run native Go backend against compose infrastructure
|
||||
env:
|
||||
DATABASE_URL: "{{.DATABASE_URL}}"
|
||||
NATS_URL: "{{.NATS_URL}}"
|
||||
NATS_SUBJECT: "{{.NATS_SUBJECT}}"
|
||||
cmds:
|
||||
- go run ./cmd/server
|
||||
|
||||
up:
|
||||
desc: development - alias for task infra
|
||||
cmds:
|
||||
- go tool task infra
|
||||
|
||||
down:
|
||||
desc: development - stop compose infrastructure
|
||||
cmds:
|
||||
- podman compose -f compose.yml down
|
||||
|
||||
seed:
|
||||
desc: development - generate a fresh burst of inventory CDC events
|
||||
cmds:
|
||||
- podman compose -f compose.yml --profile tools run --rm seed
|
||||
|
||||
inspect:
|
||||
desc: development - inspect Debezium messages in NATS JetStream
|
||||
cmds:
|
||||
- podman compose -f compose.yml --profile tools run --rm nats-cli /scripts/inspect.sh
|
||||
|
||||
build:
|
||||
desc: build the native Go backend binary
|
||||
env:
|
||||
CGO_ENABLED: "0"
|
||||
cmds:
|
||||
- go build -o bin/{{.APP_NAME}} ./cmd/server
|
||||
|
||||
binary:
|
||||
desc: run the built backend binary
|
||||
env:
|
||||
DATABASE_URL: "{{.DATABASE_URL}}"
|
||||
NATS_URL: "{{.NATS_URL}}"
|
||||
NATS_SUBJECT: "{{.NATS_SUBJECT}}"
|
||||
cmds:
|
||||
- ./bin/{{.APP_NAME}}
|
||||
|
||||
fmt:
|
||||
desc: format Go files
|
||||
cmds:
|
||||
- go fmt ./...
|
||||
|
||||
vet:
|
||||
desc: vet Go files
|
||||
cmds:
|
||||
- go vet ./...
|
||||
|
||||
test:
|
||||
desc: run tests
|
||||
cmds:
|
||||
- go test ./...
|
||||
|
||||
check:
|
||||
desc: run formatting, vet, and tests
|
||||
cmds:
|
||||
- go tool task fmt
|
||||
- go tool task vet
|
||||
- go tool task test
|
||||
|
||||
prod-install:
|
||||
desc: production - install user-level quadlets and Go backend service
|
||||
env:
|
||||
CGO_ENABLED: "0"
|
||||
cmds:
|
||||
- go build -o bin/{{.APP_NAME}} ./cmd/server
|
||||
- mkdir -p "{{.QUADLET_DIR}}" "{{.USER_UNIT_DIR}}" "{{.HOME}}/.local/bin" "{{.APP_SHARE}}"
|
||||
- cp bin/{{.APP_NAME}} "{{.APP_BIN}}"
|
||||
- cp -R postgres scripts "{{.APP_SHARE}}/"
|
||||
- cp application.prod.properties "{{.APP_SHARE}}/application.properties"
|
||||
- cp linux-files/dot_config/containers/systemd/cdc* "{{.QUADLET_DIR}}/"
|
||||
- cp linux-files/dot_config/systemd/user/cdc-inventory.service "{{.USER_UNIT_DIR}}/"
|
||||
- systemctl --user daemon-reload
|
||||
- echo "Installed production units. Run go tool task prod-start, then open http://localhost:8080"
|
||||
|
||||
prod-infra:
|
||||
desc: production - start quadlet-managed Postgres, NATS, and Debezium
|
||||
cmds:
|
||||
- systemctl --user start cdc-postgres.service cdc-nats.service cdc-debezium.service
|
||||
|
||||
prod-backend:
|
||||
desc: production - start the Go backend user service
|
||||
cmds:
|
||||
- systemctl --user start cdc-inventory.service
|
||||
|
||||
prod-start:
|
||||
desc: production - start quadlet infrastructure and Go backend
|
||||
cmds:
|
||||
- systemctl --user start cdc-postgres.service cdc-nats.service cdc-debezium.service cdc-inventory.service
|
||||
|
||||
prod-restart:
|
||||
desc: production - restart the full demo
|
||||
cmds:
|
||||
- systemctl --user restart cdc-postgres.service cdc-nats.service cdc-debezium.service cdc-inventory.service
|
||||
|
||||
prod-status:
|
||||
desc: production - show user service status
|
||||
cmds:
|
||||
- systemctl --user status cdc-postgres.service cdc-nats.service cdc-debezium.service cdc-inventory.service --no-pager
|
||||
|
||||
prod-logs:
|
||||
desc: production - follow logs for demo user services
|
||||
cmds:
|
||||
- journalctl --user -fu cdc-postgres.service -fu cdc-nats.service -fu cdc-debezium.service -fu cdc-inventory.service
|
||||
|
||||
prod-down:
|
||||
desc: production - stop all user services
|
||||
cmds:
|
||||
- systemctl --user stop cdc-inventory.service cdc-debezium.service cdc-nats.service cdc-postgres.service
|
||||
|
||||
prod-enable:
|
||||
desc: production - enable services at user login
|
||||
cmds:
|
||||
- systemctl --user enable cdc-postgres.service cdc-nats.service cdc-debezium.service cdc-inventory.service
|
||||
|
||||
prod-disable:
|
||||
desc: production - disable services at user login
|
||||
cmds:
|
||||
- systemctl --user disable cdc-inventory.service cdc-debezium.service cdc-nats.service cdc-postgres.service
|
||||
|
||||
prod-seed:
|
||||
desc: production - generate a fresh burst of inventory CDC events
|
||||
cmds:
|
||||
- systemctl --user start cdc-seed.service
|
||||
- journalctl --user -u cdc-seed.service -n 80 --no-pager
|
||||
|
||||
prod-inspect:
|
||||
desc: production - inspect Debezium messages in NATS JetStream
|
||||
cmds:
|
||||
- systemctl --user start cdc-inspect.service
|
||||
- journalctl --user -u cdc-inspect.service -n 120 --no-pager
|
||||
@@ -0,0 +1,28 @@
|
||||
debezium.source.connector.class=io.debezium.connector.postgresql.PostgresConnector
|
||||
debezium.source.offset.storage.file.filename=data/offsets.dat
|
||||
debezium.source.offset.flush.interval.ms=1000
|
||||
|
||||
debezium.source.database.hostname=cdc-postgres
|
||||
debezium.source.database.port=5432
|
||||
debezium.source.database.user=postgres
|
||||
debezium.source.database.password=postgres
|
||||
debezium.source.database.dbname=postgres
|
||||
|
||||
debezium.source.topic.prefix=postgres
|
||||
debezium.source.plugin.name=pgoutput
|
||||
debezium.source.slot.name=debezium
|
||||
debezium.source.publication.name=dbz_publication
|
||||
debezium.source.publication.autocreate.mode=filtered
|
||||
debezium.source.table.include.list=public.inventory
|
||||
|
||||
debezium.format.key=json
|
||||
debezium.format.value=json
|
||||
debezium.format.key.schemas.enable=false
|
||||
debezium.format.value.schemas.enable=false
|
||||
|
||||
debezium.sink.type=nats-jetstream
|
||||
debezium.sink.nats-jetstream.url=nats://cdc-nats:4222
|
||||
debezium.sink.nats-jetstream.create-stream=true
|
||||
debezium.sink.nats-jetstream.stream-name=DebeziumStream
|
||||
debezium.sink.nats-jetstream.subjects=postgres,postgres.>
|
||||
debezium.sink.nats-jetstream.storage=file
|
||||
@@ -0,0 +1,28 @@
|
||||
debezium.source.connector.class=io.debezium.connector.postgresql.PostgresConnector
|
||||
debezium.source.offset.storage.file.filename=data/offsets.dat
|
||||
debezium.source.offset.flush.interval.ms=1000
|
||||
|
||||
debezium.source.database.hostname=postgres
|
||||
debezium.source.database.port=5432
|
||||
debezium.source.database.user=postgres
|
||||
debezium.source.database.password=postgres
|
||||
debezium.source.database.dbname=postgres
|
||||
|
||||
debezium.source.topic.prefix=postgres
|
||||
debezium.source.plugin.name=pgoutput
|
||||
debezium.source.slot.name=debezium
|
||||
debezium.source.publication.name=dbz_publication
|
||||
debezium.source.publication.autocreate.mode=filtered
|
||||
debezium.source.table.include.list=public.inventory
|
||||
|
||||
debezium.format.key=json
|
||||
debezium.format.value=json
|
||||
debezium.format.key.schemas.enable=false
|
||||
debezium.format.value.schemas.enable=false
|
||||
|
||||
debezium.sink.type=nats-jetstream
|
||||
debezium.sink.nats-jetstream.url=nats://nats:4222
|
||||
debezium.sink.nats-jetstream.create-stream=true
|
||||
debezium.sink.nats-jetstream.stream-name=DebeziumStream
|
||||
debezium.sink.nats-jetstream.subjects=postgres,postgres.>
|
||||
debezium.sink.nats-jetstream.storage=file
|
||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,601 @@
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
:root,
|
||||
[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #f4f1ec;
|
||||
--fg: #1a1d22;
|
||||
--muted: #595f68;
|
||||
--dim: #9299a2;
|
||||
--border: rgba(26, 29, 34, 0.12);
|
||||
--border-soft: rgba(26, 29, 34, 0.06);
|
||||
--field-bg: rgba(26, 29, 34, 0.03);
|
||||
--field-border: rgba(26, 29, 34, 0.18);
|
||||
--btn-bg: rgba(26, 29, 34, 0.05);
|
||||
--btn-bg-hover: rgba(26, 29, 34, 0.09);
|
||||
--btn-border: rgba(26, 29, 34, 0.18);
|
||||
--header-border: rgba(26, 29, 34, 0.12);
|
||||
--link: #2c5a7a;
|
||||
--accent: #2c5a7a;
|
||||
--nav-bg: rgba(244, 241, 236, 0.6);
|
||||
--bg-panel: rgba(255, 253, 250, 0.72);
|
||||
--notice-fg: #2a6e2a;
|
||||
--notice-bg: rgba(42, 110, 42, 0.06);
|
||||
--notice-border: rgba(42, 110, 42, 0.25);
|
||||
--error-fg: #a03030;
|
||||
--error-bg: rgba(160, 48, 48, 0.06);
|
||||
--error-border: rgba(160, 48, 48, 0.25);
|
||||
--toggle-bg: linear-gradient(135deg, #07070d 0%, #64dcff 100%);
|
||||
--toggle-border: rgba(26, 29, 34, 0.2);
|
||||
--toggle-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
[data-theme="tokyo"] {
|
||||
color-scheme: dark;
|
||||
--bg: #07070d;
|
||||
--fg: #e8f4ff;
|
||||
--muted: #a3b3cc;
|
||||
--dim: #7886a0;
|
||||
--border: rgba(100, 220, 255, 0.12);
|
||||
--border-soft: rgba(100, 220, 255, 0.06);
|
||||
--field-bg: rgba(100, 220, 255, 0.04);
|
||||
--field-border: rgba(100, 220, 255, 0.15);
|
||||
--btn-bg: rgba(100, 220, 255, 0.06);
|
||||
--btn-bg-hover: rgba(100, 220, 255, 0.1);
|
||||
--btn-border: rgba(100, 220, 255, 0.18);
|
||||
--header-border: rgba(100, 220, 255, 0.12);
|
||||
--link: #64dcff;
|
||||
--accent: #64dcff;
|
||||
--nav-bg: rgba(7, 7, 13, 0.65);
|
||||
--bg-panel: rgba(15, 16, 28, 0.72);
|
||||
--notice-fg: #8fe08f;
|
||||
--notice-bg: rgba(143, 224, 143, 0.06);
|
||||
--notice-border: rgba(143, 224, 143, 0.25);
|
||||
--error-fg: #ff9b9b;
|
||||
--error-bg: rgba(255, 155, 155, 0.06);
|
||||
--error-border: rgba(255, 155, 155, 0.2);
|
||||
--toggle-bg: #f4f1ec;
|
||||
--toggle-border: rgba(100, 220, 255, 0.4);
|
||||
--toggle-shadow: 0 0 10px rgba(100, 220, 255, 0.5), 0 0 25px rgba(100, 220, 255, 0.3);
|
||||
}
|
||||
|
||||
html, body { max-width: 100%; overflow-x: hidden; }
|
||||
body {
|
||||
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 300;
|
||||
line-height: 1.55;
|
||||
color: var(--fg);
|
||||
background: var(--bg);
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: background 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
header {
|
||||
border-bottom: 1px solid var(--header-border);
|
||||
padding: 1rem 1.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
background: var(--nav-bg);
|
||||
backdrop-filter: blur(12px) saturate(120%);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(120%);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
main {
|
||||
overflow-wrap: break-word;
|
||||
padding: 16px;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.workbench {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(520px, 0.9fr) minmax(460px, 1.1fr);
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.interaction-pane {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
font-weight: 400;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.back-link:hover { color: var(--fg); border-color: var(--muted); background: var(--bg-panel); }
|
||||
|
||||
.brand {
|
||||
justify-self: center;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--fg);
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.brand-sep { font-weight: 300; color: var(--dim); }
|
||||
.brand-sub {
|
||||
font-weight: 400;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
[data-theme="tokyo"] .brand { color: #e8f4ff; text-shadow: 0 0 8px rgba(100, 220, 255, 0.3); }
|
||||
|
||||
footer {
|
||||
padding: 0.9rem 1.5rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.05em;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
background: var(--nav-bg);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
footer a, a { color: var(--link); }
|
||||
|
||||
h1, h2, h3 {
|
||||
font-family: inherit;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
h1 { margin: 0 0 0.9rem; font-size: clamp(1.35rem, 4vw, 2rem); }
|
||||
h2 { margin: 0 0 0.9rem; font-size: 1.1rem; }
|
||||
h3 { margin: 0 0 0.4rem; font-size: 0.9rem; }
|
||||
p { margin: 0 0 0.8rem; }
|
||||
code { font-family: inherit; font-size: 0.86em; }
|
||||
.muted { color: var(--muted); font-size: 0.78rem; }
|
||||
|
||||
.section-heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.inventory-shell,
|
||||
.control-deck,
|
||||
.cdc-pane {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
background: var(--field-bg);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.inventory-shell {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.control-deck {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.inventory-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.inventory-slot {
|
||||
font: inherit;
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
background: var(--bg-panel);
|
||||
color: var(--fg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
min-height: 118px;
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.inventory-slot:hover { background: var(--btn-bg-hover); }
|
||||
.inventory-slot.ok { border-color: var(--notice-border); }
|
||||
.inventory-slot.low { border-color: rgba(185, 126, 0, 0.35); }
|
||||
.inventory-slot.empty { border-color: var(--error-border); }
|
||||
.inventory-slot.selected {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.inventory-slot.changed {
|
||||
background: var(--notice-bg);
|
||||
border-color: var(--notice-border);
|
||||
}
|
||||
.inventory-slot strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.inventory-slot .quantity {
|
||||
display: inline-block;
|
||||
min-width: 1.8ch;
|
||||
font-size: 1.7rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.slot-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.create-slot {
|
||||
border-style: dashed;
|
||||
color: var(--muted);
|
||||
place-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
.create-slot strong {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
border: 1px solid var(--border);
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
min-width: 0;
|
||||
border-radius: 3px;
|
||||
background: var(--field-bg);
|
||||
}
|
||||
legend {
|
||||
font-weight: 500;
|
||||
padding: 0 6px;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
.selected-summary {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
background: var(--bg-panel);
|
||||
padding: 0.9rem 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: 130px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.selected-summary.create-mode {
|
||||
border-style: dashed;
|
||||
}
|
||||
.selected-summary strong {
|
||||
display: block;
|
||||
font-size: 1.05rem;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
.summary-kicker {
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.action-buttons {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
.action-buttons.two-up {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.selected-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.selected-actions fieldset,
|
||||
.create-actions fieldset {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.primary-action-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.primary-stock-action {
|
||||
min-height: 92px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 0.2rem;
|
||||
background: var(--bg-panel);
|
||||
border-color: var(--field-border);
|
||||
}
|
||||
.primary-stock-action span {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.primary-stock-action strong {
|
||||
font-size: 1.9rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.more-actions {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
background: var(--field-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.more-actions > summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
padding: 0.65rem 0.8rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.more-actions > summary::-webkit-details-marker { display: none; }
|
||||
.more-actions > summary::after {
|
||||
content: '+';
|
||||
float: right;
|
||||
color: var(--dim);
|
||||
}
|
||||
.more-actions[open] > summary {
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--fg);
|
||||
}
|
||||
.more-actions[open] > summary::after { content: '-'; }
|
||||
.more-actions-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 170px;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.compact-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.compact-form-grid label:nth-child(3),
|
||||
.compact-form-grid label:nth-child(4) {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.create-actions {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.create-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 110px minmax(0, 1fr) 100px;
|
||||
gap: 8px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.create-form-grid label:nth-child(2) {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.create-form-grid label:nth-child(4) {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.create-form-grid label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
select, input[type=text], input[type=number], input:not([type]) {
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.35rem 0.55rem;
|
||||
border: 1px solid var(--field-border);
|
||||
border-radius: 3px;
|
||||
background: var(--field-bg);
|
||||
color: var(--fg);
|
||||
outline: none;
|
||||
width: 100%;
|
||||
}
|
||||
[data-theme="tokyo"] select,
|
||||
[data-theme="tokyo"] option { background: #0f1018; color: #e8f4ff; }
|
||||
select:focus, input:focus { border-color: var(--accent); }
|
||||
label { display: block; font-size: 0.78rem; color: var(--muted); margin-bottom: 0.75rem; }
|
||||
label input, label select { margin-top: 0.25rem; }
|
||||
|
||||
.edit-actions button,
|
||||
.danger-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
padding: 0.3rem 0.7rem;
|
||||
border: 1px solid var(--btn-border);
|
||||
border-radius: 3px;
|
||||
background: var(--btn-bg);
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
button:hover { background: var(--btn-bg-hover); }
|
||||
button.danger { color: var(--error-fg); border-color: var(--error-border); background: var(--error-bg); }
|
||||
.inline { display: inline-flex; gap: 6px; align-items: center; margin: 0; flex-wrap: wrap; }
|
||||
|
||||
.cdc-pane {
|
||||
position: sticky;
|
||||
top: 72px;
|
||||
max-height: calc(100vh - 96px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.cdc-heading {
|
||||
padding: 1rem 1rem 0;
|
||||
}
|
||||
|
||||
.cdc-heading h2 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.event-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
.detail-panel {
|
||||
grid-column: 1 / -1;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
background: var(--field-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.detail-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
.detail-tab {
|
||||
border: 0;
|
||||
border-right: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
padding: 0.55rem 0.75rem;
|
||||
}
|
||||
.detail-tab.active {
|
||||
background: var(--btn-bg-hover);
|
||||
color: var(--fg);
|
||||
}
|
||||
.detail-view { display: none; padding: 0.75rem; }
|
||||
.detail-view.active { display: block; }
|
||||
pre {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 3px;
|
||||
background: rgba(26, 29, 34, 0.04);
|
||||
margin: 0.8rem 0 0;
|
||||
padding: 0.85rem 0.95rem;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
[data-theme="tokyo"] pre { background: rgba(100, 220, 255, 0.04); }
|
||||
.json-output {
|
||||
max-height: 44vh;
|
||||
margin: 0;
|
||||
}
|
||||
.event-summary p { margin-bottom: 0.35rem; }
|
||||
|
||||
.audit-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.audit-row {
|
||||
display: grid;
|
||||
grid-template-columns: 90px 120px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 3px;
|
||||
padding: 0.55rem 0.65rem;
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
.audit-row span { color: var(--muted); }
|
||||
.audit-row code {
|
||||
white-space: pre-wrap;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--toggle-border);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
z-index: 30;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: var(--toggle-shadow);
|
||||
background: var(--toggle-bg);
|
||||
}
|
||||
.theme-toggle:hover { transform: scale(1.08); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.workbench { grid-template-columns: 1fr; }
|
||||
.cdc-pane {
|
||||
position: static;
|
||||
max-height: none;
|
||||
}
|
||||
.event-grid { grid-template-columns: 1fr; }
|
||||
.selected-actions,
|
||||
.more-actions-panel,
|
||||
.create-form-grid,
|
||||
.compact-form-grid { grid-template-columns: 1fr; }
|
||||
.create-actions { grid-column: auto; }
|
||||
.primary-stock-action { min-height: 72px; }
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
header {
|
||||
grid-template-columns: 1fr;
|
||||
justify-items: start;
|
||||
padding: 0.7rem 0.9rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.brand { justify-self: start; font-size: 0.9rem; }
|
||||
main { padding: 12px; }
|
||||
footer { font-size: 0.62rem; padding: 0.7rem 0.9rem; }
|
||||
.theme-toggle { width: 20px; height: 20px; bottom: 1rem; right: 0.9rem; }
|
||||
.inventory-grid,
|
||||
.primary-action-row,
|
||||
.action-buttons.two-up { grid-template-columns: 1fr; }
|
||||
.inventory-slot { min-height: 98px; }
|
||||
.selected-summary { grid-template-columns: 1fr; gap: 4px; }
|
||||
.section-heading { align-items: flex-start; flex-direction: column; gap: 4px; }
|
||||
.audit-row { grid-template-columns: 1fr; gap: 2px; }
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
(() => {
|
||||
const grid = document.getElementById('inventory-grid');
|
||||
const note = document.getElementById('inventory-note');
|
||||
const selected = document.getElementById('selected-item');
|
||||
const createForm = document.getElementById('create-form');
|
||||
const editForm = document.getElementById('edit-form');
|
||||
const sellButton = document.getElementById('sell-one');
|
||||
const restockButton = document.getElementById('restock-five');
|
||||
const deleteButton = document.getElementById('delete-item');
|
||||
const commandJSON = document.getElementById('command-json');
|
||||
const rawJSON = document.getElementById('raw-json');
|
||||
const eventSummary = document.getElementById('event-summary');
|
||||
const auditList = document.getElementById('audit-list');
|
||||
const slotNote = document.getElementById('slot-note');
|
||||
const selectedSummary = document.getElementById('selected-summary');
|
||||
const selectedName = document.getElementById('selected-name');
|
||||
const selectedMeta = document.getElementById('selected-meta');
|
||||
const moreActions = document.querySelector('.more-actions');
|
||||
const detailTabs = Array.from(document.querySelectorAll('[data-detail-tab]'));
|
||||
const detailViews = Array.from(document.querySelectorAll('[data-detail-view]'));
|
||||
|
||||
const maxInventoryItems = 4;
|
||||
let inventory = [];
|
||||
let lastChangedID = null;
|
||||
let preferredSelectedID = '';
|
||||
let mode = 'product';
|
||||
|
||||
function pretty(value) {
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function setCommand(payload) {
|
||||
commandJSON.textContent = pretty(payload);
|
||||
}
|
||||
|
||||
function stockClass(quantity) {
|
||||
if (quantity <= 0) return 'empty';
|
||||
if (quantity <= 5) return 'low';
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
function stockLabel(quantity) {
|
||||
if (quantity <= 0) return 'empty';
|
||||
if (quantity <= 5) return 'low stock';
|
||||
return 'in stock';
|
||||
}
|
||||
|
||||
function renderInventory(items) {
|
||||
const previousSelectedID = selected.value;
|
||||
inventory = Array.isArray(items) ? items : [];
|
||||
grid.innerHTML = '';
|
||||
selected.innerHTML = '';
|
||||
|
||||
if (!inventory.length) {
|
||||
selected.innerHTML = '<option value="">no rows</option>';
|
||||
for (let i = 0; i < maxInventoryItems; i += 1) renderEmptySlot();
|
||||
mode = 'create';
|
||||
updateActionPane();
|
||||
note.textContent = `0 / ${maxInventoryItems} products`;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of inventory) {
|
||||
const row = document.createElement('button');
|
||||
row.type = 'button';
|
||||
row.className = `inventory-slot ${stockClass(item.quantity)}`;
|
||||
row.dataset.id = item.id;
|
||||
row.innerHTML = `
|
||||
<span class="slot-top"><span>${escapeHTML(item.sku)}</span><span>${stockLabel(item.quantity)}</span></span>
|
||||
<strong>${escapeHTML(item.name)}</strong>
|
||||
<span><span class="quantity">${item.quantity}</span> / ${escapeHTML(item.location)}</span>
|
||||
`;
|
||||
row.addEventListener('click', () => {
|
||||
mode = 'product';
|
||||
selected.value = String(item.id);
|
||||
if (moreActions) moreActions.open = false;
|
||||
updateActionPane();
|
||||
});
|
||||
grid.appendChild(row);
|
||||
|
||||
const option = document.createElement('option');
|
||||
option.value = item.id;
|
||||
option.textContent = `${item.sku} / ${item.name}`;
|
||||
selected.appendChild(option);
|
||||
}
|
||||
|
||||
for (let i = inventory.length; i < maxInventoryItems; i += 1) {
|
||||
renderEmptySlot();
|
||||
}
|
||||
|
||||
if (preferredSelectedID && inventory.some((item) => String(item.id) === preferredSelectedID)) {
|
||||
selected.value = preferredSelectedID;
|
||||
preferredSelectedID = '';
|
||||
mode = 'product';
|
||||
} else if (previousSelectedID && inventory.some((item) => String(item.id) === previousSelectedID)) {
|
||||
selected.value = previousSelectedID;
|
||||
mode = 'product';
|
||||
} else if (mode !== 'create') {
|
||||
selected.value = String(inventory[0].id);
|
||||
mode = 'product';
|
||||
}
|
||||
|
||||
updateActionPane();
|
||||
note.textContent = `${inventory.length} / ${maxInventoryItems} products`;
|
||||
}
|
||||
|
||||
function renderEmptySlot() {
|
||||
const empty = document.createElement('button');
|
||||
empty.type = 'button';
|
||||
empty.className = 'inventory-slot create-slot';
|
||||
empty.innerHTML = '<strong>+ Create product</strong><span>empty slot</span>';
|
||||
empty.addEventListener('click', () => {
|
||||
mode = 'create';
|
||||
selected.value = '';
|
||||
if (moreActions) moreActions.open = false;
|
||||
updateActionPane();
|
||||
});
|
||||
grid.appendChild(empty);
|
||||
}
|
||||
|
||||
function updateActionPane() {
|
||||
const item = inventory.find((row) => String(row.id) === selected.value);
|
||||
const hasSpace = inventory.length < maxInventoryItems;
|
||||
const createMode = mode === 'create';
|
||||
|
||||
document.querySelectorAll('.inventory-slot[data-id]').forEach((row) => {
|
||||
row.classList.toggle('selected', !createMode && row.dataset.id === selected.value);
|
||||
row.classList.toggle('changed', row.dataset.id === String(lastChangedID || ''));
|
||||
});
|
||||
document.querySelectorAll('.create-slot').forEach((row) => {
|
||||
row.classList.toggle('selected', createMode);
|
||||
});
|
||||
|
||||
selectedSummary.classList.toggle('create-mode', createMode);
|
||||
editForm.hidden = createMode || !item;
|
||||
createForm.hidden = !createMode;
|
||||
|
||||
if (createMode) {
|
||||
slotNote.textContent = hasSpace ? `${inventory.length} / ${maxInventoryItems} products. Create fills the empty slot.` : `${maxInventoryItems} / ${maxInventoryItems} products. Delete one before creating.`;
|
||||
selectedName.textContent = hasSpace ? 'Empty slot' : 'Slots full';
|
||||
selectedMeta.textContent = hasSpace ? 'Create a product in the available slot.' : 'No empty inventory slots are available.';
|
||||
if (moreActions) moreActions.open = false;
|
||||
createForm.querySelector('button[type="submit"]').disabled = !hasSpace;
|
||||
return;
|
||||
}
|
||||
|
||||
createForm.querySelector('button[type="submit"]').disabled = !hasSpace;
|
||||
slotNote.textContent = `${inventory.length} / ${maxInventoryItems} products.`;
|
||||
|
||||
if (!item) {
|
||||
selectedName.textContent = 'No product selected';
|
||||
selectedMeta.textContent = 'Select a product slot.';
|
||||
return;
|
||||
}
|
||||
|
||||
selectedName.textContent = `${item.sku} / ${item.name}`;
|
||||
selectedMeta.textContent = `quantity ${item.quantity} · ${item.location}`;
|
||||
}
|
||||
|
||||
function selectCreatedOrChanged(event) {
|
||||
const id = changedInventoryID(event);
|
||||
if (!id) return;
|
||||
preferredSelectedID = String(id);
|
||||
mode = event.op === 'd' ? 'product' : 'product';
|
||||
}
|
||||
|
||||
async function loadInitialInventory() {
|
||||
const response = await fetch('/api/inventory', { headers: { Accept: 'application/json' } });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const rows = await response.json();
|
||||
renderInventory(rows);
|
||||
}
|
||||
|
||||
async function sendCommand(method, url, body, label) {
|
||||
setCommand({ command: label, method, url, body: body || null, note: 'UI waits for CDC event before changing inventory rows.' });
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: body ? { 'Content-Type': 'application/json', Accept: 'application/json' } : { Accept: 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
setCommand({ ...payload, method, url, request: body || null, waiting_for: 'Debezium -> NATS JetStream -> SSE' });
|
||||
return response.ok;
|
||||
}
|
||||
|
||||
function selectedID() {
|
||||
return selected.value;
|
||||
}
|
||||
|
||||
createForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(createForm);
|
||||
const ok = await sendCommand('POST', '/api/inventory', {
|
||||
sku: String(data.get('sku')).trim(),
|
||||
name: String(data.get('name')).trim(),
|
||||
quantity: Number(data.get('quantity')),
|
||||
location: String(data.get('location')).trim(),
|
||||
}, 'CREATE inventory item');
|
||||
if (ok) createForm.reset();
|
||||
});
|
||||
|
||||
editForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
if (!selectedID()) return;
|
||||
const data = new FormData(editForm);
|
||||
const body = {};
|
||||
const name = String(data.get('name')).trim();
|
||||
const location = String(data.get('location')).trim();
|
||||
if (name) body.name = name;
|
||||
if (location) body.location = location;
|
||||
await sendCommand('PATCH', `/api/inventory/${selectedID()}`, body, 'UPDATE inventory name/location');
|
||||
editForm.elements.name.value = '';
|
||||
editForm.elements.location.value = '';
|
||||
if (moreActions) moreActions.open = false;
|
||||
});
|
||||
|
||||
sellButton.addEventListener('click', async () => {
|
||||
if (!selectedID()) return;
|
||||
await sendCommand('PATCH', `/api/inventory/${selectedID()}/sell`, null, 'UPDATE inventory quantity -1');
|
||||
});
|
||||
|
||||
restockButton.addEventListener('click', async () => {
|
||||
if (!selectedID()) return;
|
||||
await sendCommand('PATCH', `/api/inventory/${selectedID()}/restock`, { amount: 5 }, 'UPDATE inventory quantity +5');
|
||||
});
|
||||
|
||||
deleteButton.addEventListener('click', async () => {
|
||||
if (!selectedID()) return;
|
||||
await sendCommand('DELETE', `/api/inventory/${selectedID()}`, null, 'DELETE inventory item');
|
||||
if (moreActions) moreActions.open = false;
|
||||
});
|
||||
|
||||
function connectEvents() {
|
||||
const events = new EventSource('/events');
|
||||
|
||||
events.addEventListener('cdc', (message) => {
|
||||
const event = JSON.parse(message.data);
|
||||
lastChangedID = changedInventoryID(event);
|
||||
selectCreatedOrChanged(event);
|
||||
renderInventory(event.inventory);
|
||||
renderEvent(event);
|
||||
renderAudit(event);
|
||||
rawJSON.textContent = pretty(event.raw || {});
|
||||
});
|
||||
}
|
||||
|
||||
function changedInventoryID(event) {
|
||||
if (event.after && event.after.id) return event.after.id;
|
||||
if (event.before && event.before.id) return event.before.id;
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderEvent(event) {
|
||||
const changes = diff(event.before, event.after);
|
||||
eventSummary.innerHTML = `
|
||||
<p><strong>CDC event received:</strong> ${escapeHTML(event.crud)}</p>
|
||||
<p><strong>source:</strong> ${escapeHTML(event.source)}</p>
|
||||
<pre><code>${escapeHTML(changes || 'No before/after field changes available.')}</code></pre>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAudit(event) {
|
||||
if (auditList.querySelector('.muted')) auditList.innerHTML = '';
|
||||
const row = document.createElement('article');
|
||||
row.className = 'audit-row';
|
||||
row.innerHTML = `
|
||||
<strong>${escapeHTML(event.crud)}</strong>
|
||||
<span>${escapeHTML(event.table)}</span>
|
||||
<code>${escapeHTML(diff(event.before, event.after) || 'snapshot/delete event')}</code>
|
||||
`;
|
||||
auditList.prepend(row);
|
||||
while (auditList.children.length > 12) auditList.lastElementChild.remove();
|
||||
}
|
||||
|
||||
function diff(before, after) {
|
||||
if (!before && !after) return '';
|
||||
if (!before && after) return Object.entries(after).map(([key, value]) => `${key}: ${value}`).join('\n');
|
||||
if (before && !after) return Object.entries(before).map(([key, value]) => `${key}: ${value} -> deleted`).join('\n');
|
||||
|
||||
const lines = [];
|
||||
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
||||
for (const key of keys) {
|
||||
if (JSON.stringify(before[key]) !== JSON.stringify(after[key])) {
|
||||
lines.push(`${key}: ${before[key]} -> ${after[key]}`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function escapeHTML(value) {
|
||||
return String(value).replace(/[&<>"']/g, (char) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}[char]));
|
||||
}
|
||||
|
||||
selected.addEventListener('change', () => {
|
||||
mode = 'product';
|
||||
if (moreActions) moreActions.open = false;
|
||||
updateActionPane();
|
||||
});
|
||||
detailTabs.forEach((tab) => {
|
||||
tab.addEventListener('click', () => {
|
||||
const name = tab.dataset.detailTab;
|
||||
detailTabs.forEach((item) => item.classList.toggle('active', item === tab));
|
||||
detailViews.forEach((view) => view.classList.toggle('active', view.dataset.detailView === name));
|
||||
});
|
||||
});
|
||||
|
||||
loadInitialInventory().catch((err) => {
|
||||
note.textContent = `Could not load inventory: ${err.message}`;
|
||||
});
|
||||
connectEvents();
|
||||
})();
|
||||
@@ -0,0 +1,23 @@
|
||||
(function () {
|
||||
function currentSystemTheme() {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'tokyo' : 'light';
|
||||
}
|
||||
|
||||
var btn = document.getElementById('theme-toggle');
|
||||
if (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var current = document.documentElement.getAttribute('data-theme') || 'light';
|
||||
var next = current === 'light' ? 'tokyo' : 'light';
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
localStorage.setItem('poc-theme', next);
|
||||
window.dispatchEvent(new Event('theme-change'));
|
||||
});
|
||||
}
|
||||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
|
||||
if (!localStorage.getItem('poc-theme')) {
|
||||
document.documentElement.setAttribute('data-theme', currentSystemTheme());
|
||||
window.dispatchEvent(new Event('theme-change'));
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,138 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Live Inventory CDC Demo — FLÓ</title>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var stored = localStorage.getItem('poc-theme');
|
||||
var theme = stored || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'tokyo' : 'light');
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<a href="https://fló.fo" class="back-link">FLÓ.FO</a>
|
||||
<span class="brand">FLÓ <span class="brand-sep">/</span> <span class="brand-sub">CDC POC</span></span>
|
||||
</header>
|
||||
|
||||
<main id="main" class="workbench">
|
||||
<section class="interaction-pane">
|
||||
<section class="inventory-shell">
|
||||
<div class="section-heading">
|
||||
<h2>Inventory</h2>
|
||||
<span id="inventory-note" class="muted">Loading products...</span>
|
||||
</div>
|
||||
<div id="inventory-grid" class="inventory-grid"></div>
|
||||
</section>
|
||||
|
||||
<section class="control-deck">
|
||||
<select id="selected-item" name="id" class="visually-hidden" aria-label="Selected item"></select>
|
||||
|
||||
<section id="selected-summary" class="selected-summary">
|
||||
<span class="summary-kicker">Active product</span>
|
||||
<div>
|
||||
<strong id="selected-name">No product selected</strong>
|
||||
<span id="selected-meta" class="muted">Pick a product above.</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form id="edit-form" class="action-form selected-actions">
|
||||
<div class="primary-action-row">
|
||||
<button type="button" id="sell-one" class="primary-stock-action">
|
||||
<span>Sell</span>
|
||||
<strong>1</strong>
|
||||
</button>
|
||||
<button type="button" id="restock-five" class="primary-stock-action">
|
||||
<span>Restock</span>
|
||||
<strong>5</strong>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<details class="more-actions">
|
||||
<summary>More actions</summary>
|
||||
<div class="more-actions-panel">
|
||||
<fieldset class="edit-actions">
|
||||
<legend>Rename or move</legend>
|
||||
<div class="compact-form-grid">
|
||||
<label>Name <input name="name" placeholder="leave unchanged"></label>
|
||||
<label>Location <input name="location" placeholder="leave unchanged"></label>
|
||||
</div>
|
||||
<button type="submit">Save changes</button>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="danger-actions">
|
||||
<legend>Remove</legend>
|
||||
<button type="button" id="delete-item" class="danger">Delete product</button>
|
||||
</fieldset>
|
||||
</div>
|
||||
</details>
|
||||
</form>
|
||||
|
||||
<form id="create-form" class="action-form create-actions">
|
||||
<fieldset>
|
||||
<legend>Create product</legend>
|
||||
<p id="slot-note" class="muted">Create a product in the selected empty slot.</p>
|
||||
<div class="create-form-grid">
|
||||
<label>SKU <input name="sku" value="FL-440" required></label>
|
||||
<label>Name <input name="name" value="USB barcode scanner" required></label>
|
||||
<label>Quantity <input name="quantity" type="number" value="4" min="0" required></label>
|
||||
<label>Location <input name="location" value="Torshavn" required></label>
|
||||
<button type="submit">Create product</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<aside class="cdc-pane" aria-label="CDC trace">
|
||||
<div class="section-heading cdc-heading">
|
||||
<h2>CDC trace</h2>
|
||||
<span class="muted">Debezium -> NATS -> browser</span>
|
||||
</div>
|
||||
|
||||
<section class="event-grid">
|
||||
<fieldset>
|
||||
<legend>Command sent</legend>
|
||||
<pre><code id="command-json">No command sent yet.</code></pre>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>CDC event received</legend>
|
||||
<div id="event-summary" class="event-summary">Waiting for Debezium/NATS event...</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="detail-panel">
|
||||
<div class="detail-tabs" role="tablist" aria-label="CDC details">
|
||||
<button type="button" class="detail-tab active" data-detail-tab="raw">Raw JSON</button>
|
||||
<button type="button" class="detail-tab" data-detail-tab="audit">Audit trail</button>
|
||||
</div>
|
||||
<div class="detail-view active" data-detail-view="raw">
|
||||
<pre class="json-output"><code id="raw-json">{}</code></pre>
|
||||
</div>
|
||||
<div class="detail-view" data-detail-view="audit">
|
||||
<div id="audit-list" class="audit-list">
|
||||
<p class="muted">CDC events will appear here.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<span>© <script>document.write(new Date().getFullYear())</script> <a href="https://fló.fo">FLÓ.FO</a> | {{ .Version }}</span>
|
||||
</footer>
|
||||
|
||||
<button class="theme-toggle" id="theme-toggle" aria-label="Toggle theme"></button>
|
||||
|
||||
<script src="/static/js/theme.js"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,631 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
//go:embed internal/web/templates/index.html
|
||||
var templateFS embed.FS
|
||||
|
||||
//go:embed internal/web/static
|
||||
var staticFS embed.FS
|
||||
|
||||
type Config struct {
|
||||
HTTPAddr string
|
||||
DatabaseURL string
|
||||
NATSURL string
|
||||
NATSSubject string
|
||||
}
|
||||
|
||||
const maxInventoryItems = 4
|
||||
|
||||
type InventoryItem struct {
|
||||
ID int `json:"id"`
|
||||
SKU string `json:"sku"`
|
||||
Name string `json:"name"`
|
||||
Quantity int `json:"quantity"`
|
||||
Location string `json:"location"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type AcceptedResponse struct {
|
||||
Status string `json:"status"`
|
||||
CRUD string `json:"crud"`
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
type CDCEvent struct {
|
||||
CRUD string `json:"crud"`
|
||||
Op string `json:"op"`
|
||||
Table string `json:"table"`
|
||||
Before any `json:"before"`
|
||||
After any `json:"after"`
|
||||
Raw json.RawMessage `json:"raw"`
|
||||
Inventory []InventoryItem `json:"inventory"`
|
||||
Received time.Time `json:"received_at"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
db *pgxpool.Pool
|
||||
broker *Broker
|
||||
templates *template.Template
|
||||
}
|
||||
|
||||
type Broker struct {
|
||||
mu sync.Mutex
|
||||
clients map[chan CDCEvent]struct{}
|
||||
last *CDCEvent
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg := loadConfig()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
db, err := connectDB(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
log.Fatalf("connect postgres: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
tpl, err := template.ParseFS(templateFS, "internal/web/templates/index.html")
|
||||
if err != nil {
|
||||
log.Fatalf("parse templates: %v", err)
|
||||
}
|
||||
|
||||
server := &Server{
|
||||
db: db,
|
||||
broker: NewBroker(),
|
||||
templates: tpl,
|
||||
}
|
||||
|
||||
go consumeNATS(ctx, cfg, db, server.broker)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
server.routes(mux)
|
||||
|
||||
httpServer := &http.Server{
|
||||
Addr: cfg.HTTPAddr,
|
||||
Handler: logRequests(mux),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = httpServer.Shutdown(shutdownCtx)
|
||||
}()
|
||||
|
||||
log.Printf("starting live inventory CDC demo on %s", cfg.HTTPAddr)
|
||||
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("http server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfig() Config {
|
||||
return Config{
|
||||
HTTPAddr: env("HTTP_ADDR", ":8080"),
|
||||
DatabaseURL: env("DATABASE_URL", "postgres://postgres:postgres@localhost:5453/postgres?sslmode=disable"),
|
||||
NATSURL: env("NATS_URL", "nats://localhost:4222"),
|
||||
NATSSubject: env("NATS_SUBJECT", "postgres.public.inventory"),
|
||||
}
|
||||
}
|
||||
|
||||
func env(key, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func connectDB(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= 30; attempt++ {
|
||||
db, err := pgxpool.New(ctx, databaseURL)
|
||||
if err == nil {
|
||||
pingCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
err = db.Ping(pingCtx)
|
||||
cancel()
|
||||
if err == nil {
|
||||
return db, nil
|
||||
}
|
||||
db.Close()
|
||||
}
|
||||
lastErr = err
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(time.Duration(attempt) * 250 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (s *Server) routes(mux *http.ServeMux) {
|
||||
static, err := fs.Sub(staticFS, "internal/web/static")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(static)))
|
||||
mux.HandleFunc("GET /", s.handleIndex)
|
||||
mux.HandleFunc("GET /health", s.handleHealth)
|
||||
mux.HandleFunc("GET /events", s.handleEvents)
|
||||
mux.HandleFunc("GET /api/inventory", s.handleListInventory)
|
||||
mux.HandleFunc("POST /api/inventory", s.handleCreateInventory)
|
||||
mux.HandleFunc("PATCH /api/inventory/{id}", s.handleUpdateInventory)
|
||||
mux.HandleFunc("PATCH /api/inventory/{id}/sell", s.handleSellInventory)
|
||||
mux.HandleFunc("PATCH /api/inventory/{id}/restock", s.handleRestockInventory)
|
||||
mux.HandleFunc("DELETE /api/inventory/{id}", s.handleDeleteInventory)
|
||||
}
|
||||
|
||||
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
data := map[string]string{"Version": "dev"}
|
||||
if err := s.templates.ExecuteTemplate(w, "index.html", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
client := s.broker.Subscribe()
|
||||
defer s.broker.Unsubscribe(client)
|
||||
|
||||
if last := s.broker.Last(); last != nil {
|
||||
writeSSE(w, *last)
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(20 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case event := <-client:
|
||||
writeSSE(w, event)
|
||||
flusher.Flush()
|
||||
case <-ticker.C:
|
||||
_, _ = fmt.Fprint(w, ": keepalive\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleListInventory(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := listInventory(r.Context(), s.db)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, items)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateInventory(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
SKU string `json:"sku"`
|
||||
Name string `json:"name"`
|
||||
Quantity int `json:"quantity"`
|
||||
Location string `json:"location"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
req.SKU = strings.TrimSpace(req.SKU)
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
req.Location = strings.TrimSpace(req.Location)
|
||||
if req.SKU == "" || req.Name == "" || req.Location == "" {
|
||||
writeError(w, http.StatusBadRequest, errors.New("sku, name, and location are required"))
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(r.Context(), pgx.TxOptions{IsoLevel: pgx.Serializable})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
|
||||
if _, err := tx.Exec(r.Context(), `lock table inventory in exclusive mode`); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := tx.QueryRow(r.Context(), `select count(*) from inventory`).Scan(&count); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if count >= maxInventoryItems {
|
||||
writeError(w, http.StatusConflict, fmt.Errorf("inventory is limited to %d products", maxInventoryItems))
|
||||
return
|
||||
}
|
||||
|
||||
_, err = tx.Exec(r.Context(), `
|
||||
insert into inventory (sku, name, quantity, location, updated_at)
|
||||
values ($1, $2, $3, $4, now())`,
|
||||
req.SKU, req.Name, req.Quantity, req.Location,
|
||||
)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, AcceptedResponse{
|
||||
Status: "command accepted",
|
||||
CRUD: "CREATE",
|
||||
Command: fmt.Sprintf("CREATE inventory %s", req.SKU),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSellInventory(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_, err := s.db.Exec(r.Context(), `
|
||||
update inventory
|
||||
set quantity = greatest(quantity - 1, 0), updated_at = now()
|
||||
where id = $1`,
|
||||
id,
|
||||
)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, AcceptedResponse{
|
||||
Status: "command accepted",
|
||||
CRUD: "UPDATE",
|
||||
Command: "UPDATE inventory quantity -1",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleRestockInventory(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Amount int `json:"amount"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if req.Amount <= 0 {
|
||||
req.Amount = 5
|
||||
}
|
||||
_, err := s.db.Exec(r.Context(), `
|
||||
update inventory
|
||||
set quantity = quantity + $2, updated_at = now()
|
||||
where id = $1`,
|
||||
id, req.Amount,
|
||||
)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, AcceptedResponse{
|
||||
Status: "command accepted",
|
||||
CRUD: "UPDATE",
|
||||
Command: fmt.Sprintf("UPDATE inventory quantity +%d", req.Amount),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateInventory(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name *string `json:"name"`
|
||||
Location *string `json:"location"`
|
||||
Quantity *int `json:"quantity"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(r.Context(), pgx.TxOptions{})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
|
||||
current := InventoryItem{}
|
||||
err = tx.QueryRow(r.Context(), `
|
||||
select id, sku, name, quantity, location, updated_at
|
||||
from inventory
|
||||
where id = $1
|
||||
for update`, id,
|
||||
).Scan(¤t.ID, ¤t.SKU, ¤t.Name, ¤t.Quantity, ¤t.Location, ¤t.UpdatedAt)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name != nil && strings.TrimSpace(*req.Name) != "" {
|
||||
current.Name = strings.TrimSpace(*req.Name)
|
||||
}
|
||||
if req.Location != nil && strings.TrimSpace(*req.Location) != "" {
|
||||
current.Location = strings.TrimSpace(*req.Location)
|
||||
}
|
||||
if req.Quantity != nil {
|
||||
current.Quantity = *req.Quantity
|
||||
}
|
||||
|
||||
_, err = tx.Exec(r.Context(), `
|
||||
update inventory
|
||||
set name = $2, quantity = $3, location = $4, updated_at = now()
|
||||
where id = $1`,
|
||||
current.ID, current.Name, current.Quantity, current.Location,
|
||||
)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, AcceptedResponse{
|
||||
Status: "command accepted",
|
||||
CRUD: "UPDATE",
|
||||
Command: fmt.Sprintf("UPDATE inventory %s", current.SKU),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteInventory(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_, err := s.db.Exec(r.Context(), `delete from inventory where id = $1`, id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, AcceptedResponse{
|
||||
Status: "command accepted",
|
||||
CRUD: "DELETE",
|
||||
Command: fmt.Sprintf("DELETE inventory id=%d", id),
|
||||
})
|
||||
}
|
||||
|
||||
func pathID(w http.ResponseWriter, r *http.Request) (int, bool) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil || id <= 0 {
|
||||
writeError(w, http.StatusBadRequest, errors.New("valid numeric id is required"))
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func consumeNATS(ctx context.Context, cfg Config, db *pgxpool.Pool, broker *Broker) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
nc, err := nats.Connect(
|
||||
cfg.NATSURL,
|
||||
nats.Name("live-inventory-cdc-demo"),
|
||||
nats.RetryOnFailedConnect(true),
|
||||
nats.MaxReconnects(-1),
|
||||
nats.ReconnectWait(2*time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("connect nats: %v", err)
|
||||
sleepOrDone(ctx, 2*time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
sub, err := nc.Subscribe(cfg.NATSSubject, func(msg *nats.Msg) {
|
||||
event, err := normalizeCDC(ctx, db, msg.Data)
|
||||
if err != nil {
|
||||
log.Printf("normalize cdc event: %v", err)
|
||||
return
|
||||
}
|
||||
broker.Publish(event)
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("subscribe nats: %v", err)
|
||||
nc.Close()
|
||||
sleepOrDone(ctx, 2*time.Second)
|
||||
continue
|
||||
}
|
||||
log.Printf("subscribed to nats subject %s", cfg.NATSSubject)
|
||||
|
||||
<-ctx.Done()
|
||||
_ = sub.Unsubscribe()
|
||||
nc.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCDC(ctx context.Context, db *pgxpool.Pool, payload []byte) (CDCEvent, error) {
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(payload, &raw); err != nil {
|
||||
return CDCEvent{}, err
|
||||
}
|
||||
|
||||
op, _ := raw["op"].(string)
|
||||
table := ""
|
||||
if source, ok := raw["source"].(map[string]any); ok {
|
||||
table, _ = source["table"].(string)
|
||||
}
|
||||
if table == "" {
|
||||
table = "inventory"
|
||||
}
|
||||
|
||||
items, err := listInventory(ctx, db)
|
||||
if err != nil {
|
||||
return CDCEvent{}, err
|
||||
}
|
||||
|
||||
return CDCEvent{
|
||||
CRUD: crudLabel(op),
|
||||
Op: op,
|
||||
Table: table,
|
||||
Before: raw["before"],
|
||||
After: raw["after"],
|
||||
Raw: append(json.RawMessage(nil), payload...),
|
||||
Inventory: items,
|
||||
Received: time.Now().UTC(),
|
||||
Source: "Debezium -> NATS JetStream -> SSE",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func crudLabel(op string) string {
|
||||
switch op {
|
||||
case "c":
|
||||
return "CREATE"
|
||||
case "r":
|
||||
return "SNAPSHOT"
|
||||
case "u":
|
||||
return "UPDATE"
|
||||
case "d":
|
||||
return "DELETE"
|
||||
default:
|
||||
return strings.ToUpper(op)
|
||||
}
|
||||
}
|
||||
|
||||
func listInventory(ctx context.Context, db *pgxpool.Pool) ([]InventoryItem, error) {
|
||||
rows, err := db.Query(ctx, `
|
||||
select id, sku, name, quantity, location, updated_at
|
||||
from inventory
|
||||
order by id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]InventoryItem, 0)
|
||||
for rows.Next() {
|
||||
var item InventoryItem
|
||||
if err := rows.Scan(&item.ID, &item.SKU, &item.Name, &item.Quantity, &item.Location, &item.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func NewBroker() *Broker {
|
||||
return &Broker{clients: make(map[chan CDCEvent]struct{})}
|
||||
}
|
||||
|
||||
func (b *Broker) Subscribe() chan CDCEvent {
|
||||
ch := make(chan CDCEvent, 8)
|
||||
b.mu.Lock()
|
||||
b.clients[ch] = struct{}{}
|
||||
b.mu.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
func (b *Broker) Unsubscribe(ch chan CDCEvent) {
|
||||
b.mu.Lock()
|
||||
delete(b.clients, ch)
|
||||
close(ch)
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
func (b *Broker) Publish(event CDCEvent) {
|
||||
b.mu.Lock()
|
||||
b.last = &event
|
||||
for ch := range b.clients {
|
||||
select {
|
||||
case ch <- event:
|
||||
default:
|
||||
}
|
||||
}
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
func (b *Broker) Last() *CDCEvent {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.last == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *b.last
|
||||
return ©
|
||||
}
|
||||
|
||||
func writeSSE(w http.ResponseWriter, event CDCEvent) {
|
||||
data, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, "event: cdc\n")
|
||||
_, _ = fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, err error) {
|
||||
writeJSON(w, status, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
func sleepOrDone(ctx context.Context, d time.Duration) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-time.After(d):
|
||||
}
|
||||
}
|
||||
|
||||
func logRequests(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
|
||||
})
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18-alpine
|
||||
container_name: cdc_postgres
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
- wal_level=logical
|
||||
- -c
|
||||
- max_wal_senders=10
|
||||
- -c
|
||||
- max_replication_slots=10
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: postgres
|
||||
ports:
|
||||
- "5453:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql
|
||||
- ./postgres/init:/docker-entrypoint-initdb.d:ro,Z
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
nats:
|
||||
image: nats:2
|
||||
container_name: cdc_nats
|
||||
command:
|
||||
- "--debug"
|
||||
- "--http_port=8222"
|
||||
- "--js"
|
||||
- "--store_dir=/data"
|
||||
ports:
|
||||
- "4222:4222"
|
||||
- "8222:8222"
|
||||
volumes:
|
||||
- nats_data:/data
|
||||
|
||||
debezium:
|
||||
image: quay.io/debezium/server:3.5.2.Final
|
||||
container_name: cdc_debezium
|
||||
volumes:
|
||||
- ./application.properties:/debezium/config/application.properties:ro,Z
|
||||
- debezium_data:/debezium/data
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
nats:
|
||||
condition: service_started
|
||||
|
||||
seed:
|
||||
image: postgres:18-alpine
|
||||
container_name: cdc_postgres_seed
|
||||
profiles: ["tools"]
|
||||
environment:
|
||||
PGHOST: postgres
|
||||
PGUSER: postgres
|
||||
PGPASSWORD: postgres
|
||||
PGDATABASE: postgres
|
||||
volumes:
|
||||
- ./scripts:/scripts:ro,Z
|
||||
entrypoint: ["sh", "/scripts/seed.sh"]
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
debezium:
|
||||
condition: service_started
|
||||
|
||||
nats-cli:
|
||||
image: natsio/nats-box:latest
|
||||
container_name: cdc_nats_cli
|
||||
profiles: ["tools"]
|
||||
environment:
|
||||
NATS_URL: nats://nats:4222
|
||||
volumes:
|
||||
- ./scripts:/scripts:ro,Z
|
||||
entrypoint: ["sh"]
|
||||
depends_on:
|
||||
nats:
|
||||
condition: service_started
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
nats_data:
|
||||
debezium_data:
|
||||
@@ -0,0 +1,143 @@
|
||||
module live_inventory_cdc_demo
|
||||
|
||||
go 1.25.10
|
||||
|
||||
require (
|
||||
github.com/jackc/pgx/v5 v5.7.2
|
||||
github.com/nats-io/nats.go v1.39.1
|
||||
)
|
||||
|
||||
require (
|
||||
cel.dev/expr v0.25.2 // indirect
|
||||
charm.land/bubbles/v2 v2.1.0 // indirect
|
||||
charm.land/bubbletea/v2 v2.0.7 // indirect
|
||||
charm.land/lipgloss/v2 v2.0.4 // indirect
|
||||
cloud.google.com/go v0.123.0 // indirect
|
||||
cloud.google.com/go/auth v0.20.0 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
cloud.google.com/go/iam v1.11.0 // indirect
|
||||
cloud.google.com/go/monitoring v1.29.0 // indirect
|
||||
cloud.google.com/go/storage v1.63.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect
|
||||
github.com/Ladicle/tabwriter v1.0.0 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.5.0 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.27.0 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.42.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.26 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.25 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.30 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.2.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.31.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.43.4 // indirect
|
||||
github.com/aws/smithy-go v1.27.3 // indirect
|
||||
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/chainguard-dev/git-urls v1.0.2 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260622092850-f39628c8a989 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.7 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.2.2 // indirect
|
||||
github.com/dominikbraun/graph v0.23.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
|
||||
github.com/fatih/color v1.19.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.1.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/go-task/task/v3 v3.52.0 // indirect
|
||||
github.com/go-task/template v0.2.0 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.17 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
|
||||
github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.73 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-getter v1.8.6 // indirect
|
||||
github.com/hashicorp/go-version v1.9.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/klauspost/compress v1.18.7 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
|
||||
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.24 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/nats-io/nkeys v0.4.9 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.27 // indirect
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20250313105119-ba97887b0a25 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/puzpuzpuz/xsync/v4 v4.5.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sajari/fuzzy v1.0.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect
|
||||
github.com/stretchr/objx v0.5.3 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/u-root/u-root v0.16.0 // indirect
|
||||
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect
|
||||
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/zeebo/xxh3 v1.1.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
|
||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/term v0.44.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
google.golang.org/api v0.287.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20260630182238-925bb5da69e7 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 // indirect
|
||||
google.golang.org/grpc v1.82.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
mvdan.cc/sh/moreinterp v0.0.0-20260120230322-19def062a997 // indirect
|
||||
mvdan.cc/sh/v3 v3.13.2-0.20260613075524-2255122b577b // indirect
|
||||
)
|
||||
|
||||
tool github.com/go-task/task/v3/cmd/task
|
||||
@@ -0,0 +1,326 @@
|
||||
cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs=
|
||||
cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
|
||||
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
|
||||
charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0=
|
||||
charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs=
|
||||
charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q=
|
||||
charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik=
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
|
||||
cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM=
|
||||
cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4=
|
||||
cloud.google.com/go/logging v1.18.0 h1:KhzZq+1cSkPH9YUaKLLhLtQxIHitVayBmk0sGfoM9+k=
|
||||
cloud.google.com/go/logging v1.18.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI=
|
||||
cloud.google.com/go/longrunning v1.1.0 h1:qJ0R0IA8ONaRCNWTRPAS0iAmt1bj3TVgJ40z7ZGRslE=
|
||||
cloud.google.com/go/longrunning v1.1.0/go.mod h1:tH+A/6UvNypiPJWAQaKCsh+xiGbB23wUO8egwUXlD2E=
|
||||
cloud.google.com/go/monitoring v1.29.0 h1:AHhDsFaSax1/4k+qlIDX/SDGe6hggnfXJ9dkgD9qBPY=
|
||||
cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM=
|
||||
cloud.google.com/go/storage v1.63.0 h1:hvXF2xfg9I32bjujggxgkEZn/Ej6sJ9pieFgeueBLrQ=
|
||||
cloud.google.com/go/storage v1.63.0/go.mod h1:tirWVptrFNo5GEX2DQ47JooF7yaweJdAJ1hYAVMvKzE=
|
||||
cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E=
|
||||
cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 h1:l7+6kwRMJNwdCvYdDl7Eax+wzEYHSnNY7zrrfbhDdTA=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 h1:jLdiS1vO+XJFyDSWRHBx56r4s/NNtcl5J6KyCcWUX/w=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0/go.mod h1:8lmpHY+1VRoteiOwyrQMDt1YGXOrFKCz+1wJW7n3ODY=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0 h1:cSjUzZ7KU8hicTgzaSv9NmSyM9fTVK3y5lsBUl3wOis=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0/go.mod h1:dzcEjy1WJ0Q4u9twNR3LcLhNoYMRCrMCMafpxa0TjPQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk=
|
||||
github.com/Ladicle/tabwriter v1.0.0 h1:DZQqPvMumBDwVNElso13afjYLNp0Z7pHqHnu0r4t9Dg=
|
||||
github.com/Ladicle/tabwriter v1.0.0/go.mod h1:c4MdCjxQyTbGuQO/gvqJ+IA/89UEwrsD6hUCW98dyp4=
|
||||
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
|
||||
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
|
||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA=
|
||||
github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 h1:p1BBrg/Hhp6uK7zpejeI8QFXHJeC/mynzi04Sl03k9g=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13/go.mod h1:8cIfkE9MDhkRZGpQ22aV6/lkYeYSozpz16Smrs5x4Ls=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.26 h1:JI+W5B3jUA8UBz2ggbICGd9UCR6/+SB21G8EFl0SFTQ=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.26/go.mod h1:RLE2Ls/wRstvdSz1GPrIWNnXcKZ/znDdWyMuiQxdBoY=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.25 h1:TzPVjfUZ1hsKafvYE+DIzKXIik2KufQxsPHanlkttbo=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.25/go.mod h1:K4hw0buguVvtC74HnVfTRr0LzQQHAWPqJbBU9QGk2Pg=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 h1:V51LGlOq/1VsDsHUdoklAQi7rMmx4qQubvFYAlP2254=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22/go.mod h1:4Pzhyz8hJOm2bepgl+NjvRx8vlUFAIIvJnZ/MkcNPpU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.30 h1:4HbXxyipSYxexU0juMIpdS05dilL6dbB2VQHxxN2vGU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.30/go.mod h1:G7RP+uhagpKtKhd1BM9N6JQqjCcGEU47K5lBVZQyRQw=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.1 h1:yb03KevaOAG5e8suo79Af74vjIQvoeKmjl79WQchLrs=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.1/go.mod h1:mreYODw0Y4yv7xeczvqC6vciwFao8lPE9k1l1ulfY6E=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.2.1 h1:BeJmkm5YOZs6lGRGcNoIuLSoTTtGLLCEqlSiRKYodfM=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.2.1/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.31.4 h1:i465b/3c7xJd++pobNIDOggouekCuiWOnB0goQJy+94=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.31.4/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.7 h1:xbmJAnBbyYPkTzoCNCF/bpJ6ymQHRdXX1vquYfDIGYk=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.7/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.43.4 h1:Np0vmL7op0Zs5xGacYMMX3v5O5pvZ46xhb5LwDgPj8M=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.43.4/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc=
|
||||
github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY=
|
||||
github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas=
|
||||
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chainguard-dev/git-urls v1.0.2 h1:pSpT7ifrpc5X55n4aTTm7FFUE+ZQHKiqpiwNkJrVcKQ=
|
||||
github.com/chainguard-dev/git-urls v1.0.2/go.mod h1:rbGgj10OS7UgZlbzdUQIQpT0k/D4+An04HJY7Ol+Y/o=
|
||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260622092850-f39628c8a989 h1:aLA9AmFNKnFr86XM3/Jm9g4xLOVjEgRuttBWUFujdVw=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260622092850-f39628c8a989/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo=
|
||||
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2/v2 v2.2.2 h1:MYWvNYw8okuqNhwTYO587EZMiDruVa2vhV6fsGpfya0=
|
||||
github.com/dlclark/regexp2/v2 v2.2.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/dominikbraun/graph v0.23.0 h1:TdZB4pPqCLFxYhdyMFb1TBdFxp8XLcJfTTBQucVPgCo=
|
||||
github.com/dominikbraun/graph v0.23.0/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
|
||||
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
|
||||
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
|
||||
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
|
||||
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
|
||||
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
|
||||
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
||||
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
|
||||
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
|
||||
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/go-task/task/v3 v3.52.0 h1:jxzKLDowcE2JwRGTpa8WBKz4H7NrrMDfvTOdq4usPbY=
|
||||
github.com/go-task/task/v3 v3.52.0/go.mod h1:boTnzbY3DyXsF/Y3qw4uHCoysLU/XT8Tc1wfmFa2ikM=
|
||||
github.com/go-task/template v0.2.0 h1:xW7ek0o65FUSTbKcSNeg2Vyf/I7wYXFgLUznptvviBE=
|
||||
github.com/go-task/template v0.2.0/go.mod h1:dbdoUb6qKnHQi1y6o+IdIrs0J4o/SEhSTA6bbzZmdtc=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
|
||||
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
|
||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.17 h1:73NfMHdiqo9JFU9+7a5ExpVa10/R29pXfZIaW559nrg=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.17/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k=
|
||||
github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
|
||||
github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
|
||||
github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.73 h1:LXhjywNxHsex3qFY2p2iOaHK4nFvdqVp9T9QLdZfpjQ=
|
||||
github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.73/go.mod h1:AsbUhwFfdK9ipM8G0i8WVHS0IesKck6M0M9NcuMQTJ8=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-getter v1.8.6 h1:9sQboWULaydVphxc4S64oAI4YqpuCk7nPmvbk131ebY=
|
||||
github.com/hashicorp/go-getter v1.8.6/go.mod h1:nVH12eOV2P58dIiL3rsU6Fh3wLeJEKBOJzhMmzlSWoo=
|
||||
github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
|
||||
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
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.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
|
||||
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||
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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw=
|
||||
github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
|
||||
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
|
||||
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
|
||||
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/nats-io/nats.go v1.39.1 h1:oTkfKBmz7W047vRxV762M67ZdXeOtUgvbBaNoQ+3PPk=
|
||||
github.com/nats-io/nats.go v1.39.1/go.mod h1:MgRb8oOdigA6cYpEPhXJuRVH6UE/V4jblJ2jQ27IXYM=
|
||||
github.com/nats-io/nkeys v0.4.9 h1:qe9Faq2Gxwi6RZnZMXfmGMZkg3afLLOtrU+gDZJ35b0=
|
||||
github.com/nats-io/nkeys v0.4.9/go.mod h1:jcMqs+FLG+W5YO36OX6wFIFcmpdAns+w1Wm6D3I/evE=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk=
|
||||
github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20250313105119-ba97887b0a25 h1:S1hI5JiKP7883xBzZAr1ydcxrKNSVNm7+3+JwjxZEsg=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20250313105119-ba97887b0a25/go.mod h1:ZQntvDG8TkPgljxtA0R9frDoND4QORU1VXz015N5Ks4=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/puzpuzpuz/xsync/v4 v4.5.0 h1:vOSWu6b57/emh+L/Cw0BeQfvxa/cogFywXHeGUxQxAg=
|
||||
github.com/puzpuzpuz/xsync/v4 v4.5.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/sajari/fuzzy v1.0.0 h1:+FmwVvJErsd0d0hAPlj4CxqxUtQY/fOoY0DwX4ykpRY=
|
||||
github.com/sajari/fuzzy v1.0.0/go.mod h1:OjYR6KxoWOe9+dOlXeiCJd4dIbED4Oo8wpS89o0pwOo=
|
||||
github.com/sebdah/goldie/v2 v2.8.0 h1:dZb9wR8q5++oplmEiJT+U/5KyotVD+HNGCAc5gNr8rc=
|
||||
github.com/sebdah/goldie/v2 v2.8.0/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
|
||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spiffe/go-spiffe/v2 v2.8.1 h1:eXZMLsu+3MLEPJyGJkolqtVrteZfQdUpOWj6LTiDl/E=
|
||||
github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||
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=
|
||||
github.com/u-root/u-root v0.16.0 h1:wY40O83MBVks97+Is0WlFlOPSwKQMIrWP9R1IsrExg8=
|
||||
github.com/u-root/u-root v0.16.0/go.mod h1:yL/XdSSW27PdGLgUh4MNRBy54mKM+TBLzpwiB4nwj90=
|
||||
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM=
|
||||
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA=
|
||||
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.44.0 h1:NmLfL734pJhM0JKaYd2Y28+nY9dPRWYAAbxhRCrKXPw=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA=
|
||||
go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/api v0.287.0 h1:CQDMqUiqZZ0U/Yge3zyjAhNQ0OSYEH0PaA7l4xtEen4=
|
||||
google.golang.org/api v0.287.0/go.mod h1:pPW85yt3Iuc3unkpaMhFtMmOqnTdCwCqEOaUlnuxRlQ=
|
||||
google.golang.org/genproto v0.0.0-20260630182238-925bb5da69e7 h1:lQG76ePMKmtujel4VIVMiFoHVWVNtJdawbCZJtWlVXU=
|
||||
google.golang.org/genproto v0.0.0-20260630182238-925bb5da69e7/go.mod h1:LwlOWYBU335L+sR55UuR5fbbU8KmEX+3tUHf3SwMmhM=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1:jQ9p21COKWjP3VwuFrNRiiOTMh3mPpN45R7SLrH/HUU=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 h1:eM/YSd5bBFagF51o1E745Ta7RwzpW0h+z+QDNZOgmQ8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU=
|
||||
google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
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=
|
||||
mvdan.cc/sh/moreinterp v0.0.0-20260120230322-19def062a997 h1:3bbJwtPFh98dJ6lxRdR3eLHTH1CmR3BcU6TriIMiXjE=
|
||||
mvdan.cc/sh/moreinterp v0.0.0-20260120230322-19def062a997/go.mod h1:Qy/zdaMDxq9sT72Gi43K3gsV+TtTohyDO3f1cyBVwuo=
|
||||
mvdan.cc/sh/v3 v3.13.2-0.20260613075524-2255122b577b h1:NREoadYF42Gu7127VIccx/SRia+Bz8wpKBaqmXKiGXE=
|
||||
mvdan.cc/sh/v3 v3.13.2-0.20260613075524-2255122b577b/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0=
|
||||
@@ -0,0 +1,2 @@
|
||||
[Volume]
|
||||
VolumeName=cdc-debezium-data
|
||||
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=CDC demo Debezium Server
|
||||
Requires=cdc-postgres.service cdc-nats.service
|
||||
After=cdc-postgres.service cdc-nats.service
|
||||
|
||||
[Container]
|
||||
Image=quay.io/debezium/server:3.5.2.Final
|
||||
ContainerName=cdc-debezium
|
||||
Network=cdc.network
|
||||
Volume=%h/.local/share/live-inventory-cdc-demo/application.properties:/debezium/config/application.properties:ro,Z
|
||||
Volume=cdc-debezium-data.volume:/debezium/data
|
||||
|
||||
[Service]
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
TimeoutStartSec=180
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=CDC demo inspect NATS JetStream
|
||||
Requires=cdc-nats.service
|
||||
After=cdc-nats.service
|
||||
|
||||
[Container]
|
||||
Image=docker.io/natsio/nats-box:latest
|
||||
Network=cdc.network
|
||||
Environment=NATS_URL=nats://cdc-nats:4222
|
||||
Volume=%h/.local/share/live-inventory-cdc-demo/scripts:/scripts:ro,Z
|
||||
Exec=sh /scripts/inspect.sh
|
||||
|
||||
[Service]
|
||||
Restart=no
|
||||
TimeoutStartSec=120
|
||||
@@ -0,0 +1,2 @@
|
||||
[Volume]
|
||||
VolumeName=cdc-nats-data
|
||||
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=CDC demo NATS JetStream
|
||||
|
||||
[Container]
|
||||
Image=docker.io/library/nats:2
|
||||
ContainerName=cdc-nats
|
||||
Network=cdc.network
|
||||
PublishPort=4222:4222
|
||||
PublishPort=8222:8222
|
||||
Volume=cdc-nats-data.volume:/data
|
||||
Exec=--debug --http_port=8222 --js --store_dir=/data
|
||||
|
||||
[Service]
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,2 @@
|
||||
[Volume]
|
||||
VolumeName=cdc-postgres-data
|
||||
@@ -0,0 +1,26 @@
|
||||
[Unit]
|
||||
Description=CDC demo Postgres
|
||||
|
||||
[Container]
|
||||
Image=docker.io/library/postgres:18-alpine
|
||||
ContainerName=cdc-postgres
|
||||
Network=cdc.network
|
||||
PublishPort=5453:5432
|
||||
Environment=POSTGRES_USER=postgres
|
||||
Environment=POSTGRES_PASSWORD=postgres
|
||||
Environment=POSTGRES_DB=postgres
|
||||
Volume=cdc-postgres-data.volume:/var/lib/postgresql
|
||||
Volume=%h/.local/share/live-inventory-cdc-demo/postgres/init:/docker-entrypoint-initdb.d:ro,Z
|
||||
Exec=postgres -c wal_level=logical -c max_wal_senders=10 -c max_replication_slots=10
|
||||
HealthCmd=pg_isready -U postgres -d postgres
|
||||
HealthInterval=5s
|
||||
HealthTimeout=5s
|
||||
HealthRetries=20
|
||||
|
||||
[Service]
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
TimeoutStartSec=120
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=CDC demo seed data
|
||||
Requires=cdc-postgres.service cdc-debezium.service
|
||||
After=cdc-postgres.service cdc-debezium.service
|
||||
|
||||
[Container]
|
||||
Image=docker.io/library/postgres:18-alpine
|
||||
Network=cdc.network
|
||||
Environment=PGHOST=cdc-postgres
|
||||
Environment=PGUSER=postgres
|
||||
Environment=PGPASSWORD=postgres
|
||||
Environment=PGDATABASE=postgres
|
||||
Volume=%h/.local/share/live-inventory-cdc-demo/scripts:/scripts:ro,Z
|
||||
Exec=sh /scripts/seed.sh
|
||||
|
||||
[Service]
|
||||
Restart=no
|
||||
TimeoutStartSec=180
|
||||
@@ -0,0 +1,2 @@
|
||||
[Network]
|
||||
NetworkName=cdc
|
||||
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=CDC demo Go backend
|
||||
Wants=cdc-postgres.service cdc-nats.service cdc-debezium.service
|
||||
After=cdc-postgres.service cdc-nats.service cdc-debezium.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=%h/.local/share/live-inventory-cdc-demo
|
||||
Environment=HTTP_ADDR=:8080
|
||||
Environment=DATABASE_URL=postgres://postgres:postgres@localhost:5453/postgres?sslmode=disable
|
||||
Environment=NATS_URL=nats://localhost:4222
|
||||
Environment=NATS_SUBJECT=postgres.public.inventory
|
||||
ExecStart=%h/.local/bin/inventory-cdc-demo
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,16 @@
|
||||
create table if not exists inventory (
|
||||
id serial primary key,
|
||||
sku text not null unique,
|
||||
name text not null,
|
||||
quantity integer not null,
|
||||
location text not null,
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table inventory replica identity full;
|
||||
|
||||
insert into inventory (sku, name, quantity, location) values
|
||||
('FL-100', 'Archive label rolls', 12, 'Torshavn'),
|
||||
('FL-220', 'Scanner cleaning kit', 7, 'Klaksvik'),
|
||||
('FL-330', 'Cold storage boxes', 18, 'Runavik')
|
||||
on conflict (sku) do nothing;
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
STREAM="${STREAM:-DebeziumStream}"
|
||||
COUNT="${COUNT:-20}"
|
||||
CONSUMER="viewer-$(date +%s)"
|
||||
|
||||
echo "Stream subjects:"
|
||||
nats --server "$NATS_URL" stream subjects "$STREAM"
|
||||
|
||||
echo
|
||||
echo "Change events:"
|
||||
nats --server "$NATS_URL" consumer add "$STREAM" "$CONSUMER" --ephemeral --pull --defaults >/dev/null
|
||||
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
nats --server "$NATS_URL" consumer next --raw --count "$COUNT" "$STREAM" "$CONSUMER" \
|
||||
| jq -c '{op, table: .source.table, before, after}'
|
||||
else
|
||||
nats --server "$NATS_URL" consumer next --raw --count "$COUNT" "$STREAM" "$CONSUMER"
|
||||
fi
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
echo "Waiting for Debezium replication slot..."
|
||||
|
||||
until [ "$(psql -Atc "select coalesce((select active from pg_replication_slots where slot_name = 'debezium'), false);")" = "t" ]; do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "Creating inventory CDC events..."
|
||||
|
||||
psql <<'SQL'
|
||||
truncate table inventory restart identity;
|
||||
|
||||
insert into inventory (sku, name, quantity, location)
|
||||
values
|
||||
('FL-100', 'Archive label rolls', 12, 'Torshavn'),
|
||||
('FL-220', 'Scanner cleaning kit', 7, 'Klaksvik'),
|
||||
('FL-330', 'Cold storage boxes', 18, 'Runavik');
|
||||
|
||||
update inventory set quantity = quantity - 1, updated_at = now() where sku = 'FL-100';
|
||||
update inventory set quantity = quantity + 5, updated_at = now() where sku = 'FL-220';
|
||||
update inventory set location = 'Suduroy', updated_at = now() where sku = 'FL-330';
|
||||
delete from inventory where sku = 'FL-220';
|
||||
SQL
|
||||
|
||||
echo "Done."
|
||||
Reference in New Issue
Block a user