dynoxide-rs 3.2.0

An embeddable DynamoDB emulator backed by SQLite. Starts in under a millisecond, and is verified against real AWS by a public conformance suite.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
name: CI

on:
  pull_request:
  workflow_dispatch:

permissions:
  contents: read

jobs:
  test:
    name: Test (${{ matrix.name }})
    runs-on: ubuntu-latest

    strategy:
      fail-fast: false
      matrix:
        include:
          - name: default
            flags: ""
          - name: minimal-library
            flags: "--no-default-features --features native-sqlite --lib"
          - name: encryption-only
            flags: "--no-default-features --features encryption --lib"
          # `cli` builds the binary, which the server's end-to-end tests spawn.
          - name: encryption+http-server
            flags: "--no-default-features --features encryption,http-server,cli"
          # The work counters are off by default and compile to nothing when
          # absent, so nothing else in the matrix builds the harness that
          # measures repeated work. Without this leg the evidence behind the
          # performance changes could stop compiling and no job would notice.
          - name: bench-counters
            flags: "--features bench-counters --test repeated_work_counts"
          # Unit tests compile every test-only path into the library, so the
          # library-only build is checked here with an integration test: an
          # unreadable legacy Expected must be refused in DynamoDB's words
          # there too.
          - name: library-expected
            flags: "--no-default-features --features native-sqlite --test expected_library_build"

    steps:
      - uses: actions/checkout@v5
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2

      - name: Build
        run: cargo build ${{ matrix.flags }}

      - name: Test
        run: cargo test ${{ matrix.flags }}

  # serde_json keeps an object's keys in insertion order when any crate in a
  # build turns on its preserve_order feature, and a HashMap's insertion order
  # differs between two maps holding the same entries. The idempotency hash
  # sorts keys itself so it doesn't depend on that. Its test can only fail with
  # the feature on, so it runs here with it.
  map-key-order:
    name: Map key order (serde_json preserve_order)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2

      # libtest exits 0 when its filters match nothing, so a renamed test would
      # turn this job into a silent no-op; the count check catches that.
      - name: The idempotency hash ignores map key order
        shell: bash
        run: |
          set -o pipefail
          cargo test --lib --features serde_json/preserve_order -- \
            a_request_hash_ignores_map_key_order \
            | tee map-key-order.log
          grep -q 'test result: ok. 1 passed' map-key-order.log

  # The listener bind path is platform-specific (SO_REUSEADDR on Unix,
  # SO_EXCLUSIVEADDRUSE on Windows), so the unit tests need a Windows run;
  # test-build.yml only builds there.
  test-windows:
    name: Test (windows)
    runs-on: windows-latest

    steps:
      - uses: actions/checkout@v5
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2

      - name: Test
        run: cargo test --lib

      # Windows refuses to delete a file a connection task still has open,
      # which is what serve_with_shutdown exists to prevent.
      - name: Test the embedded server's shutdown
        run: cargo test --test graceful_shutdown

  # `rust-version` is part of the published Rust API contract in
  # docs/versioning.md, so it is a promise rather than a description. Every
  # other job runs on stable, which cannot see a language feature that raises
  # the floor. The toolchain here is read from the manifest rather than pinned
  # literally, so the check and the promise cannot disagree.
  msrv:
    name: MSRV
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v5

      - name: Read the declared MSRV
        id: msrv
        run: |
          set -euo pipefail
          # Match a quoted literal version rather than splitting the field on
          # quotes. `rust-version.workspace = true` has no quote to split on, so
          # a `cut` would pass the whole line through as a toolchain name and
          # fail later somewhere unrelated. The `|| true` matters too: under
          # `pipefail` a grep that matches nothing would abort the step before
          # the check below could report why.
          VERSION=$(grep -m1 -oE '^rust-version[[:space:]]*=[[:space:]]*"[0-9]+(\.[0-9]+){1,2}"' Cargo.toml \
            | grep -oE '[0-9]+(\.[0-9]+){1,2}' || true)
          if [ -z "$VERSION" ]; then
            echo "::error::Cargo.toml declares no literal rust-version to check against"
            exit 1
          fi
          echo "version=$VERSION" >> "$GITHUB_OUTPUT"
          echo "Declared MSRV: $VERSION"

      - uses: dtolnay/rust-toolchain@master
        with:
          toolchain: ${{ steps.msrv.outputs.version }}
          targets: wasm32-unknown-unknown
      - uses: Swatinem/rust-cache@v2

      # Dev-dependencies are out of scope on purpose: they never reach a
      # consumer, so one raising its own floor does not break this promise.
      # These two shapes are what `cargo add` and `cargo install` compile.
      #
      # `--locked` is the point of the job: without it the resolver may pick
      # different versions than the lockfile ships, and the check would pass
      # against a dependency set no consumer gets.
      # Every published shape, not just the default one. The floor last drifted
      # inside feature-gated code, so checking one combination would leave the
      # same hole: a construct needing a newer compiler can sit behind
      # `encryption` or `wasm-sqlite`, compile green on stable here, and break
      # a consumer building that combination on the declared floor.
      - name: Check default features
        run: cargo check --locked

      - name: Check minimal library
        run: cargo check --locked --no-default-features --features native-sqlite --lib

      - name: Check encryption
        run: cargo check --locked --no-default-features --features encryption --lib

      - name: Check encrypted-full
        run: cargo check --locked --no-default-features --features encrypted-full

      - name: Check wasm-sqlite
        run: |
          cargo check --locked --target wasm32-unknown-unknown \
            --no-default-features --features wasm-sqlite --lib

  lint:
    name: Lint
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v5
      - uses: dtolnay/rust-toolchain@stable
        with:
          components: clippy, rustfmt
      - uses: Swatinem/rust-cache@v2

      - name: Clippy (default)
        run: cargo clippy --all-targets -- -D warnings

      - name: Clippy (encryption)
        run: cargo clippy --no-default-features --features encryption --lib -- -D warnings

      - name: Format
        run: cargo fmt --check

      # The script existed but no workflow called it, so the stale version pins
      # and leftover preview wording it looks for could reach a release with
      # nothing objecting. Needs no toolchain; it rides here because this job
      # is already the home for checks that must pass rather than inform.
      - name: Check for stale version pins and preview wording in docs
        run: scripts/check-stale-docs.sh

      # The check above has twice been able to pass for the wrong reason: a
      # pattern that stopped matching once the project moved past it, and a
      # missing input that read as clean. This exercises both directions
      # against a fixture tree, so a future edit that makes it vacuous fails
      # here rather than at a release.
      - name: Check the stale-docs check can still fail
        run: scripts/check-stale-docs.test.sh

      # Two version streams means more than one file can be left behind. This
      # is cheap enough to run on every PR, which is the point: the alternative
      # is finding out at tag time, when the tag is already pushed.
      - name: Check version sources agree
        run: scripts/check-versions.sh

      # Same reasoning as the stale-docs test: a gate that has only ever been
      # seen to pass is not evidence. This mutates each file the version check
      # reads and asserts the check names it, and feeds it the version shapes
      # npm rejects.
      - name: Check the version check can still fail
        run: scripts/check-versions.test.sh

      # The release workflows share seven small scripts (version shape,
      # prerelease rule, npm dist-tag, crate probe, crate publish decision,
      # binary probe, mcp-publisher install) that only otherwise run at tag
      # time.
      # Exercised here so a change to one fails on a PR rather than mid-release.
      - name: Check the release helper scripts can still fail
        run: scripts/release-helpers.test.sh

      # The Trusted Publishing probe passes by reading a refusal from
      # crates.io, which is easy to get wrong in the direction that always
      # says yes. Every answer crates.io can give is replayed against it here,
      # including a success, which the probe has to treat as a finding rather
      # than a pass. It never reaches the network.
      - name: Check the Trusted Publishing probe can still fail
        run: scripts/trustpub-probe.test.sh

      # Nothing built the docs, so broken intra-doc links accumulated unseen:
      # ten of them, half pointing at private items and half at cfg-gated ones
      # absent from the default build docs.rs renders. They degrade the
      # published API docs rather than failing the build, which is exactly why
      # nobody noticed.
      - name: Docs
        env:
          RUSTDOCFLAGS: -D warnings
        run: cargo doc --no-deps --lib

  benchmarks-lint:
    name: Benchmarks lint
    runs-on: ubuntu-latest

    # benchmarks/ is its own cargo package rather than a workspace member, so
    # the lint job above never reaches it, and the benchmark workflow only
    # runs `cargo bench` on two of its targets. Formatting, clippy and the
    # remaining benches and binaries could break with nothing noticing.
    # Clippy over every target also type-checks every target, so it doubles
    # as the compile check. No --locked: benchmarks/Cargo.lock is not kept in
    # step with the root crate's version.
    defaults:
      run:
        working-directory: benchmarks

    steps:
      - uses: actions/checkout@v5
      - uses: dtolnay/rust-toolchain@stable
        with:
          components: clippy, rustfmt
      - uses: Swatinem/rust-cache@v2
        with:
          workspaces: benchmarks

      - name: Format
        run: cargo fmt --check

      # Both configurations: iai_core compiles a different main without the
      # feature, and that fallback is what `cargo bench` builds by default.
      - name: Clippy (every target, default features)
        run: cargo clippy --all-targets -- -D warnings

      - name: Clippy (every target, iai-callgrind)
        run: cargo clippy --all-targets --features iai-callgrind -- -D warnings

  benchmark-scripts:
    name: Benchmark script tests
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v5

      # The benchmark workflows read criterion's output through these scripts,
      # and a parser that quietly returns nothing turns a failed comparison
      # into a red check with no explanation.
      - name: Test
        run: python3 -m unittest discover -s benchmarks/scripts -p 'test_*.py' -v

  package:
    name: Package
    runs-on: ubuntu-latest

    # crates.io builds the crate from the files `cargo package` ships, not
    # from the checkout. A build that leans on a file the package leaves out
    # (anything gitignored, or outside the crate) passes every other job here
    # and then fails at `cargo publish`, in the release, after the tag and the
    # GitHub Release already exist. This builds exactly what would be
    # uploaded, and needs no registry token.
    steps:
      - uses: actions/checkout@v5
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2

      - name: Build the packaged crate
        run: cargo package --locked

  cargo-deny:
    name: cargo-deny
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v5
      - uses: EmbarkStudios/cargo-deny-action@v2
        with:
          command: check

  feature-guards:
    name: Feature guard checks
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v5
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2

      # Each case asserts its guard's own message, not just a failed build. A
      # build can fail for reasons that have nothing to do with the guard (the
      # no-backend cases still fail on type errors with their guard removed),
      # and an exit-status check would pass on those. The cases name their
      # features with --no-default-features so the defaults cannot satisfy a
      # guard on the probe's behalf. The off-Apple guard is reachable because
      # this job runs on Linux.
      - name: Backend guards must fail on their own message
        run: |
          must_fail_on() {
            desc="$1"
            message="$2"
            shift 2
            if out=$(cargo check --lib "$@" 2>&1); then
              echo "ERROR: $desc should cause compile_error!"
              exit 1
            fi
            if ! grep -qF -- "$message" <<<"$out"; then
              echo "ERROR: $desc failed, but not on its guard:"
              echo "$out"
              exit 1
            fi
            echo "OK: $desc"
          }
          # The backticks are part of the guard message, not an expansion.
          # shellcheck disable=SC2016
          must_fail_on "native-sqlite with encryption" \
            'Features `native-sqlite` and `encryption`' \
            --no-default-features --features native-sqlite,encryption
          # The backticks are part of the guard message, not an expansion.
          # shellcheck disable=SC2016
          must_fail_on "native-sqlite with encryption-cc" \
            'Features `native-sqlite` and `encryption`' \
            --no-default-features --features native-sqlite,encryption-cc
          must_fail_on "no storage backend at all" \
            'A storage backend feature must be enabled' \
            --no-default-features
          must_fail_on "no storage backend, with a server feature" \
            'A storage backend feature must be enabled' \
            --no-default-features --features http-server
          # The backticks are part of the guard message, not an expansion.
          # shellcheck disable=SC2016
          must_fail_on "encryption with encryption-cc" \
            'Features `encryption` and `encryption-cc` are mutually exclusive' \
            --no-default-features --features encryption,encryption-cc
          must_fail_on "encryption-cc off Apple platforms" \
            'intended for Apple platforms only' \
            --no-default-features --features encryption-cc

      # A non-zero exit is not enough on its own: every combination below
      # already failed to compile before the guard existed, on unrelated type
      # errors, so an exit-status-only check would pass with the guard deleted.
      # Assert on the guard's own message instead.
      #
      # The first case is the one a consumer hits by accident, where the
      # defaults union in a native backend. It does not isolate any single arm
      # of the guard's `any(...)`, because the defaults satisfy two of them at
      # once, so the three that follow pin one arm each. Without those, dropping
      # an arm from the predicate stays green.
      - name: wasm-sqlite alongside a native feature must fail on the guard
        run: |
          must_fail_on_guard() {
            desc="$1"
            shift
            if out=$(cargo check --lib "$@" 2>&1); then
              echo "ERROR: $desc should cause compile_error!"
              exit 1
            fi
            if ! grep -q "mutually exclusive with the native backends" <<<"$out"; then
              echo "ERROR: $desc failed, but not on the feature guard:"
              echo "$out"
              exit 1
            fi
            echo "OK: $desc"
          }
          must_fail_on_guard "wasm-sqlite on top of the defaults" \
            --features wasm-sqlite
          must_fail_on_guard "the native-sqlite arm" \
            --no-default-features --features wasm-sqlite,native-sqlite
          must_fail_on_guard "the cli arm" \
            --no-default-features --features wasm-sqlite,cli
          must_fail_on_guard "the http-server arm" \
            --no-default-features --features wasm-sqlite,http-server
          must_fail_on_guard "the mcp-server arm" \
            --no-default-features --features wasm-sqlite,mcp-server
          must_fail_on_guard "the import arm" \
            --no-default-features --features wasm-sqlite,import
          must_fail_on_guard "the _has-encryption arm" \
            --no-default-features --features wasm-sqlite,_has-encryption

      - name: wasm-sqlite on its own must build
        run: cargo check --no-default-features --features wasm-sqlite

      # A library that embeds the HTTP or MCP server has no use for the
      # binary's argument parser or log subscriber, so neither may reach its
      # dependency graph. The default build is checked first and must list
      # clap: if it doesn't, the match below can't see the crates it looks for
      # and would pass on anything.
      - name: The server features pull in no CLI dependencies
        run: |
          if ! cargo tree -e normal --prefix none | grep -qE '^clap '; then
            echo "ERROR: the default build lists no clap, so this check can't see it"
            exit 1
          fi
          for features in native-sqlite,http-server native-sqlite,mcp-server \
              native-sqlite,http-server,mcp-server; do
            tree=$(cargo tree -e normal --prefix none \
              --no-default-features --features "$features")
            if ! grep -qE '^dynoxide-rs ' <<<"$tree"; then
              echo "ERROR: no dependency tree for $features"
              exit 1
            fi
            if leaked=$(grep -E '^(clap|tracing-subscriber) ' <<<"$tree"); then
              echo "ERROR: $features pulls in CLI dependencies:"
              sort -u <<<"$leaked"
              exit 1
            fi
            echo "OK: $features ($(sort -u <<<"$tree" | wc -l) crates)"
          done

  wasm-check:
    name: wasm-sqlite build check
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v5
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: wasm32-unknown-unknown
          components: clippy
      - uses: Swatinem/rust-cache@v2

      - name: cargo check (wasm32, wasm-sqlite)
        run: cargo check --target wasm32-unknown-unknown --no-default-features --features wasm-sqlite --lib

      # The harness exercises WasmDatabase and the action types directly, so it
      # catches trait or re-export drift the wasm-sqlite check alone misses.
      - name: cargo check (wasm32, wasm-harness)
        run: cargo check --target wasm32-unknown-unknown --no-default-features --features wasm-harness --lib

      # The lint job runs clippy on the native build only, so warnings in code
      # that only the wasm build compiles (or code that only it leaves unused)
      # accumulated unseen. Both features, so the harness's extra surface is
      # covered too.
      - name: Clippy (wasm32, wasm-sqlite)
        run: cargo clippy --target wasm32-unknown-unknown --no-default-features --features wasm-sqlite --lib -- -D warnings

      - name: Clippy (wasm32, wasm-harness)
        run: cargo clippy --target wasm32-unknown-unknown --no-default-features --features wasm-harness --lib -- -D warnings

  engine-client:
    name: Engine client tests
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v5
        with:
          node-version: "22"

      # The EngineClient tests run against a stub worker, but the bridge unit
      # test loads the real @sqlite.org/sqlite-wasm engine over an in-memory
      # database, so the job installs npm deps (no wasm build needed) and runs
      # node --test.
      - name: Install npm dependencies
        run: npm ci
      - name: Test the engine client
        run: node --test js/*.test.js

      # Fast guard that the client's CONTRACT_VERSION matches the Rust engine's,
      # without building wasm; build-wasm.sh runs the same check before publish.
      - name: Check CONTRACT_VERSION agreement
        run: scripts/check-contract-version.sh

  browser-engine:
    name: Browser engine tests (real SQLite-wasm + OPFS)
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v5
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: wasm32-unknown-unknown
      - uses: Swatinem/rust-cache@v2

      # wasm-pack drives the wasm-bindgen build inside scripts/build-wasm.sh.
      - name: Install wasm-pack
        uses: taiki-e/install-action@v2
        with:
          tool: wasm-pack

      - uses: actions/setup-node@v5
        with:
          node-version: "22"

      - name: Install npm dependencies
        run: npm ci

      # Build the same self-contained bundle a consumer installs, on the release
      # profile, so the full --minify path (renamed identifiers, the worker that
      # actually ships) is exercised against real Chromium + OPFS on every PR -
      # not only at publish time and in a manual run. This build also runs the
      # harness-op strip guard (build-wasm.sh step 2b) against the minified
      # output. The wasm-opt cost is the price of testing what ships; the dev
      # profile (build:wasm:dev) only minifies syntax and would leave the
      # identifier-renaming transform unverified.
      - name: Build the wasm bundle
        run: npm run build:wasm

      - name: Install Playwright Chromium
        run: npx playwright install --with-deps chromium

      # Drives the shipped Worker in headless Chromium against the real SQLite-wasm engine and
      # OPFS - the path the conformance suite (native backend) does not cover.
      - name: Run the browser engine tests
        run: npm run test:browser

  # Gate the Docker smoke test on the paths that can actually break the
  # documented MCP-in-Docker recipe. src/main.rs carries the ServeArgs/McpArgs
  # flag plumbing and Cargo.{toml,lock} the feature set / rmcp version - both can
  # break the recipe without touching src/mcp/, so a src/mcp-only gate is too
  # narrow. Implemented with git diff (no third-party action).
  changes:
    name: Detect recipe-affecting changes
    runs-on: ubuntu-latest
    outputs:
      docker: ${{ steps.filter.outputs.docker }}
    steps:
      - uses: actions/checkout@v5
        with:
          fetch-depth: 0
      - name: Detect changes
        id: filter
        env:
          BASE_SHA: ${{ github.event.pull_request.base.sha }}
        run: |
          set -euo pipefail
          # workflow_dispatch has no PR base, and a missing/unreachable base ref
          # should fail open - run the smoke job rather than skip it silently.
          if [ -z "${BASE_SHA:-}" ] || ! changed=$(git diff --name-only "$BASE_SHA" HEAD 2>/dev/null); then
            echo "docker=true" >> "$GITHUB_OUTPUT"
            exit 0
          fi
          echo "Changed files:"; echo "$changed"
          if echo "$changed" | grep -qE '^(Dockerfile|Cargo\.(toml|lock)|src/main\.rs|src/mcp/|README\.md|\.github/workflows/ci\.yml)'; then
            echo "docker=true" >> "$GITHUB_OUTPUT"
          else
            echo "docker=false" >> "$GITHUB_OUTPUT"
          fi

  docker-smoke:
    name: Docker MCP smoke test
    needs: changes
    if: needs.changes.outputs.docker == 'true'
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v5
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: x86_64-unknown-linux-musl
      - uses: Swatinem/rust-cache@v2

      # FROM scratch needs a fully static binary; native-sqlite bundles SQLite
      # via a C compiler, so the musl C toolchain (musl-gcc) is required. This
      # mirrors the release-amd64 build (.github/workflows/release.yml).
      - name: Install musl tools
        run: |
          sudo apt-get update
          sudo apt-get install -y musl-tools

      - name: Build static musl binary
        run: cargo build --release --target x86_64-unknown-linux-musl --features full

      - name: Stage binary into the Docker build context
        run: |
          set -euo pipefail
          mkdir -p dist/amd64
          cp target/x86_64-unknown-linux-musl/release/dynoxide dist/amd64/dynoxide
          chmod +x dist/amd64/dynoxide
          file dist/amd64/dynoxide
          # musl builds report "static-pie linked"; zig cross-builds report
          # "statically linked". Both are self-contained and run on FROM scratch.
          file dist/amd64/dynoxide | grep -qE 'static-pie linked|statically linked'

      - uses: docker/setup-buildx-action@v4

      # Two flags are load-bearing for `--load`:
      #   --platform linux/amd64  - without it buildx builds a multi-platform
      #     manifest that --load cannot export.
      #   --provenance=false      - without it buildx attaches an attestation
      #     manifest, which is also a manifest list and is rejected by --load on
      #     the runner's classic (non-containerd) image store.
      # Single-arch amd64 is enough for a smoke test; release.yml does multi-arch.
      - name: Build image (linux/amd64)
        run: docker buildx build --provenance=false --platform linux/amd64 --load -t dynoxide:smoke .

      - name: Image declares the MCP port (R1)
        run: |
          docker image inspect dynoxide:smoke \
            --format '{{json .Config.ExposedPorts}}' | tee /dev/stderr | grep -q '19280/tcp'

      # The fast start interval is what makes a healthcheck-based container wait
      # worth recommending, and docs/installation.md quotes the 250ms figure.
      # Dropping the flag would otherwise surface only as someone's test suite
      # quietly waiting 30 seconds for a container that booted in milliseconds.
      - name: Image probes itself quickly while starting
        run: |
          docker image inspect dynoxide:smoke \
            --format '{{json .Config.Healthcheck}}' | tee /dev/stderr \
            | grep -q '"StartInterval":250000000'

      # The documented recipe. The token is a hardcoded throwaway, intentionally
      # inline (not a repository secret): it has no value outside this job. The
      # host-side bind is pinned to 127.0.0.1 so the ports are not reachable on
      # the Actions Docker bridge.
      - name: Start the documented DynamoDB + MCP recipe
        env:
          DYNOXIDE_MCP_AUTH_TOKEN: ci-smoke-test-token
        run: |
          set -euo pipefail
          docker run -d --name dynoxide-smoke \
            -p 127.0.0.1:8000:8000 -p 127.0.0.1:19280:19280 \
            -e DYNOXIDE_MCP_AUTH_TOKEN \
            dynoxide:smoke \
            serve --host 0.0.0.0 --port 8000 \
                  --mcp --mcp-host 0.0.0.0 --mcp-port 19280

      # The image config having a fast start interval is not the same as the
      # probe inside it working, and docs/testcontainers.md recommends waiting
      # on the healthcheck. Without this, `dynoxide healthcheck` could regress
      # (a drifted default port, a moved binary path, an inverted exit code)
      # and every reader following that advice would hang while CI stayed
      # green. Five seconds is generous against a quarter-second first probe.
      - name: Container reports healthy quickly
        run: |
          set -uo pipefail
          # Deliberately not `set -e`: a `docker inspect` against a container
          # that has gone away must fall through to the diagnostics below
          # rather than abort the step and print nothing.
          status=""
          for _ in $(seq 1 25); do
            status=$(docker inspect -f '{{.State.Health.Status}}' dynoxide-smoke 2>/dev/null || echo gone)
            case "$status" in
              healthy) echo "healthy"; exit 0 ;;
              unhealthy|gone) break ;;
            esac
            sleep 0.2
          done
          case "$status" in
            unhealthy) echo "::error::container reported unhealthy" ;;
            gone)      echo "::error::container disappeared before reporting healthy" ;;
            *)         echo "::error::container did not report healthy within 5s (last: ${status:-unknown})" ;;
          esac
          docker inspect -f '{{json .State.Health}}' dynoxide-smoke 2>/dev/null || true
          docker logs dynoxide-smoke 2>&1 || true
          exit 1

      # Poll the port from outside as well: that is the reachability an SDK
      # client actually needs, and an in-container probe cannot show it. GET /
      # is the same liveness probe `dynoxide healthcheck` uses (an unsigned
      # DynamoDB op would 400 on SigV4 auth).
      - name: DynamoDB reachable on :8000 (R3)
        run: |
          set -euo pipefail
          ok=
          for _ in $(seq 1 30); do
            if curl -fsS http://127.0.0.1:8000/ | grep -q 'healthy'; then
              echo "DynamoDB up"; ok=1; break
            fi
            sleep 1
          done
          [ "${ok:-}" = 1 ] || { echo "::error::DynamoDB never came up"; docker logs dynoxide-smoke; exit 1; }

      - name: MCP authenticated-reachable on :19280 (R2, R4)
        run: |
          set -euo pipefail
          body='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"ci","version":"1.0"}}}'
          resp=$(curl -fsS -X POST http://127.0.0.1:19280/mcp \
            -H 'Authorization: Bearer ci-smoke-test-token' \
            -H 'Content-Type: application/json' \
            -H 'Accept: application/json, text/event-stream' \
            -d "$body")
          echo "$resp"
          echo "$resp" | grep -q 'dynoxide'

      - name: MCP rejects a missing token with 401 (R4)
        run: |
          set -euo pipefail
          code=$(curl -s -o /dev/null -w '%{http_code}' -X POST http://127.0.0.1:19280/mcp \
            -H 'Content-Type: application/json' \
            -H 'Accept: application/json, text/event-stream' \
            -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}')
          echo "status=$code"
          [ "$code" = 401 ] || { echo "::error::expected 401 without token, got $code"; docker logs dynoxide-smoke; exit 1; }

      - name: Teardown
        if: always()
        run: docker rm -f dynoxide-smoke || true