This is an automated email from the ASF dual-hosted git repository.

hubcio pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git


The following commit(s) were added to refs/heads/master by this push:
     new b8bf85fd9 feat(swift): scaffold the Swift SDK package and CI lanes 
(#4190)
b8bf85fd9 is described below

commit b8bf85fd93a56c77b643c67d633b6e7795b428ca
Author: RustToMetal <[email protected]>
AuthorDate: Sat Sep 26 04:22:14 2026 -0500

    feat(swift): scaffold the Swift SDK package and CI lanes (#4190)
---
 .github/actions/swift/pre-merge/action.yml         |  75 +++
 .github/actions/utils/setup-swift/action.yml       |  56 +++
 .github/config/components.yml                      |  14 +
 .github/workflows/_detect.yml                      |  11 +-
 .github/workflows/_test.yml                        |   8 +
 .github/workflows/pre-merge.yml                    |  21 +-
 .gitignore                                         |   8 +
 foreign/swift/.swift-format                        |  22 +
 foreign/swift/Package.swift                        |  41 ++
 foreign/swift/README.md                            |  87 ++++
 foreign/swift/Sources/Iggy/Errors/IggyError.swift  |  78 ++++
 .../swift/Sources/Iggy/Errors/IggyErrorCode.swift  | 515 +++++++++++++++++++++
 foreign/swift/Sources/Iggy/Iggy.swift              |  25 +
 .../Sources/Iggy/Utilities/UInt128Value.swift      | 156 +++++++
 foreign/swift/Sources/Iggy/Wire/ByteCodec.swift    | 218 +++++++++
 foreign/swift/Tests/IggyTests/ByteCodecTests.swift | 120 +++++
 foreign/swift/Tests/IggyTests/ErrorCodeTests.swift |  55 +++
 licenserc.toml                                     |   7 +
 18 files changed, 1515 insertions(+), 2 deletions(-)

diff --git a/.github/actions/swift/pre-merge/action.yml 
b/.github/actions/swift/pre-merge/action.yml
new file mode 100644
index 000000000..02f383ada
--- /dev/null
+++ b/.github/actions/swift/pre-merge/action.yml
@@ -0,0 +1,75 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+name: swift-pre-merge
+description: Swift pre-merge testing github iggy actions
+
+inputs:
+  task:
+    description: "Task to run (lint, test, build, build-macos)"
+    required: true
+
+runs:
+  using: "composite"
+  steps:
+    # The macOS lane uses the Swift that ships with Xcode on the runner.
+    - name: Setup Swift
+      if: inputs.task != 'build-macos'
+      uses: ./.github/actions/utils/setup-swift
+
+    # swift-format ships with the toolchain; the configuration lives in
+    # foreign/swift/.swift-format.
+    - name: Format Check
+      shell: bash
+      if: inputs.task == 'lint'
+      run: |
+        cd foreign/swift
+        swift format lint --strict --recursive Sources Tests Package.swift
+
+    # A warning-free build is the lint gate the Swift compiler provides: the
+    # SDK builds in Swift 6 language mode with strict concurrency checking.
+    - name: Build with warnings as errors
+      shell: bash
+      if: inputs.task == 'lint'
+      run: |
+        cd foreign/swift
+        swift build -Xswiftc -warnings-as-errors
+
+    - name: Test
+      shell: bash
+      if: inputs.task == 'test'
+      run: |
+        cd foreign/swift
+        swift test --parallel
+
+    - name: Build
+      shell: bash
+      if: inputs.task == 'build'
+      run: |
+        cd foreign/swift
+        swift build -c release
+
+    # The manifest declares the Apple platforms, so one lane compiles and tests
+    # on macOS with Xcode's toolchain rather than trusting the Linux build.
+    - name: Build and test on macOS
+      shell: bash
+      if: inputs.task == 'build-macos'
+      run: |
+        cd foreign/swift
+        swift --version
+        swift build -Xswiftc -warnings-as-errors
+        swift test --parallel
diff --git a/.github/actions/utils/setup-swift/action.yml 
b/.github/actions/utils/setup-swift/action.yml
new file mode 100644
index 000000000..02d07c20f
--- /dev/null
+++ b/.github/actions/utils/setup-swift/action.yml
@@ -0,0 +1,56 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+name: setup-swift
+description: Install the Swift toolchain and cache the SwiftPM dependency 
checkouts and build products
+inputs:
+  swift-version:
+    description: "Swift toolchain version to install"
+    required: false
+    default: "6.1"
+  cache-key:
+    description: "Cache key segment naming the package that builds in the job; 
one constant per package so every lane shares one cache"
+    required: false
+    default: "sdk"
+
+runs:
+  using: "composite"
+  steps:
+    - name: Install Swift
+      uses: swift-actions/setup-swift@v2
+      with:
+        swift-version: ${{ inputs.swift-version }}
+
+    - name: Print Swift version
+      shell: bash
+      run: swift --version
+
+    # Package.resolved pins the dependency graph, so it keys the cache; the
+    # build directories hold the compiled dependencies, and SwiftPM's
+    # incremental build reuses them across source changes.
+    - name: Cache SwiftPM dependencies and build products
+      uses: actions/[email protected]
+      with:
+        path: |
+          foreign/swift/.build/checkouts
+          foreign/swift/.build/repositories
+          foreign/swift/.build/artifacts
+          foreign/swift/.build/debug
+          foreign/swift/.build/release
+        key: swiftpm-${{ runner.os }}-${{ inputs.swift-version }}-${{ 
inputs.cache-key }}-${{ hashFiles('foreign/swift/Package.resolved') }}
+        restore-keys: |
+          swiftpm-${{ runner.os }}-${{ inputs.swift-version }}-${{ 
inputs.cache-key }}-
diff --git a/.github/config/components.yml b/.github/config/components.yml
index 9ed475e6c..eb77a9764 100644
--- a/.github/config/components.yml
+++ b/.github/config/components.yml
@@ -336,6 +336,20 @@ components:
       - "foreign/cpp/**"
     tasks: ["lint", "build", "test"]
 
+  sdk-swift:
+    depends_on:
+      - "rust-sdk" # Swift SDK depends on core SDK
+      - "rust-server" # For integration tests
+      - "ci-infrastructure" # CI changes trigger full regression
+    paths:
+      - "foreign/swift/**"
+      - "bdd/swift/**"
+      - "examples/swift/**"
+      # The SDK compiles against the VSR wire contract, so a change to the
+      # protocol crate must rerun it.
+      - "core/binary_protocol/**"
+    tasks: ["lint", "test", "build", "build-macos"]
+
   # Shared BDD infrastructure that affects all BDD tests
   bdd-infrastructure:
     paths:
diff --git a/.github/workflows/_detect.yml b/.github/workflows/_detect.yml
index a3add24f8..c60e27ee5 100644
--- a/.github/workflows/_detect.yml
+++ b/.github/workflows/_detect.yml
@@ -44,6 +44,9 @@ on:
       cpp_matrix:
         description: "Matrix for C++ SDK"
         value: ${{ jobs.detect.outputs.cpp_matrix }}
+      swift_matrix:
+        description: "Matrix for Swift SDK"
+        value: ${{ jobs.detect.outputs.swift_matrix }}
       bdd_matrix:
         description: "Matrix for BDD tests"
         value: ${{ jobs.detect.outputs.bdd_matrix }}
@@ -69,6 +72,7 @@ jobs:
       java_matrix: ${{ steps.mk.outputs.java_matrix }}
       csharp_matrix: ${{ steps.mk.outputs.csharp_matrix }}
       cpp_matrix: ${{ steps.mk.outputs.cpp_matrix }}
+      swift_matrix: ${{ steps.mk.outputs.swift_matrix }}
       bdd_matrix: ${{ steps.mk.outputs.bdd_matrix }}
       examples_matrix: ${{ steps.mk.outputs.examples_matrix }}
       other_matrix: ${{ steps.mk.outputs.other_matrix }}
@@ -237,7 +241,7 @@ jobs:
               console.log(`Total files changed: ${files.length}`);
             }
 
-            const groups = { rust:[], python:[], php:[], node:[], go:[], 
java:[], csharp:[], cpp:[], bdd:[], examples:[], other:[] };
+            const groups = { rust:[], python:[], php:[], node:[], go:[], 
java:[], csharp:[], cpp:[], swift:[], bdd:[], examples:[], other:[] };
 
             // Process affected components and generate tasks
             console.log('');
@@ -266,6 +270,7 @@ jobs:
               else if (name === 'sdk-java') groups.java.push(...entries);
               else if (name === 'sdk-csharp') groups.csharp.push(...entries);
               else if (name === 'sdk-cpp') groups.cpp.push(...entries);
+              else if (name === 'sdk-swift') groups.swift.push(...entries);
               else if (name.startsWith('bdd-')) {
                 // Individual BDD tests should run separately with proper 
Docker setup
                 groups.bdd.push(...entries);
@@ -313,6 +318,7 @@ jobs:
               groups.java = [];
               groups.csharp = [];
               groups.cpp = [];
+              groups.swift = [];
               groups.bdd = [];
               groups.examples = [];
               groups.other = [];
@@ -328,6 +334,7 @@ jobs:
                 else if (name === 'sdk-java') groups.java.push(...entries);
                 else if (name === 'sdk-csharp') groups.csharp.push(...entries);
                 else if (name === 'sdk-cpp') groups.cpp.push(...entries);
+                else if (name === 'sdk-swift') groups.swift.push(...entries);
                 else if (name.startsWith('bdd-')) groups.bdd.push(...entries);
                 else if (name.startsWith('examples-')) 
groups.examples.push(...entries);
                 else groups.other.push(...entries);
@@ -365,6 +372,7 @@ jobs:
               { name: 'Java SDK', tasks: groups.java },
               { name: 'C# SDK', tasks: groups.csharp },
               { name: 'C++ SDK', tasks: groups.cpp },
+              { name: 'Swift SDK', tasks: groups.swift },
               { name: 'BDD Tests', tasks: groups.bdd },
               { name: 'Examples', tasks: groups.examples },
               { name: 'Other', tasks: groups.other }
@@ -398,6 +406,7 @@ jobs:
             setOutput('java_matrix', JSON.stringify(matrix(groups.java)));
             setOutput('csharp_matrix', JSON.stringify(matrix(groups.csharp)));
             setOutput('cpp_matrix', JSON.stringify(matrix(groups.cpp)));
+            setOutput('swift_matrix', JSON.stringify(matrix(groups.swift)));
             setOutput('bdd_matrix', JSON.stringify(matrix(groups.bdd)));
             setOutput('examples_matrix', 
JSON.stringify(matrix(groups.examples)));
             setOutput('other_matrix', JSON.stringify(matrix(groups.other)));
diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml
index 58ec5a3cc..4af5337f9 100644
--- a/.github/workflows/_test.yml
+++ b/.github/workflows/_test.yml
@@ -43,6 +43,7 @@ jobs:
     runs-on: ${{
       (inputs.task == 'build-aarch64-gnu' || inputs.task == 
'build-aarch64-musl') && 'ubuntu-24.04-arm' ||
       (inputs.task == 'build-macos-aarch64' || inputs.task == 
'clippy-macos-aarch64') && 'macos-14' ||
+      inputs.task == 'build-macos' && 'macos-15' ||
       inputs.task == 'build-windows-sdk' && 'windows-latest' ||
       'ubuntu-latest'
       }}
@@ -231,6 +232,13 @@ jobs:
         with:
           task: ${{ inputs.task }}
 
+      # Swift SDK
+      - name: Run Swift SDK task
+        if: inputs.component == 'sdk-swift'
+        uses: ./.github/actions/swift/pre-merge
+        with:
+          task: ${{ inputs.task }}
+
       - name: Upload Java coverage to Codecov
         if: inputs.component == 'sdk-java' && inputs.task == 'test'
         uses: codecov/[email protected]
diff --git a/.github/workflows/pre-merge.yml b/.github/workflows/pre-merge.yml
index 196755934..c7e4a9034 100644
--- a/.github/workflows/pre-merge.yml
+++ b/.github/workflows/pre-merge.yml
@@ -166,6 +166,19 @@ jobs:
       component: ${{ matrix.component }}
       task: ${{ matrix.task }}
 
+  # Swift SDK
+  test-swift:
+    name: Swift • ${{ matrix.task }}
+    needs: detect
+    if: ${{ fromJson(needs.detect.outputs.swift_matrix).include[0].component 
!= 'noop' }}
+    strategy:
+      fail-fast: true
+      matrix: ${{ fromJson(needs.detect.outputs.swift_matrix) }}
+    uses: ./.github/workflows/_test.yml
+    with:
+      component: ${{ matrix.component }}
+      task: ${{ matrix.task }}
+
   # Other components
   test-other:
     name: Other • ${{ matrix.component }}/${{ matrix.task }}
@@ -208,7 +221,7 @@ jobs:
   # Final status check
   finalize_pr:
     runs-on: ubuntu-24.04-arm
-    needs: [common, detect, test-rust, test-python, test-php, test-node, 
test-go, test-java, test-csharp, test-cpp, test-bdd, test-examples, test-other]
+    needs: [common, detect, test-rust, test-python, test-php, test-node, 
test-go, test-java, test-csharp, test-cpp, test-swift, test-bdd, test-examples, 
test-other]
     if: ${{ !cancelled() }}
     steps:
       - name: Get job execution times
@@ -292,6 +305,7 @@ jobs:
             const java = findJobInfo('Java •');
             const csharp = findJobInfo('C# •');
             const cpp = findJobInfo('C++ •');
+            const swift = findJobInfo('Swift •');
             const bdd = findJobInfo('BDD •');
             const examples = findJobInfo('Examples •');
             const other = findJobInfo('Other •');
@@ -313,6 +327,7 @@ jobs:
             core.setOutput('java_time', formatJobDuration(java));
             core.setOutput('csharp_time', formatJobDuration(csharp));
             core.setOutput('cpp_time', formatJobDuration(cpp));
+            core.setOutput('swift_time', formatJobDuration(swift));
             core.setOutput('bdd_time', formatJobDuration(bdd));
             core.setOutput('examples_time', formatJobDuration(examples));
             core.setOutput('other_time', formatJobDuration(other));
@@ -404,6 +419,7 @@ jobs:
           java_status=$(format_status "${{ needs.test-java.result }}" "${{ 
steps.times.outputs.java_time }}")
           csharp_status=$(format_status "${{ needs.test-csharp.result }}" "${{ 
steps.times.outputs.csharp_time }}")
           cpp_status=$(format_status "${{ needs.test-cpp.result }}" "${{ 
steps.times.outputs.cpp_time }}")
+          swift_status=$(format_status "${{ needs.test-swift.result }}" "${{ 
steps.times.outputs.swift_time }}")
           bdd_status=$(format_status "${{ needs.test-bdd.result }}" "${{ 
steps.times.outputs.bdd_time }}")
           examples_status=$(format_status "${{ needs.test-examples.result }}" 
"${{ steps.times.outputs.examples_time }}")
           other_status=$(format_status "${{ needs.test-other.result }}" "${{ 
steps.times.outputs.other_time }}")
@@ -416,6 +432,7 @@ jobs:
           echo "| ☕ Java | $java_status | ${{ steps.times.outputs.java_time }} 
|" >> "$GITHUB_STEP_SUMMARY"
           echo "| 🔷 C# | $csharp_status | ${{ steps.times.outputs.csharp_time 
}} |" >> "$GITHUB_STEP_SUMMARY"
           echo "| 🔶 C++ | $cpp_status | ${{ steps.times.outputs.cpp_time }} |" 
>> "$GITHUB_STEP_SUMMARY"
+          echo "| 🐦 Swift | $swift_status | ${{ steps.times.outputs.swift_time 
}} |" >> "$GITHUB_STEP_SUMMARY"
           echo "| 🧪 BDD | $bdd_status | ${{ steps.times.outputs.bdd_time }} |" 
>> "$GITHUB_STEP_SUMMARY"
           echo "| 📚 Examples | $examples_status | ${{ 
steps.times.outputs.examples_time }} |" >> "$GITHUB_STEP_SUMMARY"
           echo "| 📦 Other | $other_status | ${{ steps.times.outputs.other_time 
}} |" >> "$GITHUB_STEP_SUMMARY"
@@ -433,6 +450,7 @@ jobs:
              [[ "${{ needs.test-java.result }}" == "failure" ]] || \
              [[ "${{ needs.test-csharp.result }}" == "failure" ]] || \
              [[ "${{ needs.test-cpp.result }}" == "failure" ]] || \
+             [[ "${{ needs.test-swift.result }}" == "failure" ]] || \
              [[ "${{ needs.test-bdd.result }}" == "failure" ]] || \
              [[ "${{ needs.test-examples.result }}" == "failure" ]] || \
              [[ "${{ needs.test-other.result }}" == "failure" ]]; then
@@ -449,6 +467,7 @@ jobs:
                [[ "${{ needs.test-java.result }}" == "cancelled" ]] || \
                [[ "${{ needs.test-csharp.result }}" == "cancelled" ]] || \
                [[ "${{ needs.test-cpp.result }}" == "cancelled" ]] || \
+               [[ "${{ needs.test-swift.result }}" == "cancelled" ]] || \
                [[ "${{ needs.test-bdd.result }}" == "cancelled" ]] || \
                [[ "${{ needs.test-examples.result }}" == "cancelled" ]] || \
                [[ "${{ needs.test-other.result }}" == "cancelled" ]]; then
diff --git a/.gitignore b/.gitignore
index 4d402ce50..4c5ecbe94 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,6 +24,14 @@ foreign/cpp/build
 foreign/java/**/build
 foreign/java/**/out
 foreign/node/**/dist
+foreign/swift/.build
+foreign/swift/.swiftpm
+examples/swift/.build
+examples/swift/**/.build
+examples/swift/**/.swiftpm
+bdd/swift/.build
+bdd/swift/**/.build
+bdd/swift/**/.swiftpm
 foreign/node/npm-debug.log*
 foreign/node/.npm
 **/node_modules
diff --git a/foreign/swift/.swift-format b/foreign/swift/.swift-format
new file mode 100644
index 000000000..2580675bb
--- /dev/null
+++ b/foreign/swift/.swift-format
@@ -0,0 +1,22 @@
+{
+  "version": 1,
+  "lineLength": 160,
+  "indentation": { "spaces": 4 },
+  "maximumBlankLines": 1,
+  "respectsExistingLineBreaks": true,
+  "lineBreakBeforeControlFlowKeywords": false,
+  "lineBreakBeforeEachArgument": false,
+  "prioritizeKeepingFunctionOutputTogether": true,
+  "rules": {
+    "AlwaysUseLowerCamelCase": false,
+    "AmbiguousTrailingClosureOverload": true,
+    "NeverForceUnwrap": false,
+    "NeverUseForceTry": false,
+    "NeverUseImplicitlyUnwrappedOptionals": false,
+    "OrderedImports": true,
+    "UseLetInEveryBoundCaseVariable": false,
+    "NoBlockComments": true,
+    "OmitExplicitReturns": false,
+    "ValidateDocumentationComments": false
+  }
+}
diff --git a/foreign/swift/Package.swift b/foreign/swift/Package.swift
new file mode 100644
index 000000000..c644c9a27
--- /dev/null
+++ b/foreign/swift/Package.swift
@@ -0,0 +1,41 @@
+// swift-tools-version: 6.0
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import PackageDescription
+
+let package = Package(
+    name: "apache-iggy",
+    platforms: [
+        .macOS(.v13),
+        .iOS(.v16),
+        .tvOS(.v16),
+        .watchOS(.v9),
+        .visionOS(.v1),
+    ],
+    products: [
+        .library(name: "Iggy", targets: ["Iggy"])
+    ],
+    targets: [
+        .target(name: "Iggy"),
+        .testTarget(
+            name: "IggyTests",
+            dependencies: ["Iggy"]
+        ),
+    ],
+    swiftLanguageModes: [.v6]
+)
diff --git a/foreign/swift/README.md b/foreign/swift/README.md
new file mode 100644
index 000000000..1b2d243d4
--- /dev/null
+++ b/foreign/swift/README.md
@@ -0,0 +1,87 @@
+<div align="center">
+  <picture>
+    <source media="(prefers-color-scheme: dark)" 
srcset="https://raw.githubusercontent.com/apache/iggy/refs/heads/master/assets/logo/SVG/iggy-apache-color-darkbg.svg";>
+    <source media="(prefers-color-scheme: light)" 
srcset="https://raw.githubusercontent.com/apache/iggy/refs/heads/master/assets/logo/SVG/iggy-apache-color-lightbg.svg";>
+    <img alt="Apache Iggy" 
src="https://raw.githubusercontent.com/apache/iggy/refs/heads/master/assets/logo/SVG/iggy-apache-color-lightbg.svg";
 width="320">
+  </picture>
+</div>
+
+# Swift SDK for Iggy
+
+Official Swift client SDK for [Apache Iggy](https://iggy.apache.org) message 
streaming.
+
+The SDK is built up in stages. This stage carries the package scaffold, the 
error table,
+and the byte codec the wire protocol is encoded with. The protocol layer, the 
TCP and TLS
+client, the producer and consumer, examples, and BDD scenarios follow in later 
changes.
+
+## Requirements
+
+- Swift 6.0 or later (Swift 6 language mode, strict concurrency)
+- macOS 13, iOS 16, tvOS 16, watchOS 9, visionOS 1, or Linux with a Swift 6 
toolchain
+
+## Installation
+
+Swift Package Manager fetches a package from the root of a git repository, so 
the SDK
+cannot be depended on straight from this monorepo's subdirectory. Add it 
through the
+package mirror, which carries every release as a `v<version>` tag:
+
+```swift
+// Package.swift
+dependencies: [
+    .package(url: "https://github.com/apache/iggy-swift.git";, from: "0.1.0")
+],
+targets: [
+    .target(name: "MyApp", dependencies: [.product(name: "Iggy", package: 
"iggy-swift")])
+]
+```
+
+From a checkout of this repository, or a vendored copy, depend on it by path:
+
+```swift
+dependencies: [
+    .package(name: "apache-iggy", path: "../iggy/foreign/swift")
+],
+targets: [
+    .target(name: "MyApp", dependencies: [.product(name: "Iggy", package: 
"apache-iggy")])
+]
+```
+
+## Usage
+
+Every operation of the SDK throws `IggyError`, a value carrying the typed 
`code`
+(`IggyErrorCode`, one case per server error code), the raw wire code, and 
optional
+context:
+
+```swift
+import Iggy
+
+func handle(_ error: IggyError) {
+    switch error.code {
+    case .streamNameNotFound:
+        print("create the stream first")
+    default:
+        print("iggy failed: \(error)")
+    }
+}
+```
+
+The client that raises these errors lands in the next changes of the series; 
see the
+[Rust SDK](../../core/sdk) for the operations it mirrors.
+
+## Testing
+
+```bash
+cd foreign/swift
+swift test
+```
+
+## Contributing
+
+Format the sources with the toolchain's formatter before opening a pull 
request; CI runs it
+in lint mode and builds with warnings as errors:
+
+```bash
+swift format format --in-place --recursive Sources Tests Package.swift
+swift format lint --strict --recursive Sources Tests Package.swift
+swift build -Xswiftc -warnings-as-errors
+```
diff --git a/foreign/swift/Sources/Iggy/Errors/IggyError.swift 
b/foreign/swift/Sources/Iggy/Errors/IggyError.swift
new file mode 100644
index 000000000..96d4460bc
--- /dev/null
+++ b/foreign/swift/Sources/Iggy/Errors/IggyError.swift
@@ -0,0 +1,78 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+/// Error raised by every SDK operation.
+///
+/// The `code` is what the server answered with, or what the SDK decided
+/// locally (a dropped connection, a rejected argument). Match on it:
+///
+/// ```swift
+/// do {
+///     _ = try await client.getStream("orders")
+/// } catch let error as IggyError where error.code == .streamNameNotFound {
+///     // create it
+/// }
+/// ```
+///
+/// `context` carries whatever extra detail was available where the error was
+/// raised, such as the transport failure message or the protocol window the
+/// server accepts. It never carries credentials, and it takes no part in
+/// equality: two errors are equal when their codes are, as in the Rust SDK.
+public struct IggyError: Error, Sendable, Hashable, CustomStringConvertible {
+    /// The classified code.
+    public let code: IggyErrorCode
+    /// The exact code that arrived on the wire. Equal to `code.rawValue` 
unless
+    /// the server answered with a code this SDK does not know, in which case
+    /// `code` is ``IggyErrorCode/error`` and this keeps the original value.
+    public let rawCode: UInt32
+    /// Extra detail about the failure, when any was available.
+    public let context: String?
+
+    public init(_ code: IggyErrorCode, context: String? = nil) {
+        self.code = code
+        self.rawCode = code.rawValue
+        self.context = context
+    }
+
+    /// Maps a code received from the server, keeping unknown codes visible.
+    public init(wireCode: UInt32) {
+        if let code = IggyErrorCode(rawValue: wireCode) {
+            self.code = code
+            self.context = nil
+        } else {
+            self.code = .error
+            self.context = "unknown error code \(wireCode)"
+        }
+        self.rawCode = wireCode
+    }
+
+    public var description: String {
+        if let context {
+            return "\(code.name) (\(rawCode)): \(context)"
+        }
+        return "\(code.name) (\(rawCode))"
+    }
+
+    public static func == (lhs: IggyError, rhs: IggyError) -> Bool {
+        lhs.code == rhs.code && lhs.rawCode == rhs.rawCode
+    }
+
+    public func hash(into hasher: inout Hasher) {
+        hasher.combine(code)
+        hasher.combine(rawCode)
+    }
+}
diff --git a/foreign/swift/Sources/Iggy/Errors/IggyErrorCode.swift 
b/foreign/swift/Sources/Iggy/Errors/IggyErrorCode.swift
new file mode 100644
index 000000000..1b6940d1f
--- /dev/null
+++ b/foreign/swift/Sources/Iggy/Errors/IggyErrorCode.swift
@@ -0,0 +1,515 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Every discriminant the server can answer with, transcribed from
+// `core/common/src/error/iggy_error.rs`. The golden-vector generator that
+// follows in a later change dumps the same table and the tests compare.
+
+/// Numeric error code shared with every other Apache Iggy SDK and the server.
+///
+/// The raw value is the code that travels on the wire. Codes are stable across
+/// releases; a gap in the numbering is a retired code, never free space.
+public enum IggyErrorCode: UInt32, Sendable, Hashable, CaseIterable, Codable {
+    case error = 1
+    case invalidConfiguration = 2
+    case invalidCommand = 3
+    case invalidFormat = 4
+    case featureUnavailable = 5
+    case invalidIdentifier = 6
+    case invalidVersion = 7
+    case disconnected = 8
+    case cannotEstablishConnection = 9
+    case cannotCreateBaseDirectory = 10
+    case cannotCreateRuntimeDirectory = 11
+    case cannotRemoveRuntimeDirectory = 12
+    case cannotCreateStateDirectory = 13
+    case stateFileNotFound = 14
+    case stateFileCorrupted = 15
+    case invalidStateEntryChecksum = 16
+    case cannotOpenDatabase = 19
+    case resourceNotFound = 20
+    case cannotCloseWebSocketConnection = 21
+    case staleClient = 30
+    case tcpError = 31
+    case quicError = 32
+    case invalidServerAddress = 33
+    case invalidClientAddress = 34
+    case invalidIpAddress = 35
+    case httpError = 36
+    case invalidApiUrl = 37
+    case unauthenticated = 40
+    case unauthorized = 41
+    case invalidCredentials = 42
+    case invalidUsername = 43
+    case invalidPassword = 44
+    case invalidUserStatus = 45
+    case userAlreadyExists = 46
+    case userInactive = 47
+    case cannotDeleteUser = 48
+    case cannotChangePermissions = 49
+    case invalidPersonalAccessTokenName = 50
+    case personalAccessTokenAlreadyExists = 51
+    case personalAccessTokensLimitReached = 52
+    case invalidPersonalAccessToken = 53
+    case personalAccessTokenExpired = 54
+    case usersLimitReached = 55
+    case invalidPersonalAccessTokenExpiry = 56
+    case transientNotCommitted = 57
+    case transientNotAccepted = 58
+    case requestAlreadyApplied = 59
+    case notConnected = 61
+    case clientShutdown = 63
+    case invalidTlsDomain = 64
+    case invalidTlsCertificatePath = 65
+    case invalidTlsCertificate = 66
+    case failedToAddCertificate = 67
+    case invalidEncryptionKey = 70
+    case cannotEncryptData = 71
+    case cannotDecryptData = 72
+    case invalidJwtAlgorithm = 73
+    case invalidJwtSecret = 74
+    case jwtMissing = 75
+    case cannotGenerateJwt = 76
+    case accessTokenMissing = 77
+    case invalidAccessToken = 78
+    case cannotFetchJwks = 79
+    case invalidSizeBytes = 80
+    case invalidUtf8 = 81
+    case invalidNumberEncoding = 82
+    case invalidBooleanValue = 83
+    case invalidNumberValue = 84
+    case clientNotFound = 100
+    case invalidClientId = 101
+    case connectionClosed = 206
+    case cannotParseHeaderKind = 209
+    case httpResponseError = 300
+    case invalidHttpRequest = 301
+    case invalidJsonResponse = 302
+    case invalidBytesResponse = 303
+    case emptyResponse = 304
+    case cannotCreateEndpoint = 305
+    case cannotParseUrl = 306
+    case webSocketError = 400
+    case webSocketConnectionError = 401
+    case webSocketCloseError = 402
+    case webSocketReceiveError = 403
+    case webSocketSendError = 404
+    case cannotCreateStreamsDirectory = 1000
+    case cannotCreateStreamDirectory = 1001
+    case cannotCreateStreamInfo = 1002
+    case cannotUpdateStreamInfo = 1003
+    case cannotOpenStreamInfo = 1004
+    case cannotReadStreamInfo = 1005
+    case cannotCreateStream = 1006
+    case cannotDeleteStream = 1007
+    case cannotDeleteStreamDirectory = 1008
+    case streamIdNotFound = 1009
+    case streamNameNotFound = 1010
+    case streamDirectoryNotFound = 1011
+    case streamNameAlreadyExists = 1012
+    case invalidStreamName = 1013
+    case invalidStreamId = 1014
+    case cannotReadStreams = 1015
+    case invalidTopicSize = 1019
+    case tooManyStreams = 1020
+    case cannotCreateTopicsDirectory = 2000
+    case cannotCreateTopicDirectory = 2001
+    case cannotCreateTopicInfo = 2002
+    case cannotUpdateTopicInfo = 2003
+    case cannotOpenTopicInfo = 2004
+    case cannotReadTopicInfo = 2005
+    case cannotCreateTopic = 2006
+    case cannotDeleteTopic = 2007
+    case cannotDeleteTopicDirectory = 2008
+    case cannotPollTopic = 2009
+    case topicIdNotFound = 2010
+    case topicNameNotFound = 2011
+    case topicNameAlreadyExists = 2013
+    case invalidTopicName = 2014
+    case tooManyPartitions = 2015
+    case invalidTopicId = 2016
+    case cannotReadTopics = 2017
+    case invalidReplicationFactor = 2018
+    case invalidPartitionsCount = 2019
+    case topicDirectoryNotFound = 2020
+    case tooManyTopics = 2021
+    case cannotCreatePartition = 3000
+    case cannotCreatePartitionsDirectory = 3001
+    case cannotCreatePartitionDirectory = 3002
+    case cannotOpenPartitionLogFile = 3003
+    case cannotReadPartitions = 3004
+    case cannotDeletePartition = 3005
+    case cannotDeletePartitionDirectory = 3006
+    case partitionNotFound = 3007
+    case noPartitions = 3008
+    case topicFull = 3009
+    case cannotDeleteConsumerOffsetsDirectory = 3010
+    case cannotDeleteConsumerOffsetFile = 3011
+    case cannotCreateConsumerOffsetsDirectory = 3012
+    case partitionIdSpaceExhausted = 3013
+    case cannotReadConsumerOffsets = 3020
+    case consumerOffsetNotFound = 3021
+    case notResolvedConsumer = 3022
+    case cannotOpenConsumerOffsetsFile = 3023
+    case tooManyConsumerOffsets = 3024
+    case segmentNotFound = 4000
+    case segmentClosed = 4001
+    case invalidSegmentSize = 4002
+    case cannotCreateSegmentLogFile = 4003
+    case cannotCreateSegmentIndexFile = 4004
+    case cannotCreateSegmentTimeIndexFile = 4005
+    case cannotSaveMessagesToSegment = 4006
+    case cannotSaveIndexToSegment = 4007
+    case cannotSaveTimeIndexToSegment = 4008
+    case invalidMessagesCount = 4009
+    case cannotAppendMessage = 4010
+    case cannotReadMessage = 4011
+    case cannotReadMessageId = 4012
+    case cannotReadMessageState = 4013
+    case cannotReadMessageTimestamp = 4014
+    case cannotReadHeadersLength = 4015
+    case cannotReadHeadersPayload = 4016
+    case tooBigUserHeaders = 4017
+    case invalidHeaderKey = 4018
+    case invalidHeaderValue = 4019
+    case cannotReadMessageLength = 4020
+    case cannotReadMessagePayload = 4021
+    case tooBigMessagePayload = 4022
+    case tooManyMessages = 4023
+    case emptyMessagePayload = 4024
+    case invalidMessagePayloadLength = 4025
+    case cannotReadMessageChecksum = 4026
+    case invalidMessageChecksum = 4027
+    case invalidKeyValueLength = 4028
+    case commandLengthError = 4029
+    case invalidSegmentsCount = 4030
+    case nonZeroOffset = 4031
+    case nonZeroTimestamp = 4032
+    case missingIndex = 4033
+    case invalidIndexesByteSize = 4034
+    case invalidIndexesCount = 4035
+    case invalidMessagesSize = 4036
+    case tooSmallMessage = 4037
+    case invalidMessageTimestampDelta = 4038
+    case invalidBatchChecksum = 4039
+    case invalidHeaderKind = 4040
+    case unsupportedOptionKey = 4041
+    case invalidOptionValue = 4042
+    case optionsBlockTooLarge = 4043
+    case cannotSendMessagesDueToClientDisconnection = 4050
+    case backgroundSendError = 4051
+    case backgroundSendTimeout = 4052
+    case backgroundSendBufferFull = 4053
+    case backgroundWorkerDisconnected = 4054
+    case backgroundSendBufferOverflow = 4055
+    case producerSendFailed = 4056
+    case producerClosed = 4057
+    case invalidOffset = 4100
+    case invalidReservedField = 4101
+    case segmentSizeMismatchAtOpen = 4102
+    case consumerGroupIdNotFound = 5000
+    case invalidConsumerGroupId = 5002
+    case consumerGroupNameNotFound = 5003
+    case consumerGroupNameAlreadyExists = 5004
+    case invalidConsumerGroupName = 5005
+    case consumerGroupMemberNotFound = 5006
+    case cannotCreateConsumerGroupInfo = 5007
+    case cannotDeleteConsumerGroupInfo = 5008
+    case consumerGroupPartitionNotOwned = 5009
+    case missingBaseOffsetRetainedMessageBatch = 6000
+    case missingLastOffsetDeltaRetainedMessageBatch = 6001
+    case missingMaxTimestampRetainedMessageBatch = 6002
+    case missingLengthRetainedMessageBatch = 6003
+    case missingPayloadRetainedMessageBatch = 6004
+    case cannotReadBatchBaseOffset = 7000
+    case cannotReadBatchLength = 7001
+    case cannotReadLastOffsetDelta = 7002
+    case cannotReadMaxTimestamp = 7003
+    case cannotReadBatchPayload = 7004
+    case invalidConnectionString = 8000
+    case snapshotFileCompletionFailed = 9000
+    case cannotSerializeResource = 10000
+    case cannotDeserializeResource = 10001
+    case cannotReadFile = 10002
+    case cannotReadFileMetadata = 10003
+    case cannotSeekFile = 10004
+    case cannotAppendToFile = 10005
+    case cannotWriteToFile = 10006
+    case cannotOverwriteFile = 10007
+    case cannotDeleteFile = 10008
+    case cannotSyncFile = 10009
+    case cannotReadIndexOffset = 10010
+    case cannotReadIndexPosition = 10011
+    case cannotReadIndexTimestamp = 10012
+    case timestampOutOfRange = 10013
+    case shardNotFound = 11000
+    case shardCommunicationError = 11001
+    case cannotBindToSocket = 12000
+    case taskTimeout = 12001
+    case ioError = 13000
+    case alreadyAuthenticated = 14000
+    case invalidSession = 14001
+    case incompatibleProtocolVersion = 14003
+}
+
+extension IggyErrorCode {
+    /// Snake-case name of the code, identical to the Rust SDK spelling.
+    public var name: String {
+        switch self {
+        case .error: "error"
+        case .invalidConfiguration: "invalid_configuration"
+        case .invalidCommand: "invalid_command"
+        case .invalidFormat: "invalid_format"
+        case .featureUnavailable: "feature_unavailable"
+        case .invalidIdentifier: "invalid_identifier"
+        case .invalidVersion: "invalid_version"
+        case .disconnected: "disconnected"
+        case .cannotEstablishConnection: "cannot_establish_connection"
+        case .cannotCreateBaseDirectory: "cannot_create_base_directory"
+        case .cannotCreateRuntimeDirectory: "cannot_create_runtime_directory"
+        case .cannotRemoveRuntimeDirectory: "cannot_remove_runtime_directory"
+        case .cannotCreateStateDirectory: "cannot_create_state_directory"
+        case .stateFileNotFound: "state_file_not_found"
+        case .stateFileCorrupted: "state_file_corrupted"
+        case .invalidStateEntryChecksum: "invalid_state_entry_checksum"
+        case .cannotOpenDatabase: "cannot_open_database"
+        case .resourceNotFound: "resource_not_found"
+        case .cannotCloseWebSocketConnection: 
"cannot_close_web_socket_connection"
+        case .staleClient: "stale_client"
+        case .tcpError: "tcp_error"
+        case .quicError: "quic_error"
+        case .invalidServerAddress: "invalid_server_address"
+        case .invalidClientAddress: "invalid_client_address"
+        case .invalidIpAddress: "invalid_ip_address"
+        case .httpError: "http_error"
+        case .invalidApiUrl: "invalid_api_url"
+        case .unauthenticated: "unauthenticated"
+        case .unauthorized: "unauthorized"
+        case .invalidCredentials: "invalid_credentials"
+        case .invalidUsername: "invalid_username"
+        case .invalidPassword: "invalid_password"
+        case .invalidUserStatus: "invalid_user_status"
+        case .userAlreadyExists: "user_already_exists"
+        case .userInactive: "user_inactive"
+        case .cannotDeleteUser: "cannot_delete_user"
+        case .cannotChangePermissions: "cannot_change_permissions"
+        case .invalidPersonalAccessTokenName: 
"invalid_personal_access_token_name"
+        case .personalAccessTokenAlreadyExists: 
"personal_access_token_already_exists"
+        case .personalAccessTokensLimitReached: 
"personal_access_tokens_limit_reached"
+        case .invalidPersonalAccessToken: "invalid_personal_access_token"
+        case .personalAccessTokenExpired: "personal_access_token_expired"
+        case .usersLimitReached: "users_limit_reached"
+        case .invalidPersonalAccessTokenExpiry: 
"invalid_personal_access_token_expiry"
+        case .transientNotCommitted: "transient_not_committed"
+        case .transientNotAccepted: "transient_not_accepted"
+        case .requestAlreadyApplied: "request_already_applied"
+        case .notConnected: "not_connected"
+        case .clientShutdown: "client_shutdown"
+        case .invalidTlsDomain: "invalid_tls_domain"
+        case .invalidTlsCertificatePath: "invalid_tls_certificate_path"
+        case .invalidTlsCertificate: "invalid_tls_certificate"
+        case .failedToAddCertificate: "failed_to_add_certificate"
+        case .invalidEncryptionKey: "invalid_encryption_key"
+        case .cannotEncryptData: "cannot_encrypt_data"
+        case .cannotDecryptData: "cannot_decrypt_data"
+        case .invalidJwtAlgorithm: "invalid_jwt_algorithm"
+        case .invalidJwtSecret: "invalid_jwt_secret"
+        case .jwtMissing: "jwt_missing"
+        case .cannotGenerateJwt: "cannot_generate_jwt"
+        case .accessTokenMissing: "access_token_missing"
+        case .invalidAccessToken: "invalid_access_token"
+        case .cannotFetchJwks: "cannot_fetch_jwks"
+        case .invalidSizeBytes: "invalid_size_bytes"
+        case .invalidUtf8: "invalid_utf8"
+        case .invalidNumberEncoding: "invalid_number_encoding"
+        case .invalidBooleanValue: "invalid_boolean_value"
+        case .invalidNumberValue: "invalid_number_value"
+        case .clientNotFound: "client_not_found"
+        case .invalidClientId: "invalid_client_id"
+        case .connectionClosed: "connection_closed"
+        case .cannotParseHeaderKind: "cannot_parse_header_kind"
+        case .httpResponseError: "http_response_error"
+        case .invalidHttpRequest: "invalid_http_request"
+        case .invalidJsonResponse: "invalid_json_response"
+        case .invalidBytesResponse: "invalid_bytes_response"
+        case .emptyResponse: "empty_response"
+        case .cannotCreateEndpoint: "cannot_create_endpoint"
+        case .cannotParseUrl: "cannot_parse_url"
+        case .webSocketError: "web_socket_error"
+        case .webSocketConnectionError: "web_socket_connection_error"
+        case .webSocketCloseError: "web_socket_close_error"
+        case .webSocketReceiveError: "web_socket_receive_error"
+        case .webSocketSendError: "web_socket_send_error"
+        case .cannotCreateStreamsDirectory: "cannot_create_streams_directory"
+        case .cannotCreateStreamDirectory: "cannot_create_stream_directory"
+        case .cannotCreateStreamInfo: "cannot_create_stream_info"
+        case .cannotUpdateStreamInfo: "cannot_update_stream_info"
+        case .cannotOpenStreamInfo: "cannot_open_stream_info"
+        case .cannotReadStreamInfo: "cannot_read_stream_info"
+        case .cannotCreateStream: "cannot_create_stream"
+        case .cannotDeleteStream: "cannot_delete_stream"
+        case .cannotDeleteStreamDirectory: "cannot_delete_stream_directory"
+        case .streamIdNotFound: "stream_id_not_found"
+        case .streamNameNotFound: "stream_name_not_found"
+        case .streamDirectoryNotFound: "stream_directory_not_found"
+        case .streamNameAlreadyExists: "stream_name_already_exists"
+        case .invalidStreamName: "invalid_stream_name"
+        case .invalidStreamId: "invalid_stream_id"
+        case .cannotReadStreams: "cannot_read_streams"
+        case .invalidTopicSize: "invalid_topic_size"
+        case .tooManyStreams: "too_many_streams"
+        case .cannotCreateTopicsDirectory: "cannot_create_topics_directory"
+        case .cannotCreateTopicDirectory: "cannot_create_topic_directory"
+        case .cannotCreateTopicInfo: "cannot_create_topic_info"
+        case .cannotUpdateTopicInfo: "cannot_update_topic_info"
+        case .cannotOpenTopicInfo: "cannot_open_topic_info"
+        case .cannotReadTopicInfo: "cannot_read_topic_info"
+        case .cannotCreateTopic: "cannot_create_topic"
+        case .cannotDeleteTopic: "cannot_delete_topic"
+        case .cannotDeleteTopicDirectory: "cannot_delete_topic_directory"
+        case .cannotPollTopic: "cannot_poll_topic"
+        case .topicIdNotFound: "topic_id_not_found"
+        case .topicNameNotFound: "topic_name_not_found"
+        case .topicNameAlreadyExists: "topic_name_already_exists"
+        case .invalidTopicName: "invalid_topic_name"
+        case .tooManyPartitions: "too_many_partitions"
+        case .invalidTopicId: "invalid_topic_id"
+        case .cannotReadTopics: "cannot_read_topics"
+        case .invalidReplicationFactor: "invalid_replication_factor"
+        case .invalidPartitionsCount: "invalid_partitions_count"
+        case .topicDirectoryNotFound: "topic_directory_not_found"
+        case .tooManyTopics: "too_many_topics"
+        case .cannotCreatePartition: "cannot_create_partition"
+        case .cannotCreatePartitionsDirectory: 
"cannot_create_partitions_directory"
+        case .cannotCreatePartitionDirectory: 
"cannot_create_partition_directory"
+        case .cannotOpenPartitionLogFile: "cannot_open_partition_log_file"
+        case .cannotReadPartitions: "cannot_read_partitions"
+        case .cannotDeletePartition: "cannot_delete_partition"
+        case .cannotDeletePartitionDirectory: 
"cannot_delete_partition_directory"
+        case .partitionNotFound: "partition_not_found"
+        case .noPartitions: "no_partitions"
+        case .topicFull: "topic_full"
+        case .cannotDeleteConsumerOffsetsDirectory: 
"cannot_delete_consumer_offsets_directory"
+        case .cannotDeleteConsumerOffsetFile: 
"cannot_delete_consumer_offset_file"
+        case .cannotCreateConsumerOffsetsDirectory: 
"cannot_create_consumer_offsets_directory"
+        case .partitionIdSpaceExhausted: "partition_id_space_exhausted"
+        case .cannotReadConsumerOffsets: "cannot_read_consumer_offsets"
+        case .consumerOffsetNotFound: "consumer_offset_not_found"
+        case .notResolvedConsumer: "not_resolved_consumer"
+        case .cannotOpenConsumerOffsetsFile: 
"cannot_open_consumer_offsets_file"
+        case .tooManyConsumerOffsets: "too_many_consumer_offsets"
+        case .segmentNotFound: "segment_not_found"
+        case .segmentClosed: "segment_closed"
+        case .invalidSegmentSize: "invalid_segment_size"
+        case .cannotCreateSegmentLogFile: "cannot_create_segment_log_file"
+        case .cannotCreateSegmentIndexFile: "cannot_create_segment_index_file"
+        case .cannotCreateSegmentTimeIndexFile: 
"cannot_create_segment_time_index_file"
+        case .cannotSaveMessagesToSegment: "cannot_save_messages_to_segment"
+        case .cannotSaveIndexToSegment: "cannot_save_index_to_segment"
+        case .cannotSaveTimeIndexToSegment: "cannot_save_time_index_to_segment"
+        case .invalidMessagesCount: "invalid_messages_count"
+        case .cannotAppendMessage: "cannot_append_message"
+        case .cannotReadMessage: "cannot_read_message"
+        case .cannotReadMessageId: "cannot_read_message_id"
+        case .cannotReadMessageState: "cannot_read_message_state"
+        case .cannotReadMessageTimestamp: "cannot_read_message_timestamp"
+        case .cannotReadHeadersLength: "cannot_read_headers_length"
+        case .cannotReadHeadersPayload: "cannot_read_headers_payload"
+        case .tooBigUserHeaders: "too_big_user_headers"
+        case .invalidHeaderKey: "invalid_header_key"
+        case .invalidHeaderValue: "invalid_header_value"
+        case .cannotReadMessageLength: "cannot_read_message_length"
+        case .cannotReadMessagePayload: "cannot_read_message_payload"
+        case .tooBigMessagePayload: "too_big_message_payload"
+        case .tooManyMessages: "too_many_messages"
+        case .emptyMessagePayload: "empty_message_payload"
+        case .invalidMessagePayloadLength: "invalid_message_payload_length"
+        case .cannotReadMessageChecksum: "cannot_read_message_checksum"
+        case .invalidMessageChecksum: "invalid_message_checksum"
+        case .invalidKeyValueLength: "invalid_key_value_length"
+        case .commandLengthError: "command_length_error"
+        case .invalidSegmentsCount: "invalid_segments_count"
+        case .nonZeroOffset: "non_zero_offset"
+        case .nonZeroTimestamp: "non_zero_timestamp"
+        case .missingIndex: "missing_index"
+        case .invalidIndexesByteSize: "invalid_indexes_byte_size"
+        case .invalidIndexesCount: "invalid_indexes_count"
+        case .invalidMessagesSize: "invalid_messages_size"
+        case .tooSmallMessage: "too_small_message"
+        case .invalidMessageTimestampDelta: "invalid_message_timestamp_delta"
+        case .invalidBatchChecksum: "invalid_batch_checksum"
+        case .invalidHeaderKind: "invalid_header_kind"
+        case .unsupportedOptionKey: "unsupported_option_key"
+        case .invalidOptionValue: "invalid_option_value"
+        case .optionsBlockTooLarge: "options_block_too_large"
+        case .cannotSendMessagesDueToClientDisconnection: 
"cannot_send_messages_due_to_client_disconnection"
+        case .backgroundSendError: "background_send_error"
+        case .backgroundSendTimeout: "background_send_timeout"
+        case .backgroundSendBufferFull: "background_send_buffer_full"
+        case .backgroundWorkerDisconnected: "background_worker_disconnected"
+        case .backgroundSendBufferOverflow: "background_send_buffer_overflow"
+        case .producerSendFailed: "producer_send_failed"
+        case .producerClosed: "producer_closed"
+        case .invalidOffset: "invalid_offset"
+        case .invalidReservedField: "invalid_reserved_field"
+        case .segmentSizeMismatchAtOpen: "segment_size_mismatch_at_open"
+        case .consumerGroupIdNotFound: "consumer_group_id_not_found"
+        case .invalidConsumerGroupId: "invalid_consumer_group_id"
+        case .consumerGroupNameNotFound: "consumer_group_name_not_found"
+        case .consumerGroupNameAlreadyExists: 
"consumer_group_name_already_exists"
+        case .invalidConsumerGroupName: "invalid_consumer_group_name"
+        case .consumerGroupMemberNotFound: "consumer_group_member_not_found"
+        case .cannotCreateConsumerGroupInfo: 
"cannot_create_consumer_group_info"
+        case .cannotDeleteConsumerGroupInfo: 
"cannot_delete_consumer_group_info"
+        case .consumerGroupPartitionNotOwned: 
"consumer_group_partition_not_owned"
+        case .missingBaseOffsetRetainedMessageBatch: 
"missing_base_offset_retained_message_batch"
+        case .missingLastOffsetDeltaRetainedMessageBatch: 
"missing_last_offset_delta_retained_message_batch"
+        case .missingMaxTimestampRetainedMessageBatch: 
"missing_max_timestamp_retained_message_batch"
+        case .missingLengthRetainedMessageBatch: 
"missing_length_retained_message_batch"
+        case .missingPayloadRetainedMessageBatch: 
"missing_payload_retained_message_batch"
+        case .cannotReadBatchBaseOffset: "cannot_read_batch_base_offset"
+        case .cannotReadBatchLength: "cannot_read_batch_length"
+        case .cannotReadLastOffsetDelta: "cannot_read_last_offset_delta"
+        case .cannotReadMaxTimestamp: "cannot_read_max_timestamp"
+        case .cannotReadBatchPayload: "cannot_read_batch_payload"
+        case .invalidConnectionString: "invalid_connection_string"
+        case .snapshotFileCompletionFailed: "snapshot_file_completion_failed"
+        case .cannotSerializeResource: "cannot_serialize_resource"
+        case .cannotDeserializeResource: "cannot_deserialize_resource"
+        case .cannotReadFile: "cannot_read_file"
+        case .cannotReadFileMetadata: "cannot_read_file_metadata"
+        case .cannotSeekFile: "cannot_seek_file"
+        case .cannotAppendToFile: "cannot_append_to_file"
+        case .cannotWriteToFile: "cannot_write_to_file"
+        case .cannotOverwriteFile: "cannot_overwrite_file"
+        case .cannotDeleteFile: "cannot_delete_file"
+        case .cannotSyncFile: "cannot_sync_file"
+        case .cannotReadIndexOffset: "cannot_read_index_offset"
+        case .cannotReadIndexPosition: "cannot_read_index_position"
+        case .cannotReadIndexTimestamp: "cannot_read_index_timestamp"
+        case .timestampOutOfRange: "timestamp_out_of_range"
+        case .shardNotFound: "shard_not_found"
+        case .shardCommunicationError: "shard_communication_error"
+        case .cannotBindToSocket: "cannot_bind_to_socket"
+        case .taskTimeout: "task_timeout"
+        case .ioError: "io_error"
+        case .alreadyAuthenticated: "already_authenticated"
+        case .invalidSession: "invalid_session"
+        case .incompatibleProtocolVersion: "incompatible_protocol_version"
+        }
+    }
+}
diff --git a/foreign/swift/Sources/Iggy/Iggy.swift 
b/foreign/swift/Sources/Iggy/Iggy.swift
new file mode 100644
index 000000000..bddbbbe7e
--- /dev/null
+++ b/foreign/swift/Sources/Iggy/Iggy.swift
@@ -0,0 +1,25 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+/// Version identity the SDK sends to the server during the login handshake.
+public enum IggyVersion {
+    /// Name of this SDK as reported to the server.
+    public static let sdkName = "swift-sdk"
+    /// Version of this SDK as reported to the server. Provisional until the
+    /// release wiring lands; the release tooling reads this constant.
+    public static let sdkVersion = "0.1.0"
+}
diff --git a/foreign/swift/Sources/Iggy/Utilities/UInt128Value.swift 
b/foreign/swift/Sources/Iggy/Utilities/UInt128Value.swift
new file mode 100644
index 000000000..0aaf5fcbc
--- /dev/null
+++ b/foreign/swift/Sources/Iggy/Utilities/UInt128Value.swift
@@ -0,0 +1,156 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import Foundation
+
+/// An unsigned 128-bit integer split into two 64-bit halves.
+///
+/// Message identifiers and 128-bit user headers travel as `u128` on the wire.
+/// The standard library's `UInt128` is only available from macOS 15 / iOS 18,
+/// so the SDK carries its own representation that works on every supported
+/// platform. `low` holds the least significant 64 bits.
+public struct UInt128Value: Sendable, Hashable, Codable {
+    public var low: UInt64
+    public var high: UInt64
+
+    public init(low: UInt64, high: UInt64) {
+        self.low = low
+        self.high = high
+    }
+
+    public init(_ value: UInt64) {
+        self.init(low: value, high: 0)
+    }
+
+    /// The 16 bytes of a UUID interpreted as a little-endian integer, which is
+    /// how the Rust SDK derives message identifiers from `Uuid::to_u128_le`.
+    public init(uuid: UUID) {
+        (low, high) = withUnsafeBytes(of: uuid.uuid) { raw in
+            (UInt64(littleEndian: raw.loadUnaligned(as: UInt64.self)), 
UInt64(littleEndian: raw.loadUnaligned(fromByteOffset: 8, as: UInt64.self)))
+        }
+    }
+
+    public static let zero = UInt128Value(low: 0, high: 0)
+
+    /// A random identifier, never zero.
+    public static func random() -> UInt128Value {
+        var generator = SystemRandomNumberGenerator()
+        var value = UInt128Value(low: generator.next(), high: generator.next())
+        if value == .zero {
+            value.low = 1
+        }
+        return value
+    }
+
+    public var isZero: Bool { low == 0 && high == 0 }
+
+    /// Little-endian wire bytes: the low half first, then the high half.
+    public var littleEndianBytes: [UInt8] {
+        withUnsafeBytes(of: (low.littleEndian, high.littleEndian)) { Array($0) 
}
+    }
+
+    /// Decodes 16 little-endian bytes. `bytes` must hold exactly 16 bytes;
+    /// any other length is a programming error and traps, since every
+    /// caller slices a bounds-checked buffer.
+    public init(littleEndianBytes bytes: ArraySlice<UInt8>) {
+        precondition(bytes.count == 16, "a 128-bit value needs exactly 16 
bytes")
+        (low, high) = bytes.withUnsafeBytes { raw in
+            (UInt64(littleEndian: raw.loadUnaligned(as: UInt64.self)), 
UInt64(littleEndian: raw.loadUnaligned(fromByteOffset: 8, as: UInt64.self)))
+        }
+    }
+
+    /// The value reinterpreted as a UUID (bytes in little-endian order).
+    public var uuid: UUID {
+        withUnsafeBytes(of: (low.littleEndian, high.littleEndian)) { raw in
+            UUID(uuid: raw.load(as: uuid_t.self))
+        }
+    }
+}
+
+extension UInt128Value: ExpressibleByIntegerLiteral {
+    public init(integerLiteral value: UInt64) {
+        self.init(value)
+    }
+}
+
+extension UInt128Value: Comparable {
+    public static func < (lhs: UInt128Value, rhs: UInt128Value) -> Bool {
+        if lhs.high != rhs.high {
+            return lhs.high < rhs.high
+        }
+        return lhs.low < rhs.low
+    }
+}
+
+extension UInt128Value: CustomStringConvertible {
+    /// Decimal rendering, matching how the Rust SDK prints a `u128`.
+    public var description: String {
+        if high == 0 {
+            return String(low)
+        }
+        var digits: [UInt8] = []
+        var currentHigh = high
+        var currentLow = low
+        while currentHigh != 0 || currentLow != 0 {
+            let (quotientHigh, remainderHigh) = 
currentHigh.quotientAndRemainder(dividingBy: 10)
+            let (quotientLow, remainderLow) = UInt64.divide(high: 
remainderHigh, low: currentLow, by: 10)
+            digits.append(UInt8(remainderLow))
+            currentHigh = quotientHigh
+            currentLow = quotientLow
+        }
+        return String(digits.reversed().map { Character(String($0)) })
+    }
+
+    /// Parses the decimal rendering produced by ``description``.
+    public init?(_ text: String) {
+        guard !text.isEmpty, text.utf8.allSatisfy({ $0 >= 48 && $0 <= 57 }) 
else {
+            return nil
+        }
+        var value = UInt128Value.zero
+        for digit in text.utf8 {
+            let (mulHigh, mulLow, mulOverflow) = value.multipliedBy10()
+            if mulOverflow {
+                return nil
+            }
+            let (low, carry) = mulLow.addingReportingOverflow(UInt64(digit - 
48))
+            let (high, overflow) = mulHigh.addingReportingOverflow(carry ? 1 : 
0)
+            if overflow {
+                return nil
+            }
+            value = UInt128Value(low: low, high: high)
+        }
+        self = value
+    }
+
+    private func multipliedBy10() -> (high: UInt64, low: UInt64, overflow: 
Bool) {
+        let (lowHigh, lowLow) = low.multipliedFullWidth(by: 10)
+        let (highHigh, highLow) = high.multipliedFullWidth(by: 10)
+        let (newHigh, carry) = highLow.addingReportingOverflow(lowHigh)
+        return (newHigh, lowLow, highHigh != 0 || carry)
+    }
+}
+
+extension UInt64 {
+    /// Divides the 128-bit value `high:low` by `divisor`, assuming the 
quotient
+    /// fits in 64 bits (`high < divisor`).
+    fileprivate static func divide(high: UInt64, low: UInt64, by divisor: 
UInt64) -> (quotient: UInt64, remainder: UInt64) {
+        divisor.dividingFullWidth((high: high, low: low))
+    }
+}
+
+/// Identifier of a message, unique within a partition.
+public typealias MessageID = UInt128Value
diff --git a/foreign/swift/Sources/Iggy/Wire/ByteCodec.swift 
b/foreign/swift/Sources/Iggy/Wire/ByteCodec.swift
new file mode 100644
index 000000000..6233b1cef
--- /dev/null
+++ b/foreign/swift/Sources/Iggy/Wire/ByteCodec.swift
@@ -0,0 +1,218 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import Foundation
+
+/// Failure while encoding or decoding wire bytes. Internal to the codec; the
+/// protocol layer maps it onto an ``IggyError`` where a reply is decoded.
+enum WireError: Error, Equatable {
+    case truncated(offset: Int, need: Int, have: Int)
+    case invalidUTF8(offset: Int)
+    case validation(String)
+}
+
+/// Little-endian append-only encoder over a byte array.
+///
+/// Pass the encoded size to ``init(capacity:)`` when it is known up front,
+/// as the batch encoder does, so the buffer is allocated once.
+struct ByteWriter {
+    private(set) var bytes: [UInt8]
+
+    init(capacity: Int = 0) {
+        bytes = []
+        bytes.reserveCapacity(capacity)
+    }
+
+    mutating func write(_ value: UInt8) {
+        bytes.append(value)
+    }
+
+    mutating func write(_ value: Bool) {
+        bytes.append(value ? 1 : 0)
+    }
+
+    mutating func write(_ value: UInt16) {
+        withUnsafeBytes(of: value.littleEndian) { bytes.append(contentsOf: $0) 
}
+    }
+
+    mutating func write(_ value: UInt32) {
+        withUnsafeBytes(of: value.littleEndian) { bytes.append(contentsOf: $0) 
}
+    }
+
+    mutating func write(_ value: UInt64) {
+        withUnsafeBytes(of: value.littleEndian) { bytes.append(contentsOf: $0) 
}
+    }
+
+    mutating func write(_ value: Float) {
+        write(value.bitPattern)
+    }
+
+    mutating func write(_ value: UInt128Value) {
+        write(value.low)
+        write(value.high)
+    }
+
+    mutating func write(_ value: [UInt8]) {
+        bytes.append(contentsOf: value)
+    }
+
+    mutating func write(_ value: ArraySlice<UInt8>) {
+        bytes.append(contentsOf: value)
+    }
+
+    mutating func write(_ value: String) {
+        bytes.append(contentsOf: value.utf8)
+    }
+
+    /// `[len: u8][utf8]`, the layout of every wire name, which must be 1 to
+    /// 255 bytes long.
+    mutating func writeName(_ value: String) throws {
+        let length = value.utf8.count
+        guard length >= 1, length <= Int(UInt8.max) else {
+            throw WireError.validation("wire name must be 1-255 bytes, got 
\(length)")
+        }
+        bytes.append(UInt8(length))
+        bytes.append(contentsOf: value.utf8)
+    }
+
+    /// `[len: u32][utf8]`, the layout of longer free-form strings.
+    mutating func writeLongString(_ value: String) throws {
+        guard let length = UInt32(exactly: value.utf8.count) else {
+            throw WireError.validation("string of \(value.utf8.count) bytes 
exceeds the u32 length prefix")
+        }
+        write(length)
+        bytes.append(contentsOf: value.utf8)
+    }
+}
+
+/// Little-endian cursor over a byte slice. Every read is bounds-checked and
+/// fails with ``WireError/truncated(offset:need:have:)`` instead of trapping,
+/// because the bytes come from the network.
+struct ByteReader {
+    let bytes: ArraySlice<UInt8>
+    private(set) var offset: Int
+
+    init(_ bytes: ArraySlice<UInt8>) {
+        self.bytes = bytes
+        self.offset = bytes.startIndex
+    }
+
+    init(_ bytes: [UInt8]) {
+        self.init(bytes[...])
+    }
+
+    var isAtEnd: Bool { offset >= bytes.endIndex }
+    var remaining: Int { bytes.endIndex - offset }
+    /// Position relative to the start of the buffer handed to the reader.
+    var position: Int { offset - bytes.startIndex }
+
+    private func require(_ count: Int) throws {
+        if remaining < count {
+            throw WireError.truncated(offset: position, need: count, have: 
remaining)
+        }
+    }
+
+    mutating func readUInt8() throws -> UInt8 {
+        try require(1)
+        let value = bytes[offset]
+        offset += 1
+        return value
+    }
+
+    mutating func readBool() throws -> Bool {
+        try readUInt8() != 0
+    }
+
+    mutating func readUInt16() throws -> UInt16 {
+        UInt16(littleEndianBytes: try readBytes(2))
+    }
+
+    mutating func readUInt32() throws -> UInt32 {
+        UInt32(littleEndianBytes: try readBytes(4))
+    }
+
+    mutating func readUInt64() throws -> UInt64 {
+        UInt64(littleEndianBytes: try readBytes(8))
+    }
+
+    mutating func readFloat() throws -> Float {
+        Float(bitPattern: try readUInt32())
+    }
+
+    mutating func readUInt128() throws -> UInt128Value {
+        let low = try readUInt64()
+        let high = try readUInt64()
+        return UInt128Value(low: low, high: high)
+    }
+
+    mutating func readBytes(_ count: Int) throws -> ArraySlice<UInt8> {
+        try require(count)
+        let slice = bytes[offset..<offset + count]
+        offset += count
+        return slice
+    }
+
+    mutating func readString(_ count: Int) throws -> String {
+        let start = position
+        let slice = try readBytes(count)
+        guard let value = String(bytes: slice, encoding: .utf8) else {
+            throw WireError.invalidUTF8(offset: start)
+        }
+        return value
+    }
+
+    /// `[len: u8][utf8]` with the 1...255 byte bound every wire name carries.
+    mutating func readName() throws -> String {
+        let length = Int(try readUInt8())
+        if length == 0 {
+            throw WireError.validation("wire name must be 1-255 bytes, got 0")
+        }
+        return try readString(length)
+    }
+
+    /// `[len: u32][utf8]`.
+    mutating func readLongString() throws -> String {
+        let declared = try readUInt32()
+        // A length that does not fit the platform's Int cannot be in the
+        // buffer either; report it as truncation rather than trapping.
+        guard let length = Int(exactly: declared) else {
+            throw WireError.truncated(offset: position, need: Int.max, have: 
remaining)
+        }
+        return try readString(length)
+    }
+}
+
+extension UInt16 {
+    /// Decodes the first two bytes of `bytes`, which the caller has 
bounds-checked.
+    init(littleEndianBytes bytes: ArraySlice<UInt8>) {
+        self = bytes.withUnsafeBytes { UInt16(littleEndian: 
$0.loadUnaligned(as: UInt16.self)) }
+    }
+}
+
+extension UInt32 {
+    /// Decodes the first four bytes of `bytes`, which the caller has 
bounds-checked.
+    init(littleEndianBytes bytes: ArraySlice<UInt8>) {
+        self = bytes.withUnsafeBytes { UInt32(littleEndian: 
$0.loadUnaligned(as: UInt32.self)) }
+    }
+}
+
+extension UInt64 {
+    /// Decodes the first eight bytes of `bytes`, which the caller has 
bounds-checked.
+    init(littleEndianBytes bytes: ArraySlice<UInt8>) {
+        self = bytes.withUnsafeBytes { UInt64(littleEndian: 
$0.loadUnaligned(as: UInt64.self)) }
+    }
+}
diff --git a/foreign/swift/Tests/IggyTests/ByteCodecTests.swift 
b/foreign/swift/Tests/IggyTests/ByteCodecTests.swift
new file mode 100644
index 000000000..e293cc2f7
--- /dev/null
+++ b/foreign/swift/Tests/IggyTests/ByteCodecTests.swift
@@ -0,0 +1,120 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import Foundation
+import Testing
+
+@testable import Iggy
+
+@Suite("Byte codec")
+struct ByteCodecTests {
+    @Test func integersRoundTripLittleEndian() throws {
+        var writer = ByteWriter()
+        writer.write(UInt8(0xAB))
+        writer.write(UInt16(0x0102))
+        writer.write(UInt32(0x0304_0506))
+        writer.write(UInt64(0x0708_090A_0B0C_0D0E))
+        writer.write(UInt128Value(low: 1, high: 2))
+        writer.write(Float(1.5))
+        writer.write(true)
+        try writer.writeName("ab")
+        try writer.writeLongString("cd")
+        #expect(writer.bytes[0..<7] == [0xAB, 0x02, 0x01, 0x06, 0x05, 0x04, 
0x03])
+
+        var reader = ByteReader(writer.bytes)
+        #expect(try reader.readUInt8() == 0xAB)
+        #expect(try reader.readUInt16() == 0x0102)
+        #expect(try reader.readUInt32() == 0x0304_0506)
+        #expect(try reader.readUInt64() == 0x0708_090A_0B0C_0D0E)
+        #expect(try reader.readUInt128() == UInt128Value(low: 1, high: 2))
+        #expect(try reader.readFloat() == 1.5)
+        #expect(try reader.readBool())
+        #expect(try reader.readName() == "ab")
+        #expect(try reader.readLongString() == "cd")
+        #expect(reader.isAtEnd)
+    }
+
+    @Test func truncatedReadsFailInsteadOfTrapping() {
+        var reader = ByteReader([1, 2, 3])
+        #expect(throws: WireError.truncated(offset: 0, need: 4, have: 3)) {
+            try reader.readUInt32()
+        }
+        #expect(throws: WireError.self) {
+            try reader.readBytes(4)
+        }
+        var name = ByteReader([5, 0x61])
+        #expect(throws: WireError.self) {
+            try name.readName()
+        }
+        var empty = ByteReader([0])
+        #expect(throws: WireError.self) {
+            try empty.readName()
+        }
+    }
+
+    @Test func namesOutsideTheWireBoundAreRejected() {
+        var writer = ByteWriter()
+        #expect(throws: WireError.self) { try writer.writeName("") }
+        #expect(throws: WireError.self) { try 
writer.writeName(String(repeating: "x", count: 256)) }
+        #expect(writer.bytes.isEmpty)
+        #expect(throws: Never.self) { try writer.writeName(String(repeating: 
"x", count: 255)) }
+        #expect(writer.bytes.count == 256)
+    }
+
+    @Test func invalidUTF8IsRejected() {
+        var reader = ByteReader([2, 0xFF, 0xFE])
+        #expect(throws: WireError.invalidUTF8(offset: 1)) {
+            try reader.readName()
+        }
+    }
+
+    @Test func slicesReadRelativeToTheirStart() throws {
+        let bytes: [UInt8] = [9, 9, 9, 1, 0, 0, 0]
+        var reader = ByteReader(bytes[3...])
+        #expect(try reader.readUInt32() == 1)
+        #expect(reader.position == 4)
+    }
+}
+
+@Suite("128-bit values")
+struct UInt128ValueTests {
+    @Test func decimalRenderingAndParsing() {
+        #expect(UInt128Value(low: 0, high: 0).description == "0")
+        #expect(UInt128Value(low: UInt64.max, high: 0).description == 
"18446744073709551615")
+        #expect(UInt128Value(low: 0, high: 1).description == 
"18446744073709551616")
+        #expect(UInt128Value(low: UInt64.max, high: UInt64.max).description == 
"340282366920938463463374607431768211455")
+        #expect(UInt128Value("340282366920938463463374607431768211455") == 
UInt128Value(low: UInt64.max, high: UInt64.max))
+        #expect(UInt128Value("18446744073709551616") == UInt128Value(low: 0, 
high: 1))
+        #expect(UInt128Value("340282366920938463463374607431768211456") == nil)
+        #expect(UInt128Value("") == nil)
+        #expect(UInt128Value("12a") == nil)
+    }
+
+    @Test func uuidRoundTrip() {
+        let uuid = UUID()
+        let value = UInt128Value(uuid: uuid)
+        #expect(value.uuid == uuid)
+        #expect(UInt128Value(littleEndianBytes: value.littleEndianBytes[...]) 
== value)
+        #expect(!UInt128Value.random().isZero)
+        #expect(UInt128Value.random() != UInt128Value.random())
+    }
+
+    @Test func ordering() {
+        #expect(UInt128Value(low: 0, high: 1) > UInt128Value(low: UInt64.max, 
high: 0))
+        #expect(UInt128Value(low: 2, high: 1) > UInt128Value(low: 1, high: 1))
+    }
+}
diff --git a/foreign/swift/Tests/IggyTests/ErrorCodeTests.swift 
b/foreign/swift/Tests/IggyTests/ErrorCodeTests.swift
new file mode 100644
index 000000000..50750bf33
--- /dev/null
+++ b/foreign/swift/Tests/IggyTests/ErrorCodeTests.swift
@@ -0,0 +1,55 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import Testing
+
+@testable import Iggy
+
+@Suite("Error codes")
+struct ErrorCodeTests {
+    /// Pins the table to the server's `IggyError` discriminants: the count,
+    /// both ends, and a code from every band.
+    @Test func tableMatchesTheServerCodes() {
+        #expect(IggyErrorCode.allCases.count == 240)
+        #expect(IggyErrorCode.error.rawValue == 1)
+        #expect(IggyErrorCode.allCases.map(\.rawValue).min() == 1)
+        #expect(IggyErrorCode.allCases.map(\.rawValue).max() == 14003)
+        #expect(IggyErrorCode.incompatibleProtocolVersion.rawValue == 14003)
+        #expect(IggyErrorCode(rawValue: 57) == .transientNotCommitted)
+        #expect(IggyErrorCode(rawValue: 58) == .transientNotAccepted)
+        #expect(IggyErrorCode(rawValue: 1009) == .streamIdNotFound)
+        #expect(IggyErrorCode(rawValue: 2010) == .topicIdNotFound)
+        #expect(IggyErrorCode(rawValue: 4042) == .invalidOptionValue)
+        #expect(IggyErrorCode(rawValue: 5006) == .consumerGroupMemberNotFound)
+        #expect(IggyErrorCode.streamIdNotFound.name == "stream_id_not_found")
+        #expect(Set(IggyErrorCode.allCases.map(\.name)).count == 
IggyErrorCode.allCases.count)
+    }
+
+    @Test func unknownWireCodesKeepTheRawValue() {
+        let error = IggyError(wireCode: 65_535)
+        #expect(error.code == .error)
+        #expect(error.rawCode == 65_535)
+        #expect(IggyError(wireCode: 
IggyErrorCode.streamIdNotFound.rawValue).code == .streamIdNotFound)
+    }
+
+    @Test func equalityIgnoresContext() {
+        #expect(IggyError(.invalidFormat, context: "x") == 
IggyError(.invalidFormat))
+        #expect(IggyError(.invalidFormat) != IggyError(.invalidCommand))
+        #expect(IggyError(wireCode: 65_535) != IggyError(.error))
+        #expect(IggyError(.invalidFormat, context: "x").hashValue == 
IggyError(.invalidFormat).hashValue)
+    }
+}
diff --git a/licenserc.toml b/licenserc.toml
index 7339eac25..4957b49f8 100644
--- a/licenserc.toml
+++ b/licenserc.toml
@@ -79,6 +79,12 @@ excludes = [
     "**/META-INF/services/**",
     ".github/config/hawkeye.version",
     "**/.python-version",
+    "**/Package.resolved",
+    "**/.swift-format",
+    # SwiftPM requires `// swift-tools-version` as the very first line, which 
the
+    # header parser reads as part of the header. The header is still present.
+    "**/Package.swift",
+    "**/.build/**",
     "**/gradle/wrapper/**",
     "**/.clang-tidy",
     # cargo-php regenerates this file with an extra separator after the header.
@@ -93,6 +99,7 @@ extensions = [
     "php",
     "rs",
     "js",
+    "swift",
     "ts",
 ]
 style_out = "doubleslash"

Reply via email to