commit d9a0cd749ede4a34a135d7978db3af1cc77e408e Author: Bartal Laearsson Date: Tue Aug 11 20:42:44 2026 +0100 initial commit diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..77dee48 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..c15ebbb --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..39d782d --- /dev/null +++ b/Taskfile.yml @@ -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 diff --git a/application.prod.properties b/application.prod.properties new file mode 100644 index 0000000..d40bdde --- /dev/null +++ b/application.prod.properties @@ -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 diff --git a/application.properties b/application.properties new file mode 100644 index 0000000..d689023 --- /dev/null +++ b/application.properties @@ -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 diff --git a/bin/inventory-cdc-demo b/bin/inventory-cdc-demo new file mode 100755 index 0000000..c0bc17c Binary files /dev/null and b/bin/inventory-cdc-demo differ diff --git a/cmd/server/internal/web/static/css/style.css b/cmd/server/internal/web/static/css/style.css new file mode 100644 index 0000000..560400f --- /dev/null +++ b/cmd/server/internal/web/static/css/style.css @@ -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; } +} diff --git a/cmd/server/internal/web/static/js/app.js b/cmd/server/internal/web/static/js/app.js new file mode 100644 index 0000000..298124f --- /dev/null +++ b/cmd/server/internal/web/static/js/app.js @@ -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 = ''; + 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 = ` + ${escapeHTML(item.sku)}${stockLabel(item.quantity)} + ${escapeHTML(item.name)} + ${item.quantity} / ${escapeHTML(item.location)} + `; + 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 = '+ Create productempty slot'; + 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 = ` +

CDC event received: ${escapeHTML(event.crud)}

+

source: ${escapeHTML(event.source)}

+
${escapeHTML(changes || 'No before/after field changes available.')}
+ `; + } + + function renderAudit(event) { + if (auditList.querySelector('.muted')) auditList.innerHTML = ''; + const row = document.createElement('article'); + row.className = 'audit-row'; + row.innerHTML = ` + ${escapeHTML(event.crud)} + ${escapeHTML(event.table)} + ${escapeHTML(diff(event.before, event.after) || 'snapshot/delete event')} + `; + 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(); +})(); diff --git a/cmd/server/internal/web/static/js/theme.js b/cmd/server/internal/web/static/js/theme.js new file mode 100644 index 0000000..aedba53 --- /dev/null +++ b/cmd/server/internal/web/static/js/theme.js @@ -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')); + } + }); +})(); diff --git a/cmd/server/internal/web/templates/index.html b/cmd/server/internal/web/templates/index.html new file mode 100644 index 0000000..c227a81 --- /dev/null +++ b/cmd/server/internal/web/templates/index.html @@ -0,0 +1,138 @@ + + + + + +Live Inventory CDC Demo — FLÓ + + + + + + + +
+ FLÓ.FO + FLÓ / CDC POC +
+ +
+
+
+
+

Inventory

+ Loading products... +
+
+
+ +
+ + +
+ Active product +
+ No product selected + Pick a product above. +
+
+ +
+
+ + +
+ +
+ More actions +
+
+ Rename or move +
+ + +
+ +
+ +
+ Remove + +
+
+
+
+ +
+
+ Create product +

Create a product in the selected empty slot.

+
+ + + + + +
+
+
+
+
+ + +
+ + + + + + + + + diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..ab02bff --- /dev/null +++ b/cmd/server/main.go @@ -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)) + }) +} diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..30e9acf --- /dev/null +++ b/compose.yml @@ -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: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..47caa51 --- /dev/null +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9495f91 --- /dev/null +++ b/go.sum @@ -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= diff --git a/linux-files/dot_config/containers/systemd/cdc-debezium-data.volume b/linux-files/dot_config/containers/systemd/cdc-debezium-data.volume new file mode 100644 index 0000000..0e93cbc --- /dev/null +++ b/linux-files/dot_config/containers/systemd/cdc-debezium-data.volume @@ -0,0 +1,2 @@ +[Volume] +VolumeName=cdc-debezium-data diff --git a/linux-files/dot_config/containers/systemd/cdc-debezium.container b/linux-files/dot_config/containers/systemd/cdc-debezium.container new file mode 100644 index 0000000..8f9b1a9 --- /dev/null +++ b/linux-files/dot_config/containers/systemd/cdc-debezium.container @@ -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 diff --git a/linux-files/dot_config/containers/systemd/cdc-inspect.container b/linux-files/dot_config/containers/systemd/cdc-inspect.container new file mode 100644 index 0000000..425e601 --- /dev/null +++ b/linux-files/dot_config/containers/systemd/cdc-inspect.container @@ -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 diff --git a/linux-files/dot_config/containers/systemd/cdc-nats-data.volume b/linux-files/dot_config/containers/systemd/cdc-nats-data.volume new file mode 100644 index 0000000..24bb121 --- /dev/null +++ b/linux-files/dot_config/containers/systemd/cdc-nats-data.volume @@ -0,0 +1,2 @@ +[Volume] +VolumeName=cdc-nats-data diff --git a/linux-files/dot_config/containers/systemd/cdc-nats.container b/linux-files/dot_config/containers/systemd/cdc-nats.container new file mode 100644 index 0000000..39072ba --- /dev/null +++ b/linux-files/dot_config/containers/systemd/cdc-nats.container @@ -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 diff --git a/linux-files/dot_config/containers/systemd/cdc-postgres-data.volume b/linux-files/dot_config/containers/systemd/cdc-postgres-data.volume new file mode 100644 index 0000000..138b3eb --- /dev/null +++ b/linux-files/dot_config/containers/systemd/cdc-postgres-data.volume @@ -0,0 +1,2 @@ +[Volume] +VolumeName=cdc-postgres-data diff --git a/linux-files/dot_config/containers/systemd/cdc-postgres.container b/linux-files/dot_config/containers/systemd/cdc-postgres.container new file mode 100644 index 0000000..d51609e --- /dev/null +++ b/linux-files/dot_config/containers/systemd/cdc-postgres.container @@ -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 diff --git a/linux-files/dot_config/containers/systemd/cdc-seed.container b/linux-files/dot_config/containers/systemd/cdc-seed.container new file mode 100644 index 0000000..1b98bb7 --- /dev/null +++ b/linux-files/dot_config/containers/systemd/cdc-seed.container @@ -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 diff --git a/linux-files/dot_config/containers/systemd/cdc.network b/linux-files/dot_config/containers/systemd/cdc.network new file mode 100644 index 0000000..d3786af --- /dev/null +++ b/linux-files/dot_config/containers/systemd/cdc.network @@ -0,0 +1,2 @@ +[Network] +NetworkName=cdc diff --git a/linux-files/dot_config/systemd/user/cdc-inventory.service b/linux-files/dot_config/systemd/user/cdc-inventory.service new file mode 100644 index 0000000..dacac1e --- /dev/null +++ b/linux-files/dot_config/systemd/user/cdc-inventory.service @@ -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 diff --git a/postgres/init/001_schema.sql b/postgres/init/001_schema.sql new file mode 100644 index 0000000..a748d15 --- /dev/null +++ b/postgres/init/001_schema.sql @@ -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; diff --git a/scripts/inspect.sh b/scripts/inspect.sh new file mode 100644 index 0000000..901f331 --- /dev/null +++ b/scripts/inspect.sh @@ -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 diff --git a/scripts/seed.sh b/scripts/seed.sh new file mode 100644 index 0000000..50e2063 --- /dev/null +++ b/scripts/seed.sh @@ -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."