joseph-cursio/swiftidempotency
Compile-time type enforcement and test scaffolding for idempotent operations
What it provides
Three tiers of safety. Strongest first.
### 1. Compile-time enforcement via `IdempotencyKey`
```swift
import SwiftIdempotency
func chargeCard(amount: Int, idempotencyKey: IdempotencyKey) async throws { ... }
// ❌ Compile error: cannot convert UUID to IdempotencyKey
chargeCard(amount: 100, idempotencyKey: UUID())
// ✅ Works: key derived from a stable webhook event id
chargeCard(amount: 100, idempotencyKey: IdempotencyKey(fromEntity: webhookEvent))
// ✅ Works but auditable: explicit label signals "I checked this"
chargeCard(amount: 100, idempotencyKey: IdempotencyKey(fromAuditedString: "stripe-charge-2026-q2"))
```
The type has **no `init()`**, no `init(_ uuid: UUID)`, no
`ExpressibleByStringLiteral`. The only construction paths are
`init(from:)` (requires `Identifiable`) and `init(fromAuditedString:)`
(requires the caller to explicitly audit a string as stable across
retries). Using `UUID()` or `Date()` as a key becomes a type error — not
a runtime mistake, not a lint finding, a compile-time failure.
### 2. Annotation attributes the linter reads
```swift
import SwiftIdempotency
@Idempotent
func upsertUser(id: UserID, data: UserData) throws { ... }
@NonIdempotent
func sendWelcomeEmail(to user: User) async throws { ... }
@Observational
func logAudit(_ event: AuditEvent) { ... }
@ExternallyIdempotent(by: "idempotencyKey")
func chargeCard(amount: Int, idempotencyKey: IdempotencyKey) async throws { ... }
```
Equivalent to the doc-comment form `/// @lint.effect idempotent` etc.
SwiftProjectLint's idempotency rules recognise both forms — pick the
idiom that fits your codebase, or mix and match. Linter's
`idempotencyViolation` and `nonIdempotentInRetryContext` rules fire
identically for either form.
### 3. Test scaffolding via `@IdempotencyTests` and `#assertIdempotent`
For **zero-argument functions**, attach `@IdempotencyTests` to the
enclosing `@Suite` type. For each `@Idempotent`-marked zero-argument
member, the macro emits one `@Test` method in a generated extension:
```swift
@Suite
@IdempotencyTests
struct MaintenanceChecks {
@Idempotent
func currentSystemStatus() -> Int { 200 }
@Idempotent
func flushCaches() async throws { ... }
}
// Macro-generated:
// extension MaintenanceChecks {
// @Test func testIdempotencyOfCurrentSystemStatus() async throws {
// let (first, second) = await SwiftIdempotency.__idempotencyInvokeTwice {
// currentSystemStatus()
// }
// #expect(first == second)
// }
// @Test func testIdempotencyOfFlushCaches() async throws {
// let (first, second) = try await SwiftIdempotency.__idempotencyInvokeTwice {
// try await flushCaches()
// }
// #expect(first == second)
// }
// }
```
The expansion is effect-aware — `try` and `await` appear only when the
target's signature requires them, so non-throwing targets don't
produce spurious `"no calls to throwing functions occur within 'try'
expression"` warnings.
For parameterised functions, use the freestanding `#assertIdempotent`
expression macro at a specific call site. The macro has sync and async
overloads; Swift's overload resolution picks the right one based on the
closure's effects, so callers just write what their closure needs:
```swift
// Sync — no `await` in the body, compiles without it at the call site.
@Test func chargeIsIdempotent() throws {
let event = StripeEvent(id: "evt_abc123")
let result = try #assertIdempotent {
try processPayment(for: event)
}
#expect(result.status == .succeeded)
}
// Async — `await` in the body forces the async overload.
@Test func webhookIsIdempotent() async throws {
let payload = WebhookPayload(eventId: "evt_abc123", amount: 250)
let result = try await #assertIdempotent {
try await handleWebhook(payload: payload, store: store)
}
#expect(result.status == "succeeded")
}
```
Both overloads invoke the closure twice, compare return values via
`Equatable`, abort via `precondition` on mismatch, and return the first
result.
#### Comparing structured responses
`#assertIdempotent` compares return values via `Equatable`, so its
answer is only as sharp as the type's `==`. For primitives and typed
models with synthesised `Equatable`, that's exactly right. For **raw
response bytes** — `Data` buffers of JSON, encoded protobufs, etc. —
`Equatable` is on the byte sequence, and that's not guaranteed stable:
`JSONEncoder` key ordering and most framework response encoders are
non-deterministic, so two semantically-identical responses can diverge
on the wire.
**Decode before comparing:**
```swift
@Test func webhookReplaySafe() async throws {
try await app.test(.router) { client in
let result = try await #assertIdempotent {
try await client.execute(
uri: "/webhook",
method: .post,
headers: [.contentType: "application/json"],
body: ByteBuffer(data: requestBody)
) { response -> ChargeResult in
try JSONDecoder().decode(
ChargeResult.self,
from: Data(buffer: response.body)
)
}
}
#expect(result.status == "succeeded")
}
}
```
The closure returns the *decoded* `ChargeResult`, whose synthesised
`Equatable` compares field-by-field. The assertion is stable regardless
of the encoder's key ordering. The same caveat and fix apply to the
`@IdempotencyTests` auto-generated tests — prefer a typed return over
raw bytes in the target function.
#### What `#assertIdempotent` cannot detect
`#assertIdempotent` compares return values. It does **not** observe
side effects that don't appear in the return type. A handler that
mutates a database, emits a notification, or publishes to a queue —
and returns `HTTPStatus.ok` (or `Void`, or `Bool`, or `.created`)
regardless — will silently pass `#assertIdempotent` even when it's
demonstrably non-idempotent at the observable-state level.
```swift
// Non-idempotent: double-writes to Redis, returns .ok either way.
func startLiveActivity(req: Request, /* ... */) async throws -> HTTPStatus {
_ = try await req.redis.hset("data", to: json, in: key).get()
_ = try await req.redis.zadd(element, to: scheduleKey).get()
return .ok
}
// #assertIdempotent "passes" — both calls returned .ok.
// Redis state now has duplicate entries. Assertion is silent.
try await #assertIdempotent { try await startLiveActivity(...) }
```
The macro is sharpest on handlers whose return value reflects the
side effect — a `create → Entity` handler, an `increment → Int`, a
`fetch → [Row]`. On handlers with trivial returns, pair the macro
with explicit state inspection:
```swift
@Test func startActivityIsIdempotent() async throws {
let redis = try await makeEphemeralRedis()
let key = IdempotencyKey(fromAuditedString: "test-key-001")
let body = StartLiveActivityRequest(/* ... */, idempotencyKey: key)
_ = try await #assertIdempotent {
try await startLiveActivity(req: req, body: body, idempotencyKey: key)
}
let sessionCount = try await redis.hlen("data").get()
#expect(sessionCount == 1) // catches what Option C cannot
}
```
Treat return-equality as a **necessary but not sufficient** check
on any handler whose return type doesn't reflect the side effect.
Pair `#assertIdempotent` with effect-observation testing (see the
next section) for handlers where the return type is trivial.
SwiftProjectLint's `nonIdempotentInRetryContext` rule fills part
of the gap statically — it flags handlers that call known-non-
idempotent operations inside a `@lint.context replayable` or
`@ExternallyIdempotent(by:)` body — but static analysis has its
own shape of limitation. Neither layer is complete on its own.
### 4. Effect-observation testing via `IdempotentEffectRecorder`
When the return value can't tell you whether a handler is idempotent,
watch what the handler *does* instead. v0.3.0 ships
`IdempotentEffectRecorder` — a protocol you conform test doubles to —
and `assertIdempotentEffects(recorders:body:)` — a helper that runs a
body twice and asserts no recorder observed new side effects on the
second call.
```swift
import SwiftIdempotency
import SwiftIdempotencyTestSupport
import Testing
// Test double — increments on every observable mutation/write/send.
final class MockCoinRepo: IdempotentEffectRecorder {
private(set) var effectCount = 0
private(set) var puts: [CoinEntry] = []
func putItem(_ entry: CoinEntry) async throws {
puts.append(entry)
effectCount += 1
}
}
@Test("addUser is idempotent when keyed on the request id")
func addUserIsIdempotent() async throws {
let coinRepo = MockCoinRepo()
let handler = UsersHandler(coinRepo: coinRepo)
let request = UserRequest(id: "req-42", amount: 10)
try await assertIdempotentEffects(recorders: [coinRepo]) {
_ = try await handler.handleAddUser(entry: request)
}
// Passes iff the second invocation's snapshot equals the first —
// i.e. coinRepo saw zero new puts on retry.
}
```
Reads should NOT count. The point of Option B is to detect
*observable-state-changing* retries; a handler that reads a row
twice is idempotent, a handler that writes twice is not.
#### When to reach for Option B vs `#assertIdempotent`
| Your handler… | Use |
|---|---|
| Returns a typed model with meaningful `Equatable` | `#assertIdempotent` |
| Returns `Void` / `HTTPStatus.ok` / `Bool` / other trivial type | `assertIdempotentEffects` |
| Writes to a DB / sends a message / publishes to a queue and returns a status | `assertIdempotentEffects` (add both if the return type is also meaningful) |
| Returns a non-`Equatable` reference type | `assertIdempotentEffects`, or project to a struct |
The two layers compose — use both when the handler has both a
meaningful return value *and* observable side effects.
#### Failure mode: `preconditionFailure` (default) vs `issueRecord`
```swift
// Default: aborts the process via Swift.preconditionFailure on a
// non-idempotent body. Matches #assertIdempotent's failure mode.
try await assertIdempotentEffects(recorders: [mockDB]) {
try await handler.run()
}
// Swift Testing path: reports via Testing.Issue.record without
// aborting. Useful for failure-path tests wrapped in withKnownIssue.
await withKnownIssue {
await assertIdempotentEffects(
recorders: [mockDB],
failureMode: .issueRecord
) {
await nonIdempotentHandler.run()
}
}
```
#### Richer snapshots via the `Snapshot` associatedtype
`IdempotentEffectRecorder` has an associated `Snapshot: Equatable`
that defaults to `Int` (backed by `effectCount`). Conform and you
get count-based comparison for free. Override with any `Equatable`
type — an ordered call log, a dictionary of per-operation counters,
whatever captures the state you care about — to detect non-
idempotency invisible to counts alone (e.g. retries that undo-then-
redo, leaving count unchanged but call order diverged).
```swift
final class DetailedMock: IdempotentEffectRecorder {
typealias Snapshot = [String] // ordered call log
private(set) var callLog: [String] = []
var effectCount: Int { callLog.count }
func snapshot() -> [String] { callLog }
func putItem(_ id: String) { callLog.append("put(\(id))") }
func deleteItem(_ id: String) { callLog.append("del(\(id))") }
}
```
The protocol lives in the main `SwiftIdempotency` target (not
`SwiftIdempotencyTestSupport`) so production mocks — observability
shims, retry instrumentation — can conform without pulling in the
test-support dependency. The `assertIdempotentEffects` helper lives
in `SwiftIdempotencyTestSupport` because it imports `Testing` for
the `.issueRecord` failure mode.
The shape was validated end-to-end in the Penny bot
package-integration trial against an adopter-realistic coin-double-
grant bug — see [`docs/penny-package-trial/`](docs/penny-package-trial/)
for the full trial artifacts (scope, findings, retrospective).Installation
// Package.swift
dependencies: [
.package(url: "https://github.com/Joseph-Cursio/SwiftIdempotency.git", from: "0.3.0"),
],
targets: [
.target(
name: "YourApp",
dependencies: [
.product(name: "SwiftIdempotency", package: "SwiftIdempotency"),
]
),
.testTarget(
name: "YourAppTests",
dependencies: [
.product(name: "SwiftIdempotency", package: "SwiftIdempotency"),
// Only add when using assertIdempotentEffects (Option B).
// #assertIdempotent's runtime helpers live in the main
// SwiftIdempotency library; test targets using only that
// macro don't need this product.
.product(name: "SwiftIdempotencyTestSupport", package: "SwiftIdempotency"),
]
),
]Swift 5.10+; requires Swift Testing for @Test-based peer expansion and for the .issueRecord failure mode of assertIdempotentEffects.
Vapor adopters using Swift Testing: import VaporTesting rather than XCTVapor. XCTVapor's app.test(...) silently drops failures when invoked from a Swift Testing @Suite — Vapor itself warns at runtime if you try — and VaporTesting is the Swift Testing-native counterpart exposing the same API shape.
AWS Lambda adopters: swift-aws-lambda-events event types (SQSEvent.Message, SNSEvent.Record, etc.) are Decodable-only — they expose no public memberwise initialiser, so tests can't synthesise synthetic events via struct initialisation. Factor your per-event business logic into functions that take the specific primitive fields they need (messageId: String, body: String, ...) and unwrap the event envelope at the framework boundary. That shape also lets @ExternallyIdempotent(by: "messageId") point at a real parameter label — the dotted-path form by: "message.messageId" is rejected at macro-expansion time.
Migrating inline-closure handlers
The attribute macros (@Idempotent, @NonIdempotent, @Observational, @ExternallyIdempotent) attach to declarations — func, var, struct, etc. They do not attach to expressions like inline trailing closures, which is the idiomatic route-registration shape in both Vapor and Hummingbird:
// ❌ Attribute macros can't attach to an inline closure.
app.post("charge") { req async throws -> Response in
let body = try req.content.decode(ChargeRequest.self)
// handler body
}To add @ExternallyIdempotent(by:) et al. to a handler in this shape, extract the body into a named function and call it from the closure:
// ✅ Named decl — attribute macros attach cleanly.
@ExternallyIdempotent(by: "idempotencyKey")
func charge(
req: Request,
body: ChargeRequest,
idempotencyKey: IdempotencyKey
) async throws -> Response {
// handler body
}
// Registration becomes a thin decode-and-delegate.
app.post("charge") { req async throws -> Response in
let body = try req.content.decode(ChargeRequest.self)
return try await charge(req: req, body: body, idempotencyKey: body.idempotencyKey)
}The refactor is mechanical but invasive on codebases that use inline closures heavily — expect one new file per handler and a two-to-three- line registration delegate replacing each body. Worked example with full diff: luka-vapor package-integration trial.
The same-named doc-comment form (/// @lint.effect externally_idempotent(by: "idempotencyKey")) has the identical constraint: doc comments attach to declarations, not to closure expressions. This is a Swift-attribute-and-doc-comment constraint, not a macro-specific one.
Context annotations are different.
/// @lint.context replayableon an enclosing function (e.g.,func routes(_ app:)) walks into inline trailing closures registered inside it — so the retry-context linter rule reaches closure bodies without this refactor. Only the per-handler effect declarations (@ExternallyIdempotent(by:)etc.) require the extraction.
Using with Fluent ORM
Vapor's Fluent ORM is the biggest persistence library in the Vapor
ecosystem. Integrating SwiftIdempotency with Fluent adopters hits
three rough edges worth calling out up front — all have known
workarounds, and the idiomatic integration path sidesteps the first
two entirely.
### Header-sourced keys are the idiomatic path
Route through `init(fromAuditedString:)` with an HTTP header, falling
back to a natural business-key field when the header is absent:
```swift
app.post("api", "acronym") { req async throws -> Acronym in
let acronym = try req.content.decode(Acronym.self)
let keyString = req.headers.first(name: "Idempotency-Key") ?? acronym.short
let key = IdempotencyKey(fromAuditedString: keyString)
return try await createAcronym(req: req, acronym: acronym, idempotencyKey: key)
}
@ExternallyIdempotent(by: "idempotencyKey")
func createAcronym(
req: Request,
acronym: Acronym,
idempotencyKey: IdempotencyKey
) async throws -> Acronym {
try await acronym.save(on: req.db)
return acronym
}
```
Stripe-convention-aligned, integrates with `Request.headers` without
adapter code, and the fallback to the business key gives clients that
don't supply the header deterministic dedup via the adopter's own
data.
Create handlers have a bootstrap problem that rules out
`init(fromEntity:)` anyway — a Fluent Model being created has no
`id` until after save, so the entity can't be the source of its own
pre-save dedup key. `init(fromEntity:)` is a post-save-read-or-lookup
tool, not a create-handler tool.
### Post-save keys: `init(fromFluentModel:)` (v0.2.0+)
For post-save handlers that want to key from the saved entity,
add the `SwiftIdempotencyFluent` product to your adopter
Package.swift alongside `SwiftIdempotency`:
```swift
// Package.swift
.package(url: "https://github.com/Joseph-Cursio/SwiftIdempotency.git",
from: "0.2.0"),
// ...
.product(name: "SwiftIdempotency", package: "SwiftIdempotency"),
.product(name: "SwiftIdempotencyFluent", package: "SwiftIdempotency"),
```
Then construct the key directly from the Fluent Model:
```swift
import SwiftIdempotency
import SwiftIdempotencyFluent
// Post-save handler:
let key = try IdempotencyKey(fromFluentModel: savedAcronym)
// → key.rawValue == savedAcronym.requireID().uuidString
```
The initializer throws `FluentError.idRequired` if the Model's
`id` is nil (pre-save) — the same error `model.requireID()`
throws. For pre-save create handlers, route through the header-
sourced path above instead.
Zero adopter-side boilerplate: no `Identifiable` adapter struct,
no force-unwrap. The bare Fluent `Model` works directly, and the
throwing init surfaces the pre-save state as a clean Swift error
rather than a runtime crash.
**Supported `IDValue` types:** any that conform to
`CustomStringConvertible` — `UUID`, `Int`, `String`, and typed
wrappers around those satisfy this. Models using `@CompositeID`
have a custom struct `IDValue` and are rejected at compile time;
route those through `init(fromAuditedString:)` on a manually-
composed string.
### Pre-v0.2.0 fallback: `init(fromEntity:)` via an adapter struct
If you're pinned to SwiftIdempotency `< 0.2.0` and can't add the
`SwiftIdempotencyFluent` product, the original adapter-struct
pattern still works:
```swift
struct IdentifiableAcronym: Identifiable {
let acronym: Acronym
var id: UUID { acronym.id! } // safe only post-save
init(_ acronym: Acronym) { self.acronym = acronym }
}
let key = IdempotencyKey(fromEntity: IdentifiableAcronym(savedAcronym))
```
One adapter per Model type the adopter keys on, force-unwrap on
the Optional id. Third-party Model types defined outside the
adopter's module may not be retroactively conformable this way;
fall back to `init(fromAuditedString:)` on a stable business key
for those. Upgrading to v0.2.0+ and switching to
`init(fromFluentModel:)` is recommended.
### `#assertIdempotent` on Model returns needs an Equatable projection
Fluent `Model` is `final class` without explicit `Equatable`
conformance. `#assertIdempotent` requires an `Equatable` return —
handing a Model-returning closure directly produces a compile
error. **Use a dedicated Equatable `struct`** to project the
fields the assertion cares about.
Tuples do NOT work here, despite Swift synthesising `==` for
tuples whose elements are `Equatable`. The synthesised `==` is
not the same as `Equatable` *protocol* conformance, and
`#assertIdempotent`'s generic `<Result: Equatable>` constraint
rejects tuples at type-check time with
```
error: type '(UUID?, String, String)' cannot conform to 'Equatable'
note: only concrete types such as structs, enums and classes can
conform to protocols
note: required by macro 'assertIdempotent' where
'Result' = '(UUID?, String, String)'
```
Only named types can satisfy protocol constraints. The correct
pattern:
```swift
struct AcronymProjection: Equatable {
let id: UUID?
let short: String
let long: String
}
// ❌ Acronym is a final class; Equatable not synthesized.
_ = try #assertIdempotent {
try await Acronym.find(id, on: db)
}
// ❌ Tuple return — does not satisfy the macro's Equatable constraint.
_ = try #assertIdempotent {
let a = try await Acronym.find(id, on: db)!
return (a.id, a.short, a.long)
}
// ✅ Dedicated Equatable struct.
_ = try #assertIdempotent {
let a = try await Acronym.find(id, on: db)!
return AcronymProjection(id: a.id, short: a.short, long: a.long)
}
```
Size the projection struct to whichever fields matter for the
operation being asserted. For create handlers, include the
mutable `id: UUID?`: a non-idempotent create produces distinct
UUIDs across the two invocations, the projections compare
unequal, and the precondition fires.
See
[`examples/swiftdata-sample/`](examples/swiftdata-sample/) for a
fully-compiling example of this pattern against a SwiftData
`@Model` type; the shape is identical across Fluent `Model`,
SwiftData `@Model`, and any other non-Equatable reference return.
### Full worked migration
These patterns are drawn from a fully-compiling adopter trial with
passing tests — see
[`docs/hellovapor-package-trial/`](docs/hellovapor-package-trial/)
for the complete migration diff and the findings that motivated each
pattern. The earlier
[`docs/luka-vapor-package-trial/`](docs/luka-vapor-package-trial/)
covers the first adopter-integration round (non-Fluent Vapor, same
inline-closure refactor shape).Using with SwiftData
SwiftData is Apple's first-party persistence layer on iOS 17+ / macOS 14+. Unlike Fluent, SwiftData's @Model macro emits PersistentModel conformance with an inherited Identifiable conformance — so the SwiftIdempotencyFluent product isn't needed here. The integration turns on a single question: does the adopter's @Model type expose a stable identifier named id, or something else?
Clean path: @Model with id: UUID or id: String
When the adopter names their identifier id, fromEntity: works out of the box. The user's declared id satisfies Identifiable.id; SwiftData's synthesized persistentModelID: PersistentIdentifier sits alongside as a separate property.
import SwiftData
import SwiftIdempotency
@Model
final class OfflineAlbum {
@Attribute(.unique) var id: String
var name: String
var favorite: Bool
init(id: String, name: String, favorite: Bool) {
self.id = id
self.name = name
self.favorite = favorite
}
}
let album = OfflineAlbum(id: "album-42", name: "Kind of Blue", favorite: true)
let key = IdempotencyKey(fromEntity: album)
// key.rawValue == "album-42"@Attribute(.unique) on id is recommended — it makes the persistence layer the final dedup gate, which pairs naturally with the idempotency-key flow at the handler layer. String, UUID, Int, and typed wrappers over those all satisfy CustomStringConvertible and work with fromEntity:.
Business-named-UUID path: annotationId / uuid / pk / etc.
Real iOS codebases often name their stable identifier something other than id — annotationId, uuid, pk, domain-specific names. fromEntity: does not reach these types: Swift's Identifiable synthesis requires a member named id (or an explicit typealias ID = …), and absent both, SwiftData falls through to id: PersistentIdentifier, which isn't CustomStringConvertible:
error: initializer 'init(fromEntity:)' requires that
'PersistentIdentifier' conform to 'CustomStringConvertible'Two workarounds — pick based on how much adopter-side boilerplate is acceptable.
Option A — fromAuditedString: over the stringified UUID (canonical, zero boilerplate):
@Model
final class AnnotationNote {
@Attribute(.unique) var annotationId: UUID
var content: String
// ... no id property
}
let annotation = AnnotationNote(annotationId: UUID(), content: "...")
let key = IdempotencyKey(fromAuditedString: annotation.annotationId.uuidString)This is the path the lllyys/vreader package-adoption trial uses. Zero per-Model boilerplate; the fromAuditedString: label flags the construction as a deliberate audit moment in code review.
Option B — opt in to fromEntity: via typealias + computed id (three lines per Model):
@Model
final class AnnotationNote: Identifiable {
@Attribute(.unique) var annotationId: UUID
var content: String
// Opt in to IdempotencyKey(fromEntity:):
typealias ID = UUID
var id: UUID { annotationId }
}
let key = IdempotencyKey(fromEntity: annotation)The typealias pins Identifiable.ID to the unwrapped UUID (not the synthesized PersistentIdentifier), and the computed id satisfies the protocol requirement. Cost: three lines per @Model type the adopter keys on. Use when the team prefers the fromEntity: ergonomics over per-call-site fromAuditedString: .uuidString ceremony.
#assertIdempotent on SwiftData Model returns
Same guidance as Fluent: @Model classes are non-Equatable reference types, so you cannot return one directly from a #assertIdempotent closure. Use a dedicated Equatable struct projection:
struct AnnotationProjection: Equatable {
let annotationId: UUID
let content: String
}
let projection = try #assertIdempotent {
let note = AnnotationNote(annotationId: fixedID, content: "...")
return AnnotationProjection(
annotationId: note.annotationId,
content: note.content
)
}Tuples do not work here for the same reason they don't work on Fluent Models — synthesized == is not Equatable protocol conformance, and #assertIdempotent's <Result: Equatable> constraint rejects tuples at compile time. See the #assertIdempotent on Model returns needs an Equatable projection subsection above for the full rationale.
SwiftData trials reference
These patterns are drawn from two fully-compiling trials with passing tests:
— the Clean-path pattern against a synthetic @Model with id: String. See examples/swiftdata-sample/ for a consumer sample exercising the same code.
— the Business-named-UUID pattern against real adopter- authored code (lllyys/vreader's AnnotationNote). Test-target-only integration via xcodebuild + iOS simulator. Documents the typealias ID-opt-in and the fromAuditedString: canonical-path trade-off above.
Design boundaries
What this package does:
- Compile-time type enforcement via
IdempotencyKey - Recognisable attribute names for hand-written and linter-consumed
annotations
- Test-time scaffolding via
@IdempotencyTestsextension-macro
expansion (zero-arg @Idempotent-marked members) and #assertIdempotent expression macro (sync + async overloads)
What this package does NOT do:
- Production-runtime instrumentation. Macros cannot inject into every
call site silently. Runtime safety is covered by compile-time (types), test-time (generated / explicit tests), and lint-time (SwiftProjectLint rules) — not production AOP.
- Auto-generated mocks or dependency injection. The test that
@IdempotencyTests generates for a zero-arg function calls it literally twice. If your function touches the filesystem or a real database, you're responsible for test isolation.
- Parameterised
@IdempotencyTestsexpansion. Only zero-argument
@Idempotent-marked members get auto-generated tests. Parameterised functions can either use #assertIdempotent at test sites, or wait for a future slice that introduces an IdempotencyTestArgs protocol.
- Dynamic observable-equivalence checking. The current
implementation uses Option C semantics — same return value + no throw on second call. It doesn't capture side effects via mocks. Genuinely non-idempotent functions whose side effects are invisible to the return value will not be caught by the auto-generated test alone.
Using without SwiftProjectLint
The package has standalone value — two of the three tiers work independently, no linter required.
Standalone — full value:
IdempotencyKey— compile-time type enforcement.UUID()/Date()at call sites become type errors, rejected by the compiler before any external tool runs.#assertIdempotentand@IdempotencyTests— compile-time macro expansion into ordinary Swift Testing calls. Tests run atswift testtime, no external tooling in the loop.
Needs the linter to pay off:
@Idempotent/@NonIdempotent/@Observational/@ExternallyIdempotent(by:)— marker attributes that carry no runtime semantics on their own. Without a linter reading them, they're documentation; a/// @lint.effect idempotentdoc comment is informationally equivalent. Still safe to add (unread markers are silent), just not load-bearing without the tool.
Recommended standalone adoption shape:
- Migrate one or two high-value call sites to take
IdempotencyKeyinstead ofStringorUUID. Payment charges, email delivery, webhook processing, and message-queue producers are the common targets. - Sprinkle
#assertIdempotent { ... }inside existing@Testmethods where handlers should be retry-safe. The macro is effect-polymorphic — sync, async, throwing, non-throwing — and picks the right overload based on the closure's effects. - Skip the attribute macros until you either add SwiftProjectLint or want self-documenting contracts for future tooling.
What you give up without the linter: no verification that a function claiming @Idempotent is actually idempotent in its body; no retry-context reasoning; no framework-primitive recognition (Fluent ORM verbs, routing DSL, HTTP primitives). Those are linter-side guarantees. The package and the linter compose additively — same annotations, both read them — but the standalone proposition stands on its own two tiers.
Coordination with SwiftProjectLint
If your project already uses SwiftProjectLint, adding this package is additive:
- Annotation forms coexist. Existing
/// @lint.effect idempotent
doc comments keep working. Attribute form can be added alongside or used instead.
- Linter reads both. The linter's
EffectAnnotationParserscans
attribute lists for @Idempotent et al., same as it scans doc comments. The two signals feed the same rule pipeline.
- Conflict semantics. If the same declaration has both forms and
they disagree (e.g. /// @lint.effect idempotent + @NonIdempotent), the linter withdraws the entry (collision policy) — matching how two conflicting @lint.effect declarations across files are handled.
- The tiers layer, they don't overlap wastefully. Once a callee
takes IdempotencyKey directly, the compile-time type check rejects UUID() / Date() at call sites before the linter's MissingIdempotencyKey rule would have flagged them. That rule's value concentrates on un-migrated call sites where the key is still typed as String. Both tiers carry their weight: the type catches what it can earliest; the linter catches the string-typed remainder.
If your project doesn't use SwiftProjectLint yet, this package is still useful on its own — IdempotencyKey and the generated tests provide value independent of static analysis.
Status
Early release. Annotation attributes (@Idempotent, @NonIdempotent, @Observational, @ExternallyIdempotent), IdempotencyKey, zero-arg @IdempotencyTests extension expansion, and #assertIdempotent (sync + async) are implemented and tested. v0.2.0 adds a dedicated SwiftIdempotencyFluent product with IdempotencyKey.init(fromFluentModel:) for Fluent Model adopters — see §"Using with Fluent ORM" above. Deferred for future work:
- Parameterised
@IdempotencyTestsexpansion — today only zero-arg
@Idempotent-marked members get auto-generated tests; extending to parameterised members needs an IdempotencyTestArgs protocol design so the macro has a stable way to synthesise arguments
- Option A / B observable-equivalence (dependency-injected mocks)
- Additional framework-specific integrations beyond Fluent (Hummingbird,
SwiftNIO, and others would follow the SwiftIdempotencyFluent opt-in product pattern)
See the design document in swiftIdempotency/docs for the full roadmap and the claude_phase_5_macros_plan.md for this package's specific scope.
License
Apache License 2.0. See LICENSE.
Package Metadata
Repository: joseph-cursio/swiftidempotency
Default branch: main
README: README.md