Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 28 additions & 4 deletions .github/actions/setup-mistkit/action.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: Setup MistKit
description: Replaces the local MistKit path dependency with a remote branch reference
description: Replaces the local MistKit path dependency with a remote reference, pinned to the branch's current commit

inputs:
branch:
Expand All @@ -8,19 +8,43 @@ inputs:
runs:
using: composite
steps:
# Resolve the branch to its current HEAD commit and pin the dependency by
# `revision:` rather than `branch:`. This makes the dependency content-addressed,
# so `swift package dump-package` (which swift-build@v1 hashes for its cache key)
# changes whenever the MistKit branch advances — otherwise a new MistKit commit on
# the same branch yields a stale cache hit and is never rebuilt. Falls back to a
# `branch:` pin if the ref can't be resolved (e.g. offline).
- name: Update Package.swift (Unix)
if: inputs.branch != '' && runner.os != 'Windows'
shell: bash
run: |
BRANCH='${{ inputs.branch }}'
REF=$(git ls-remote https://github.com/brightdigit/MistKit.git "$BRANCH" | head -n1 | cut -f1)
if [ -n "$REF" ]; then
REQ='revision: "'"$REF"'"'
echo "Pinning MistKit to $BRANCH @ $REF"
else
REQ='branch: "'"$BRANCH"'"'
echo "Could not resolve $BRANCH to a commit; pinning by branch"
fi
if [ "$RUNNER_OS" = "macOS" ]; then
sed -i '' 's|\.package(name: "MistKit", path: "\.\./\.\.")|.package(url: "https://github.com/brightdigit/MistKit.git", branch: "'"${{ inputs.branch }}"'")|g' Package.swift
sed -i '' 's|\.package(name: "MistKit", path: "\.\./\.\.")|.package(url: "https://github.com/brightdigit/MistKit.git", '"$REQ"')|g' Package.swift
else
sed -i 's|\.package(name: "MistKit", path: "\.\./\.\.")|.package(url: "https://github.com/brightdigit/MistKit.git", branch: "'"${{ inputs.branch }}"'")|g' Package.swift
sed -i 's|\.package(name: "MistKit", path: "\.\./\.\.")|.package(url: "https://github.com/brightdigit/MistKit.git", '"$REQ"')|g' Package.swift
fi
rm -f Package.resolved
- name: Update Package.swift (Windows)
if: inputs.branch != '' && runner.os == 'Windows'
shell: pwsh
run: |
(Get-Content Package.swift) -replace '\.package\(name: "MistKit", path: "\.\./\.\."\)', ".package(url: `"https://github.com/brightdigit/MistKit.git`", branch: `"${{ inputs.branch }}`")" | Set-Content Package.swift
$branch = '${{ inputs.branch }}'
$ref = (git ls-remote https://github.com/brightdigit/MistKit.git $branch | Select-Object -First 1) -split "`t" | Select-Object -First 1
if ($ref) {
$req = "revision: `"$ref`""
Write-Host "Pinning MistKit to $branch @ $ref"
} else {
$req = "branch: `"$branch`""
Write-Host "Could not resolve $branch to a commit; pinning by branch"
}
(Get-Content Package.swift) -replace '\.package\(name: "MistKit", path: "\.\./\.\."\)', ".package(url: `"https://github.com/brightdigit/MistKit.git`", $req)" | Set-Content Package.swift
Remove-Item -Path Package.resolved -Force -ErrorAction SilentlyContinue
17 changes: 14 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,22 @@ MistKit uses separate types for requests and responses at the OpenAPI schema lev

**Type Layers:**
1. **Domain Layer**: `FieldValue` enum - Pure Swift types, no API metadata (`Sources/MistKit/Models/FieldValues/FieldValue.swift`)
2. **API Request Layer**: `FieldValueRequest` - No type field, CloudKit infers type from value structure
2. **API Request Layer**: `FieldValueRequest` - Optional type field; CloudKit infers type from value structure, except for ambiguous scalars (see below) and IN/NOT_IN list filters, which are tagged explicitly
3. **API Response Layer**: `FieldValueResponse` - Optional type field for explicit type information

**Request type tagging (issue #375):** Most request values omit `type` and let CloudKit infer it from the value structure. Three scalar types are ambiguous on the wire and **must** carry an explicit `type`, otherwise CloudKit infers the wrong type and rejects the write with `BAD_REQUEST`:
- `TIMESTAMP` (`.date`) — a millisecond number, otherwise read as `INT64`/`DOUBLE`
- `BYTES` (`.bytes`) — a base64 string, otherwise read as `STRING`
- `DOUBLE` (`.double`) — a whole-valued double serializes without a fraction, otherwise read as `INT64`

Object/array-shaped values (`REFERENCE`, `ASSET`, `LOCATION`, `LIST`) and `STRING`/`INT64` are unambiguous and stay untagged. Tagging happens in `makeScalarRequest` (`Components.Schemas.FieldValueRequest.swift`). `type` is *not* required globally because CloudKit documents it as optional.

**Response type recovery (issue #375):** The generated `value` `oneOf` is *undiscriminated* — the decoder tries cases first-match-wins (`String → Int64 → Double → Bytes → Date`), so a whole-millisecond `TIMESTAMP` decodes as `Int64Value` and a base64 `BYTES` string decodes as `StringValue`. The response conversion therefore honors an explicit `type` *over* the decoded case (`makeTypedScalar` in `FieldValue+Components+Scalar.swift`). For the genuinely-ambiguous scalars whose correct interpretation differs from inference it produces the typed value directly: `TIMESTAMP`/`DOUBLE` from any numeric case, `BYTES` from any string case. `INT64`/`STRING` validate the category then defer to inference (which already yields them, and for `INT64` avoids truncating a fractional number). When `type` is absent it falls back to first-match-wins inference (`makeInferredScalar`), which is lossy for the ambiguous scalars (BYTES→`.string`, whole-number TIMESTAMP→`.int64`).

When a scalar `type` *contradicts* the value's category — a numeric type (`TIMESTAMP`/`DOUBLE`/`INT64`) over a non-number, or a string type (`STRING`/`BYTES`) over a non-string — the response is internally inconsistent and the conversion **throws** `ConversionError.typeValueMismatch` (via `requireNumeric`/`requireString`) rather than coercing to the value's shape. This matches the codebase's existing fail-loud `unmappableFieldValue` philosophy. The strict check is scoped to **scalar** type tags; complex/list declared types (`REFERENCE`/`ASSET`/`LOCATION`/`LIST`) are left to the value's self-describing structure and are not validated against the tag.

**Why Separate Request/Response Types?**
- CloudKit API has asymmetric behavior: requests omit type field, responses may include it
- CloudKit API has asymmetric behavior: requests tag type only when ambiguous, responses may always include it
- OpenAPI schema accurately models this asymmetry (openapi.yaml:867-920)
- Swift code generation produces type-safe request/response types
- Compiler prevents accidentally using response types in requests
Expand All @@ -134,7 +145,7 @@ MistKit uses separate types for requests and responses at the OpenAPI schema lev

**Conversion:**
- Request conversion: `Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift` converts domain `FieldValue` → `FieldValueRequest`
- Response conversion: `Sources/MistKit/Models/FieldValues/FieldValue+Components.swift` converts `FieldValueResponse` → domain `FieldValue`
- Response conversion: `Sources/MistKit/Models/FieldValues/FieldValue+Components.swift` (entry point + complex types) and `FieldValue+Components+Scalar.swift` (scalar type recovery) convert `FieldValueResponse` → domain `FieldValue`

### Modern Swift Features to Utilize
- Swift Concurrency (async/await) for all network operations
Expand Down
2 changes: 1 addition & 1 deletion Examples/BushelCloud/.gitrepo
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
[subrepo]
remote = git@github.com:brightdigit/BushelCloud.git
branch = mistkit
commit = 7f026acd1b3f926a81ee6a308f0ad4f43d95e16b
commit = fdbe52ad61da1eac3e824b0977426a1b70f53498
parent = b993bb931ea3d0698fc22428dc02851f26b36701
method = merge
cmdver = 0.4.9
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,10 @@ public struct BushelCloudKitService: Sendable, RecordManaging, CloudKitRecordCol
"Calling MistKit service.modifyRecords() with \(batch.count) RecordOperation objects"
)

let results = try await service.modifyRecords(
// Annotate the element type explicitly: the Linux Swift compiler otherwise
// infers `[RecordInfo]` for this call, breaking the .success/.failure switch
// below (see brightdigit/BushelCloud CI on Ubuntu).
let results: [RecordResult] = try await service.modifyRecords(
batch,
database: .public(.prefers(.serverToServer))
)
Expand All @@ -243,15 +246,19 @@ public struct BushelCloudKitService: Sendable, RecordManaging, CloudKitRecordCol
)

// Track results based on classification
var batchSucceeded = 0
var batchFailed = 0
for result in results {
switch result {
case .failure(let error):
totalFailed += 1
batchFailed += 1
failedRecordNames.append(error.recordName)
Self.logger.debug(
"Error: recordName=\(error.recordName), code=\(error.serverErrorCode.rawValue)"
)
case .success(let record):
batchSucceeded += 1
// Classify as create or update based on pre-fetch
if classification.creates.contains(record.recordName) {
totalCreated += 1
Expand All @@ -261,9 +268,6 @@ public struct BushelCloudKitService: Sendable, RecordManaging, CloudKitRecordCol
}
}

let batchSucceeded = results.filter { $0.record != nil }.count
let batchFailed = results.count - batchSucceeded

if batchFailed > 0 {
print(" ⚠️ \(batchFailed) operations failed (see verbose logs for details)")
print(" ✓ \(batchSucceeded) records confirmed")
Expand Down
2 changes: 1 addition & 1 deletion Examples/CelestraCloud/.github/workflows/CelestraCloud.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ concurrency:

env:
PACKAGE_NAME: CelestraCloud
MISTKIT_BRANCH: v1.0.0-beta.2
MISTKIT_BRANCH: 375-fieldvaluerequest-timestamp

jobs:
configure:
Expand Down
15 changes: 13 additions & 2 deletions Examples/CelestraCloud/.github/workflows/update-feeds.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ env:
CLOUDKIT_KEY_ID: ${{ secrets.CLOUDKIT_KEY_ID }}
CLOUDKIT_ENVIRONMENT: ${{ (github.event_name == 'pull_request' || github.event_name == 'push') && 'development' || github.event.inputs.environment || 'production' }}
CLOUDKIT_PRIVATE_KEY_PATH: /tmp/cloudkit_key.pem
MISTKIT_BRANCH: v1.0.0-beta.2
MISTKIT_BRANCH: 375-fieldvaluerequest-timestamp

jobs:
# Determine which tier to run based on schedule or manual input
Expand Down Expand Up @@ -141,12 +141,23 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4

# Resolve MISTKIT_BRANCH to its current HEAD commit so the cache key below
# changes whenever MistKit is updated. Without this the binary cache keys only
# on CelestraCloud's own sources, so a new MistKit commit on the same branch
# produces a stale cache hit and the fix under test is never rebuilt.
- name: Resolve MistKit branch SHA
id: mistkit-sha
run: |
SHA=$(git ls-remote https://github.com/brightdigit/MistKit.git "$MISTKIT_BRANCH" | head -n1 | cut -f1)
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
echo "Resolved MISTKIT_BRANCH=$MISTKIT_BRANCH to ${SHA:-<unresolved>}"

- name: Cache compiled binary
id: cache-binary
uses: actions/cache@v4
with:
path: .build/release/celestra-cloud
key: celestra-cloud-${{ runner.os }}-${{ hashFiles('Sources/**/*.swift', 'Package.swift') }}-${{ github.event.inputs.force_rebuild || 'false' }}
key: celestra-cloud-${{ runner.os }}-${{ hashFiles('Sources/**/*.swift', 'Package.swift') }}-${{ steps.mistkit-sha.outputs.sha }}-${{ github.event.inputs.force_rebuild || 'false' }}

- name: Setup MistKit
if: steps.cache-binary.outputs.cache-hit != 'true'
Expand Down
4 changes: 2 additions & 2 deletions Examples/CelestraCloud/.gitrepo
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
[subrepo]
remote = git@github.com:brightdigit/CelestraCloud.git
branch = mistkit
commit = dbdba1a3748da6f3adefce90c06083108bce9f36
parent = 9600f7882f53b51825bc8f2d6a163f4d4c97d10e
commit = 8657d77b436c2d1c4f9c82f6ca60a4debe958b0b
parent = 7d989019ea1508fc990bad2f0dd30e5be4087eb0
method = merge
cmdver = 0.4.9
7 changes: 7 additions & 0 deletions Sources/MistKit/Models/ConversionError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ public import Foundation
public enum ConversionError: LocalizedError, Sendable, Equatable {
/// A field value's structure matched no known `FieldValue` case.
case unmappableFieldValue(fieldName: String, value: String, type: String?)
/// A response declared a scalar `type` that the field's value cannot satisfy
/// (e.g. a `TIMESTAMP` tag over a string value). Such a response is internally
/// inconsistent and cannot be faithfully represented.
case typeValueMismatch(fieldName: String, declaredType: String, value: String)
/// A list element matched no known `FieldValue` case.
case unmappableListItem(fieldName: String, item: String)
/// A nested-list element was not one of the supported basic types.
Expand Down Expand Up @@ -73,6 +77,9 @@ public enum ConversionError: LocalizedError, Sendable, Equatable {
case .unmappableFieldValue(let fieldName, let value, let type):
return "Unmappable FieldValue for field '\(fieldName)' "
+ "(value: \(value), type: \(type ?? "nil"))"
case .typeValueMismatch(let fieldName, let declaredType, let value):
return "Field '\(fieldName)' declared type \(declaredType) "
+ "but its value is incompatible (\(value))"
case .unmappableListItem(let fieldName, let item):
return "Unmappable list item for field '\(fieldName)' (\(item))"
case .unmappableNestedListItem(let fieldName, let item):
Expand Down
Loading
Loading