From af912ca0d252d4daf1910484531a3c21198700cd Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Wed, 9 Sep 2026 10:36:45 +0100 Subject: [PATCH 1/4] research(#481): save empty-list probe harness and blocker notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #481 (homogeneous FieldValue.list) hinges on one design question that changes the shape of the public type: how to represent an empty list. The response `type` enum carries a flat `LIST` (openapi.yaml:1573), not the granular `*_LIST` family requests use (openapi.yaml:1546). A non-empty list recovers its element type from the first element, but an empty `[]` supplies no hint at all — so whether `case empty` is required or merely defensive depends on how CloudKit actually round-trips an empty list field. Two live runs against the MistDemo dev container both failed BAD_REQUEST, but the populated list failed identically to the empty one ("Field X not found in Note") — the container does not auto-create schema fields, so the runs say nothing about empty lists. The question is unresolved, not answered; resolving it needs a LIST field added to `Note` via `cktool import-schema`, which requires a management token and modifies a shared container. Saved so this is resumable: - .claude/probes/481-empty-list/probe.swift — compiles against this branch, reads credentials from MistDemo.env - .claude/probes/481-empty-list/README.md — what was and was not established, step-by-step resume instructions, and what each possible outcome implies - memory: the no-schema-auto-create fact, which is what made the first reading of these failures misleading No production code changed yet. `case empty` remains the intended design since it is correct under every outcome. Co-Authored-By: Claude Opus 5 --- .claude/memory/MEMORY.md | 1 + .../project_mistdemo_no_schema_autocreate.md | 32 ++++++ .claude/probes/481-empty-list/README.md | 107 ++++++++++++++++++ .claude/probes/481-empty-list/probe.swift | 74 ++++++++++++ 4 files changed, 214 insertions(+) create mode 100644 .claude/memory/project_mistdemo_no_schema_autocreate.md create mode 100644 .claude/probes/481-empty-list/README.md create mode 100644 .claude/probes/481-empty-list/probe.swift diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index ca330e38..92709f65 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -56,4 +56,5 @@ Project-scoped agent memory for MistKit. This directory **replaces** any native - [FieldValue.bytes is domain Data](project_fieldvalue_bytes_is_base64_string.md) — wire/generated BytesValue stays base64 String; do not infer .bytes from untagged strings - [RetryPolicy deliberately removed](project_retry_policy_deliberately_removed.md) — do not reintroduce client retry-with-jitter (#148 lineage); rate-limit honoring is fine - [MistDemo is the live-verification oracle](project_mistdemo_is_live_verification_oracle.md) — web UI + test-public/test-private count as live-confirmed; do not re-flag covered CloudKit wire facts as unproven +- [MistDemo container has no schema auto-create](project_mistdemo_no_schema_autocreate.md) — unknown fields fail `BAD_REQUEST "Field X not found"`; that says nothing about the value sent (#481) - [Docs live in the DocC catalog](project_docs_live_in_docc.md) — top-level `docs/` removed 2026-09-08; prose guides, the talk article, and retrospectives are DocC articles; verify with the symbol-graph + `docc convert` recipe diff --git a/.claude/memory/project_mistdemo_no_schema_autocreate.md b/.claude/memory/project_mistdemo_no_schema_autocreate.md new file mode 100644 index 00000000..ace32e57 --- /dev/null +++ b/.claude/memory/project_mistdemo_no_schema_autocreate.md @@ -0,0 +1,32 @@ +--- +name: project_mistdemo_no_schema_autocreate +description: The MistDemo dev container does NOT auto-create schema fields on write; unknown fields fail BAD_REQUEST "Field X not found" +metadata: + type: project +--- + +The `iCloud.com.brightdigit.MistDemo` **development** container does **not** auto-create +record fields on write. Writing a field absent from `Examples/MistDemo/schema.ckdb` fails: + +``` +BAD_REQUEST — "Field probeFilledList not found in Note" +``` + +Verified 2026-09-09 (issue #481) with two live server-to-server writes to the public DB: +both an empty `.list([])` and a populated `.list([.string, .string])` were rejected the same +way, on a field name not in the schema. + +**Why:** CloudKit's "development environments auto-create schema" behavior is commonly +assumed, and it is easy to read a `BAD_REQUEST` on a *new* field as evidence about the +*value* you sent. It is not — it only means the field isn't in the schema. In #481 this +nearly produced a false conclusion that CloudKit rejects empty list values. + +**How to apply:** To probe wire behavior for a field type `Note` doesn't already have +(`title` STRING, `index` INT64, `image` ASSET), you must first add it to `schema.ckdb` and +push with `xcrun cktool import-schema` — which needs a CloudKit **management token** +(separate from the API/server-to-server credentials in `MistDemo.env`, and not currently +saved; `cktool export-schema` reports `No management token found`). Treat a schema push as +an outward-facing change to a shared container and confirm before running it. + +Related: [[project_mistdemo_is_live_verification_oracle]] — MistDemo is the live oracle, but +only for behaviors its existing schema can actually exercise. diff --git a/.claude/probes/481-empty-list/README.md b/.claude/probes/481-empty-list/README.md new file mode 100644 index 00000000..aacfac1a --- /dev/null +++ b/.claude/probes/481-empty-list/README.md @@ -0,0 +1,107 @@ +# Probe: how does CloudKit represent an **empty** list field? (issue #481) + +**Status: BLOCKED — needs a schema change to the MistDemo container.** + +## Why this question matters + +Issue #481 replaces `FieldValue.list([FieldValue])` with a homogeneous payload +(`FieldValue.List`). The one design decision that changes the shape of the public type is +how to represent an **empty** list: + +- The response `type` enum carries a single flat `LIST` (`openapi.yaml:1573`), **not** the + granular `*_LIST` family that requests use (`openapi.yaml:1546`). So a tagged list + response says "this is a list" and nothing about its element type. +- For a non-empty list that is fine — elements are self-describing by shape, so the element + type is recovered from the first element (this is what `FilterBuilder.cloudKitListType(for:)` + already does, `FilterBuilder.swift:170-203`). +- For an **empty** list, `[]` carries no elements to inspect and the container tag names no + element type. **We have no hint about the element type on the way back.** + +So: does an empty list field come back as `[]`, come back **absent**, or fail to write at +all? That determines whether `case empty` is the only honest decode target, or merely +defensive. + +## What was actually established (2026-09-09) + +Two live runs against `iCloud.com.brightdigit.MistDemo`, `development`, public DB, +server-to-server auth. Both failed with `BAD_REQUEST`: + +| Written | Server response | +|---|---| +| `.list([])` | `Field probeEmptyList not found in Note` | +| `.list([.string("a"), .string("b")])` | `Field probeFilledList not found in Note` | + +**The second row is the load-bearing one.** The *populated* list was rejected too, so this +container does **not** auto-create schema fields on write. The failures therefore say +nothing about empty lists specifically — they only say the field is absent from the schema. + +**Conclusion: the question is UNRESOLVED.** Do not cite these runs as evidence that +CloudKit rejects empty lists. They show only that `Note` has no list field. + +Nothing was written to the container — both writes were rejected server-side. + +## Why it is blocked + +`Note` (`Examples/MistDemo/schema.ckdb`) has **no list field of any type**: + +``` +RECORD TYPE Note ( + "title" STRING QUERYABLE SORTABLE SEARCHABLE, + "index" INT64 QUERYABLE SORTABLE, + "image" ASSET, + ... +); +``` + +Probing requires adding one, which means `cktool import-schema` against a **real shared +container** that MistDemo's integration phases run against. That needs a management token +(not present in `MistDemo.env`, not in the keychain — `cktool export-schema` reports +`No management token found`), and it is an outward-facing change, so it was not done +unprompted. + +## How to resume + +1. **Get a management token**: `xcrun cktool save-token` (Apple Developer portal → + CloudKit management token), or set `CLOUDKIT_MANAGEMENT_TOKEN`. +2. **Add a list field to `Note`** in `Examples/MistDemo/schema.ckdb`: + ``` + "tags" LIST, + ``` + Development-environment schema additions are additive; verify first with + `xcrun cktool validate-schema`. +3. **Push it**: + ```bash + xcrun cktool import-schema --team-id \ + --container-id iCloud.com.brightdigit.MistDemo \ + --environment development \ + --file Examples/MistDemo/schema.ckdb + ``` +4. **Point `probe.swift` at the real field** — rename `probeEmptyList`/`probeFilledList` to + the field(s) you added (a single `tags` field, written once empty and once populated, is + enough). +5. **Run it** from a scratch package that depends on this branch: + ```swift + // Package.swift + dependencies: [.package(path: "")], + targets: [.executableTarget(name: "probe", + dependencies: [.product(name: "MistKit", package: "481-fieldvalue-homogeneous-list")])] + ``` + ```bash + swift run probe + ``` + `probe.swift` reads credentials directly from `MistDemo.env` at the repo root. + +## What each outcome means for the design + +| Empty list reads back as | Implication for `FieldValue.List` | +|---|---| +| `[]` (present, no type hint) | `case empty` is **required** — it is the only value the decoder can honestly produce. | +| **absent** from the record | `case empty` is harmless but never produced on read; still needed to *write* an empty list. | +| write rejected outright | Empty lists are not expressible; consider forbidding them at construction instead. | + +**Design decision taken in the meantime:** ship `case empty`. It is correct under all three +outcomes — it costs one switch case if empty lists turn out to be absent-on-read, and it is +the only correct answer if they come back as `[]`. Avoids inventing an element type the wire +never supplied (cf. `.claude/memory/feedback_no_silent_policy_defaults.md`). + +Record this result in issue #481 and in `.claude/memory/` once established. diff --git a/.claude/probes/481-empty-list/probe.swift b/.claude/probes/481-empty-list/probe.swift new file mode 100644 index 00000000..17445a01 --- /dev/null +++ b/.claude/probes/481-empty-list/probe.swift @@ -0,0 +1,74 @@ +import Foundation +import MistKit + +// Probe: how does CloudKit represent an EMPTY list field on the way back? +// Writes a record with an empty list + a populated list, then looks it up. + +func env(_ key: String) -> String? { + guard let path = try? String(contentsOfFile: "/Users/leo/Documents/Projects/MistKit/MistDemo.env", encoding: .utf8) else { return nil } + for line in path.split(separator: "\n") { + let parts = line.split(separator: "=", maxSplits: 1).map(String.init) + if parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces) == key { + return parts[1].trimmingCharacters(in: .whitespaces) + } + } + return nil +} + +let container = env("CLOUDKIT_CONTAINER_IDENTIFIER")! +let keyID = env("CLOUDKIT_KEY_ID")! +let keyPath = env("CLOUDKIT_PRIVATE_KEY_PATH")! + +let creds = try Credentials( + serverToServer: ServerToServerCredentials(keyID: keyID, privateKey: .file(path: keyPath)) +) +let service = CloudKitService( + containerIdentifier: container, + credentials: creds, + environment: .development +) + +let suffix = Int(Date().timeIntervalSince1970) +let name = "emptylistprobe_\(suffix)" + +print("=== WRITING record \(name) ===") +print(" probeEmptyList = .list([]) (empty)") +print(" probeFilledList = .list([.string]) (populated)") + +do { + let created = try await service.createRecord( + recordType: "Note", + recordName: name, + fields: [ + "title": .string("empty list probe"), + "probeEmptyList": .list([]), + "probeFilledList": .list([.string("a"), .string("b")]), + ], + database: .public(.prefers(.serverToServer)) + ) + print("\n=== CREATE RESPONSE fields ===") + for (k, v) in created.fields.sorted(by: { $0.key < $1.key }) { + print(" \(k) = \(v)") + } + print("\n probeEmptyList present in create response? \(created.fields["probeEmptyList"] != nil)") + + print("\n=== LOOKUP (read back) ===") + let results = try await service.lookupRecords(recordNames: [name], database: .public(.prefers(.serverToServer))) + for r in results { + switch r { + case .success(let rec): + for (k, v) in rec.fields.sorted(by: { $0.key < $1.key }) { + print(" \(k) = \(v)") + } + print("\n >>> probeEmptyList present on read? \(rec.fields["probeEmptyList"] != nil)") + if let e = rec.fields["probeEmptyList"] { + print(" >>> probeEmptyList value: \(e)") + } + print(" >>> probeFilledList value: \(rec.fields["probeFilledList"].map { "\($0)" } ?? "ABSENT")") + case .failure(let err): + print(" lookup failure: \(err)") + } + } +} catch { + print("\n!!! ERROR: \(error)") +} From e766e19a130f25a135c7f50c6d423858b1002faa Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Wed, 9 Sep 2026 17:41:12 +0100 Subject: [PATCH 2/4] Make FieldValue lists homogeneous via Arity (#481) Model each FieldValue kind as value-or-list so mixed/nested lists are unrepresentable, accept live *_LIST response tags, and cover empty+populated string lists in MistDemo test-public. Co-authored-by: Cursor --- .claude/agent-notes.md | 1 + .claude/docs/README.md | 2 + ...omogeneous-list-without-duplicate-enums.md | 252 ++++++++++++++ .claude/memory/MEMORY.md | 2 + ...loudkit_list_response_type_is_star_list.md | 27 ++ ...ject_fieldvalue_arity_homogeneous_lists.md | 3 + .claude/probes/481-empty-list/README.md | 107 ------ .claude/probes/481-empty-list/probe.swift | 74 ----- AGENTS.md | 24 +- .../Commands/ExportCommand.swift | 8 +- .../Extensions/FieldValue+URL.swift | 2 +- .../CloudKit/MockCloudKitServiceTests.swift | 2 +- .../Extensions/FieldValueURLTests.swift | 10 +- .../Utilities/FieldValue+Assertions.swift | 12 +- .../Extensions/Article+MistKit.swift | 2 +- .../Extensions/Feed+MistKit.swift | 2 +- .../Extensions/RecordInfo+Parsing.swift | 27 +- .../ArticleConversion+FromCloudKit.swift | 2 +- .../ArticleConversion+ToCloudKit.swift | 8 +- .../FeedConversion+FromCloudKit.swift | 2 +- .../FeedConversion+ToCloudKit.swift | 8 +- .../Phases/DownloadAssetPhase.swift | 2 +- .../Phases/LookupRecordsPhase.swift | 2 +- .../Phases/RereferenceAssetPhase.swift | 2 +- .../Phases/StringListRoundTripPhase.swift | 138 ++++++++ .../Tests/PrivateDatabaseTest.swift | 1 + .../Tests/PublicDatabaseTest.swift | 1 + .../Utilities/FieldValueFormatter.swift | 90 ++--- .../FieldValue+FieldTypeTests+BytesType.swift | 2 +- ...FieldValue+FieldTypeTests+DoubleType.swift | 8 +- .../FieldValue+FieldTypeTests+Int64Type.swift | 10 +- ...FieldValue+FieldTypeTests+StringType.swift | 4 +- ...lue+FieldTypeTests+TimestampDateType.swift | 6 +- ...Value+FieldTypeTests+UnsupportedType.swift | 2 +- .../CSVFormatterTests+EdgeCases.swift | 2 +- ...ryTests+FormatterBehaviorConsistency.swift | 2 +- ...eFormatterTests+EdgeCases+FieldTypes.swift | 2 +- .../YAMLFormatterTests+EdgeCases.swift | 2 +- .../Server/MockBackend+Helpers.swift | 6 +- Examples/MistDemo/schema.ckdb | 1 + ReleaseNotes.md | 1 + Scripts/lint.sh | 10 +- .../CloudKitService+AssetRereference.swift | 2 +- .../FieldValues/FieldValue+Codable.swift | 111 ++++--- .../FieldValue+Components+List.swift | 313 +++++++++++++----- .../FieldValue+Components+Scalar.swift | 32 +- .../FieldValues/FieldValue+Components.swift | 44 ++- .../FieldValues/FieldValue+Convenience.swift | 124 ++++--- .../FieldValue+DeprecatedScalars.swift | 84 +++++ .../FieldValue+ListConvenience.swift | 71 ++++ .../FieldValue+ResponseTypeTag.swift | 37 ++- .../Models/FieldValues/FieldValue.swift | 35 +- .../Queries/FilterBuilder/FilterBuilder.swift | 26 +- ...Components.Schemas.FieldValueRequest.swift | 126 +++++-- .../Components.Schemas.ListValuePayload.swift | 40 +-- Sources/MistKitOpenAPI/Types.swift | 23 +- ...tServiceTests.Query+FilterConversion.swift | 12 +- ...eTests.RecordWriteConvenience+ZoneID.swift | 4 +- ...tServiceTests.RecordWriteConvenience.swift | 8 +- .../CloudKitServiceTests.RequestOptions.swift | 2 +- ...dKitServiceTests.Rereference+Compose.swift | 2 +- .../CloudKitServiceTests.Sharing+Create.swift | 4 +- ...udKitServiceTests.SizeLimits+Records.swift | 6 +- .../RecordOperationConversionTests.swift | 8 +- .../Models/ConversionFailureTests.swift | 2 +- ...FieldValueConversionTests+BasicTypes.swift | 12 +- ...eldValueConversionTests+ComplexTypes.swift | 16 +- .../FieldValueConversionTests+EdgeCases.swift | 16 +- .../FieldValueConversionTests+Lists.swift | 138 ++++---- ...ldValueConversionTests+ResponseTypes.swift | 42 +-- .../Models/FieldValues/FieldValueTests.swift | 80 +++-- .../FilterBuilderTests+Comparators.swift | 12 +- .../FilterBuilderTests+ComplexValues.swift | 4 +- .../FilterBuilderTests+ListFilters.swift | 10 +- .../Queries/QueryFilterTests+Comparison.swift | 8 +- .../QueryFilterTests+ComplexFields.swift | 6 +- .../Queries/QueryFilterTests+EdgeCases.swift | 12 +- .../Queries/QueryFilterTests+Equality.swift | 4 +- .../Queries/QueryFilterTests+List.swift | 4 +- .../Queries/QueryFilterTests+ListMember.swift | 4 +- .../RecordOperationEncodedSizeTests.swift | 8 +- .../SubscriptionConversionTests.swift | 2 +- .../RecordManagement/AltTestRecord.swift | 2 +- .../CloudKitRecordTests+Formatting.swift | 4 +- .../CloudKitRecordTests+Parsing.swift | 14 +- ...ieldValueConvenienceTests+LegacyList.swift | 54 +++ .../FieldValueConvenienceTests+Lists.swift | 51 +++ .../FieldValueConvenienceTests.swift | 86 +++-- .../RecordManagingTests+List.swift | 4 +- .../RecordManagingTests+Query.swift | 20 +- .../RecordManagement/TestRecord.swift | 8 +- openapi.yaml | 8 +- 92 files changed, 1739 insertions(+), 877 deletions(-) create mode 100644 .claude/docs/research/481-homogeneous-list-without-duplicate-enums.md create mode 100644 .claude/memory/project_cloudkit_list_response_type_is_star_list.md create mode 100644 .claude/memory/project_fieldvalue_arity_homogeneous_lists.md delete mode 100644 .claude/probes/481-empty-list/README.md delete mode 100644 .claude/probes/481-empty-list/probe.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/StringListRoundTripPhase.swift create mode 100644 Sources/MistKit/Models/FieldValues/FieldValue+DeprecatedScalars.swift create mode 100644 Sources/MistKit/Models/FieldValues/FieldValue+ListConvenience.swift create mode 100644 Tests/MistKitTests/RecordManagement/FieldValueConvenienceTests+LegacyList.swift create mode 100644 Tests/MistKitTests/RecordManagement/FieldValueConvenienceTests+Lists.swift diff --git a/.claude/agent-notes.md b/.claude/agent-notes.md index ed9785b4..2823bc58 100644 --- a/.claude/agent-notes.md +++ b/.claude/agent-notes.md @@ -40,3 +40,4 @@ Standing always/never directives and corrections from the human. Agents must rea - The 2026-09-08 removal of the top-level `docs/` tree (folded into README + the DocC catalog) needed NO archive tag — Leo's call; the squash-merge preservation rule means *ask*, not *always tag*. - Long-form guides, the talk write-up, and retrospectives live in `Sources/MistKit/Documentation.docc/` (published on Swift Package Index) — do NOT recreate a top-level `docs/` directory for prose; every DocC code sample must be copied from current source, not from older docs. - `.claude/memory/_raw/` (uncurated archaeology reports + `recovered/`) was removed 2026-09-09 at Leo's request — do NOT recreate an uncurated holding pen under `.claude/memory/`; findings go straight into named memory files or GitHub issues. +- #481 FieldValue lists: prefer `Arity` (`.value`/`.list`) on each kind over a parallel `FieldValue.List` enum; deprecated polyfills for **scalar** constructions only (`.string(_: String)` → `.value`); do **not** polyfill old `.list([FieldValue])` — break list call sites cleanly. Research: `.claude/docs/research/481-homogeneous-list-without-duplicate-enums.md`. diff --git a/.claude/docs/README.md b/.claude/docs/README.md index 0a1bd37f..a508ae5f 100644 --- a/.claude/docs/README.md +++ b/.claude/docs/README.md @@ -63,6 +63,8 @@ implementation phases. `research/` holds dated investigations into specific failures, kept for the reasoning rather than as current reference. Each is a point-in-time record. +- [481-homogeneous-list-without-duplicate-enums.md](research/481-homogeneous-list-without-duplicate-enums.md) — #481: prefer `Arity` on each kind over parallel list enum / protocol storage; deprecated scalar polyfills only + ## Related Example-specific domain docs live with their examples, not here: diff --git a/.claude/docs/research/481-homogeneous-list-without-duplicate-enums.md b/.claude/docs/research/481-homogeneous-list-without-duplicate-enums.md new file mode 100644 index 00000000..7a16c775 --- /dev/null +++ b/.claude/docs/research/481-homogeneous-list-without-duplicate-enums.md @@ -0,0 +1,252 @@ +# Homogeneous `FieldValue.list` without a parallel enum? + +**Date:** 2026-09-09 +**Issue:** [#481 — Make FieldValue.list homogeneous by construction](https://github.com/brightdigit/MistKit/issues/481) + +## Question + +Can MistKit avoid a parallel `ListValue` (or `FieldValue.List`) enum by using protocols, generics, type erasure, or another Swift shape — while still making heterogeneity and nesting unrepresentable, and while preserving MistKit’s load-bearing `default`-free conversion switches? + +## Summary recommendation + +**Protocols do not replace a closed kind set** for stored `FieldValue`. Prefer folding scalar-vs-list into each kind via `Arity` (`.value` / `.list`) rather than a parallel `FieldValue.List` enum — one taxonomy, homogeneous lists, typed empty lists (`.string(.list([]))`). + +**Migration polyfill (decided 2026-09-09):** deprecated static overloads for **single values only** (e.g. `string(_: String)` → `.string(.value(...))`). Call sites are almost all scalars. **No** polyfill for old `.list([FieldValue])` — list construction migrates to `.string(.list(...))` and breaks cleanly. + +--- + +## Findings + +### 1. What is actually duplicated? + +#### Domain surface today + +`FieldValue` is a single closed enum (`Codable`, `Equatable`, `Sendable`) with nine cases — eight leaf kinds plus a recursive list of `FieldValue` (`Sources/MistKit/Models/FieldValues/FieldValue.swift:33-42`): + +| Domain case | Payload | +|---|---| +| `.string` / `.int64` / `.double` / `.bytes` / `.date` | scalar | +| `.location` / `.reference` / `.asset` | complex | +| `.list([FieldValue])` | **heterogeneous + nestable** | + +CloudKit’s schema grammar is `LIST "<" primitive-type ">"` only — there is no `LIST>` and no mixed-element list (`.claude/docs/sosumi-cloudkit-schema-source.md:47-63`, `.claude/docs/cloudkit-schema-reference.md:48-57`). So the domain case is **wider than the wire**. + +#### Wire taxonomy (inevitable mapping) + +Request `type` already enumerates both scalars and the granular list family (`openapi.yaml:1546`): + +```text +STRING, INT64, DOUBLE, BYTES, TIMESTAMP, REFERENCE, ASSET, ASSETID, LOCATION, +STRING_LIST, INT64_LIST, DOUBLE_LIST, BYTES_LIST, TIMESTAMP_LIST, +REFERENCE_LIST, LOCATION_LIST, ASSET_LIST +``` + +Live S2S probe on `Note.tags` showed responses return `STRING_LIST` (and by implication the rest of the `*_LIST` family), including for empty `[]` (`.claude/memory/project_cloudkit_list_response_type_is_star_list.md`). `FieldValueResponse.type` in `openapi.yaml` was updated to match. + +So CloudKit itself already has **two parallel name families** for the same eight primitives: `T` and `T_LIST`. A domain `List` enum that mirrors those eight kinds is aligning MistKit with the wire taxonomy, not inventing a second inventable taxonomy. + +#### Conversion / filter switches (accidental workarounds on top of the gap) + +| Site | What it assumes | Citation | +|---|---|---| +| `FilterBuilder.cloudKitListType(for:)` | Element type from `values.first` only; `.list` → `nil` (“let CloudKit reject”) | `FilterBuilder.swift:167-204` | +| `FieldValueRequest.init(list:)` | Lists sent **untagged**; no `TIMESTAMP_LIST` / `BYTES_LIST` on record writes | `Components.Schemas.FieldValueRequest.swift:129-133` | +| `ListValuePayload.init(from:)` | Nine-way `default`-free switch including nested `.list` | `ListValuePayload.swift:45-70` | +| Response list decode | Rebuilds `[FieldValue]` element-by-element; nested lists supported in code | `FieldValue+Components+List.swift:36-138` | +| `makeTypedComplex` | `LIST` validated at **container** only; “element types stay lenient” | `FieldValue+Components.swift:151-154` | +| `ResponseTypeTag` | Maps response `_typePayload` with a `default`-free switch; only `.LIST` today | `FieldValue+ResponseTypeTag.swift:55-66` | + +**Classification of “duplication”:** + +| Kind | What it is | Verdict | +|---|---|---| +| **(a) Domain API surface** | Parallel cases `string` vs `strings([String])`, etc. | Real, intentional once lists are homogeneous-by-construction — same as CloudKit’s `STRING` vs `STRING_LIST` | +| **(b) Inevitable wire mapping** | Domain kind ↔ OpenAPI `_typePayload` / `ListValuePayload` / generated oneOf | Cannot be removed; OpenAPI types are non-generic enums | +| **(c) Accidental** | First-element guessing, nested-list encode path, lenient element validation, untagged list writes | Removable *by* a closed homogeneous list type + total switches — not by protocols alone | + +Issue #481’s claim that the invariant is “already assumed in three places” is accurate; those sites are workarounds, not a second independent design. + +### 2. Protocol-oriented / generic alternatives (evaluated for this codebase) + +#### A. `protocol ListElement` + `HomogeneousList` + +Sketch: + +```swift +protocol ListElement: Codable, Equatable, Sendable { + static var cloudKitListType: Components.Schemas.FieldValueRequest._typePayload { get } +} +struct HomogeneousList: Codable, Equatable, Sendable { + var values: [Element] +} +``` + +**What this buys:** At a *typed* call site (`HomogeneousList`), homogeneity is compile-time. + +**What it does not buy for `FieldValue`:** `FieldValue` must remain a **single** non-generic type so records can be `[String: FieldValue]` and conversion can `switch` exhaustively. You cannot write a per-case generic: + +```swift +// Not legal Swift — cases do not introduce their own generic parameters. +enum FieldValue { + case list(HomogeneousList) +} +``` + +Generics attach to the **enum type**, not to individual cases ([Swift Forums pitch “Enum with generic cases”](https://forums.swift.org/t/enum-with-generic-cases/5760) — still an unimplemented pitch; language today requires `enum FieldValue { … }`). Making all of `FieldValue` generic breaks the heterogeneous field map and every conversion boundary. + +So `HomogeneousList` only helps as a **helper** behind factories (`FieldValue.list(["a","b"])`), not as the stored associated value of `.list` unless you erase it. + +#### B. Nested `FieldValue.List` as generic vs enum + +- **Generic nested type** `FieldValue.List`: same problem — cannot store `List` and `List` in one non-generic `FieldValue` without erasure. +- **Enum nested type** `FieldValue.List` with eight cases: this **is** the #481 proposal (naming preference in the issue’s open questions). It preserves exhaustiveness and forbids nesting (no `case lists`). + +#### C. Existential `[any ListElement]` + +[SE-0309](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0309-unlock-existential-types-for-all-protocols.md) unlocked using protocols with associated types as existentials; [SE-0346](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0346-light-weight-same-type-syntax.md) / [SE-0353](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0353-constrained-existential-types.md) add constrained existentials like `any Collection`. + +For a marker `ListElement` **without** a primary associated type that pins the element to one concrete type, `[any ListElement]` is exactly **heterogeneous again** — `.list([.string("a") as any ListElement, 1 as any ListElement])` type-checks if both conform. That restores the bug #481 exists to delete. + +Constrained existentials (`any ListElement where …`) do not give “array of one concrete element kind chosen at runtime but fixed per value” without either: + +- a generic wrapper (back to A), or +- a closed set of concrete list types (back to an enum / type erasure over that set). + +#### D. Phantom-typed wrappers + +`struct ListOf { let values: [Tag.Element] }` still needs a non-generic sum type to embed in `FieldValue`. Phantoms do not shrink the closed set of CloudKit list kinds; they only move the tag into the type system for *callers who already know the tag*. + +#### E. Can `FieldValue` grow a generic list case? + +**No, usefully.** Either: + +1. `enum FieldValue` — entire value becomes mono-kind; unusable as a CloudKit field dictionary value; or +2. Fixed `case list(HomogeneousList)` — only string lists; or +3. Eight list cases / one list enum — which is the parallel enum. + +Swift’s enumeration model documents associated values per case, but generics are parameters of the type ([TSLP Enumerations](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/enumerations/)); there is no shipped SE that adds per-case generics. + +#### F. Type-erased `AnyFieldValueList` + +```swift +struct AnyFieldValueList: Codable, Equatable, Sendable { + // must store kind + bytes or an inner enum anyway +} +``` + +To implement `Equatable` / `Codable` / `Sendable` and a total `cloudKitListType`, the box **contains** a closed kind discriminant. That is the parallel enum with extra indirection. Exhaustive `switch` on `FieldValue` no longer sees element kinds unless you switch on the box’s inner enum — so conversion sites either gain `default`/`as?` paths or reintroduce the enum API publicly. + +Synthesized `Codable` for enums with associated values is available ([SE-0295](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0295-codable-synthesis-for-enums-with-associated-values.md)); existentials and hand-rolled type erasers typically need custom `Codable`, which fights MistKit’s “domain enum is the source of truth” pattern. + +### 3. What breaks against MistKit’s hard constraints? + +| Constraint | Protocol / existential approach | Closed `FieldValue.List` enum | +|---|---|---| +| `FieldValue: Codable, Equatable, Sendable` public | Existential arrays / type erasers need custom Codable/Equatable; easy to get wrong | Straightforward nested enum; same conformances as today | +| `default`-free switches at request / list payload / FilterBuilder / response tag | Erasure forces runtime casts or a second discriminant; new kinds can slip through | Compiler forces every boundary when a case is added — same instinct as `FieldValueRequest.init(from:)` (`FieldValueRequest.swift:44-46`) | +| OpenAPI generated types are non-generic | Still need a total map from domain → `_typePayload` / `ListValuePayload` | Direct case → tag mapping | +| IN/NOT_IN needs total `*_LIST` tag | Static protocol requirement can supply the tag **per concrete type**, but FilterBuilder today takes `[FieldValue]` / would take erased lists — back to first-element or inner enum | `switch list` is total; empty lists still need a chosen case or schema-known type (probe: response recovers type via `STRING_LIST`) | +| Nested lists forbidden | Protocol does not forbid `ListElement` = some list type unless carefully constrained; easy to get wrong | No `lists` case → unrepresentable | +| Pre-1.0 source break OK; silent heterogeneity not | Existential storage fails the second requirement | Passes | + +`feedback_no_silent_policy_defaults.md` is about non-defaulted *policy* parameters, not enums — but the same ethic applies: a runtime check that “usually” enforces homogeneity is a silent policy. #481 correctly rejects `case list(ElementKind, [FieldValue])` for that reason. + +### 4. Hybrid designs that reduce *maintenance* duplication without losing exhaustiveness + +| Hybrid | Effect | Fit for #481 | +|---|---|---| +| **Closed `FieldValue.List` enum (eight cases)** | Homogeneous + non-nestable by construction; total tag mapping | **Primary approach** | +| **Shared internal “element kind → wire” tables** | One place maps kind → `*_LIST` / payload builder; scalars and lists call into it | Good *refactor*, orthogonal to public shape | +| **Single source-of-truth `ElementKind` enum** used by both scalar cases and list | Still need payloads (`String` vs `[String]`); often *more* types, not fewer | Optional internal; weak as public API | +| **Macro / codegen** to emit scalar + list cases + switches | Reduces edit drift; does not change the semantic model | Optional later; overkill for eight fixed CloudKit kinds | +| **`case list(ElementKind, [FieldValue])` + runtime assert** | Appears DRY; heterogeneity still representable; fails silent-heterogeneity rule | **Reject** (as #481) | +| **Protocols only on factories** (`static func list(_:[String]) -> FieldValue`) | Ergonomics without storing existentials | Compatible with enum approach | + +**Why protocols do not remove the duplicate enum:** each `ListElement` conformance re-states “this Swift type is CloudKit kind X and encodes like Y” — the same information as a `case strings`. The existential/type-eraser then re-states the closed set again so `FieldValue` can hold it. Net duplication **increases** (conformances + box + conversion casts) while exhaustiveness **decreases**. + +### 5. Empty lists and naming (from issue + probe) + +- Live probe: empty `[]` round-trips **present**, tagged `STRING_LIST`. Decode does **not** require `case empty` once response openapi accepts `*_LIST` (`.claude/memory/project_cloudkit_list_response_type_is_star_list.md`). +- Naming: generated `Components.Schemas.ListValue` / `ListValuePayload` already exist. Prefer **`FieldValue.List`** (issue open question) so domain vs wire stay legible. +- `ASSETID`: response tag shares `AssetValue` with `ASSET` (`FieldValue+ResponseTypeTag.swift:52-63`); no separate list case — request family has `ASSET_LIST` only (`openapi.yaml:1546`). + +--- + +## Alternatives compared + +| Approach | Homogeneous by construction? | Nesting forbidden? | Exhaustive switches? | Fits non-generic `FieldValue`? | Codable/Equatable/Sendable | Verdict | +|---|---|---|---|---|---|---| +| Keep `[FieldValue]` | No | No | Yes (but wrong model) | Yes | Yes | Status quo — reject | +| **`FieldValue.List` enum (8 cases)** | Yes | Yes | Yes | Yes | Yes | **Use this** | +| `HomogeneousList` only | Yes at typed sites | If constrained | N/A alone | **No** without erasure | Yes for concrete E | Helper only | +| `[any ListElement]` | **No** | No | Weak | Yes | Painful | **Don’t use** | +| `AnyFieldValueList` box | Only if box hides an enum | Only if box forbids | Only via inner enum | Yes | Custom | Enum with extra junk — **don’t use** as public model | +| Generic `FieldValue` | Per-field only | Possible | Awkward | **Breaks** field maps | Yes | **Don’t use** | +| `(ElementKind, [FieldValue])` + runtime check | No (runtime only) | Runtime only | Kind yes, elements no | Yes | Yes | **Don’t use** | +| Macro-emitted parallel cases | Yes | Yes | Yes | Yes | Yes | Optional tooling later | + +--- + +## Recommendation detail + +**Use this** (updated 2026-09-09 after Arity discussion) + +1. Replace scalar cases + `case list([FieldValue])` with **one case per CloudKit kind** carrying `Arity` (`.value` / `.list`). Removes the parallel list enum and makes empty lists typed (`.string(.list([]))`). +2. **Deprecated scalar polyfills only** — `static func string(_: String)`, `int64(_: Int)`, … marked `@available(*, deprecated)` forwarding to `.value`. No `.list([FieldValue])` shim; heterogeneous list construction breaks cleanly (pre-1.0; matches issue + `feedback_available_semantics.md`). +3. Make request/response/`cloudKitListType` mapping **total** on `(kind, arity)`. +4. Optionally factor shared kind→wire helpers internally; do not use protocols as storage. +5. Pattern matches are not polyfilled — update to `.string(.value(let s))` / accessors. + +**Don’t use that** + +- Protocols + existentials as the **stored** list representation. +- Type-erased public boxes that re-hide an enum. +- Runtime-checked heterogeneous `[FieldValue]` / deprecated `list([FieldValue])` polyfill. +- Making `FieldValue` itself generic. +- Parallel `FieldValue.List` enum — superseded by `Arity` unless implementation hits a blocker. + +**Open questions left for implementation (not blocked by this research)** + +- Public name for the wrapper: `Arity` vs `FieldValue.SingularOrList` vs nested `FieldValue.Value`. +- How aggressively to tag **record-field** list writes with `*_LIST` (IN/NOT_IN already need tags; record writes currently omit list tags — `FieldValueRequest.swift:129-133`). +- Whether deprecated scalar factories stay through 1.0 or are beta-only. +- Deprecate-flatten `listValue: [FieldValue]?` vs only typed accessors. + +--- + +## What this does NOT decide + +- Fixing `FieldValueResponse.type` in `openapi.yaml` from flat `LIST` to the live `*_LIST` family (probe-confirmed; blocks decoding today). That is **required for honest list reads** and unlocks empty-list type recovery, but it is an OpenAPI/generated-client change adjacent to #481, not answered by the protocol-vs-enum question. +- Whether DocC articles (`WhatCloudKitGotWrong`, `FieldTypePolymorphism`) that still describe response `LIST` as live truth should be refreshed after the openapi fix — they are superseded on the wire by the 2026-09-09 probe. +- Concrete migration of Examples / MistDemo formatters / test renames (issue checklist). +- Implementing homogeneous lists — research only. + +--- + +## Sources + +**In-repo** + +- GitHub issue #481 body + empty-list probe comment +- `Sources/MistKit/Models/FieldValues/FieldValue.swift` +- `Sources/MistKit/Models/FieldValues/FieldValue+Components.swift` +- `Sources/MistKit/Models/FieldValues/FieldValue+Components+List.swift` +- `Sources/MistKit/Models/FieldValues/FieldValue+ResponseTypeTag.swift` +- `Sources/MistKit/Models/FieldValues/FieldValue+Convenience.swift` +- `Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift` +- `Sources/MistKit/OpenAPI/Components/Components.Schemas.ListValuePayload.swift` +- `Sources/MistKit/Models/Queries/FilterBuilder/FilterBuilder.swift` +- `openapi.yaml` (~1525–1576) +- `.claude/docs/cloudkit-schema-reference.md`, `.claude/docs/sosumi-cloudkit-schema-source.md` +- `.claude/memory/project_cloudkit_list_response_type_is_star_list.md` +- `.claude/memory/feedback_no_silent_policy_defaults.md` +- CLAUDE.md FieldValue architecture; DocC `WhatCloudKitGotWrong` / `FieldTypePolymorphism` (context; response-`LIST` claim outdated vs probe) + +**Swift / language** + +- [The Swift Programming Language — Enumerations](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/enumerations/) +- [SE-0309 Unlock existential types for all protocols](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0309-unlock-existential-types-for-all-protocols.md) +- [SE-0346 Light-weight same-type syntax](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0346-light-weight-same-type-syntax.md) +- [SE-0353 Constrained existential types](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0353-constrained-existential-types.md) +- [SE-0295 Codable synthesis for enums with associated values](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0295-codable-synthesis-for-enums-with-associated-values.md) +- [Swift Forums: Enum with generic cases (unimplemented pitch)](https://forums.swift.org/t/enum-with-generic-cases/5760) diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index 92709f65..91d7af36 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -57,4 +57,6 @@ Project-scoped agent memory for MistKit. This directory **replaces** any native - [RetryPolicy deliberately removed](project_retry_policy_deliberately_removed.md) — do not reintroduce client retry-with-jitter (#148 lineage); rate-limit honoring is fine - [MistDemo is the live-verification oracle](project_mistdemo_is_live_verification_oracle.md) — web UI + test-public/test-private count as live-confirmed; do not re-flag covered CloudKit wire facts as unproven - [MistDemo container has no schema auto-create](project_mistdemo_no_schema_autocreate.md) — unknown fields fail `BAD_REQUEST "Field X not found"`; that says nothing about the value sent (#481) +- [List responses use `*_LIST`, empty lists round-trip as `[]`](project_cloudkit_list_response_type_is_star_list.md) — live S2S: `STRING_LIST`+`[]` present on read; openapi `FieldValueResponse` flat `LIST` is wrong (#481) +- [FieldValue lists use Arity](project_fieldvalue_arity_homogeneous_lists.md) — `#481`: `Arity` on each kind; no `case list`; scalar deprecations only; response `*_LIST` - [Docs live in the DocC catalog](project_docs_live_in_docc.md) — top-level `docs/` removed 2026-09-08; prose guides, the talk article, and retrospectives are DocC articles; verify with the symbol-graph + `docc convert` recipe diff --git a/.claude/memory/project_cloudkit_list_response_type_is_star_list.md b/.claude/memory/project_cloudkit_list_response_type_is_star_list.md new file mode 100644 index 00000000..0c3a5722 --- /dev/null +++ b/.claude/memory/project_cloudkit_list_response_type_is_star_list.md @@ -0,0 +1,27 @@ +--- +name: project_cloudkit_list_response_type_is_star_list +description: Live CloudKit list responses use STRING_LIST (etc.), not flat LIST; empty lists round-trip as present [] +metadata: + type: project +--- + +Verified 2026-09-09 against `iCloud.com.brightdigit.MistDemo` / `development` / +public, S2S, on `Note.tags` (`LIST`). + +**Empty lists:** writable; create and lookup both return the field present as +`{"type":"STRING_LIST","value":[]}`. Not absent, not rejected. + +**Response type tag:** CloudKit returns the granular `*_LIST` family +(`STRING_LIST`, and by implication `INT64_LIST` / `DOUBLE_LIST` / …), **not** the +flat `LIST`. Request `type` already had the `*_LIST` family. Before #481, MistKit +failed to decode any list field response with: + +``` +Cannot initialize _typePayload from invalid String value STRING_LIST +``` + +`FieldValueResponse.type` in `openapi.yaml` now matches the wire `*_LIST` family. + +**Design implication for #481:** because empty `[]` still carries `STRING_LIST`, +element type is recoverable on read — no separate empty case is required for +honest decode. Also filed on issue #481. diff --git a/.claude/memory/project_fieldvalue_arity_homogeneous_lists.md b/.claude/memory/project_fieldvalue_arity_homogeneous_lists.md new file mode 100644 index 00000000..755a2a67 --- /dev/null +++ b/.claude/memory/project_fieldvalue_arity_homogeneous_lists.md @@ -0,0 +1,3 @@ +# FieldValue lists use Arity, not a parallel List enum (#481) + +Locked domain shape (2026-09-09): each `FieldValue` kind carries `Arity` (`.value` / `.list`). There is no `case list`. Empty lists are typed (e.g. `.string(.list([]))`). Deprecated scalar factories only (`string(_: String)` → `.value`); no `.list([FieldValue])` polyfill. Response OpenAPI uses `*_LIST` family, not flat `LIST`. See research `.claude/docs/research/481-homogeneous-list-without-duplicate-enums.md`. diff --git a/.claude/probes/481-empty-list/README.md b/.claude/probes/481-empty-list/README.md deleted file mode 100644 index aacfac1a..00000000 --- a/.claude/probes/481-empty-list/README.md +++ /dev/null @@ -1,107 +0,0 @@ -# Probe: how does CloudKit represent an **empty** list field? (issue #481) - -**Status: BLOCKED — needs a schema change to the MistDemo container.** - -## Why this question matters - -Issue #481 replaces `FieldValue.list([FieldValue])` with a homogeneous payload -(`FieldValue.List`). The one design decision that changes the shape of the public type is -how to represent an **empty** list: - -- The response `type` enum carries a single flat `LIST` (`openapi.yaml:1573`), **not** the - granular `*_LIST` family that requests use (`openapi.yaml:1546`). So a tagged list - response says "this is a list" and nothing about its element type. -- For a non-empty list that is fine — elements are self-describing by shape, so the element - type is recovered from the first element (this is what `FilterBuilder.cloudKitListType(for:)` - already does, `FilterBuilder.swift:170-203`). -- For an **empty** list, `[]` carries no elements to inspect and the container tag names no - element type. **We have no hint about the element type on the way back.** - -So: does an empty list field come back as `[]`, come back **absent**, or fail to write at -all? That determines whether `case empty` is the only honest decode target, or merely -defensive. - -## What was actually established (2026-09-09) - -Two live runs against `iCloud.com.brightdigit.MistDemo`, `development`, public DB, -server-to-server auth. Both failed with `BAD_REQUEST`: - -| Written | Server response | -|---|---| -| `.list([])` | `Field probeEmptyList not found in Note` | -| `.list([.string("a"), .string("b")])` | `Field probeFilledList not found in Note` | - -**The second row is the load-bearing one.** The *populated* list was rejected too, so this -container does **not** auto-create schema fields on write. The failures therefore say -nothing about empty lists specifically — they only say the field is absent from the schema. - -**Conclusion: the question is UNRESOLVED.** Do not cite these runs as evidence that -CloudKit rejects empty lists. They show only that `Note` has no list field. - -Nothing was written to the container — both writes were rejected server-side. - -## Why it is blocked - -`Note` (`Examples/MistDemo/schema.ckdb`) has **no list field of any type**: - -``` -RECORD TYPE Note ( - "title" STRING QUERYABLE SORTABLE SEARCHABLE, - "index" INT64 QUERYABLE SORTABLE, - "image" ASSET, - ... -); -``` - -Probing requires adding one, which means `cktool import-schema` against a **real shared -container** that MistDemo's integration phases run against. That needs a management token -(not present in `MistDemo.env`, not in the keychain — `cktool export-schema` reports -`No management token found`), and it is an outward-facing change, so it was not done -unprompted. - -## How to resume - -1. **Get a management token**: `xcrun cktool save-token` (Apple Developer portal → - CloudKit management token), or set `CLOUDKIT_MANAGEMENT_TOKEN`. -2. **Add a list field to `Note`** in `Examples/MistDemo/schema.ckdb`: - ``` - "tags" LIST, - ``` - Development-environment schema additions are additive; verify first with - `xcrun cktool validate-schema`. -3. **Push it**: - ```bash - xcrun cktool import-schema --team-id \ - --container-id iCloud.com.brightdigit.MistDemo \ - --environment development \ - --file Examples/MistDemo/schema.ckdb - ``` -4. **Point `probe.swift` at the real field** — rename `probeEmptyList`/`probeFilledList` to - the field(s) you added (a single `tags` field, written once empty and once populated, is - enough). -5. **Run it** from a scratch package that depends on this branch: - ```swift - // Package.swift - dependencies: [.package(path: "")], - targets: [.executableTarget(name: "probe", - dependencies: [.product(name: "MistKit", package: "481-fieldvalue-homogeneous-list")])] - ``` - ```bash - swift run probe - ``` - `probe.swift` reads credentials directly from `MistDemo.env` at the repo root. - -## What each outcome means for the design - -| Empty list reads back as | Implication for `FieldValue.List` | -|---|---| -| `[]` (present, no type hint) | `case empty` is **required** — it is the only value the decoder can honestly produce. | -| **absent** from the record | `case empty` is harmless but never produced on read; still needed to *write* an empty list. | -| write rejected outright | Empty lists are not expressible; consider forbidding them at construction instead. | - -**Design decision taken in the meantime:** ship `case empty`. It is correct under all three -outcomes — it costs one switch case if empty lists turn out to be absent-on-read, and it is -the only correct answer if they come back as `[]`. Avoids inventing an element type the wire -never supplied (cf. `.claude/memory/feedback_no_silent_policy_defaults.md`). - -Record this result in issue #481 and in `.claude/memory/` once established. diff --git a/.claude/probes/481-empty-list/probe.swift b/.claude/probes/481-empty-list/probe.swift deleted file mode 100644 index 17445a01..00000000 --- a/.claude/probes/481-empty-list/probe.swift +++ /dev/null @@ -1,74 +0,0 @@ -import Foundation -import MistKit - -// Probe: how does CloudKit represent an EMPTY list field on the way back? -// Writes a record with an empty list + a populated list, then looks it up. - -func env(_ key: String) -> String? { - guard let path = try? String(contentsOfFile: "/Users/leo/Documents/Projects/MistKit/MistDemo.env", encoding: .utf8) else { return nil } - for line in path.split(separator: "\n") { - let parts = line.split(separator: "=", maxSplits: 1).map(String.init) - if parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces) == key { - return parts[1].trimmingCharacters(in: .whitespaces) - } - } - return nil -} - -let container = env("CLOUDKIT_CONTAINER_IDENTIFIER")! -let keyID = env("CLOUDKIT_KEY_ID")! -let keyPath = env("CLOUDKIT_PRIVATE_KEY_PATH")! - -let creds = try Credentials( - serverToServer: ServerToServerCredentials(keyID: keyID, privateKey: .file(path: keyPath)) -) -let service = CloudKitService( - containerIdentifier: container, - credentials: creds, - environment: .development -) - -let suffix = Int(Date().timeIntervalSince1970) -let name = "emptylistprobe_\(suffix)" - -print("=== WRITING record \(name) ===") -print(" probeEmptyList = .list([]) (empty)") -print(" probeFilledList = .list([.string]) (populated)") - -do { - let created = try await service.createRecord( - recordType: "Note", - recordName: name, - fields: [ - "title": .string("empty list probe"), - "probeEmptyList": .list([]), - "probeFilledList": .list([.string("a"), .string("b")]), - ], - database: .public(.prefers(.serverToServer)) - ) - print("\n=== CREATE RESPONSE fields ===") - for (k, v) in created.fields.sorted(by: { $0.key < $1.key }) { - print(" \(k) = \(v)") - } - print("\n probeEmptyList present in create response? \(created.fields["probeEmptyList"] != nil)") - - print("\n=== LOOKUP (read back) ===") - let results = try await service.lookupRecords(recordNames: [name], database: .public(.prefers(.serverToServer))) - for r in results { - switch r { - case .success(let rec): - for (k, v) in rec.fields.sorted(by: { $0.key < $1.key }) { - print(" \(k) = \(v)") - } - print("\n >>> probeEmptyList present on read? \(rec.fields["probeEmptyList"] != nil)") - if let e = rec.fields["probeEmptyList"] { - print(" >>> probeEmptyList value: \(e)") - } - print(" >>> probeFilledList value: \(rec.fields["probeFilledList"].map { "\($0)" } ?? "ABSENT")") - case .failure(let err): - print(" lookup failure: \(err)") - } - } -} catch { - print("\n!!! ERROR: \(error)") -} diff --git a/AGENTS.md b/AGENTS.md index fac28b4f..3e261ba0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -129,24 +129,26 @@ swift run mistdemo query MistKit uses separate types for requests and responses at the OpenAPI schema level to accurately model CloudKit's asymmetric API behavior: **Type Layers:** -1. **Domain Layer**: `FieldValue` enum - Pure Swift types, no API metadata (`Sources/MistKit/Models/FieldValues/FieldValue.swift`) -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 +1. **Domain Layer**: `FieldValue` enum — each kind carries `Arity` (`.value` / `.list`), so lists are homogeneous by construction and nesting is unrepresentable (`Sources/MistKit/Models/FieldValues/FieldValue.swift`). There is no `case list([FieldValue])` (issue #481). +2. **API Request Layer**: `FieldValueRequest` - Optional type field; CloudKit infers type from value structure, except for ambiguous scalars (see below) and list values / IN/NOT_IN filters, which are tagged with the granular `*_LIST` family +3. **API Response Layer**: `FieldValueResponse` - Optional type field; list responses use `STRING_LIST` / `INT64_LIST` / … (not a flat `LIST` tag — verified live 2026-09-09) -**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`) — domain `Data`, encoded as a base64 string on the wire, otherwise read as `STRING` -- `DOUBLE` (`.double`) — a whole-valued double serializes without a fraction, otherwise read as `INT64` +**Homogeneous lists (issue #481):** Write `.string(.list(["a","b"]))` or `.string(.list([]))` — empty lists keep their element type. Deprecated scalar factories (`FieldValue.string(_: String)`, etc.) forward to `.value` for migration; there is no polyfill for the old heterogeneous `.list([FieldValue])`. Accessors: `stringValue` unwraps `.value` only; `stringListValue` unwraps `.list`; deprecated `listValue` flattens to `[FieldValue]` of `.value`s. -Object/array-shaped values (`REFERENCE`, `ASSET`, `LOCATION`, `LIST`) and `STRING`/`INT64` are unambiguous and stay untagged. Tagging happens in the exhaustive `init(from:)` switch (`Components.Schemas.FieldValueRequest.swift`). `type` is *not* required globally because CloudKit documents it as optional. +**Request type tagging (issue #375 / #481):** Most scalar 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(.value)`) — a millisecond number, otherwise read as `INT64`/`DOUBLE` +- `BYTES` (`.bytes(.value)`) — domain `Data`, encoded as a base64 string on the wire, otherwise read as `STRING` +- `DOUBLE` (`.double(.value)`) — a whole-valued double serializes without a fraction, otherwise read as `INT64` -**Timestamps must be whole milliseconds.** CloudKit rejects a fractional `TIMESTAMP` (e.g. `1747999812347.89`) with `BAD_REQUEST "Invalid value, expected type TIMESTAMP"`, and Swift's `Date` carries sub-millisecond precision — so `.date` values are `.rounded()` on the way out, not just tagged. **The same constraint applies to `LocationValue.timestamp`** (`Components.Schemas.FieldValueRequest.swift:89`), which is a second, nested millisecond field with no `type` tag of its own to disambiguate it; that instance was found only via a live CelestraCloud integration failure (PR #377, commit `1eb639a`). Sub-millisecond precision is therefore destroyed on every write: a `Date` in is a different `Date` out. +Object-shaped values (`REFERENCE`, `ASSET`, `LOCATION`) and scalar `STRING`/`INT64` stay untagged. **List arities always tag** `STRING_LIST` / `INT64_LIST` / … (including record-field writes, not only IN/NOT_IN). Tagging happens in the exhaustive `init(from:)` switch (`Components.Schemas.FieldValueRequest.swift`). `type` is *not* required globally because CloudKit documents it as optional. + +**Timestamps must be whole milliseconds.** CloudKit rejects a fractional `TIMESTAMP` (e.g. `1747999812347.89`) with `BAD_REQUEST "Invalid value, expected type TIMESTAMP"`, and Swift's `Date` carries sub-millisecond precision — so `.date` values (scalar and list elements) are `.rounded()` on the way out, not just tagged. **The same constraint applies to `LocationValue.timestamp`**, which is a second, nested millisecond field with no `type` tag of its own to disambiguate it; that instance was found only via a live CelestraCloud integration failure (PR #377, commit `1eb639a`). Sub-millisecond precision is therefore destroyed on every write: a `Date` in is a different `Date` out. **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 (decoded with `Data(base64Encoded:)`; malformed tagged base64 throws `ConversionError.typeValueMismatch` with the unwrapped string). `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`). Do **not** infer `.bytes` from untagged base64 — ordinary strings such as `"Chen"` decode as valid base64. Domain `.bytes` is `Data`; `bytesValue` re-encodes to base64 and `dataValue` matches `.bytes` only (no `.string` fallback). The generated `BytesValue` in `Sources/MistKitOpenAPI/` stays `String`. 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. -**Complex/list contradiction validation (issue #376):** the same fail-loud check now extends to the complex/list response `type` tags. A declared `REFERENCE`/`ASSET`/`ASSETID`/`LOCATION`/`LIST` whose decoded value isn't the matching `oneOf` case (`ReferenceValue`/`AssetValue`/`LocationValue`/`ListValue`) **throws** `ConversionError.typeValueMismatch` instead of silently coercing to the value's shape (`makeTypedComplex` in `FieldValue+Components.swift`, gated by the `ExpectedComplexValue` mapping). `ASSETID` shares `AssetValue` with `ASSET`; the `LIST` tag is validated only at the container level (element types stay lenient). Untagged responses are unaffected — they still resolve purely from the value's self-describing structure via `makeComplexFieldValue`, so well-formed responses never start failing. +**Complex/list contradiction validation (issues #376 / #481):** the same fail-loud check extends to complex and list response `type` tags. A declared `REFERENCE`/`ASSET`/`ASSETID`/`LOCATION` whose decoded value isn't the matching `oneOf` case **throws** `ConversionError.typeValueMismatch`. Granular list tags (`STRING_LIST`, …) require a `ListValue` container and decode to the matching `.kind(.list(...))`; a list tag over a non-list value throws. Untagged list responses infer a homogeneous element kind from the elements (or stay unrecoverable only when empty and untagged — live CloudKit still sends `STRING_LIST` for empty lists). `ASSETID` shares `AssetValue` with `ASSET`. **Why Separate Request/Response Types?** - CloudKit API has asymmetric behavior: requests tag type only when ambiguous, responses may always include it @@ -378,7 +380,7 @@ A `ClientTransport` extension could provide a generic upload method, but would n - `Sources/MistKit/Models/Queries/FilterBuilder/FilterBuilder+StringFilters.swift` — string-specific: `beginsWith`, `notBeginsWith`, `containsAllTokens` - `Sources/MistKit/Models/Queries/FilterBuilder/FilterBuilder+ListMemberFilters.swift` — list-specific: `listContains`, etc. -**IN/NOT_IN serialization:** Uses `ListValuePayload` (`Components.Schemas.ListValuePayload`) to wrap array values, and tags the list's element type explicitly via `cloudKitListType(for:)` (`.STRING_LIST`, `.INT64_LIST`, …). The v1.0.0-alpha.5 fix (issue #192 / PR #205) **added that `type` tag** — without it CloudKit cannot determine the element type and rejects every `.in()` query with `HTTP 400 BadRequestException: Unexpected input`, at any array size. `ListValuePayload` was already in use and was never the problem; the `value` key structure is unchanged. The element type is derived from the *first* element, so a heterogeneous list is tagged by element zero, and an empty list is sent untagged. +**IN/NOT_IN serialization:** Uses `ListValuePayload` (`Components.Schemas.ListValuePayload`) to wrap array values, and tags the list's element type explicitly via `cloudKitListType(for:)` (`.STRING_LIST`, `.INT64_LIST`, …). The v1.0.0-alpha.5 fix (issue #192 / PR #205) **added that `type` tag** — without it CloudKit cannot determine the element type and rejects every `.in()` query with `HTTP 400 BadRequestException: Unexpected input`, at any array size. `ListValuePayload` was already in use and was never the problem; the `value` key structure is unchanged. After #481, IN elements are scalar `.value` arities; `cloudKitListType` is total on element kind (a `.list` arity as an IN element yields no tag). Empty IN arrays are sent untagged. ### CloudKit Web Services Integration - Base URL: `https://api.apple-cloudkit.com` diff --git a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ExportCommand.swift b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ExportCommand.swift index 985f2a98..271f0cd0 100644 --- a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ExportCommand.swift +++ b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ExportCommand.swift @@ -109,7 +109,7 @@ internal enum ExportCommand { // Filter signed-only restore images if exportConfig.signedOnly { restoreImages = restoreImages.filter { record in - if case .int64(let isSigned) = record.fields["isSigned"] { + if case .int64(.value(let isSigned)) = record.fields["isSigned"] { return isSigned != 0 } return false @@ -119,21 +119,21 @@ internal enum ExportCommand { // Filter out betas if exportConfig.noBetas { restoreImages = restoreImages.filter { record in - if case .int64(let isPrerelease) = record.fields["isPrerelease"] { + if case .int64(.value(let isPrerelease)) = record.fields["isPrerelease"] { return isPrerelease == 0 } return true } xcodeVersions = xcodeVersions.filter { record in - if case .int64(let isPrerelease) = record.fields["isPrerelease"] { + if case .int64(.value(let isPrerelease)) = record.fields["isPrerelease"] { return isPrerelease == 0 } return true } swiftVersions = swiftVersions.filter { record in - if case .int64(let isPrerelease) = record.fields["isPrerelease"] { + if case .int64(.value(let isPrerelease)) = record.fields["isPrerelease"] { return isPrerelease == 0 } return true diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Extensions/FieldValue+URL.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Extensions/FieldValue+URL.swift index 6aba7070..a97f04f3 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/Extensions/FieldValue+URL.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Extensions/FieldValue+URL.swift @@ -47,7 +47,7 @@ extension FieldValue { /// /// - Returns: The URL if this is a string FieldValue with a valid URL format, otherwise `nil` public var urlValue: URL? { - if case .string(let value) = self { + if case .string(.value(let value)) = self { return URL(string: value) } return nil diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/MockCloudKitServiceTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/MockCloudKitServiceTests.swift index 63d678fd..54614101 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/MockCloudKitServiceTests.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/MockCloudKitServiceTests.swift @@ -110,7 +110,7 @@ internal struct MockCloudKitServiceTests { #expect(storedRecords.count == 1) let storedFields = storedRecords[0].fields - if case .int64(let fileSize) = storedFields["fileSize"] { + if case .int64(.value(let fileSize)) = storedFields["fileSize"] { #expect(fileSize == 99_999) } else { Issue.record("fileSize field not found or wrong type") diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Extensions/FieldValueURLTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Extensions/FieldValueURLTests.swift index bca6a0a0..9e0cd214 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/Extensions/FieldValueURLTests.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Extensions/FieldValueURLTests.swift @@ -41,7 +41,7 @@ internal struct FieldValueURLTests { let url = URL(string: "https://example.com/file.dmg")! let fieldValue = FieldValue(url: url) - if case .string(let value) = fieldValue { + if case .string(.value(let value)) = fieldValue { #expect(value == "https://example.com/file.dmg") } else { Issue.record("Expected .string FieldValue") @@ -53,7 +53,7 @@ internal struct FieldValueURLTests { let url = URL(string: "https://example.com/path/to/file.ipsw")! let fieldValue = FieldValue(url: url) - if case .string(let value) = fieldValue { + if case .string(.value(let value)) = fieldValue { #expect(value == "https://example.com/path/to/file.ipsw") } else { Issue.record("Expected .string FieldValue") @@ -65,7 +65,7 @@ internal struct FieldValueURLTests { let url = URL(string: "https://example.com/file.dmg?version=1.0&platform=mac")! let fieldValue = FieldValue(url: url) - if case .string(let value) = fieldValue { + if case .string(.value(let value)) = fieldValue { #expect(value == "https://example.com/file.dmg?version=1.0&platform=mac") } else { Issue.record("Expected .string FieldValue") @@ -77,7 +77,7 @@ internal struct FieldValueURLTests { let url = URL(fileURLWithPath: "/Users/test/file.dmg") let fieldValue = FieldValue(url: url) - if case .string(let value) = fieldValue { + if case .string(.value(let value)) = fieldValue { #expect(value == "file:///Users/test/file.dmg") } else { Issue.record("Expected .string FieldValue") @@ -210,7 +210,7 @@ internal struct FieldValueURLTests { let fieldValue = FieldValue(url: url) // When sent to CloudKit, this becomes a STRING field with the absolute URL - if case .string(let stringValue) = fieldValue { + if case .string(.value(let stringValue)) = fieldValue { // Verify it's a valid absolute URL string #expect(stringValue.hasPrefix("https://")) #expect(URL(string: stringValue) != nil) diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Utilities/FieldValue+Assertions.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Utilities/FieldValue+Assertions.swift index b985a0d1..cd6c1985 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/Utilities/FieldValue+Assertions.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Utilities/FieldValue+Assertions.swift @@ -35,7 +35,7 @@ internal import Testing extension FieldValue { /// Asserts that this FieldValue is a string with the expected value public func assertStringEquals(_ expected: String) { - guard case .string(let actual) = self else { + guard case .string(.value(let actual)) = self else { Issue.record("Expected .string, got \(self)") return } @@ -44,7 +44,7 @@ extension FieldValue { /// Asserts that this FieldValue is an int64 with the expected value public func assertInt64Equals(_ expected: Int) { - guard case .int64(let actual) = self else { + guard case .int64(.value(let actual)) = self else { Issue.record("Expected .int64, got \(self)") return } @@ -53,7 +53,7 @@ extension FieldValue { /// Asserts that this FieldValue is a double with the expected value public func assertDoubleEquals(_ expected: Double) { - guard case .double(let actual) = self else { + guard case .double(.value(let actual)) = self else { Issue.record("Expected .double, got \(self)") return } @@ -63,7 +63,7 @@ extension FieldValue { /// Asserts that this FieldValue is a boolean stored as INT64 (0 or 1) public func assertBoolEquals(_ expected: Bool) { // Boolean is stored as INT64 (0 or 1) in CloudKit - guard case .int64(let actual) = self else { + guard case .int64(.value(let actual)) = self else { Issue.record("Expected .int64 (boolean), got \(self)") return } @@ -73,7 +73,7 @@ extension FieldValue { /// Asserts that this FieldValue is a reference with the expected record name public func assertReferenceEquals(_ expectedRecordName: String) { - guard case .reference(let ref) = self else { + guard case .reference(.value(let ref)) = self else { Issue.record("Expected .reference, got \(self)") return } @@ -90,7 +90,7 @@ extension FieldValue { /// Asserts that this FieldValue is a date with the expected value public func assertDateEquals(_ expected: Date) { - guard case .date(let actual) = self else { + guard case .date(.value(let actual)) = self else { Issue.record("Expected .date, got \(self)") return } diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Extensions/Article+MistKit.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Extensions/Article+MistKit.swift index bd526ac4..ed8d6b07 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Extensions/Article+MistKit.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Extensions/Article+MistKit.swift @@ -116,7 +116,7 @@ extension Article: CloudKitConvertible { addOptionalInt(&fields, key: "estimatedReadingTime", value: estimatedReadingTime) if !tags.isEmpty { - fields["tags"] = .list(tags.map { .string($0) }) + fields["tags"] = .string(.list(tags)) } return fields diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Extensions/Feed+MistKit.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Extensions/Feed+MistKit.swift index 09ba2c95..a13d8097 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Extensions/Feed+MistKit.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Extensions/Feed+MistKit.swift @@ -131,7 +131,7 @@ extension Feed: CloudKitConvertible { // Array fields if !tags.isEmpty { - fields["tags"] = .list(tags.map { .string($0) }) + fields["tags"] = .string(.list(tags)) } return fields diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Extensions/RecordInfo+Parsing.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Extensions/RecordInfo+Parsing.swift index 83e21cdc..8853953a 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Extensions/RecordInfo+Parsing.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Extensions/RecordInfo+Parsing.swift @@ -47,7 +47,7 @@ extension RecordInfo { forKey key: String, recordType: String ) throws -> String { - guard case .string(let value) = fields[key], !value.isEmpty else { + guard case .string(.value(let value)) = fields[key], !value.isEmpty else { throw CloudKitConversionError.missingRequiredField( fieldName: key, recordType: recordType @@ -61,7 +61,7 @@ extension RecordInfo { /// - Parameter key: The field key to extract. /// - Returns: The string value, or nil if the field is missing. public func optionalString(forKey key: String) -> String? { - guard case .string(let value) = fields[key] else { + guard case .string(.value(let value)) = fields[key] else { return nil } return value @@ -74,7 +74,7 @@ extension RecordInfo { /// - defaultValue: The default value if the field is missing. /// - Returns: The boolean value, or the default if the field is missing. public func bool(forKey key: String, default defaultValue: Bool = false) -> Bool { - guard case .int64(let value) = fields[key] else { + guard case .int64(.value(let value)) = fields[key] else { return defaultValue } return value != 0 @@ -87,7 +87,7 @@ extension RecordInfo { /// - defaultValue: The default value if the field is missing. /// - Returns: The Int64 value, or the default if the field is missing. public func int64(forKey key: String, default defaultValue: Int64 = 0) -> Int64 { - guard case .int64(let value) = fields[key] else { + guard case .int64(.value(let value)) = fields[key] else { return defaultValue } return Int64(value) @@ -100,7 +100,7 @@ extension RecordInfo { /// - defaultValue: The default value if the field is missing. /// - Returns: The Int value, or the default if the field is missing. public func int(forKey key: String, default defaultValue: Int = 0) -> Int { - guard case .int64(let value) = fields[key] else { + guard case .int64(.value(let value)) = fields[key] else { return defaultValue } return Int(value) @@ -111,7 +111,7 @@ extension RecordInfo { /// - Parameter key: The field key to extract. /// - Returns: The Date value, or nil if the field is missing. public func optionalDate(forKey key: String) -> Date? { - guard case .date(let value) = fields[key] else { + guard case .date(.value(let value)) = fields[key] else { return nil } return value @@ -124,7 +124,7 @@ extension RecordInfo { /// - defaultValue: The default value if the field is missing. /// - Returns: The Date value, or the default if the field is missing. public func date(forKey key: String, default defaultValue: Date) -> Date { - guard case .date(let value) = fields[key] else { + guard case .date(.value(let value)) = fields[key] else { return defaultValue } return value @@ -135,7 +135,7 @@ extension RecordInfo { /// - Parameter key: The field key to extract. /// - Returns: The Double value, or nil if the field is missing. public func optionalDouble(forKey key: String) -> Double? { - guard case .double(let value) = fields[key] else { + guard case .double(.value(let value)) = fields[key] else { return nil } return value @@ -146,7 +146,7 @@ extension RecordInfo { /// - Parameter key: The field key to extract. /// - Returns: The Int value, or nil if the field is missing. public func optionalInt(forKey key: String) -> Int? { - guard case .int64(let value) = fields[key] else { + guard case .int64(.value(let value)) = fields[key] else { return nil } return Int(value) @@ -157,14 +157,9 @@ extension RecordInfo { /// - Parameter key: The field key to extract. /// - Returns: The array of strings, or an empty array if the field is missing. public func stringArray(forKey key: String) -> [String] { - guard case .list(let values) = fields[key] else { + guard case .string(.list(let values)) = fields[key] else { return [] } - return values.compactMap { fieldValue in - guard case .string(let str) = fieldValue else { - return nil - } - return str - } + return values } } diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/ArticleConversion+FromCloudKit.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/ArticleConversion+FromCloudKit.swift index b628db72..5c4ac295 100644 --- a/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/ArticleConversion+FromCloudKit.swift +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/ArticleConversion+FromCloudKit.swift @@ -54,7 +54,7 @@ extension ArticleConversion { "author": .string("Jane Smith"), "imageURL": .string("https://example.com/img.jpg"), "language": .string("en-US"), - "tags": .list([.string("news"), .string("tech")]), + "tags": .string(.list(["news", "tech"])), "wordCount": .int64(750), "estimatedReadingTime": .int64(4), "fetchedTimestamp": .date(fetchedDate), diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/ArticleConversion+ToCloudKit.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/ArticleConversion+ToCloudKit.swift index 1c0d3561..a5475620 100644 --- a/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/ArticleConversion+ToCloudKit.swift +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/ArticleConversion+ToCloudKit.swift @@ -100,12 +100,12 @@ extension ArticleConversion { #expect(fields["estimatedReadingTime"] == .int64(3)) // Check array field - if case .list(let tagValues) = fields["tags"] { + if case .string(.list(let tagValues)) = fields["tags"] { #expect(tagValues.count == 2) - #expect(tagValues[0] == .string("tech")) - #expect(tagValues[1] == .string("swift")) + #expect(tagValues[0] == "tech") + #expect(tagValues[1] == "swift") } else { - Issue.record("tags field should be a list") + Issue.record("tags field should be a string list") } } diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion+FromCloudKit.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion+FromCloudKit.swift index 6069dcea..083a2ca6 100644 --- a/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion+FromCloudKit.swift +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion+FromCloudKit.swift @@ -55,7 +55,7 @@ extension FeedConversion { "createdTimestamp": .date(Date(timeIntervalSince1970: 1_000_000)), "verifiedTimestamp": .date(Date(timeIntervalSince1970: 2_000_000)), "updateFrequency": .double(3_600.0), - "tags": .list([.string("tech"), .string("news")]), + "tags": .string(.list(["tech", "news"])), "totalAttempts": .int64(10), "successfulAttempts": .int64(8), "attemptedTimestamp": .date(Date(timeIntervalSince1970: 3_000_000)), diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion+ToCloudKit.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion+ToCloudKit.swift index 1ba5245a..b386e368 100644 --- a/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion+ToCloudKit.swift +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion+ToCloudKit.swift @@ -141,12 +141,12 @@ extension FeedConversion { #expect(fields["minUpdateInterval"] == .double(1_800.0)) // Check array field - if case .list(let tagValues) = fields["tags"] { + if case .string(.list(let tagValues)) = fields["tags"] { #expect(tagValues.count == 2) - #expect(tagValues[0] == .string("tech")) - #expect(tagValues[1] == .string("news")) + #expect(tagValues[0] == "tech") + #expect(tagValues[1] == "news") } else { - Issue.record("tags field should be a list") + Issue.record("tags field should be a string list") } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DownloadAssetPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DownloadAssetPhase.swift index 8c468e84..dcd2d83e 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DownloadAssetPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DownloadAssetPhase.swift @@ -62,7 +62,7 @@ internal struct DownloadAssetPhase: IntegrationPhase { "Lookup of '\(recordName)' did not return a record for asset download" ) } - guard case .asset(let asset) = record.fields["image"] else { + guard case .asset(.value(let asset)) = record.fields["image"] else { throw IntegrationTestError.verificationFailed( "Record '\(recordName)' has no 'image' asset to download" ) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupRecordsPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupRecordsPhase.swift index 135bc531..59e80a32 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupRecordsPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupRecordsPhase.swift @@ -48,7 +48,7 @@ internal struct LookupRecordsPhase: IntegrationPhase { private static func verifyTimestampRoundTrip(in records: [RecordInfo]) throws { let expected = CreateRecordsPhase.verificationTimestamp.timeIntervalSince1970 for record in records where record.fields["timestamp"] != nil { - guard case .date(let value)? = record.fields["timestamp"] else { + guard case .date(.value(let value))? = record.fields["timestamp"] else { throw IntegrationTestError.verificationFailed( "Record \(record.recordName) timestamp did not round-trip as a date" ) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/RereferenceAssetPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/RereferenceAssetPhase.swift index 258dd0cb..46e00c23 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/RereferenceAssetPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/RereferenceAssetPhase.swift @@ -45,7 +45,7 @@ internal struct RereferenceAssetPhase: IntegrationPhase { private static func verify( _ record: RecordInfo, expected: Asset, context: PhaseContext ) throws { - guard case .asset(let targetAsset) = record.fields["image"] else { + guard case .asset(.value(let targetAsset)) = record.fields["image"] else { throw IntegrationTestError.verificationFailed( "Target record '\(record.recordName)' has no 'image' asset after re-reference" ) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/StringListRoundTripPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/StringListRoundTripPhase.swift new file mode 100644 index 00000000..04d353b7 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/StringListRoundTripPhase.swift @@ -0,0 +1,138 @@ +// +// StringListRoundTripPhase.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import MistKit + +/// Live-verifies `Note.tags` (`LIST`) round-trips through create + lookup +/// using the homogeneous ``FieldValue/Arity`` API (issue #481). +/// +/// Writes one record with a non-empty string list and one with an empty list, +/// looks both up, and asserts the domain values come back as +/// `.string(.list(...))`. Appends the new record names so ``CleanupPhase`` +/// deletes them. +internal struct StringListRoundTripPhase: IntegrationPhase { + internal typealias Input = CreatedRecordNames + internal typealias Output = CreatedRecordNames + + internal static let title = "String list (tags) round-trip" + internal static let emoji = "🏷️" + internal static let apiName = "createRecord+lookupRecords" + + private static let populatedTags = ["a", "b"] + private static let emptyTags: [String] = [] + + internal func run( + input: CreatedRecordNames, + context: PhaseContext + ) async throws -> CreatedRecordNames { + print("\n\(Self.emoji) \(Self.title)") + + let populatedName = "mistkit-tags-\(UUID().uuidString.lowercased())" + let emptyName = "mistkit-tags-empty-\(UUID().uuidString.lowercased())" + + _ = try await context.service.createRecord( + recordType: MistDemoConfig.recordType, + recordName: populatedName, + fields: [ + "title": .string(.value("Tags populated")), + "tags": .string(.list(Self.populatedTags)), + ], + database: context.database + ) + _ = try await context.service.createRecord( + recordType: MistDemoConfig.recordType, + recordName: emptyName, + fields: [ + "title": .string(.value("Tags empty")), + "tags": .string(.list(Self.emptyTags)), + ], + database: context.database + ) + + if context.verbose { + print(" ✅ Created: \(populatedName) tags=\(Self.populatedTags)") + print(" ✅ Created: \(emptyName) tags=[]") + } + + let results = try await context.service.lookupRecords( + recordNames: [populatedName, emptyName], + database: context.database + ) + let recordsByName = Dictionary( + uniqueKeysWithValues: results.compactMap { result -> (String, RecordInfo)? in + guard case .success(let record) = result else { return nil } + return (record.recordName, record) + } + ) + + try Self.verifyTags( + in: recordsByName[populatedName], + recordName: populatedName, + expected: Self.populatedTags + ) + try Self.verifyTags( + in: recordsByName[emptyName], + recordName: emptyName, + expected: Self.emptyTags + ) + + print("✅ String list tags round-trip verified (populated + empty)") + + return CreatedRecordNames(input.names + [populatedName, emptyName]) + } + + private static func verifyTags( + in record: RecordInfo?, + recordName: String, + expected: [String] + ) throws { + guard let record else { + throw IntegrationTestError.verificationFailed( + "Lookup of '\(recordName)' did not return a record for tags round-trip" + ) + } + guard let tags = record.fields["tags"]?.stringListValue else { + throw IntegrationTestError.verificationFailed( + """ + Record \(record.recordName) tags did not round-trip as .string(.list); \ + got \(String(describing: record.fields["tags"])) + """ + ) + } + guard tags == expected else { + throw IntegrationTestError.verificationFailed( + """ + Record \(record.recordName) tags mismatch: \ + expected \(expected), got \(tags) + """ + ) + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift index 9e82ea4e..2096e093 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift @@ -52,6 +52,7 @@ internal struct PrivateDatabaseTest: PhasedIntegrationTest { RereferenceAssetPhase(), QueryRecordsPhase(), LookupRecordsPhase(), + StringListRoundTripPhase(), InitialSyncPhase(), ModifyRecordsPhase(), FetchZoneChangesPhase(), diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift index 72e26b79..aaea6707 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift @@ -61,6 +61,7 @@ internal struct PublicDatabaseTest: PhasedIntegrationTest { RereferenceAssetPhase(), QueryRecordsPhase(), LookupRecordsPhase(), + StringListRoundTripPhase(), ModifyRecordsPhase(), QueryRequestOptionsPhase(), ModifyRequestOptionsPhase(), diff --git a/Examples/MistDemo/Sources/MistDemoKit/Utilities/FieldValueFormatter.swift b/Examples/MistDemo/Sources/MistDemoKit/Utilities/FieldValueFormatter.swift index dc817b79..b31c2b7a 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Utilities/FieldValueFormatter.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Utilities/FieldValueFormatter.swift @@ -32,59 +32,67 @@ internal import MistKit /// Utility for formatting FieldValue objects for display. internal enum FieldValueFormatter { - // Extract the raw display string from a FieldValue. - // swiftlint:disable:next cyclomatic_complexity + /// Extract the raw display string from a FieldValue. internal static func displayString( _ value: FieldValue ) -> String { switch value { - case .string(let string): - return string - case .int64(let int): - return "\(int)" - case .double(let double): - return "\(double)" - case .bytes(let bytes): - return bytes.base64EncodedString() - case .date(let date): - return formatDate(date) - case .location(let location): - return "(\(location.latitude), \(location.longitude))" - case .reference(let reference): - return reference.recordName - case .asset(let asset): - return asset.downloadURL ?? "no URL" - case .list(let values): - let items = values.map { displayString($0) } - return "[\(items.joined(separator: ", "))]" + case .string(let arity): + return formatArity(arity, element: { $0 }) + case .int64(let arity): + return formatArity(arity, element: { "\($0)" }) + case .double(let arity): + return formatArity(arity, element: { "\($0)" }) + case .bytes(let arity): + return formatArity(arity, element: { $0.base64EncodedString() }) + case .date(let arity): + return formatArity(arity, element: formatDate) + case .location(let arity): + return formatArity(arity, element: { "(\($0.latitude), \($0.longitude))" }) + case .reference(let arity): + return formatArity(arity, element: \.recordName) + case .asset(let arity): + return formatArity(arity, element: { $0.downloadURL ?? "no URL" }) } } - // Format a single FieldValue for display. - // swiftlint:disable:next cyclomatic_complexity + /// Format a single FieldValue for display. internal static func formatFieldValue( _ value: FieldValue ) -> String { switch value { - case .string(let string): - return "\"\(string)\"" - case .int64(let int): - return "\(int)" - case .double(let double): - return "\(double)" - case .bytes(let bytes): - return "bytes(\(bytes.count) bytes, base64: \(bytes.base64EncodedString()))" - case .date(let date): - return "date(\(formatDate(date)))" - case .location(let location): - return "location(\(location.latitude), \(location.longitude))" - case .reference(let reference): - return "reference(\(reference.recordName))" - case .asset(let asset): - return "asset(\(asset.downloadURL ?? "no URL"))" + case .string(let arity): + return formatArity(arity, element: { "\"\($0)\"" }) + case .int64(let arity): + return formatArity(arity, element: { "\($0)" }) + case .double(let arity): + return formatArity(arity, element: { "\($0)" }) + case .bytes(let arity): + return formatArity(arity) { bytes in + "bytes(\(bytes.count) bytes, base64: \(bytes.base64EncodedString()))" + } + case .date(let arity): + return formatArity(arity, element: { "date(\(formatDate($0)))" }) + case .location(let arity): + return formatArity(arity) { location in + "location(\(location.latitude), \(location.longitude))" + } + case .reference(let arity): + return formatArity(arity, element: { "reference(\($0.recordName))" }) + case .asset(let arity): + return formatArity(arity, element: { "asset(\($0.downloadURL ?? "no URL"))" }) + } + } + + private static func formatArity( + _ arity: FieldValue.Arity, + element: (T) -> String + ) -> String { + switch arity { + case .value(let value): + return element(value) case .list(let values): - let items = values.map { formatFieldValue($0) } - return "[\(items.joined(separator: ", "))]" + return "[\(values.map(element).joined(separator: ", "))]" } } diff --git a/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+BytesType.swift b/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+BytesType.swift index 4612d532..c2f0598b 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+BytesType.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+BytesType.swift @@ -41,7 +41,7 @@ extension FieldValueFieldTypeTests { let fieldValue = FieldValue(value: "aGVsbG8=" as String, fieldType: .bytes) #expect(fieldValue != nil) - if case .bytes(let value) = fieldValue { + if case .bytes(.value(let value)) = fieldValue { #expect(value == Data("hello".utf8)) } else { Issue.record("Expected .bytes case") diff --git a/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+DoubleType.swift b/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+DoubleType.swift index 50ec0a66..ddcf2063 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+DoubleType.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+DoubleType.swift @@ -41,7 +41,7 @@ extension FieldValueFieldTypeTests { let fieldValue = FieldValue(value: 19.99 as Double, fieldType: .double) #expect(fieldValue != nil) - if case .double(let value) = fieldValue { + if case .double(.value(let value)) = fieldValue { #expect(value == 19.99) } else { Issue.record("Expected .double case") @@ -53,7 +53,7 @@ extension FieldValueFieldTypeTests { let fieldValue = FieldValue(value: -3.14 as Double, fieldType: .double) #expect(fieldValue != nil) - if case .double(let value) = fieldValue { + if case .double(.value(let value)) = fieldValue { #expect(value == -3.14) } else { Issue.record("Expected .double case") @@ -65,7 +65,7 @@ extension FieldValueFieldTypeTests { let fieldValue = FieldValue(value: 0.0 as Double, fieldType: .double) #expect(fieldValue != nil) - if case .double(let value) = fieldValue { + if case .double(.value(let value)) = fieldValue { #expect(value == 0.0) } else { Issue.record("Expected .double case") @@ -77,7 +77,7 @@ extension FieldValueFieldTypeTests { let fieldValue = FieldValue(value: 42.0 as Double, fieldType: .double) #expect(fieldValue != nil) - if case .double(let value) = fieldValue { + if case .double(.value(let value)) = fieldValue { #expect(value == 42.0) } else { Issue.record("Expected .double case") diff --git a/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+Int64Type.swift b/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+Int64Type.swift index fd4a0ca6..d9e49b78 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+Int64Type.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+Int64Type.swift @@ -41,7 +41,7 @@ extension FieldValueFieldTypeTests { let fieldValue = FieldValue(value: Int64(42), fieldType: .int64) #expect(fieldValue != nil) - if case .int64(let value) = fieldValue { + if case .int64(.value(let value)) = fieldValue { #expect(value == 42) } else { Issue.record("Expected .int64 case") @@ -53,7 +53,7 @@ extension FieldValueFieldTypeTests { let fieldValue = FieldValue(value: 42 as Int, fieldType: .int64) #expect(fieldValue != nil) - if case .int64(let value) = fieldValue { + if case .int64(.value(let value)) = fieldValue { #expect(value == 42) } else { Issue.record("Expected .int64 case") @@ -65,7 +65,7 @@ extension FieldValueFieldTypeTests { let fieldValue = FieldValue(value: Int64(-123), fieldType: .int64) #expect(fieldValue != nil) - if case .int64(let value) = fieldValue { + if case .int64(.value(let value)) = fieldValue { #expect(value == -123) } else { Issue.record("Expected .int64 case") @@ -77,7 +77,7 @@ extension FieldValueFieldTypeTests { let fieldValue = FieldValue(value: Int64(0), fieldType: .int64) #expect(fieldValue != nil) - if case .int64(let value) = fieldValue { + if case .int64(.value(let value)) = fieldValue { #expect(value == 0) } else { Issue.record("Expected .int64 case") @@ -95,7 +95,7 @@ extension FieldValueFieldTypeTests { let fieldValue = FieldValue(value: Int64.max, fieldType: .int64) #expect(fieldValue != nil) - if case .int64(let value) = fieldValue { + if case .int64(.value(let value)) = fieldValue { #expect(value == Int(Int64.max)) } else { Issue.record("Expected .int64 case") diff --git a/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+StringType.swift b/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+StringType.swift index dbff1450..b7be94eb 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+StringType.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+StringType.swift @@ -41,7 +41,7 @@ extension FieldValueFieldTypeTests { let fieldValue = FieldValue(value: "Hello World" as String, fieldType: .string) #expect(fieldValue != nil) - if case .string(let value) = fieldValue { + if case .string(.value(let value)) = fieldValue { #expect(value == "Hello World") } else { Issue.record("Expected .string case") @@ -53,7 +53,7 @@ extension FieldValueFieldTypeTests { let fieldValue = FieldValue(value: "" as String, fieldType: .string) #expect(fieldValue != nil) - if case .string(let value) = fieldValue { + if case .string(.value(let value)) = fieldValue { #expect(value.isEmpty) } else { Issue.record("Expected .string case") diff --git a/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+TimestampDateType.swift b/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+TimestampDateType.swift index 7b331260..19891b7d 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+TimestampDateType.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+TimestampDateType.swift @@ -42,7 +42,7 @@ extension FieldValueFieldTypeTests { let fieldValue = FieldValue(value: date, fieldType: .timestamp) #expect(fieldValue != nil) - if case .date(let value) = fieldValue { + if case .date(.value(let value)) = fieldValue { #expect(value.timeIntervalSince1970 == 1_705_315_800) } else { Issue.record("Expected .date case") @@ -55,7 +55,7 @@ extension FieldValueFieldTypeTests { let fieldValue = FieldValue(value: date, fieldType: .timestamp) #expect(fieldValue != nil) - if case .date(let value) = fieldValue { + if case .date(.value(let value)) = fieldValue { #expect(value.timeIntervalSince1970 == 0) } else { Issue.record("Expected .date case") @@ -68,7 +68,7 @@ extension FieldValueFieldTypeTests { let fieldValue = FieldValue(value: date, fieldType: .timestamp) #expect(fieldValue != nil) - if case .date(let value) = fieldValue { + if case .date(.value(let value)) = fieldValue { #expect(value.timeIntervalSince1970 == date.timeIntervalSince1970) } else { Issue.record("Expected .date case") diff --git a/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+UnsupportedType.swift b/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+UnsupportedType.swift index e192a836..5a82d4d4 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+UnsupportedType.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+UnsupportedType.swift @@ -41,7 +41,7 @@ extension FieldValueFieldTypeTests { let fieldValue = FieldValue(value: "anything" as String, fieldType: .asset) #expect(fieldValue != nil) - if case .asset(let asset) = fieldValue { + if case .asset(.value(let asset)) = fieldValue { #expect(asset.downloadURL == "anything") } else { Issue.record("Expected .asset case") diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/CSVFormatter/CSVFormatterTests+EdgeCases.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/CSVFormatter/CSVFormatterTests+EdgeCases.swift index 29303708..0f06c2d5 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/CSVFormatter/CSVFormatterTests+EdgeCases.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/CSVFormatter/CSVFormatterTests+EdgeCases.swift @@ -115,7 +115,7 @@ extension CSVFormatterTests { recordName: "list-001", recordType: "List", fields: [ - "tags": .list([.string("tag1"), .string("tag2"), .string("tag3")]) + "tags": .string(.list(["tag1", "tag2", "tag3"])) ] ) let formatter = CSVFormatter() diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/OutputFormatterFactory/OutputFormatterFactoryTests+FormatterBehaviorConsistency.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/OutputFormatterFactory/OutputFormatterFactoryTests+FormatterBehaviorConsistency.swift index 57fb64c3..33c659b3 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/OutputFormatterFactory/OutputFormatterFactoryTests+FormatterBehaviorConsistency.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/OutputFormatterFactory/OutputFormatterFactoryTests+FormatterBehaviorConsistency.swift @@ -82,7 +82,7 @@ extension OutputFormatterFactoryTests { fields: [ "reference": .reference(.init(recordName: "ref-001")), "location": .location(.init(latitude: 37.7749, longitude: -122.4194)), - "list": .list([.string("item1"), .string("item2")]), + "list": .string(.list(["item1", "item2"])), ] ) diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/TableFormatter/TableFormatterTests+EdgeCases+FieldTypes.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/TableFormatter/TableFormatterTests+EdgeCases+FieldTypes.swift index cca9af32..06f38606 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/TableFormatter/TableFormatterTests+EdgeCases+FieldTypes.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/TableFormatter/TableFormatterTests+EdgeCases+FieldTypes.swift @@ -119,7 +119,7 @@ extension TableFormatterTests.EdgeCases { recordName: "list-001", recordType: "List", fields: [ - "tags": .list([.string("tag1"), .string("tag2"), .string("tag3")]) + "tags": .string(.list(["tag1", "tag2", "tag3"])) ] ) let formatter = TableFormatter() diff --git a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/YAMLFormatter/YAMLFormatterTests+EdgeCases.swift b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/YAMLFormatter/YAMLFormatterTests+EdgeCases.swift index 0b551598..367f1e68 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/YAMLFormatter/YAMLFormatterTests+EdgeCases.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Output/Formatters/YAMLFormatter/YAMLFormatterTests+EdgeCases.swift @@ -116,7 +116,7 @@ extension YAMLFormatterTests { recordName: "list-001", recordType: "List", fields: [ - "tags": .list([.string("tag1"), .string("tag2"), .string("tag3")]) + "tags": .string(.list(["tag1", "tag2", "tag3"])) ] ) let formatter = YAMLFormatter() diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Helpers.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Helpers.swift index 5de63da9..b3a9f675 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Helpers.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Helpers.swift @@ -72,11 +72,11 @@ var result: [String: String] = [:] for (name, value) in fields { switch value { - case .string(let string): + case .string(.value(let string)): result[name] = string - case .int64(let int): + case .int64(.value(let int)): result[name] = String(int) - case .double(let double): + case .double(.value(let double)): result[name] = String(double) default: continue diff --git a/Examples/MistDemo/schema.ckdb b/Examples/MistDemo/schema.ckdb index b8243bff..0fd83dd6 100644 --- a/Examples/MistDemo/schema.ckdb +++ b/Examples/MistDemo/schema.ckdb @@ -7,6 +7,7 @@ RECORD TYPE Note ( "title" STRING QUERYABLE SORTABLE SEARCHABLE, "index" INT64 QUERYABLE SORTABLE, "image" ASSET, + "tags" LIST, GRANT READ, CREATE, WRITE TO "_creator", GRANT READ, CREATE, WRITE TO "_icloud", diff --git a/ReleaseNotes.md b/ReleaseNotes.md index 916c2454..9031d72b 100644 --- a/ReleaseNotes.md +++ b/ReleaseNotes.md @@ -1,5 +1,6 @@ ## 1.0.0-beta.5 +* Make `FieldValue` lists homogeneous by construction: each kind carries `Arity` (`.value` / `.list`), so mixed and nested lists are unrepresentable. Response OpenAPI accepts live `*_LIST` tags (e.g. `STRING_LIST`); record-field list writes are tagged; deprecated scalar factories cover `.string("x")`-style call sites (#481) * Represent `FieldValue.bytes` as `Data` instead of a base64 `String` (#467) * Add `Asset.download(using:)`, which fetches an asset's `downloadURL` from the CDN and returns the bytes, plus `CloudKitError.missingAssetDownloadURL`. `fileChecksum` turned out to be a server-minted identity token rather than a digest of the file, so no client-side checksum verification is offered — check `Asset.size` to guard against truncation (#466, #473) * Add `VALIDATE` to `Reference.Action` for CloudKit Web Services reference dictionaries (#464) diff --git a/Scripts/lint.sh b/Scripts/lint.sh index 16e060c4..610165ad 100755 --- a/Scripts/lint.sh +++ b/Scripts/lint.sh @@ -284,9 +284,13 @@ fi # unreachable per above). periphery_index_store() { local candidate - for candidate in "$PACKAGE_DIR"/.build/*/debug/index/store \ - "$PACKAGE_DIR"/.build/debug/index/store \ - "$PACKAGE_DIR"/.build/out; do + # Prefer swiftbuild's `.build/out` when present. An older + # `.build//debug/index/store` may still exist from prior + # toolchains and yields false-positive unused declarations if chosen + # first (test-only and freshly-added public APIs look dead). + for candidate in "$PACKAGE_DIR"/.build/out \ + "$PACKAGE_DIR"/.build/*/debug/index/store \ + "$PACKAGE_DIR"/.build/debug/index/store; do if [ -d "$candidate/v5/units" ]; then printf '%s\n' "$candidate" return 0 diff --git a/Sources/MistKit/CloudKitService/CloudKitService+AssetRereference.swift b/Sources/MistKit/CloudKitService/CloudKitService+AssetRereference.swift index e5fd83f2..81019dee 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+AssetRereference.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+AssetRereference.swift @@ -194,7 +194,7 @@ extension CloudKitService { return try await updateRecord( recordType: recordType, recordName: targetRecordName, - fields: [resolvedTargetField: .asset(asset)], + fields: [resolvedTargetField: .asset(.value(asset))], recordChangeTag: recordChangeTag, database: database ) diff --git a/Sources/MistKit/Models/FieldValues/FieldValue+Codable.swift b/Sources/MistKit/Models/FieldValues/FieldValue+Codable.swift index be35b038..e11d5b09 100644 --- a/Sources/MistKit/Models/FieldValues/FieldValue+Codable.swift +++ b/Sources/MistKit/Models/FieldValues/FieldValue+Codable.swift @@ -59,32 +59,32 @@ extension FieldValue { -> FieldValue? { if let value = try? container.decode(String.self) { - return .string(value) + return .string(.value(value)) } if let value = try? container.decode(Int.self) { - return .int64(value) + return .int64(.value(value)) } if let value = try? container.decode(Double.self) { - return .double(value) + return .double(.value(value)) } return nil } - /// Decode complex field value types (list, location, reference, asset, date) + /// Decode complex field value types (homogeneous lists, location, reference, asset) private static func decodeComplexTypes(from container: any SingleValueDecodingContainer) throws -> FieldValue? { - if let value = try? container.decode([FieldValue].self) { - return .list(value) + if let value = try Self.decodeHomogeneousList(from: container) { + return value } if let value = try? container.decode(Location.self) { - return .location(value) + return .location(.value(value)) } if let value = try? container.decode(Reference.self) { - return .reference(value) + return .reference(.value(value)) } if let value = try? container.decode(Asset.self) { - return .asset(value) + return .asset(.value(value)) } // No `.date` branch here on purpose: a CloudKit timestamp arrives as a bare // millisecond `Double`, which `decodeBasicTypes` (run first) already claims @@ -94,63 +94,72 @@ extension FieldValue { return nil } + /// Decode a JSON array into a homogeneous ``Arity/list`` when every element shares a kind. + private static func decodeHomogeneousList( + from container: any SingleValueDecodingContainer + ) throws -> FieldValue? { + if let values = try? container.decode([String].self) { + return .string(.list(values)) + } + if let values = try? container.decode([Int].self) { + return .int64(.list(values)) + } + if let values = try? container.decode([Double].self) { + return .double(.list(values)) + } + if let values = try? container.decode([Location].self) { + return .location(.list(values)) + } + if let values = try? container.decode([Reference].self) { + return .reference(.list(values)) + } + if let values = try? container.decode([Asset].self) { + return .asset(.list(values)) + } + return nil + } + /// Encode field value to encoder public func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() try encodeValue(to: &container) } + // swiftlint:disable:next cyclomatic_complexity private func encodeValue(to container: inout any SingleValueEncodingContainer) throws { - if try encodeScalar(to: &container) { - return - } - try encodeComplex(to: &container) - } - - /// Encode the scalar cases (string, bytes, int64, double, date). - /// - /// - Returns: `true` when `self` was a scalar case and has been encoded; - /// `false` when `self` is a complex case that this method did not handle. - private func encodeScalar(to container: inout any SingleValueEncodingContainer) throws -> Bool { switch self { - case .string(let val): + case .string(.value(let val)): try container.encode(val) - case .bytes(let val): + case .string(.list(let vals)): + try container.encode(vals) + case .bytes(.value(let val)): try container.encode(val.base64EncodedString()) - case .int64(let val): + case .bytes(.list(let vals)): + try container.encode(vals.map { $0.base64EncodedString() }) + case .int64(.value(let val)): try container.encode(val) - case .double(let val): + case .int64(.list(let vals)): + try container.encode(vals) + case .double(.value(let val)): try container.encode(val) - case .date(let val): + case .double(.list(let vals)): + try container.encode(vals) + case .date(.value(let val)): try container.encode(val.timeIntervalSince1970 * Self.millisecondsPerSecond) - default: - return false - } - return true - } - - /// Encode the complex cases (location, reference, asset, list). - private func encodeComplex(to container: inout any SingleValueEncodingContainer) throws { - switch self { - case .location(let val): - try container.encode(val) - case .reference(let val): + case .date(.list(let vals)): + try container.encode(vals.map { $0.timeIntervalSince1970 * Self.millisecondsPerSecond }) + case .location(.value(let val)): try container.encode(val) - case .asset(let val): + case .location(.list(let vals)): + try container.encode(vals) + case .reference(.value(let val)): try container.encode(val) - case .list(let val): + case .reference(.list(let vals)): + try container.encode(vals) + case .asset(.value(let val)): try container.encode(val) - default: - // Scalar cases are handled by `encodeScalar(to:)`, which is always - // called first; reaching here would mean a new case was added without - // routing, so fail loudly rather than silently emit nothing. - throw EncodingError.invalidValue( - self, - EncodingError.Context( - codingPath: container.codingPath, - debugDescription: "Unhandled FieldValue case in encodeComplex(to:)" - ) - ) + case .asset(.list(let vals)): + try container.encode(vals) } } } @@ -168,6 +177,6 @@ extension FieldValue { /// /// - Parameter booleanValue: The boolean value to convert public init(booleanValue: Bool) { - self = .int64(booleanValue ? 1 : 0) + self = .int64(.value(booleanValue ? 1 : 0)) } } diff --git a/Sources/MistKit/Models/FieldValues/FieldValue+Components+List.swift b/Sources/MistKit/Models/FieldValues/FieldValue+Components+List.swift index 7d08dced..8d12508a 100644 --- a/Sources/MistKit/Models/FieldValues/FieldValue+Components+List.swift +++ b/Sources/MistKit/Models/FieldValues/FieldValue+Components+List.swift @@ -30,111 +30,274 @@ internal import Foundation internal import MistKitOpenAPI -/// List-value conversions for `FieldValue` ← `Components.Schemas` response types. +// swiftlint:disable file_length +/// Homogeneous list-value conversions for `FieldValue` ← `Components.Schemas` response types. extension FieldValue { - /// Initialize from list field value + /// Initialize from a CloudKit list payload. + /// When `elementKind` is set (from a `*_LIST` response tag), every element must match that + /// kind or conversion throws ``ConversionError/typeValueMismatch``. When `elementKind` is + /// `nil` (untagged list), the kind is inferred from the first element and remaining elements + /// must match; an empty untagged list becomes `.string(.list([]))`. internal init( listValue: [Components.Schemas.ListValuePayload], + elementKind: ListElementKind?, fieldName: String ) throws(ConversionError) { - var convertedList: [FieldValue] = [] - for item in listValue { - convertedList.append(try Self(listItem: item, fieldName: fieldName)) + let kind: ListElementKind + if let elementKind { + kind = elementKind + } else if let first = listValue.first { + kind = try Self.inferredListElementKind(from: first, fieldName: fieldName) + } else { + // Empty untagged list: domain needs an element kind; STRING_LIST is the live default + // for empty typed lists and matches `.string(.list([]))` (issue #481). + self = .string(.list([])) + return } - self = .list(convertedList) + self = try Self.makeHomogeneousList( + from: listValue, + kind: kind, + fieldName: fieldName + ) } - /// Initialize from individual list item - internal init( - listItem: Components.Schemas.ListValuePayload, + // Infer the list element kind from a single untagged payload (first-match wire shape). + // swiftlint:disable:next cyclomatic_complexity + private static func inferredListElementKind( + from item: Components.Schemas.ListValuePayload, fieldName: String - ) throws(ConversionError) { - if let simpleValue = try Self.makeSimpleListItem(from: listItem, fieldName: fieldName) { - self = simpleValue - } else if let complexValue = try Self.makeComplexListItem(from: listItem, fieldName: fieldName) - { - self = complexValue - } else { - let failure = ConversionError.unmappableListItem(fieldName: fieldName, item: "\(listItem)") + ) throws(ConversionError) -> ListElementKind { + switch item { + case .StringValue: + return .string + case .Int64Value: + return .int64 + case .DoubleValue: + return .double + case .BytesValue: + return .bytes + case .DateValue: + return .date + case .LocationValue: + return .location + case .ReferenceValue: + return .reference + case .AssetValue: + return .asset + case .ListValue: + let failure = ConversionError.unmappableListItem(fieldName: fieldName, item: "\(item)") try failure.reportAndThrow() } } - /// Initialize from nested list value (simplified for basic types) - internal init( - nestedListValue: [Components.Schemas.ListValuePayload], + // swiftlint:disable:next cyclomatic_complexity function_body_length + private static func makeHomogeneousList( + from listValue: [Components.Schemas.ListValuePayload], + kind: ListElementKind, fieldName: String - ) throws(ConversionError) { - var convertedNestedList: [FieldValue] = [] - for item in nestedListValue { - convertedNestedList.append(try Self(basicListItem: item, fieldName: fieldName)) + ) throws(ConversionError) -> FieldValue { + // Manual loops (not `map`) so typed throws stay `ConversionError`. + switch kind { + case .string: + var elements: [String] = [] + elements.reserveCapacity(listValue.count) + for item in listValue { + elements.append( + try requireStringElement(item, fieldName: fieldName, declaredType: "STRING_LIST") + ) + } + return .string(.list(elements)) + case .int64: + var elements: [Int] = [] + elements.reserveCapacity(listValue.count) + for item in listValue { + elements.append( + try requireInt64Element(item, fieldName: fieldName, declaredType: "INT64_LIST") + ) + } + return .int64(.list(elements)) + case .double: + var elements: [Double] = [] + elements.reserveCapacity(listValue.count) + for item in listValue { + elements.append( + try requireDoubleElement(item, fieldName: fieldName, declaredType: "DOUBLE_LIST") + ) + } + return .double(.list(elements)) + case .bytes: + var elements: [Data] = [] + elements.reserveCapacity(listValue.count) + for item in listValue { + elements.append( + try requireBytesElement(item, fieldName: fieldName, declaredType: "BYTES_LIST") + ) + } + return .bytes(.list(elements)) + case .date: + var elements: [Date] = [] + elements.reserveCapacity(listValue.count) + for item in listValue { + elements.append( + try requireDateElement(item, fieldName: fieldName, declaredType: "TIMESTAMP_LIST") + ) + } + return .date(.list(elements)) + case .location: + var elements: [Location] = [] + elements.reserveCapacity(listValue.count) + for item in listValue { + elements.append( + try requireLocationElement(item, fieldName: fieldName, declaredType: "LOCATION_LIST") + ) + } + return .location(.list(elements)) + case .reference: + var elements: [Reference] = [] + elements.reserveCapacity(listValue.count) + for item in listValue { + elements.append( + try requireReferenceElement(item, fieldName: fieldName, declaredType: "REFERENCE_LIST") + ) + } + return .reference(.list(elements)) + case .asset: + var elements: [Asset] = [] + elements.reserveCapacity(listValue.count) + for item in listValue { + elements.append( + try requireAssetElement(item, fieldName: fieldName, declaredType: "ASSET_LIST") + ) + } + return .asset(.list(elements)) } - self = .list(convertedNestedList) } - /// Initialize from basic list item types only - internal init( - basicListItem: Components.Schemas.ListValuePayload, - fieldName: String - ) throws(ConversionError) { - switch basicListItem { - case .StringValue(let stringValue): - self = .string(stringValue) - case .Int64Value(let intValue): - self = .int64(Int(intValue)) - case .DoubleValue(let doubleValue): - self = .double(doubleValue) - case .BytesValue(let bytesValue): - self = .bytes( - try Self.dataFromBase64(bytesValue, fieldName: fieldName, declaredType: "BYTES") - ) - default: - let failure = ConversionError.unmappableNestedListItem( - fieldName: fieldName, - item: "\(basicListItem)" - ) - try failure.reportAndThrow() + private static func requireStringElement( + _ item: Components.Schemas.ListValuePayload, + fieldName: String, + declaredType: String + ) throws(ConversionError) -> String { + guard case .StringValue(let value) = item else { + try reportListElementMismatch(item, fieldName: fieldName, declaredType: declaredType) } + return value } - private static func makeSimpleListItem( - from listItem: Components.Schemas.ListValuePayload, - fieldName: String - ) throws(ConversionError) -> FieldValue? { - if case .StringValue(let strVal) = listItem { - return .string(strVal) + private static func requireInt64Element( + _ item: Components.Schemas.ListValuePayload, + fieldName: String, + declaredType: String + ) throws(ConversionError) -> Int { + guard case .Int64Value(let value) = item else { + try reportListElementMismatch(item, fieldName: fieldName, declaredType: declaredType) } - if case .Int64Value(let intVal) = listItem { - return .int64(Int(intVal)) + return Int(value) + } + + private static func requireDoubleElement( + _ item: Components.Schemas.ListValuePayload, + fieldName: String, + declaredType: String + ) throws(ConversionError) -> Double { + switch item { + case .DoubleValue(let value): + return value + case .Int64Value(let value): + return Double(value) + default: + try reportListElementMismatch(item, fieldName: fieldName, declaredType: declaredType) } - if case .DoubleValue(let dblVal) = listItem { - return .double(dblVal) + } + + private static func requireBytesElement( + _ item: Components.Schemas.ListValuePayload, + fieldName: String, + declaredType: String + ) throws(ConversionError) -> Data { + let string: String + switch item { + case .BytesValue(let value), .StringValue(let value): + string = value + default: + try reportListElementMismatch(item, fieldName: fieldName, declaredType: declaredType) + } + return try dataFromBase64(string, fieldName: fieldName, declaredType: declaredType) + } + + private static func requireDateElement( + _ item: Components.Schemas.ListValuePayload, + fieldName: String, + declaredType: String + ) throws(ConversionError) -> Date { + let milliseconds: Double + switch item { + case .DateValue(let value): + milliseconds = value + case .Int64Value(let value): + milliseconds = Double(value) + case .DoubleValue(let value): + milliseconds = value + default: + try reportListElementMismatch(item, fieldName: fieldName, declaredType: declaredType) } - if case .BytesValue(let bytesVal) = listItem { - return .bytes(try dataFromBase64(bytesVal, fieldName: fieldName, declaredType: "BYTES")) + return Date(timeIntervalSince1970: milliseconds / 1_000) + } + + private static func requireLocationElement( + _ item: Components.Schemas.ListValuePayload, + fieldName: String, + declaredType: String + ) throws(ConversionError) -> Location { + guard case .LocationValue(let locationValue) = item else { + try reportListElementMismatch(item, fieldName: fieldName, declaredType: declaredType) } - if case .DateValue(let dateVal) = listItem { - return .date(Date(timeIntervalSince1970: dateVal / 1_000)) + guard case .location(.value(let location)) = FieldValue(locationValue: locationValue) else { + try reportListElementMismatch(item, fieldName: fieldName, declaredType: declaredType) } - return nil + return location } - private static func makeComplexListItem( - from listItem: Components.Schemas.ListValuePayload, - fieldName: String - ) throws(ConversionError) -> FieldValue? { - if case .LocationValue(let locationValue) = listItem { - return Self(locationValue: locationValue) + private static func requireReferenceElement( + _ item: Components.Schemas.ListValuePayload, + fieldName: String, + declaredType: String + ) throws(ConversionError) -> Reference { + guard case .ReferenceValue(let referenceValue) = item else { + try reportListElementMismatch(item, fieldName: fieldName, declaredType: declaredType) } - if case .ReferenceValue(let referenceValue) = listItem { - return Self(referenceValue: referenceValue) + guard case .reference(.value(let reference)) = FieldValue(referenceValue: referenceValue) + else { + try reportListElementMismatch(item, fieldName: fieldName, declaredType: declaredType) } - if case .AssetValue(let assetValue) = listItem { - return Self(assetValue: assetValue) + return reference + } + + private static func requireAssetElement( + _ item: Components.Schemas.ListValuePayload, + fieldName: String, + declaredType: String + ) throws(ConversionError) -> Asset { + guard case .AssetValue(let assetValue) = item else { + try reportListElementMismatch(item, fieldName: fieldName, declaredType: declaredType) } - if case .ListValue(let nestedList) = listItem { - return try Self(nestedListValue: nestedList, fieldName: fieldName) + guard case .asset(.value(let asset)) = FieldValue(assetValue: assetValue) else { + try reportListElementMismatch(item, fieldName: fieldName, declaredType: declaredType) } - return nil + return asset + } + + private static func reportListElementMismatch( + _ item: Components.Schemas.ListValuePayload, + fieldName: String, + declaredType: String + ) throws(ConversionError) -> Never { + let failure = ConversionError.typeValueMismatch( + fieldName: fieldName, + declaredType: declaredType, + value: "\(item)" + ) + try failure.reportAndThrow() } } +// swiftlint:enable file_length diff --git a/Sources/MistKit/Models/FieldValues/FieldValue+Components+Scalar.swift b/Sources/MistKit/Models/FieldValues/FieldValue+Components+Scalar.swift index 5699195f..6db6e7eb 100644 --- a/Sources/MistKit/Models/FieldValues/FieldValue+Components+Scalar.swift +++ b/Sources/MistKit/Models/FieldValues/FieldValue+Components+Scalar.swift @@ -30,6 +30,7 @@ internal import Foundation internal import MistKitOpenAPI +// swiftlint:disable file_length /// Scalar-value conversions for `FieldValue` ← `Components.Schemas` response types. extension FieldValue { /// A decoded response `value` narrowed to its five scalar `oneOf` cases. @@ -54,18 +55,18 @@ extension FieldValue { fileprivate var inferred: FieldValue { switch self { case .string(let strVal): - return .string(strVal) + return .string(.value(strVal)) case .bytes(let bytesVal): if let data = Data(base64Encoded: bytesVal) { - return .bytes(data) + return .bytes(.value(data)) } - return .string(bytesVal) + return .string(.value(bytesVal)) case .int64(let intVal): - return .int64(Int(intVal)) + return .int64(.value(Int(intVal))) case .double(let dblVal): - return .double(dblVal) + return .double(.value(dblVal)) case .date(let dateVal): - return .date(Date(timeIntervalSince1970: dateVal / 1_000)) + return .date(.value(Date(timeIntervalSince1970: dateVal / 1_000))) } } @@ -158,21 +159,31 @@ extension FieldValue { switch ResponseTypeTag(fieldType) { case .numeric(.timestamp): let number = try requireNumeric(value, fieldName: fieldName, declaredType: declared) - return .date(Date(timeIntervalSince1970: number / 1_000)) + return .date(.value(Date(timeIntervalSince1970: number / 1_000))) case .numeric(.double): - return .double(try requireNumeric(value, fieldName: fieldName, declaredType: declared)) + return .double( + .value( + try requireNumeric( + value, + fieldName: fieldName, + declaredType: declared + ) + ) + ) case .numeric(.int64): // Validate the category, then defer to inference so a fractional number isn't truncated. _ = try requireNumeric(value, fieldName: fieldName, declaredType: declared) return nil case .text(.bytes): let string = try requireString(value, fieldName: fieldName, declaredType: declared) - return .bytes(try dataFromBase64(string, fieldName: fieldName, declaredType: declared)) + return .bytes( + .value(try dataFromBase64(string, fieldName: fieldName, declaredType: declared)) + ) case .text(.string): // Validate the category, then defer to inference, which already produces `.string`. _ = try requireString(value, fieldName: fieldName, declaredType: declared) return nil - case .complex: + case .complex, .list: return nil } } @@ -222,3 +233,4 @@ extension FieldValue { ScalarPayload(value)?.inferred } } +// swiftlint:enable file_length diff --git a/Sources/MistKit/Models/FieldValues/FieldValue+Components.swift b/Sources/MistKit/Models/FieldValues/FieldValue+Components.swift index 091c0e35..0302eabf 100644 --- a/Sources/MistKit/Models/FieldValues/FieldValue+Components.swift +++ b/Sources/MistKit/Models/FieldValues/FieldValue+Components.swift @@ -88,7 +88,7 @@ extension FieldValue { course: locationValue.course, timestamp: locationValue.timestamp.map { Date(timeIntervalSince1970: $0 / 1_000) } ) - self = .location(location) + self = .location(.value(location)) } /// Initialize from reference field value @@ -111,12 +111,12 @@ extension FieldValue { recordName: recordName, action: action ) - self = .reference(reference) + self = .reference(.value(reference)) } /// Initialize from asset field value internal init(assetValue: Components.Schemas.AssetValue) { - self = .asset(Asset(from: assetValue)) + self = .asset(.value(Asset(from: assetValue))) } private static func makeComplexFieldValue( @@ -133,13 +133,15 @@ extension FieldValue { return Self(assetValue: assetValue) } if case .ListValue(let listValue) = value { - return try Self(listValue: listValue, fieldName: fieldName) + // Untagged list: infer a homogeneous element kind from the payloads. + return try Self(listValue: listValue, elementKind: nil, fieldName: fieldName) } return nil } - /// Build a complex/list `FieldValue` from an explicit CloudKit `type`, validating that the - /// decoded value's structure satisfies the declared tag (issue #376). + /// Build a complex or homogeneous-list `FieldValue` from an explicit CloudKit `type`, + /// validating that the decoded value's structure satisfies the declared tag (issues #376, + /// #481). /// /// The `value` `oneOf` is undiscriminated, so — just as a scalar `type` is honored over the /// decoded case in ``makeTypedScalar(from:type:fieldName:)`` — a complex/list `type` that @@ -149,9 +151,7 @@ extension FieldValue { /// treatment of scalar contradictions. /// /// `ASSETID` maps to the same `AssetValue` as `ASSET` (there is no distinct domain case). - /// The `LIST` tag is validated only at the container level — the value must be a `ListValue` - /// — leaving element types to the existing lenient list conversion (the response `type` enum - /// carries a single `LIST`, unlike the request's granular `*_LIST` family). A `nil` or scalar + /// List tags use the granular `*_LIST` family and select the element kind; a `nil` or scalar /// `type` returns nil so the caller falls through to scalar typing / inference, leaving /// untagged responses on the value-shape path unchanged. private static func makeTypedComplex( @@ -159,17 +159,27 @@ extension FieldValue { type fieldType: Components.Schemas.FieldValueResponse._typePayload?, fieldName: String ) throws(ConversionError) -> FieldValue? { - // A nil or scalar `type` is not our concern — defer to scalar typing / inference. - guard let fieldType, case .complex(let expected) = ResponseTypeTag(fieldType) else { + guard let fieldType else { return nil } - // The value's decoded shape must satisfy the declared complex/list tag; a contradiction - // is a fail-loud `typeValueMismatch`. A match reuses the value-shape conversion so a - // tagged value converts identically to the same value untagged. - guard expected.matches(value) else { - try reportComplexMismatch(value, fieldName: fieldName, declaredType: fieldType.rawValue) + switch ResponseTypeTag(fieldType) { + case .complex(let expected): + guard expected.matches(value) else { + try reportComplexMismatch(value, fieldName: fieldName, declaredType: fieldType.rawValue) + } + return try makeComplexFieldValue(from: value, fieldName: fieldName) + case .list(let elementKind): + guard case .ListValue(let listValue) = value else { + try reportComplexMismatch(value, fieldName: fieldName, declaredType: fieldType.rawValue) + } + return try Self( + listValue: listValue, + elementKind: elementKind, + fieldName: fieldName + ) + case .numeric, .text: + return nil } - return try makeComplexFieldValue(from: value, fieldName: fieldName) } /// Throw ``ConversionError/typeValueMismatch`` for a complex/list `type` declared over a diff --git a/Sources/MistKit/Models/FieldValues/FieldValue+Convenience.swift b/Sources/MistKit/Models/FieldValues/FieldValue+Convenience.swift index fe09a28f..670c5b57 100644 --- a/Sources/MistKit/Models/FieldValues/FieldValue+Convenience.swift +++ b/Sources/MistKit/Models/FieldValues/FieldValue+Convenience.swift @@ -31,43 +31,67 @@ public import Foundation /// Convenience extensions for extracting typed values from FieldValue cases extension FieldValue { - /// Extract a String value if this is a .string case + /// Extract a String value if this is a `.string(.value)` case. /// - /// - Returns: The string value, or nil if this is not a .string case + /// - Returns: The string value, or nil if this is not a single-string field public var stringValue: String? { - if case .string(let value) = self { + if case .string(.value(let value)) = self { return value } return nil } - /// Extract an Int value if this is an .int64 case + /// Extract a string list if this is a `.string(.list)` case. + public var stringListValue: [String]? { + if case .string(.list(let values)) = self { + return values + } + return nil + } + + /// Extract an Int value if this is an `.int64(.value)` case. /// - /// - Returns: The integer value, or nil if this is not an .int64 case + /// - Returns: The integer value, or nil if this is not a single-int64 field public var intValue: Int? { - if case .int64(let value) = self { + if case .int64(.value(let value)) = self { return value } return nil } - /// Extract a Double value if this is a .double case + /// Extract an int64 list if this is an `.int64(.list)` case. + public var int64ListValue: [Int]? { + if case .int64(.list(let values)) = self { + return values + } + return nil + } + + /// Extract a Double value if this is a `.double(.value)` case. /// - /// - Returns: The double value, or nil if this is not a .double case + /// - Returns: The double value, or nil if this is not a single-double field public var doubleValue: Double? { - if case .double(let value) = self { + if case .double(.value(let value)) = self { return value } return nil } + /// Extract a double list if this is a `.double(.list)` case. + public var doubleListValue: [Double]? { + if case .double(.list(let values)) = self { + return values + } + return nil + } + // swiftlint:disable discouraged_optional_boolean - /// Extract a Bool value from .int64 cases + /// Extract a Bool value from `.int64(.value)` cases /// /// CloudKit represents booleans as INT64 where 0 is false and 1 is true. /// This method asserts that the value is either 0 or 1. /// - /// - Returns: The boolean value, or nil if this is not an .int64 case + /// - Returns: The boolean value, or nil if this is not an `.int64(.value)` case public var boolValue: Bool? { boolValue(assertionHandler: { condition, message in assert(condition, message) @@ -75,78 +99,100 @@ extension FieldValue { } // swiftlint:enable discouraged_optional_boolean - /// Extract a Date value if this is a .date case + /// Extract a Date value if this is a `.date(.value)` case. /// - /// - Returns: The date value, or nil if this is not a .date case + /// - Returns: The date value, or nil if this is not a single-date field public var dateValue: Date? { - if case .date(let value) = self { + if case .date(.value(let value)) = self { return value } return nil } - /// Extract base64-encoded bytes if this is a `.bytes` case. + /// Extract a date list if this is a `.date(.list)` case. + public var dateListValue: [Date]? { + if case .date(.list(let values)) = self { + return values + } + return nil + } + + /// Extract base64-encoded bytes if this is a `.bytes(.value)` case. /// - /// - Returns: The payload as a base64 string, or nil if this is not a `.bytes` case + /// - Returns: The payload as a base64 string, or nil if this is not a single-bytes field public var bytesValue: String? { - if case .bytes(let value) = self { + if case .bytes(.value(let value)) = self { return value.base64EncodedString() } return nil } - /// Extract the binary payload if this is a `.bytes` case. + /// Extract a bytes list (as base64 strings) if this is a `.bytes(.list)` case. + public var bytesListValue: [Data]? { + if case .bytes(.list(let values)) = self { + return values + } + return nil + } + + /// Extract the binary payload if this is a `.bytes(.value)` case. /// - /// Matches `.bytes` only. An untagged CloudKit `BYTES` response is claimed by + /// Matches `.bytes(.value)` only. An untagged CloudKit `BYTES` response is claimed by /// first-match-wins inference as `.string`, so `dataValue` returns `nil` for /// it; the base64 text remains available via ``stringValue``. This accessor /// does not attempt `Data(base64Encoded:)` on a `.string` payload: base64 has /// no false-positive signal, so ordinary strings such as `"Chen"` or `"test"` /// would decode as plausible-looking garbage. /// - /// - Returns: The `Data` payload, or nil if this is not a `.bytes` case + /// - Returns: The `Data` payload, or nil if this is not a single-bytes field public var dataValue: Data? { - if case .bytes(let value) = self { + if case .bytes(.value(let value)) = self { return value } return nil } - /// Extract a Location value if this is a .location case + /// Extract a Location value if this is a `.location(.value)` case. /// - /// - Returns: The location value, or nil if this is not a .location case + /// - Returns: The location value, or nil if this is not a single-location field public var locationValue: Location? { - if case .location(let value) = self { + if case .location(.value(let value)) = self { return value } return nil } - /// Extract a Reference value if this is a .reference case + /// Extract a location list if this is a `.location(.list)` case. + public var locationListValue: [Location]? { + if case .location(.list(let values)) = self { + return values + } + return nil + } + + /// Extract a Reference value if this is a `.reference(.value)` case. /// - /// - Returns: The reference value, or nil if this is not a .reference case + /// - Returns: The reference value, or nil if this is not a single-reference field public var referenceValue: Reference? { - if case .reference(let value) = self { + if case .reference(.value(let value)) = self { return value } return nil } - /// Extract an Asset value if this is an .asset case - /// - /// - Returns: The asset value, or nil if this is not an .asset case - public var assetValue: Asset? { - if case .asset(let value) = self { - return value + /// Extract a reference list if this is a `.reference(.list)` case. + public var referenceListValue: [Reference]? { + if case .reference(.list(let values)) = self { + return values } return nil } - /// Extract a list of FieldValues if this is a .list case + /// Extract an Asset value if this is an `.asset(.value)` case. /// - /// - Returns: The array of field values, or nil if this is not a .list case - public var listValue: [FieldValue]? { - if case .list(let value) = self { + /// - Returns: The asset value, or nil if this is not a single-asset field + public var assetValue: Asset? { + if case .asset(.value(let value)) = self { return value } return nil @@ -156,13 +202,13 @@ extension FieldValue { /// Internal method to extract Bool value with custom assertion handler /// /// - Parameter assertionHandler: Custom assertion handler for testing, defaults to system assert - /// - Returns: The boolean value, or nil if this is not an .int64 case + /// - Returns: The boolean value, or nil if this is not an `.int64(.value)` case internal func boolValue( assertionHandler: (_ condition: Bool, _ message: String) -> Void = { condition, message in assert(condition, message) } ) -> Bool? { - if case .int64(let value) = self { + if case .int64(.value(let value)) = self { assertionHandler( value == 0 || value == 1, "Boolean int64 value must be 0 or 1, got \(value)" diff --git a/Sources/MistKit/Models/FieldValues/FieldValue+DeprecatedScalars.swift b/Sources/MistKit/Models/FieldValues/FieldValue+DeprecatedScalars.swift new file mode 100644 index 00000000..b4412d2e --- /dev/null +++ b/Sources/MistKit/Models/FieldValues/FieldValue+DeprecatedScalars.swift @@ -0,0 +1,84 @@ +// +// FieldValue+DeprecatedScalars.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// Deprecated scalar construction shims for the pre-``Arity`` call shape +/// (`.string("x")` → `.string(.value("x"))`). There is deliberately **no** +/// `.list([FieldValue])` polyfill — list call sites migrate to +/// `.string(.list(...))` (and peers) and break cleanly (issue #481). +extension FieldValue { + /// Creates a string field. Prefer `.string(.value(...))`. + @available(*, deprecated, message: "Use .string(.value(...))") + public static func string(_ value: String) -> FieldValue { + .string(.value(value)) + } + + /// Creates an int64 field. Prefer `.int64(.value(...))`. + @available(*, deprecated, message: "Use .int64(.value(...))") + public static func int64(_ value: Int) -> FieldValue { + .int64(.value(value)) + } + + /// Creates a double field. Prefer `.double(.value(...))`. + @available(*, deprecated, message: "Use .double(.value(...))") + public static func double(_ value: Double) -> FieldValue { + .double(.value(value)) + } + + /// Creates a bytes field. Prefer `.bytes(.value(...))`. + @available(*, deprecated, message: "Use .bytes(.value(...))") + public static func bytes(_ value: Data) -> FieldValue { + .bytes(.value(value)) + } + + /// Creates a date field. Prefer `.date(.value(...))`. + @available(*, deprecated, message: "Use .date(.value(...))") + public static func date(_ value: Date) -> FieldValue { + .date(.value(value)) + } + + /// Creates a location field. Prefer `.location(.value(...))`. + @available(*, deprecated, message: "Use .location(.value(...))") + public static func location(_ value: Location) -> FieldValue { + .location(.value(value)) + } + + /// Creates a reference field. Prefer `.reference(.value(...))`. + @available(*, deprecated, message: "Use .reference(.value(...))") + public static func reference(_ value: Reference) -> FieldValue { + .reference(.value(value)) + } + + /// Creates an asset field. Prefer `.asset(.value(...))`. + @available(*, deprecated, message: "Use .asset(.value(...))") + public static func asset(_ value: Asset) -> FieldValue { + .asset(.value(value)) + } +} diff --git a/Sources/MistKit/Models/FieldValues/FieldValue+ListConvenience.swift b/Sources/MistKit/Models/FieldValues/FieldValue+ListConvenience.swift new file mode 100644 index 00000000..ac1bd096 --- /dev/null +++ b/Sources/MistKit/Models/FieldValues/FieldValue+ListConvenience.swift @@ -0,0 +1,71 @@ +// +// FieldValue+ListConvenience.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +extension FieldValue { + /// Extract an asset list if this is an `.asset(.list)` case. + public var assetListValue: [Asset]? { + if case .asset(.list(let values)) = self { + return values + } + return nil + } + + /// Flatten a homogeneous list back to `[FieldValue]` of `.value` elements. + /// + /// Prefer the typed `*ListValue` accessors. Kept for read-only consumers that + /// previously matched `.list([FieldValue])`. + @available( + *, + deprecated, + message: "Use stringListValue, int64ListValue, or the matching *ListValue accessor" + ) + public var listValue: [FieldValue]? { + switch self { + case .string(.list(let values)): + return values.map { .string(.value($0)) } + case .int64(.list(let values)): + return values.map { .int64(.value($0)) } + case .double(.list(let values)): + return values.map { .double(.value($0)) } + case .bytes(.list(let values)): + return values.map { .bytes(.value($0)) } + case .date(.list(let values)): + return values.map { .date(.value($0)) } + case .location(.list(let values)): + return values.map { .location(.value($0)) } + case .reference(.list(let values)): + return values.map { .reference(.value($0)) } + case .asset(.list(let values)): + return values.map { .asset(.value($0)) } + case .string(.value), .int64(.value), .double(.value), .bytes(.value), + .date(.value), .location(.value), .reference(.value), .asset(.value): + return nil + } + } +} diff --git a/Sources/MistKit/Models/FieldValues/FieldValue+ResponseTypeTag.swift b/Sources/MistKit/Models/FieldValues/FieldValue+ResponseTypeTag.swift index 2d27ab6b..41a9935d 100644 --- a/Sources/MistKit/Models/FieldValues/FieldValue+ResponseTypeTag.swift +++ b/Sources/MistKit/Models/FieldValues/FieldValue+ResponseTypeTag.swift @@ -43,14 +43,16 @@ extension FieldValue { case numeric(NumericScalarTag) /// A tag that requires a string-backed value (`BYTES`, `STRING`). case text(TextScalarTag) - /// A tag that requires a structured value (`REFERENCE`, `ASSET`/`ASSETID`, `LOCATION`, - /// `LIST`). + /// A tag that requires a structured scalar value (`REFERENCE`, `ASSET`/`ASSETID`, `LOCATION`). case complex(ExpectedComplexValue) + /// A tag that requires a `ListValue` whose elements match the declared kind (`*_LIST`). + case list(ListElementKind) // Classify a declared response `type`. // // `ASSETID` shares `AssetValue` — and therefore the `.asset` classification — with - // `ASSET`; there is no distinct domain case for it. + // `ASSET`; there is no distinct domain case for it. List responses use the granular + // `*_LIST` family (issue #481); there is no flat `LIST` tag. // swiftlint:disable:next cyclomatic_complexity internal init(_ fieldType: Components.Schemas.FieldValueResponse._typePayload) { switch fieldType { @@ -62,7 +64,14 @@ extension FieldValue { case .REFERENCE: self = .complex(.reference) case .ASSET, .ASSETID: self = .complex(.asset) case .LOCATION: self = .complex(.location) - case .LIST: self = .complex(.list) + case .STRING_LIST: self = .list(.string) + case .INT64_LIST: self = .list(.int64) + case .DOUBLE_LIST: self = .list(.double) + case .BYTES_LIST: self = .list(.bytes) + case .TIMESTAMP_LIST: self = .list(.date) + case .REFERENCE_LIST: self = .list(.reference) + case .LOCATION_LIST: self = .list(.location) + case .ASSET_LIST: self = .list(.asset) } } } @@ -80,24 +89,36 @@ extension FieldValue { case string } - /// The decoded `value` case a complex/list `FieldValueResponse` `type` tag requires (#376). + /// The decoded `value` case a complex (non-list) `FieldValueResponse` `type` tag requires + /// (#376). internal enum ExpectedComplexValue: Hashable, Sendable { case reference case asset case location - case list - /// Whether `value`'s decoded `oneOf` case satisfies this declared complex/list tag. + /// Whether `value`'s decoded `oneOf` case satisfies this declared complex tag. internal func matches( _ value: Components.Schemas.FieldValueResponse.valuePayload ) -> Bool { switch (self, value) { case (.reference, .ReferenceValue), (.asset, .AssetValue), - (.location, .LocationValue), (.list, .ListValue): + (.location, .LocationValue): return true default: return false } } } + + /// The element kind a `*_LIST` response tag requires (issue #481). + internal enum ListElementKind: Hashable, Sendable { + case string + case int64 + case double + case bytes + case date + case location + case reference + case asset + } } diff --git a/Sources/MistKit/Models/FieldValues/FieldValue.swift b/Sources/MistKit/Models/FieldValues/FieldValue.swift index 1e11ad30..72e86d4f 100644 --- a/Sources/MistKit/Models/FieldValues/FieldValue.swift +++ b/Sources/MistKit/Models/FieldValues/FieldValue.swift @@ -29,15 +29,30 @@ public import Foundation -/// Represents a CloudKit field value as defined in the CloudKit Web Services API +/// Represents a CloudKit field value as defined in the CloudKit Web Services API. +/// +/// Each kind carries ``Arity`` so a field is either a single value or a homogeneous +/// list of that kind. Heterogeneous and nested lists are unrepresentable — matching +/// CloudKit's `LIST` schema grammar (issue #481). public enum FieldValue: Codable, Equatable, Sendable { - case string(String) - case int64(Int) - case double(Double) - case bytes(Data) // Binary data; base64-encoded on the wire - case date(Date) // Date/time value - case location(Location) - case reference(Reference) - case asset(Asset) - case list([FieldValue]) + case string(Arity) + case int64(Arity) + case double(Arity) + case bytes(Arity) // Binary data; base64-encoded on the wire + case date(Arity) // Date/time value + case location(Arity) + case reference(Arity) + case asset(Arity) +} + +extension FieldValue { + /// Whether a field holds one value or a homogeneous list of that kind. + /// + /// Empty lists use `.list([])` on the appropriate kind (e.g. `.string(.list([]))`); + /// there is no separate empty case — the element type is part of the domain value + /// even when the array is empty. + public enum Arity: Codable, Equatable, Sendable { + case value(T) + case list([T]) + } } diff --git a/Sources/MistKit/Models/Queries/FilterBuilder/FilterBuilder.swift b/Sources/MistKit/Models/Queries/FilterBuilder/FilterBuilder.swift index a2d5fd12..736f5d42 100644 --- a/Sources/MistKit/Models/Queries/FilterBuilder/FilterBuilder.swift +++ b/Sources/MistKit/Models/Queries/FilterBuilder/FilterBuilder.swift @@ -174,6 +174,10 @@ internal struct FilterBuilder { // Maps a single element to the `*_LIST` request type its list requires. // + // Only ``FieldValue/Arity/value(_:)`` elements are valid in IN/NOT_IN. A `.list` + // arity returns `nil` (omit the type) rather than inventing a nested-list tag — + // CloudKit rejects nested lists, and the call site should not pass them (issue #481). + // // The `switch` is `default`-free so a new `FieldValue` case has to be classified here // rather than silently emitting no `type` tag. // swiftlint:disable:next cyclomatic_complexity @@ -181,25 +185,25 @@ internal struct FilterBuilder { for first: FieldValue ) -> Components.Schemas.FieldValueRequest._typePayload? { switch first { - case .string: + case .string(.value): return .STRING_LIST - case .int64: + case .int64(.value): return .INT64_LIST - case .double: + case .double(.value): return .DOUBLE_LIST - case .bytes: + case .bytes(.value): return .BYTES_LIST - case .date: + case .date(.value): return .TIMESTAMP_LIST - case .reference: + case .reference(.value): return .REFERENCE_LIST - case .location: + case .location(.value): return .LOCATION_LIST - case .asset: + case .asset(.value): return .ASSET_LIST - case .list: - // Nested lists aren't valid in IN/NOT_IN; omit the type and let CloudKit reject - // rather than emit an undocumented bare "LIST" tag. + case .string(.list), .int64(.list), .double(.list), .bytes(.list), + .date(.list), .reference(.list), .location(.list), .asset(.list): + // List-shaped FieldValues are not valid IN/NOT_IN elements. return nil } } diff --git a/Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift b/Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift index d80ca1c2..184ea803 100644 --- a/Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift +++ b/Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift @@ -34,30 +34,52 @@ internal import MistKitOpenAPI extension Components.Schemas.FieldValueRequest { // Initialize from MistKit FieldValue for CloudKit API requests. // - // CloudKit infers a field's type from the value structure, so most values are sent + // CloudKit infers a field's type from the value structure, so most scalar values are sent // without an explicit `type`. The exceptions are scalars whose JSON form is ambiguous — // a `TIMESTAMP`, `BYTES`, or `DOUBLE` is indistinguishable on the wire from an // `INT64`/`DOUBLE` number or a `STRING`. For those we tag `type` so CloudKit doesn't - // infer the wrong type and reject the write with `BAD_REQUEST`. Object/array-shaped - // values (reference, asset, location, list) are unambiguous and stay untagged. + // infer the wrong type and reject the write with `BAD_REQUEST`. Homogeneous lists always + // carry the granular `*_LIST` tag (issue #481) so element type is never first-element + // guesswork — including empty lists and ambiguous element kinds (`TIMESTAMP_LIST`, + // `BYTES_LIST`, `DOUBLE_LIST`). // // The `switch` is deliberately `default`-free: it is the single dispatch point from the // domain enum to the wire representation, so a new `FieldValue` case breaks the build here // instead of silently falling into a catch-all. - // swiftlint:disable:next cyclomatic_complexity + // swiftlint:disable:next cyclomatic_complexity function_body_length internal init(from fieldValue: FieldValue) { switch fieldValue { - case .string(let value): + case .string(.value(let value)): self.init(value: .StringValue(value)) - case .int64(let value): + case .string(.list(let values)): + self.init( + value: .ListValue(values.map { .StringValue($0) }), + _type: .STRING_LIST + ) + case .int64(.value(let value)): self.init(value: .Int64Value(Int64(value))) - case .double(let value): + case .int64(.list(let values)): + self.init( + value: .ListValue(values.map { .Int64Value(Int64($0)) }), + _type: .INT64_LIST + ) + case .double(.value(let value)): // Whole-valued doubles serialize without a fraction and would be read as INT64. self.init(value: .DoubleValue(value), _type: .DOUBLE) - case .bytes(let value): + case .double(.list(let values)): + self.init( + value: .ListValue(values.map { .DoubleValue($0) }), + _type: .DOUBLE_LIST + ) + case .bytes(.value(let value)): // A base64 string is otherwise indistinguishable from a STRING. self.init(value: .BytesValue(value.base64EncodedString()), _type: .BYTES) - case .date(let value): + case .bytes(.list(let values)): + self.init( + value: .ListValue(values.map { .BytesValue($0.base64EncodedString()) }), + _type: .BYTES_LIST + ) + case .date(.value(let value)): // Tag TIMESTAMP (else inferred as INT64/DOUBLE) and round to whole milliseconds: // CloudKit rejects a fractional TIMESTAMP value (e.g. 1747999812347.89) with // BAD_REQUEST "expected type TIMESTAMP", and Date carries sub-millisecond precision. @@ -65,20 +87,70 @@ extension Components.Schemas.FieldValueRequest { value: .DateValue((value.timeIntervalSince1970 * 1_000).rounded()), _type: .TIMESTAMP ) - case .location(let location): + case .date(.list(let values)): + self.init( + value: .ListValue( + values.map { .DateValue(($0.timeIntervalSince1970 * 1_000).rounded()) } + ), + _type: .TIMESTAMP_LIST + ) + case .location(.value(let location)): self.init(location: location) - case .reference(let reference): + case .location(.list(let locations)): + self.init( + value: .ListValue(locations.map { Self.makeLocationPayload($0) }), + _type: .LOCATION_LIST + ) + case .reference(.value(let reference)): self.init(reference: reference) - case .asset(let asset): + case .reference(.list(let references)): + self.init( + value: .ListValue(references.map { Self.makeReferencePayload($0) }), + _type: .REFERENCE_LIST + ) + case .asset(.value(let asset)): self.init(asset: asset) - case .list(let list): - self.init(list: list) + case .asset(.list(let assets)): + self.init( + value: .ListValue(assets.map { Self.makeAssetPayload($0) }), + _type: .ASSET_LIST + ) } } /// Initialize from Location to Components LocationValue private init(location: Location) { - let locationValue = Components.Schemas.LocationValue( + self.init(value: .LocationValue(Self.makeLocationValue(location))) + } + + /// Initialize from Reference to Components ReferenceValue + private init(reference: Reference) { + self.init(value: .ReferenceValue(Self.makeReferenceValue(reference))) + } + + /// Initialize from Asset to Components AssetValue + private init(asset: Asset) { + self.init(value: .AssetValue(Self.makeAssetValue(asset))) + } + + private static func makeLocationPayload(_ location: Location) + -> Components.Schemas.ListValuePayload + { + .LocationValue(makeLocationValue(location)) + } + + private static func makeReferencePayload(_ reference: Reference) + -> Components.Schemas.ListValuePayload + { + .ReferenceValue(makeReferenceValue(reference)) + } + + private static func makeAssetPayload(_ asset: Asset) -> Components.Schemas.ListValuePayload { + .AssetValue(makeAssetValue(asset)) + } + + private static func makeLocationValue(_ location: Location) -> Components.Schemas.LocationValue { + Components.Schemas.LocationValue( latitude: location.latitude, longitude: location.longitude, horizontalAccuracy: location.horizontalAccuracy, @@ -87,14 +159,15 @@ extension Components.Schemas.FieldValueRequest { speed: location.speed, course: location.course, // CloudKit rejects a fractional TIMESTAMP on LocationValue.timestamp with BAD_REQUEST, - // same wire-type constraint as the scalar .date case; Date carries sub-millisecond precision. + // same wire-type constraint as the scalar .date case; Date carries sub-millisecond + // precision. timestamp: location.timestamp.map { ($0.timeIntervalSince1970 * 1_000).rounded() } ) - self.init(value: .LocationValue(locationValue)) } - /// Initialize from Reference to Components ReferenceValue - private init(reference: Reference) { + private static func makeReferenceValue(_ reference: Reference) + -> Components.Schemas.ReferenceValue + { let action: Components.Schemas.ReferenceValue.actionPayload? switch reference.action { case .some(.deleteSelf): @@ -106,16 +179,14 @@ extension Components.Schemas.FieldValueRequest { case nil: action = nil } - let referenceValue = Components.Schemas.ReferenceValue( + return Components.Schemas.ReferenceValue( recordName: reference.recordName, action: action ) - self.init(value: .ReferenceValue(referenceValue)) } - /// Initialize from Asset to Components AssetValue - private init(asset: Asset) { - let assetValue = Components.Schemas.AssetValue( + private static func makeAssetValue(_ asset: Asset) -> Components.Schemas.AssetValue { + Components.Schemas.AssetValue( fileChecksum: asset.fileChecksum, size: asset.size, referenceChecksum: asset.referenceChecksum, @@ -123,12 +194,5 @@ extension Components.Schemas.FieldValueRequest { receipt: asset.receipt, downloadURL: asset.downloadURL ) - self.init(value: .AssetValue(assetValue)) - } - - /// Initialize from List to Components list value - private init(list: [FieldValue]) { - let listValues = list.map { Components.Schemas.ListValuePayload(from: $0) } - self.init(value: .ListValue(listValues)) } } diff --git a/Sources/MistKit/OpenAPI/Components/Components.Schemas.ListValuePayload.swift b/Sources/MistKit/OpenAPI/Components/Components.Schemas.ListValuePayload.swift index 8490d0be..263649f6 100644 --- a/Sources/MistKit/OpenAPI/Components/Components.Schemas.ListValuePayload.swift +++ b/Sources/MistKit/OpenAPI/Components/Components.Schemas.ListValuePayload.swift @@ -31,42 +31,42 @@ internal import Foundation internal import MistKitOpenAPI extension Components.Schemas.ListValuePayload { - /// Initialize from MistKit FieldValue for list elements + /// Initialize from a MistKit ``FieldValue`` used as an IN/NOT_IN (or similar) list element. /// - /// The `switch` is deliberately `default`-free: it is the single dispatch point from the - /// domain enum to a list element's wire representation, so a new `FieldValue` case breaks - /// the build here instead of silently degrading. (It previously fell through to a - /// `default: assertionFailure(...)` that returned an empty list — a debug-only trap that - /// in release builds would have written `[]` in place of the value.) + /// Only ``FieldValue/Arity/value(_:)`` payloads are valid list elements — CloudKit has no + /// nested lists (issue #481). Passing a `.list` arity traps rather than emitting a nested + /// `ListValue` on the wire. /// - /// The complexity is the nine-case dispatch itself and is irreducible without - /// reintroducing a catch-all, so it is suppressed below as in the sibling switches - /// (`Components.Schemas.FieldValueRequest`, `FilterBuilder`, `FieldValue.ResponseTypeTag`). + /// The `switch` is deliberately `default`-free so a new `FieldValue` case breaks the build + /// here instead of silently degrading. internal init(from fieldValue: FieldValue) { // swiftlint:disable:this cyclomatic_complexity switch fieldValue { - case .string(let value): + case .string(.value(let value)): self = .StringValue(value) - case .int64(let value): + case .int64(.value(let value)): self = .Int64Value(Int64(value)) - case .double(let value): + case .double(.value(let value)): self = .DoubleValue(value) - case .bytes(let value): + case .bytes(.value(let value)): self = .BytesValue(value.base64EncodedString()) - case .date(let value): + case .date(.value(let value)): // Round to whole milliseconds, same constraint as the scalar `.date` case in // `Components.Schemas.FieldValueRequest`: CloudKit rejects a fractional TIMESTAMP // with BAD_REQUEST "expected type TIMESTAMP", and Date carries sub-millisecond // precision. List elements carry no `type` tag of their own, so the value's shape - // is all CloudKit has to go on. + // is all CloudKit has to go on for IN/NOT_IN element arrays. self = .DateValue((value.timeIntervalSince1970 * 1_000).rounded()) - case .location(let location): + case .location(.value(let location)): self = .LocationValue(Self.makeLocationValue(location)) - case .reference(let reference): + case .reference(.value(let reference)): self = .ReferenceValue(Self.makeReferenceValue(reference)) - case .asset(let asset): + case .asset(.value(let asset)): self = .AssetValue(Self.makeAssetValue(asset)) - case .list(let nestedList): - self = .ListValue(nestedList.map { Self(from: $0) }) + case .string(.list), .int64(.list), .double(.list), .bytes(.list), + .date(.list), .location(.list), .reference(.list), .asset(.list): + preconditionFailure( + "Nested FieldValue lists are not valid CloudKit list elements (issue #481)" + ) } } diff --git a/Sources/MistKitOpenAPI/Types.swift b/Sources/MistKitOpenAPI/Types.swift index 306fad98..0cd014c6 100644 --- a/Sources/MistKitOpenAPI/Types.swift +++ b/Sources/MistKitOpenAPI/Types.swift @@ -1407,6 +1407,8 @@ public enum Components { } /// A CloudKit field value from API responses. /// May include optional type field for explicit type information. + /// List fields carry the granular *_LIST family (e.g. STRING_LIST), matching + /// live CloudKit responses — not a flat LIST tag. /// /// /// - Remark: Generated from `#/components/schemas/FieldValueResponse`. @@ -1518,7 +1520,9 @@ public enum Components { } /// - Remark: Generated from `#/components/schemas/FieldValueResponse/value`. public var value: Components.Schemas.FieldValueResponse.valuePayload - /// The CloudKit field type (optional, may be inferred from value) + /// The CloudKit field type (optional). List responses use STRING_LIST, + /// INT64_LIST, etc. (verified live against MistDemo, 2026-09-09). + /// /// /// - Remark: Generated from `#/components/schemas/FieldValueResponse/type`. @frozen public enum _typePayload: String, Codable, Hashable, Sendable, CaseIterable { @@ -1526,14 +1530,23 @@ public enum Components { case INT64 = "INT64" case DOUBLE = "DOUBLE" case BYTES = "BYTES" + case TIMESTAMP = "TIMESTAMP" case REFERENCE = "REFERENCE" case ASSET = "ASSET" case ASSETID = "ASSETID" case LOCATION = "LOCATION" - case TIMESTAMP = "TIMESTAMP" - case LIST = "LIST" + case STRING_LIST = "STRING_LIST" + case INT64_LIST = "INT64_LIST" + case DOUBLE_LIST = "DOUBLE_LIST" + case BYTES_LIST = "BYTES_LIST" + case TIMESTAMP_LIST = "TIMESTAMP_LIST" + case REFERENCE_LIST = "REFERENCE_LIST" + case LOCATION_LIST = "LOCATION_LIST" + case ASSET_LIST = "ASSET_LIST" } - /// The CloudKit field type (optional, may be inferred from value) + /// The CloudKit field type (optional). List responses use STRING_LIST, + /// INT64_LIST, etc. (verified live against MistDemo, 2026-09-09). + /// /// /// - Remark: Generated from `#/components/schemas/FieldValueResponse/type`. public var _type: Components.Schemas.FieldValueResponse._typePayload? @@ -1541,7 +1554,7 @@ public enum Components { /// /// - Parameters: /// - value: - /// - _type: The CloudKit field type (optional, may be inferred from value) + /// - _type: The CloudKit field type (optional). List responses use STRING_LIST, public init( value: Components.Schemas.FieldValueResponse.valuePayload, _type: Components.Schemas.FieldValueResponse._typePayload? = nil diff --git a/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+FilterConversion.swift b/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+FilterConversion.swift index ec0ac91f..10b3c995 100644 --- a/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+FilterConversion.swift +++ b/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+FilterConversion.swift @@ -43,14 +43,14 @@ extension CloudKitServiceTests.Query { return } // Test equality filter - let equalFilter = QueryFilter.equals("title", .string("Test")) + let equalFilter = QueryFilter.equals("title", .string(.value("Test"))) let componentsFilter = Components.Schemas.Filter(from: equalFilter) #expect(componentsFilter.fieldName == "title") #expect(componentsFilter.comparator == .EQUALS) // Test comparison filter - let greaterThanFilter = QueryFilter.greaterThan("count", .int64(10)) + let greaterThanFilter = QueryFilter.greaterThan("count", .int64(.value(10))) let componentsGT = Components.Schemas.Filter(from: greaterThanFilter) #expect(componentsGT.fieldName == "count") @@ -64,11 +64,11 @@ extension CloudKitServiceTests.Query { return } let testCases: [(FieldValue, String)] = [ - (.string("test"), "string"), - (.int64(42), "int64"), - (.double(3.14), "double"), + (.string(.value("test")), "string"), + (.int64(.value(42)), "int64"), + (.double(.value(3.14)), "double"), (FieldValue(booleanValue: true), "boolean"), - (.date(Date()), "date"), + (.date(.value(Date())), "date"), ] for (fieldValue, typeName) in testCases { diff --git a/Tests/MistKitTests/CloudKitService/RecordWrite/CloudKitServiceTests.RecordWriteConvenience+ZoneID.swift b/Tests/MistKitTests/CloudKitService/RecordWrite/CloudKitServiceTests.RecordWriteConvenience+ZoneID.swift index 12d34277..a02fa71b 100644 --- a/Tests/MistKitTests/CloudKitService/RecordWrite/CloudKitServiceTests.RecordWriteConvenience+ZoneID.swift +++ b/Tests/MistKitTests/CloudKitService/RecordWrite/CloudKitServiceTests.RecordWriteConvenience+ZoneID.swift @@ -54,7 +54,7 @@ extension CloudKitServiceTests.RecordWriteConvenience { _ = try await service.createRecord( recordType: "Note", recordName: "note-1", - fields: ["title": .string("Hello")], + fields: ["title": .string(.value("Hello"))], zoneID: ZoneID(zoneName: "Articles", ownerName: "_abc123"), database: Helper.publicDatabase ) @@ -88,7 +88,7 @@ extension CloudKitServiceTests.RecordWriteConvenience { _ = try await service.updateRecord( recordType: "Note", recordName: "note-1", - fields: ["title": .string("Renamed")], + fields: ["title": .string(.value("Renamed"))], recordChangeTag: "tag-1", zoneID: ZoneID(zoneName: "Articles", ownerName: "_abc123"), database: Helper.publicDatabase diff --git a/Tests/MistKitTests/CloudKitService/RecordWrite/CloudKitServiceTests.RecordWriteConvenience.swift b/Tests/MistKitTests/CloudKitService/RecordWrite/CloudKitServiceTests.RecordWriteConvenience.swift index e283bfa0..fcaa440b 100644 --- a/Tests/MistKitTests/CloudKitService/RecordWrite/CloudKitServiceTests.RecordWriteConvenience.swift +++ b/Tests/MistKitTests/CloudKitService/RecordWrite/CloudKitServiceTests.RecordWriteConvenience.swift @@ -55,7 +55,7 @@ extension CloudKitServiceTests { let record = try await service.createRecord( recordType: "Note", recordName: "note-1", - fields: ["title": .string("Hello")], + fields: ["title": .string(.value("Hello"))], database: Helper.publicDatabase ) @@ -75,7 +75,7 @@ extension CloudKitServiceTests { await #expect(throws: CloudKitError.self) { _ = try await service.createRecord( recordType: "Note", - fields: ["title": .string("Hello")], + fields: ["title": .string(.value("Hello"))], database: Helper.publicDatabase ) } @@ -96,7 +96,7 @@ extension CloudKitServiceTests { let record = try await service.updateRecord( recordType: "Note", recordName: "note-1", - fields: ["title": .string("Renamed")], + fields: ["title": .string(.value("Renamed"))], recordChangeTag: "tag-1", database: Helper.publicDatabase ) @@ -119,7 +119,7 @@ extension CloudKitServiceTests { _ = try await service.updateRecord( recordType: "Note", recordName: "note-1", - fields: ["title": .string("Renamed")], + fields: ["title": .string(.value("Renamed"))], database: Helper.publicDatabase ) } diff --git a/Tests/MistKitTests/CloudKitService/RequestOptions/CloudKitServiceTests.RequestOptions.swift b/Tests/MistKitTests/CloudKitService/RequestOptions/CloudKitServiceTests.RequestOptions.swift index cd0103bd..d2a69add 100644 --- a/Tests/MistKitTests/CloudKitService/RequestOptions/CloudKitServiceTests.RequestOptions.swift +++ b/Tests/MistKitTests/CloudKitService/RequestOptions/CloudKitServiceTests.RequestOptions.swift @@ -96,7 +96,7 @@ extension CloudKitServiceTests { operationType: .create, recordType: "TestRecord", recordName: "rec-1", - fields: ["title": .string("Test")] + fields: ["title": .string(.value("Test"))] ) _ = try? await service.modifyRecords( diff --git a/Tests/MistKitTests/CloudKitService/Rereference/CloudKitServiceTests.Rereference+Compose.swift b/Tests/MistKitTests/CloudKitService/Rereference/CloudKitServiceTests.Rereference+Compose.swift index f793fe83..42b926eb 100644 --- a/Tests/MistKitTests/CloudKitService/Rereference/CloudKitServiceTests.Rereference+Compose.swift +++ b/Tests/MistKitTests/CloudKitService/Rereference/CloudKitServiceTests.Rereference+Compose.swift @@ -68,7 +68,7 @@ extension CloudKitServiceTests.Rereference { ) #expect(updated.recordName == "note-b") - guard case .asset(let asset) = updated.fields["image"] else { + guard case .asset(.value(let asset)) = updated.fields["image"] else { Issue.record("Updated target should carry an image asset") return } diff --git a/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Create.swift b/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Create.swift index 1acb7ae7..f0431ffc 100644 --- a/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Create.swift +++ b/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Create.swift @@ -111,7 +111,7 @@ extension CloudKitServiceTests.Sharing { let created = try await service.createShare( rootRecordType: "Note", rootRecordName: "root-1", - rootFields: ["title": .string("Shared Note")], + rootFields: ["title": .string(.value("Shared Note"))], zoneID: Self.zoneID, participants: [Self.sharee], database: .private @@ -150,7 +150,7 @@ extension CloudKitServiceTests.Sharing { _ = try await service.createShare( rootRecordType: "Note", rootRecordName: "root-1", - rootFields: ["title": .string("Shared Note")], + rootFields: ["title": .string(.value("Shared Note"))], zoneID: Self.zoneID, publicPermission: .none, participants: [Self.sharee], diff --git a/Tests/MistKitTests/CloudKitService/SizeLimits/CloudKitServiceTests.SizeLimits+Records.swift b/Tests/MistKitTests/CloudKitService/SizeLimits/CloudKitServiceTests.SizeLimits+Records.swift index a59f48ed..c12f77d4 100644 --- a/Tests/MistKitTests/CloudKitService/SizeLimits/CloudKitServiceTests.SizeLimits+Records.swift +++ b/Tests/MistKitTests/CloudKitService/SizeLimits/CloudKitServiceTests.SizeLimits+Records.swift @@ -62,11 +62,11 @@ extension CloudKitServiceTests.SizeLimits { let oversized = String(repeating: "x", count: 1_500_000) let smallOperation = RecordOperation.create( recordType: "Note", - fields: ["body": .string("small")] + fields: ["body": .string(.value("small"))] ) let oversizedOperation = RecordOperation.create( recordType: "Note", - fields: ["body": .string(oversized)] + fields: ["body": .string(.value(oversized))] ) await #expect { @@ -90,7 +90,7 @@ extension CloudKitServiceTests.SizeLimits { let service = try Self.makeService(reason: "iCloud storage quota exhausted") let operation = RecordOperation.create( recordType: "Note", - fields: ["body": .string("small")] + fields: ["body": .string(.value("small"))] ) await #expect { diff --git a/Tests/MistKitTests/Extensions/RecordOperationConversionTests.swift b/Tests/MistKitTests/Extensions/RecordOperationConversionTests.swift index 95bade16..f6edf9ff 100644 --- a/Tests/MistKitTests/Extensions/RecordOperationConversionTests.swift +++ b/Tests/MistKitTests/Extensions/RecordOperationConversionTests.swift @@ -58,7 +58,7 @@ internal struct RecordOperationConversionTests { operationType: operationType, recordType: "TestRecord", recordName: "test-record-name", - fields: ["title": .string("Test")] + fields: ["title": .string(.value("Test"))] ) let apiOperation = try Components.Schemas.RecordOperation(from: operation) @@ -77,8 +77,8 @@ internal struct RecordOperationConversionTests { recordType: "TestRecord", recordName: "test-name", fields: [ - "title": .string("Hello"), - "count": .int64(42), + "title": .string(.value("Hello")), + "count": .int64(.value(42)), ] ) @@ -96,7 +96,7 @@ internal struct RecordOperationConversionTests { operationType: .update, recordType: "TestRecord", recordName: "test-name", - fields: ["title": .string("Updated")], + fields: ["title": .string(.value("Updated"))], recordChangeTag: "abc123" ) diff --git a/Tests/MistKitTests/Models/ConversionFailureTests.swift b/Tests/MistKitTests/Models/ConversionFailureTests.swift index c20c9285..ceae64c9 100644 --- a/Tests/MistKitTests/Models/ConversionFailureTests.swift +++ b/Tests/MistKitTests/Models/ConversionFailureTests.swift @@ -69,7 +69,7 @@ internal struct ConversionFailureTests { let field = try FieldValue(response, fieldName: "image") - guard case .asset(let asset) = field else { + guard case .asset(.value(let asset)) = field else { Issue.record("Expected .asset, got \(field)") return } diff --git a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+BasicTypes.swift b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+BasicTypes.swift index cf4b1ee0..064e2666 100644 --- a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+BasicTypes.swift +++ b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+BasicTypes.swift @@ -13,7 +13,7 @@ extension FieldValueConversionTests { Issue.record("FieldValue is not available on this operating system.") return } - let fieldValue = FieldValue.string("test string") + let fieldValue = FieldValue.string(.value("test string")) let components = Components.Schemas.FieldValueRequest(from: fieldValue) if case .StringValue(let value) = components.value { @@ -31,7 +31,7 @@ extension FieldValueConversionTests { Issue.record("FieldValue is not available on this operating system.") return } - let fieldValue = FieldValue.int64(42) + let fieldValue = FieldValue.int64(.value(42)) let components = Components.Schemas.FieldValueRequest(from: fieldValue) if case .Int64Value(let value) = components.value { @@ -49,7 +49,7 @@ extension FieldValueConversionTests { Issue.record("FieldValue is not available on this operating system.") return } - let fieldValue = FieldValue.double(3.14159) + let fieldValue = FieldValue.double(.value(3.14159)) let components = Components.Schemas.FieldValueRequest(from: fieldValue) if case .DoubleValue(let value) = components.value { @@ -92,7 +92,7 @@ extension FieldValueConversionTests { return } let payload = Data("hello".utf8) - let fieldValue = FieldValue.bytes(payload) + let fieldValue = FieldValue.bytes(.value(payload)) let components = Components.Schemas.FieldValueRequest(from: fieldValue) if case .BytesValue(let value) = components.value { @@ -111,7 +111,7 @@ extension FieldValueConversionTests { return } let date = Date(timeIntervalSince1970: 1_000_000) - let fieldValue = FieldValue.date(date) + let fieldValue = FieldValue.date(.value(date)) let components = Components.Schemas.FieldValueRequest(from: fieldValue) if case .DateValue(let value) = components.value { @@ -132,7 +132,7 @@ extension FieldValueConversionTests { // Date carries sub-millisecond precision; CloudKit rejects a fractional TIMESTAMP // value with BAD_REQUEST, so the millisecond value must be a whole number. let date = Date(timeIntervalSince1970: 1_747_999_812.3478923) - let components = Components.Schemas.FieldValueRequest(from: .date(date)) + let components = Components.Schemas.FieldValueRequest(from: .date(.value(date))) if case .DateValue(let value) = components.value { #expect(value == 1_747_999_812_348) diff --git a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+ComplexTypes.swift b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+ComplexTypes.swift index b8fec619..814d4389 100644 --- a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+ComplexTypes.swift +++ b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+ComplexTypes.swift @@ -23,7 +23,7 @@ extension FieldValueConversionTests { course: 45.0, timestamp: Date(timeIntervalSince1970: 1_000_000) ) - let fieldValue = FieldValue.location(location) + let fieldValue = FieldValue.location(.value(location)) let components = Components.Schemas.FieldValueRequest(from: fieldValue) if case .LocationValue(let value) = components.value { @@ -47,7 +47,7 @@ extension FieldValueConversionTests { return } let location = Location(latitude: 0.0, longitude: 0.0) - let fieldValue = FieldValue.location(location) + let fieldValue = FieldValue.location(.value(location)) let components = Components.Schemas.FieldValueRequest(from: fieldValue) if case .LocationValue(let value) = components.value { @@ -71,7 +71,7 @@ extension FieldValueConversionTests { return } let reference = Reference(recordName: "test-record-123") - let fieldValue = FieldValue.reference(reference) + let fieldValue = FieldValue.reference(.value(reference)) let components = Components.Schemas.FieldValueRequest(from: fieldValue) if case .ReferenceValue(let value) = components.value { @@ -89,7 +89,7 @@ extension FieldValueConversionTests { return } let reference = Reference(recordName: "test-record-456", action: .deleteSelf) - let fieldValue = FieldValue.reference(reference) + let fieldValue = FieldValue.reference(.value(reference)) let components = Components.Schemas.FieldValueRequest(from: fieldValue) if case .ReferenceValue(let value) = components.value { @@ -109,7 +109,7 @@ extension FieldValueConversionTests { let reference = Reference( recordName: "test-record-789", action: Reference.Action.none ) - let fieldValue = FieldValue.reference(reference) + let fieldValue = FieldValue.reference(.value(reference)) let components = Components.Schemas.FieldValueRequest(from: fieldValue) if case .ReferenceValue(let value) = components.value { @@ -127,7 +127,7 @@ extension FieldValueConversionTests { return } let reference = Reference(recordName: "test-record-validate", action: .validate) - let fieldValue = FieldValue.reference(reference) + let fieldValue = FieldValue.reference(.value(reference)) let components = Components.Schemas.FieldValueRequest(from: fieldValue) if case .ReferenceValue(let value) = components.value { @@ -152,7 +152,7 @@ extension FieldValueConversionTests { receipt: "receipt_xyz", downloadURL: "https://example.com/file.jpg" ) - let fieldValue = FieldValue.asset(asset) + let fieldValue = FieldValue.asset(.value(asset)) let components = Components.Schemas.FieldValueRequest(from: fieldValue) if case .AssetValue(let value) = components.value { @@ -174,7 +174,7 @@ extension FieldValueConversionTests { return } let asset = Asset() - let fieldValue = FieldValue.asset(asset) + let fieldValue = FieldValue.asset(.value(asset)) let components = Components.Schemas.FieldValueRequest(from: fieldValue) if case .AssetValue(let value) = components.value { diff --git a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+EdgeCases.swift b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+EdgeCases.swift index d3a7fbc1..c3ea399e 100644 --- a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+EdgeCases.swift +++ b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+EdgeCases.swift @@ -13,10 +13,10 @@ extension FieldValueConversionTests { Issue.record("FieldValue is not available on this operating system.") return } - let intZero = FieldValue.int64(0) + let intZero = FieldValue.int64(.value(0)) _ = Components.Schemas.FieldValueRequest(from: intZero) - let doubleZero = FieldValue.double(0.0) + let doubleZero = FieldValue.double(.value(0.0)) _ = Components.Schemas.FieldValueRequest(from: doubleZero) } @@ -26,10 +26,10 @@ extension FieldValueConversionTests { Issue.record("FieldValue is not available on this operating system.") return } - let negativeInt = FieldValue.int64(-100) + let negativeInt = FieldValue.int64(.value(-100)) _ = Components.Schemas.FieldValueRequest(from: negativeInt) - let negativeDouble = FieldValue.double(-3.14) + let negativeDouble = FieldValue.double(.value(-3.14)) _ = Components.Schemas.FieldValueRequest(from: negativeDouble) } @@ -39,10 +39,10 @@ extension FieldValueConversionTests { Issue.record("FieldValue is not available on this operating system.") return } - let largeInt = FieldValue.int64(Int.max) + let largeInt = FieldValue.int64(.value(Int.max)) _ = Components.Schemas.FieldValueRequest(from: largeInt) - let largeDouble = FieldValue.double(Double.greatestFiniteMagnitude) + let largeDouble = FieldValue.double(.value(Double.greatestFiniteMagnitude)) _ = Components.Schemas.FieldValueRequest(from: largeDouble) } @@ -52,7 +52,7 @@ extension FieldValueConversionTests { Issue.record("FieldValue is not available on this operating system.") return } - let emptyString = FieldValue.string("") + let emptyString = FieldValue.string(.value("")) _ = Components.Schemas.FieldValueRequest(from: emptyString) } @@ -62,7 +62,7 @@ extension FieldValueConversionTests { Issue.record("FieldValue is not available on this operating system.") return } - let specialString = FieldValue.string("Hello\nWorld\t🌍") + let specialString = FieldValue.string(.value("Hello\nWorld\t🌍")) _ = Components.Schemas.FieldValueRequest(from: specialString) } } diff --git a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+Lists.swift b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+Lists.swift index 9503a612..44a8c95e 100644 --- a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+Lists.swift +++ b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+Lists.swift @@ -7,16 +7,16 @@ internal import Testing extension FieldValueConversionTests { @Suite("List Conversions") internal struct Lists { - @Test("Convert list FieldValue with strings to Components.FieldValue") + @Test("Convert STRING_LIST FieldValue with strings tags STRING_LIST") internal func convertListWithStrings() { guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { Issue.record("FieldValue is not available on this operating system.") return } - let list: [FieldValue] = [.string("one"), .string("two"), .string("three")] - let fieldValue = FieldValue.list(list) + let fieldValue = FieldValue.string(.list(["one", "two", "three"])) let components = Components.Schemas.FieldValueRequest(from: fieldValue) + #expect(components._type == .STRING_LIST) if case .ListValue(let values) = components.value { #expect(values.count == 3) } else { @@ -24,16 +24,16 @@ extension FieldValueConversionTests { } } - @Test("Convert list FieldValue with numbers to Components.FieldValue") + @Test("Convert INT64_LIST FieldValue with numbers tags INT64_LIST") internal func convertListWithNumbers() { guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { Issue.record("FieldValue is not available on this operating system.") return } - let list: [FieldValue] = [.int64(1), .int64(2), .int64(3)] - let fieldValue = FieldValue.list(list) + let fieldValue = FieldValue.int64(.list([1, 2, 3])) let components = Components.Schemas.FieldValueRequest(from: fieldValue) + #expect(components._type == .INT64_LIST) if case .ListValue(let values) = components.value { #expect(values.count == 3) } else { @@ -41,38 +41,16 @@ extension FieldValueConversionTests { } } - @Test("Convert list FieldValue with mixed types to Components.FieldValue") - internal func convertListWithMixedTypes() { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("FieldValue is not available on this operating system.") - return - } - let list: [FieldValue] = [ - .string("text"), - .int64(42), - .double(3.14), - FieldValue(booleanValue: true), - ] - let fieldValue = FieldValue.list(list) - let components = Components.Schemas.FieldValueRequest(from: fieldValue) - - if case .ListValue(let values) = components.value { - #expect(values.count == 4) - } else { - Issue.record("Expected listValue") - } - } - - @Test("Convert empty list FieldValue to Components.FieldValue") + @Test("Convert empty STRING_LIST tags STRING_LIST") internal func convertEmptyList() { guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { Issue.record("FieldValue is not available on this operating system.") return } - let list: [FieldValue] = [] - let fieldValue = FieldValue.list(list) + let fieldValue = FieldValue.string(.list([])) let components = Components.Schemas.FieldValueRequest(from: fieldValue) + #expect(components._type == .STRING_LIST) if case .ListValue(let values) = components.value { #expect(values.isEmpty) } else { @@ -80,49 +58,38 @@ extension FieldValueConversionTests { } } - @Test("Convert nested list FieldValue to Components.FieldValue") - internal func convertNestedList() { + @Test("STRING_LIST response empty array decodes as .string(.list([]))") + internal func decodeEmptyStringList() throws { guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { Issue.record("FieldValue is not available on this operating system.") return } - let innerList: [FieldValue] = [.string("a"), .string("b")] - let outerList: [FieldValue] = [.list(innerList), .string("c")] - let fieldValue = FieldValue.list(outerList) - let components = Components.Schemas.FieldValueRequest(from: fieldValue) - - // FieldValueRequest does not have a type field - CloudKit infers type from structure - if case .ListValue(let values) = components.value { - #expect(values.count == 2) - } else { - Issue.record("Expected ListValue") - } + let data = Data(#"{"value": [], "type": "STRING_LIST"}"#.utf8) + let response = try JSONDecoder().decode( + Components.Schemas.FieldValueResponse.self, + from: data + ) + let value = try FieldValue(response, fieldName: "tags") + #expect(value == .string(.list([]))) } - @Test("BYTES list element that is not valid base64 throws typeValueMismatch") - internal func malformedBytesListElementThrows() { + @Test("STRING_LIST response filled array decodes as .string(.value(.list))") + internal func decodeFilledStringList() throws { guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { Issue.record("FieldValue is not available on this operating system.") return } - ConversionFailureReporter.$assertionHandler.withValue( - { _, _, _ in }, - operation: { - #expect( - throws: ConversionError.typeValueMismatch( - fieldName: "field", - declaredType: "BYTES", - value: "not!valid!" - ) - ) { - _ = try FieldValue(listItem: .BytesValue("not!valid!"), fieldName: "field") - } - } + let data = Data(#"{"value": ["a", "b"], "type": "STRING_LIST"}"#.utf8) + let response = try JSONDecoder().decode( + Components.Schemas.FieldValueResponse.self, + from: data ) + let value = try FieldValue(response, fieldName: "tags") + #expect(value == .string(.list(["a", "b"]))) } - @Test("Nested BYTES list element that is not valid base64 throws typeValueMismatch") - internal func malformedBytesNestedListElementThrows() { + @Test("BYTES_LIST element that is not valid base64 throws typeValueMismatch") + internal func malformedBytesListElementThrows() { guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { Issue.record("FieldValue is not available on this operating system.") return @@ -133,12 +100,13 @@ extension FieldValueConversionTests { #expect( throws: ConversionError.typeValueMismatch( fieldName: "field", - declaredType: "BYTES", + declaredType: "BYTES_LIST", value: "not!valid!" ) ) { _ = try FieldValue( - nestedListValue: [.BytesValue("not!valid!")], + listValue: [.BytesValue("not!valid!")], + elementKind: .bytes, fieldName: "field" ) } @@ -146,20 +114,23 @@ extension FieldValueConversionTests { ) } - @Test("BYTES list element with valid base64 reads as .bytes Data") + @Test("BYTES_LIST element with valid base64 reads as .bytes(.value(.list))") internal func validBytesListElement() throws { guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { Issue.record("FieldValue is not available on this operating system.") return } - let value = try FieldValue(listItem: .BytesValue("aGVsbG8="), fieldName: "field") - #expect(value == .bytes(Data("hello".utf8))) + let value = try FieldValue( + listValue: [.BytesValue("aGVsbG8=")], + elementKind: .bytes, + fieldName: "field" + ) + #expect(value == .bytes(.list([Data("hello".utf8)]))) } /// A fractional millisecond inside a list must be rounded, exactly as the scalar /// `.date` case is. CloudKit rejects a fractional TIMESTAMP with - /// `BAD_REQUEST "Invalid value, expected type TIMESTAMP"`, and list elements carry no - /// `type` tag of their own — so the value's shape is all CloudKit has to go on. + /// `BAD_REQUEST "Invalid value, expected type TIMESTAMP"`. @Test("List .date elements round to whole milliseconds") internal func convertListWithDatesRoundsMilliseconds() { guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { @@ -167,8 +138,11 @@ extension FieldValueConversionTests { return } let date = Date(timeIntervalSince1970: 1_747_999_812.3478923) - let components = Components.Schemas.FieldValueRequest(from: .list([.date(date)])) + let components = Components.Schemas.FieldValueRequest( + from: .date(.list([date])) + ) + #expect(components._type == .TIMESTAMP_LIST) guard case .ListValue(let values) = components.value, let first = values.first else { Issue.record("Expected a ListValue with one element") return @@ -194,8 +168,11 @@ extension FieldValueConversionTests { longitude: -122.4194, timestamp: Date(timeIntervalSince1970: 1_747_999_812.3478923) ) - let components = Components.Schemas.FieldValueRequest(from: .list([.location(location)])) + let components = Components.Schemas.FieldValueRequest( + from: .location(.list([location])) + ) + #expect(components._type == .LOCATION_LIST) guard case .ListValue(let values) = components.value, let first = values.first else { Issue.record("Expected a ListValue with one element") return @@ -211,5 +188,26 @@ extension FieldValueConversionTests { #expect(timestamp == 1_747_999_812_348) #expect(timestamp == timestamp.rounded()) } + + @Test("STRING_LIST tag over non-list value throws typeValueMismatch") + internal func stringListOverScalarThrows() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + ConversionFailureReporter.$assertionHandler.withValue( + { _, _, _ in }, + operation: { + #expect(throws: ConversionError.self) { + let data = Data(#"{"value": "plain", "type": "STRING_LIST"}"#.utf8) + let response = try JSONDecoder().decode( + Components.Schemas.FieldValueResponse.self, + from: data + ) + _ = try FieldValue(response, fieldName: "field") + } + } + ) + } } } diff --git a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+ResponseTypes.swift b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+ResponseTypes.swift index 7129185f..5c99ed61 100644 --- a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+ResponseTypes.swift +++ b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+ResponseTypes.swift @@ -40,7 +40,7 @@ extension FieldValueConversionTests { } // A whole number decodes as Int64Value, but type TIMESTAMP must recover .date. let value = try Self.decode(#"{"value": 1493382919000, "type": "TIMESTAMP"}"#) - #expect(value == .date(Date(timeIntervalSince1970: 1_493_382_919))) + #expect(value == .date(.value(Date(timeIntervalSince1970: 1_493_382_919)))) } @Test("Fractional TIMESTAMP reads back as .date") @@ -50,7 +50,7 @@ extension FieldValueConversionTests { return } let value = try Self.decode(#"{"value": 1000500.0, "type": "TIMESTAMP"}"#) - #expect(value == .date(Date(timeIntervalSince1970: 1_000.5))) + #expect(value == .date(.value(Date(timeIntervalSince1970: 1_000.5)))) } @Test("BYTES with type reads back as .bytes, not .string") @@ -60,7 +60,7 @@ extension FieldValueConversionTests { return } let value = try Self.decode(#"{"value": "aGVsbG8=", "type": "BYTES"}"#) - #expect(value == .bytes(Data("hello".utf8))) + #expect(value == .bytes(.value(Data("hello".utf8)))) } @Test("Whole-valued DOUBLE with type reads back as .double, not .int64") @@ -70,7 +70,7 @@ extension FieldValueConversionTests { return } let value = try Self.decode(#"{"value": 5, "type": "DOUBLE"}"#) - #expect(value == .double(5.0)) + #expect(value == .double(.value(5.0))) } @Test("Explicit STRING and INT64 types match their inferred value shape") @@ -79,8 +79,8 @@ extension FieldValueConversionTests { Issue.record("FieldValue is not available on this operating system.") return } - #expect(try Self.decode(#"{"value": "hello", "type": "STRING"}"#) == .string("hello")) - #expect(try Self.decode(#"{"value": 42, "type": "INT64"}"#) == .int64(42)) + #expect(try Self.decode(#"{"value": "hello", "type": "STRING"}"#) == .string(.value("hello"))) + #expect(try Self.decode(#"{"value": 42, "type": "INT64"}"#) == .int64(.value(42))) } @Test("A scalar type that contradicts the value's shape throws") @@ -108,7 +108,7 @@ extension FieldValueConversionTests { } // 3.5 satisfies the numeric category, so INT64 validates then defers to inference, // preserving .double rather than truncating to an integer. - #expect(try Self.decode(#"{"value": 3.5, "type": "INT64"}"#) == .double(3.5)) + #expect(try Self.decode(#"{"value": 3.5, "type": "INT64"}"#) == .double(.value(3.5))) } @Test("A complex or list declared type that contradicts the value's shape throws") @@ -123,7 +123,7 @@ extension FieldValueConversionTests { expectThrows(#"{"value": 42, "type": "REFERENCE"}"#) expectThrows(#"{"value": "text", "type": "ASSET"}"#) expectThrows(#"{"value": 42, "type": "LOCATION"}"#) - expectThrows(#"{"value": 42, "type": "LIST"}"#) + expectThrows(#"{"value": 42, "type": "STRING_LIST"}"#) // A complex tag over the *wrong* complex value (LOCATION shape under a REFERENCE tag) // is likewise a contradiction. expectThrows(#"{"value": {"latitude": 1, "longitude": 2}, "type": "REFERENCE"}"#) @@ -138,12 +138,12 @@ extension FieldValueConversionTests { // A declared complex/list type whose value satisfies it round-trips unchanged — // the validation only rejects contradictions, never well-formed responses. let reference = try Self.decode(#"{"value": {"recordName": "rec1"}, "type": "REFERENCE"}"#) - #expect(reference == .reference(Reference(recordName: "rec1"))) + #expect(reference == .reference(.value(Reference(recordName: "rec1")))) let locationJSON = #"{"value": {"latitude": 37.3, "longitude": -122}, "type": "LOCATION"}"# let location = try Self.decode(locationJSON) - guard case .location(let loc) = location else { - Issue.record("Expected .location, got \(location)") + guard case .location(.value(let loc)) = location else { + Issue.record("Expected .location(.value(.value)), got \(location)") return } #expect(loc.latitude == 37.3) @@ -152,14 +152,14 @@ extension FieldValueConversionTests { // ASSETID maps to the same AssetValue as ASSET. let assetJSON = #"{"value": {"fileChecksum": "chk"}, "type": "ASSETID"}"# let asset = try Self.decode(assetJSON) - guard case .asset(let assetValue) = asset else { - Issue.record("Expected .asset, got \(asset)") + guard case .asset(.value(let assetValue)) = asset else { + Issue.record("Expected .asset(.value(.value)), got \(asset)") return } #expect(assetValue.fileChecksum == "chk") - let list = try Self.decode(#"{"value": ["a", "b"], "type": "LIST"}"#) - #expect(list == .list([.string("a"), .string("b")])) + let list = try Self.decode(#"{"value": ["a", "b"], "type": "STRING_LIST"}"#) + #expect(list == .string(.list(["a", "b"]))) } @Test("Without a type, scalars fall back to first-match-wins inference") @@ -168,10 +168,10 @@ extension FieldValueConversionTests { Issue.record("FieldValue is not available on this operating system.") return } - #expect(try Self.decode(#"{"value": "plain"}"#) == .string("plain")) - #expect(try Self.decode(#"{"value": "Chen"}"#) == .string("Chen")) - #expect(try Self.decode(#"{"value": 42}"#) == .int64(42)) - #expect(try Self.decode(#"{"value": 3.5}"#) == .double(3.5)) + #expect(try Self.decode(#"{"value": "plain"}"#) == .string(.value("plain"))) + #expect(try Self.decode(#"{"value": "Chen"}"#) == .string(.value("Chen"))) + #expect(try Self.decode(#"{"value": 42}"#) == .int64(.value(42))) + #expect(try Self.decode(#"{"value": 3.5}"#) == .double(.value(3.5))) } @Test("Tagged BYTES that is not valid base64 throws typeValueMismatch with the raw string") @@ -192,8 +192,8 @@ extension FieldValueConversionTests { Issue.record("FieldValue is not available on this operating system.") return } - #expect(try Self.decode(#"{"value": "not!valid!"}"#) == .string("not!valid!")) - #expect(try Self.decode(#"{"value": "aGVsbG8="}"#) == .string("aGVsbG8=")) + #expect(try Self.decode(#"{"value": "not!valid!"}"#) == .string(.value("not!valid!"))) + #expect(try Self.decode(#"{"value": "aGVsbG8="}"#) == .string(.value("aGVsbG8="))) } /// Expects decoding `json` to throw `typeValueMismatch` whose `value` is the diff --git a/Tests/MistKitTests/Models/FieldValues/FieldValueTests.swift b/Tests/MistKitTests/Models/FieldValues/FieldValueTests.swift index 603f5f3f..e853fc9a 100644 --- a/Tests/MistKitTests/Models/FieldValues/FieldValueTests.swift +++ b/Tests/MistKitTests/Models/FieldValues/FieldValueTests.swift @@ -8,67 +8,62 @@ internal import Testing internal struct FieldValueTests { /// Cases that survive a full JSON encode → decode round-trip unchanged. /// - /// Exercises both encode paths in `FieldValue+Codable`: the scalar arm - /// (`.string`/`.int64`/`.double` via `encodeScalar`) and the complex arm - /// (`.list`/`.location`/`.reference`/`.asset` via `encodeComplex`). The - /// complex cases are exactly those `encodeScalar` returns `false` for, so - /// this drives the `encodeScalar` → `encodeComplex` delegation. - /// - /// The throwing `default` in `encodeComplex` is intentionally unreachable — - /// every `FieldValue` case is routed by one of the two arms — so it is a - /// defensive guard covered by switch exhaustiveness, not by a test. + /// Exercises both encode paths in `FieldValue+Codable`: scalar `.value` arms and + /// homogeneous `.list` / complex `.value` arms. Heterogeneous lists are + /// unrepresentable after issue #481. private static let roundTripCases: [FieldValue] = [ - .string("test"), - .int64(123), + .string(.value("test")), + .int64(.value(123)), // Fractional on purpose: a whole-valued double decodes back as `.int64`. - .double(3.14), - .list([.string("item1"), .int64(42)]), - .location( - Location(latitude: 37.7749, longitude: -122.4194, horizontalAccuracy: 10.0) - ), - .reference(Reference(recordName: "test-record")), + .double(.value(3.14)), + .string(.list(["item1", "item2"])), + .int64(.list([1, 2, 42])), + .location(.value(Location(latitude: 37.7749, longitude: -122.4194, horizontalAccuracy: 10.0))), + .reference(.value(Reference(recordName: "test-record"))), .asset( - Asset(fileChecksum: "abc123", size: 1_024, downloadURL: "https://example.com/file") + .value( + Asset(fileChecksum: "abc123", size: 1_024, downloadURL: "https://example.com/file") + ) ), ] /// Tests FieldValue string type creation and equality @Test("FieldValue string type creation and equality") internal func fieldValueString() { - let value = FieldValue.string("test") - #expect(value == .string("test")) + let value = FieldValue.string(.value("test")) + #expect(value == .string(.value("test"))) } /// Tests FieldValue int64 type creation and equality @Test("FieldValue int64 type creation and equality") internal func fieldValueInt64() { - let value = FieldValue.int64(123) - #expect(value == .int64(123)) + let value = FieldValue.int64(.value(123)) + #expect(value == .int64(.value(123))) } /// Tests FieldValue double type creation and equality @Test("FieldValue double type creation and equality") internal func fieldValueDouble() { - let value = FieldValue.double(3.14) - #expect(value == .double(3.14)) + let value = FieldValue.double(.value(3.14)) + #expect(value == .double(.value(3.14))) } /// Tests FieldValue boolean helper creation and equality @Test("FieldValue boolean helper creation and equality") internal func fieldValueBoolean() { let trueValue = FieldValue(booleanValue: true) - #expect(trueValue == .int64(1)) + #expect(trueValue == .int64(.value(1))) let falseValue = FieldValue(booleanValue: false) - #expect(falseValue == .int64(0)) + #expect(falseValue == .int64(.value(0))) } /// Tests FieldValue date type creation and equality @Test("FieldValue date type creation and equality") internal func fieldValueDate() { let date = Date() - let value = FieldValue.date(date) - #expect(value == .date(date)) + let value = FieldValue.date(.value(date)) + #expect(value == .date(.value(date))) } /// Tests FieldValue location type creation and equality @@ -79,16 +74,16 @@ internal struct FieldValueTests { longitude: -122.4194, horizontalAccuracy: 10.0 ) - let value = FieldValue.location(location) - #expect(value == .location(location)) + let value = FieldValue.location(.value(location)) + #expect(value == .location(.value(location))) } /// Tests FieldValue reference type creation and equality @Test("FieldValue reference type creation and equality") internal func fieldValueReference() { let reference = Reference(recordName: "test-record") - let value = FieldValue.reference(reference) - #expect(value == .reference(reference)) + let value = FieldValue.reference(.value(reference)) + #expect(value == .reference(.value(reference))) } /// Tests FieldValue asset type creation and equality @@ -99,16 +94,15 @@ internal struct FieldValueTests { size: 1_024, downloadURL: "https://example.com/file" ) - let value = FieldValue.asset(asset) - #expect(value == .asset(asset)) + let value = FieldValue.asset(.value(asset)) + #expect(value == .asset(.value(asset))) } - /// Tests FieldValue list type creation and equality - @Test("FieldValue list type creation and equality") + /// Tests FieldValue homogeneous string list creation and equality + @Test("FieldValue homogeneous string list creation and equality") internal func fieldValueList() { - let list = [FieldValue.string("item1"), FieldValue.int64(42)] - let value = FieldValue.list(list) - #expect(value == .list(list)) + let value = FieldValue.string(.list(["item1", "item2"])) + #expect(value == .string(.list(["item1", "item2"]))) } /// Tests FieldValue JSON encode → decode round-trips for scalar and complex cases @@ -130,7 +124,7 @@ internal struct FieldValueTests { @Test("FieldValue date encodes as milliseconds") internal func fieldValueDateEncodesMilliseconds() throws { let date = Date(timeIntervalSince1970: 1_700_000_000) - let data = try JSONEncoder().encode(FieldValue.date(date)) + let data = try JSONEncoder().encode(FieldValue.date(.value(date))) let milliseconds = try JSONDecoder().decode(Double.self, from: data) #expect(milliseconds == date.timeIntervalSince1970 * 1_000) } @@ -143,11 +137,11 @@ internal struct FieldValueTests { internal func fieldValueBytesEncodesAsString() throws { let payload = Data("abc123".utf8) let encoded = payload.base64EncodedString() - let bytesData = try JSONEncoder().encode(FieldValue.bytes(payload)) - let stringData = try JSONEncoder().encode(FieldValue.string(encoded)) + let bytesData = try JSONEncoder().encode(FieldValue.bytes(.value(payload))) + let stringData = try JSONEncoder().encode(FieldValue.string(.value(encoded))) #expect(bytesData == stringData) let decoded = try JSONDecoder().decode(FieldValue.self, from: bytesData) - #expect(decoded == .string(encoded)) + #expect(decoded == .string(.value(encoded))) } } diff --git a/Tests/MistKitTests/Models/Queries/FilterBuilder/FilterBuilderTests+Comparators.swift b/Tests/MistKitTests/Models/Queries/FilterBuilder/FilterBuilderTests+Comparators.swift index b6010a9d..e804b451 100644 --- a/Tests/MistKitTests/Models/Queries/FilterBuilder/FilterBuilderTests+Comparators.swift +++ b/Tests/MistKitTests/Models/Queries/FilterBuilder/FilterBuilderTests+Comparators.swift @@ -13,7 +13,7 @@ extension FilterBuilderTests { Issue.record("FilterBuilder is not available on this operating system.") return } - let filter = FilterBuilder.equals("name", .string("John")) + let filter = FilterBuilder.equals("name", .string(.value("John"))) #expect(filter.comparator == .EQUALS) #expect(filter.fieldName == "name") } @@ -24,7 +24,7 @@ extension FilterBuilderTests { Issue.record("FilterBuilder is not available on this operating system.") return } - let filter = FilterBuilder.notEquals("age", .int64(25)) + let filter = FilterBuilder.notEquals("age", .int64(.value(25))) #expect(filter.comparator == .NOT_EQUALS) #expect(filter.fieldName == "age") } @@ -35,7 +35,7 @@ extension FilterBuilderTests { Issue.record("FilterBuilder is not available on this operating system.") return } - let filter = FilterBuilder.lessThan("score", .double(100.0)) + let filter = FilterBuilder.lessThan("score", .double(.value(100.0))) #expect(filter.comparator == .LESS_THAN) #expect(filter.fieldName == "score") } @@ -46,7 +46,7 @@ extension FilterBuilderTests { Issue.record("FilterBuilder is not available on this operating system.") return } - let filter = FilterBuilder.lessThanOrEquals("count", .int64(50)) + let filter = FilterBuilder.lessThanOrEquals("count", .int64(.value(50))) #expect(filter.comparator == .LESS_THAN_OR_EQUALS) #expect(filter.fieldName == "count") } @@ -58,7 +58,7 @@ extension FilterBuilderTests { return } let date = Date() - let filter = FilterBuilder.greaterThan("createdAt", .date(date)) + let filter = FilterBuilder.greaterThan("createdAt", .date(.value(date))) #expect(filter.comparator == .GREATER_THAN) #expect(filter.fieldName == "createdAt") } @@ -69,7 +69,7 @@ extension FilterBuilderTests { Issue.record("FilterBuilder is not available on this operating system.") return } - let filter = FilterBuilder.greaterThanOrEquals("priority", .int64(3)) + let filter = FilterBuilder.greaterThanOrEquals("priority", .int64(.value(3))) #expect(filter.comparator == .GREATER_THAN_OR_EQUALS) #expect(filter.fieldName == "priority") } diff --git a/Tests/MistKitTests/Models/Queries/FilterBuilder/FilterBuilderTests+ComplexValues.swift b/Tests/MistKitTests/Models/Queries/FilterBuilder/FilterBuilderTests+ComplexValues.swift index 539bd7ef..61e699b0 100644 --- a/Tests/MistKitTests/Models/Queries/FilterBuilder/FilterBuilderTests+ComplexValues.swift +++ b/Tests/MistKitTests/Models/Queries/FilterBuilder/FilterBuilderTests+ComplexValues.swift @@ -25,7 +25,7 @@ extension FilterBuilderTests { return } let reference = Reference(recordName: "user-123") - let filter = FilterBuilder.equals("owner", .reference(reference)) + let filter = FilterBuilder.equals("owner", .reference(.value(reference))) #expect(filter.comparator == .EQUALS) #expect(filter.fieldName == "owner") } @@ -40,7 +40,7 @@ extension FilterBuilderTests { latitude: 37.7749, longitude: -122.4194 ) - let filter = FilterBuilder.equals("location", .location(location)) + let filter = FilterBuilder.equals("location", .location(.value(location))) #expect(filter.comparator == .EQUALS) #expect(filter.fieldName == "location") } diff --git a/Tests/MistKitTests/Models/Queries/FilterBuilder/FilterBuilderTests+ListFilters.swift b/Tests/MistKitTests/Models/Queries/FilterBuilder/FilterBuilderTests+ListFilters.swift index 74683a6e..caac4286 100644 --- a/Tests/MistKitTests/Models/Queries/FilterBuilder/FilterBuilderTests+ListFilters.swift +++ b/Tests/MistKitTests/Models/Queries/FilterBuilder/FilterBuilderTests+ListFilters.swift @@ -13,7 +13,7 @@ extension FilterBuilderTests { Issue.record("FilterBuilder is not available on this operating system.") return } - let values: [FieldValue] = [.string("active"), .string("pending")] + let values: [FieldValue] = [.string(.value("active")), .string(.value("pending"))] let filter = FilterBuilder.in("status", values) #expect(filter.comparator == .IN) #expect(filter.fieldName == "status") @@ -26,7 +26,7 @@ extension FilterBuilderTests { Issue.record("FilterBuilder is not available on this operating system.") return } - let values: [FieldValue] = [.string("deleted"), .string("archived")] + let values: [FieldValue] = [.string(.value("deleted")), .string(.value("archived"))] let filter = FilterBuilder.notIn("status", values) #expect(filter.comparator == .NOT_IN) #expect(filter.fieldName == "status") @@ -39,7 +39,7 @@ extension FilterBuilderTests { Issue.record("FilterBuilder is not available on this operating system.") return } - let values: [FieldValue] = [.int64(1), .int64(2), .int64(3)] + let values: [FieldValue] = [.int64(.value(1)), .int64(.value(2)), .int64(.value(3))] let filter = FilterBuilder.in("categoryId", values) #expect(filter.comparator == .IN) #expect(filter.fieldName == "categoryId") @@ -52,7 +52,7 @@ extension FilterBuilderTests { Issue.record("FilterBuilder is not available on this operating system.") return } - let filter = FilterBuilder.listContains("tags", .string("important")) + let filter = FilterBuilder.listContains("tags", .string(.value("important"))) #expect(filter.comparator == .LIST_CONTAINS) #expect(filter.fieldName == "tags") } @@ -63,7 +63,7 @@ extension FilterBuilderTests { Issue.record("FilterBuilder is not available on this operating system.") return } - let filter = FilterBuilder.notListContains("tags", .string("spam")) + let filter = FilterBuilder.notListContains("tags", .string(.value("spam"))) #expect(filter.comparator == .NOT_LIST_CONTAINS) #expect(filter.fieldName == "tags") } diff --git a/Tests/MistKitTests/Models/Queries/QueryFilterTests+Comparison.swift b/Tests/MistKitTests/Models/Queries/QueryFilterTests+Comparison.swift index ec56b399..a1136b29 100644 --- a/Tests/MistKitTests/Models/Queries/QueryFilterTests+Comparison.swift +++ b/Tests/MistKitTests/Models/Queries/QueryFilterTests+Comparison.swift @@ -13,7 +13,7 @@ extension QueryFilterTests { Issue.record("QueryFilter is not available on this operating system.") return } - let filter = QueryFilter.lessThan("age", .int64(30)) + let filter = QueryFilter.lessThan("age", .int64(.value(30))) let components = Components.Schemas.Filter(from: filter) #expect(components.comparator == .LESS_THAN) #expect(components.fieldName == "age") @@ -25,7 +25,7 @@ extension QueryFilterTests { Issue.record("QueryFilter is not available on this operating system.") return } - let filter = QueryFilter.lessThanOrEquals("score", .double(85.5)) + let filter = QueryFilter.lessThanOrEquals("score", .double(.value(85.5))) let components = Components.Schemas.Filter(from: filter) #expect(components.comparator == .LESS_THAN_OR_EQUALS) #expect(components.fieldName == "score") @@ -38,7 +38,7 @@ extension QueryFilterTests { return } let date = Date() - let filter = QueryFilter.greaterThan("updatedAt", .date(date)) + let filter = QueryFilter.greaterThan("updatedAt", .date(.value(date))) let components = Components.Schemas.Filter(from: filter) #expect(components.comparator == .GREATER_THAN) #expect(components.fieldName == "updatedAt") @@ -50,7 +50,7 @@ extension QueryFilterTests { Issue.record("QueryFilter is not available on this operating system.") return } - let filter = QueryFilter.greaterThanOrEquals("rating", .int64(4)) + let filter = QueryFilter.greaterThanOrEquals("rating", .int64(.value(4))) let components = Components.Schemas.Filter(from: filter) #expect(components.comparator == .GREATER_THAN_OR_EQUALS) #expect(components.fieldName == "rating") diff --git a/Tests/MistKitTests/Models/Queries/QueryFilterTests+ComplexFields.swift b/Tests/MistKitTests/Models/Queries/QueryFilterTests+ComplexFields.swift index 02eb04df..d43d6389 100644 --- a/Tests/MistKitTests/Models/Queries/QueryFilterTests+ComplexFields.swift +++ b/Tests/MistKitTests/Models/Queries/QueryFilterTests+ComplexFields.swift @@ -27,7 +27,7 @@ extension QueryFilterTests { return } let reference = Reference(recordName: "parent-record-123") - let filter = QueryFilter.equals("parentRef", .reference(reference)) + let filter = QueryFilter.equals("parentRef", .reference(.value(reference))) let components = Components.Schemas.Filter(from: filter) #expect(components.comparator == .EQUALS) #expect(components.fieldName == "parentRef") @@ -40,7 +40,7 @@ extension QueryFilterTests { return } let now = Date() - let filter = QueryFilter.lessThan("expiresAt", .date(now)) + let filter = QueryFilter.lessThan("expiresAt", .date(.value(now))) let components = Components.Schemas.Filter(from: filter) #expect(components.comparator == .LESS_THAN) #expect(components.fieldName == "expiresAt") @@ -52,7 +52,7 @@ extension QueryFilterTests { Issue.record("QueryFilter is not available on this operating system.") return } - let filter = QueryFilter.greaterThanOrEquals("temperature", .double(98.6)) + let filter = QueryFilter.greaterThanOrEquals("temperature", .double(.value(98.6))) let components = Components.Schemas.Filter(from: filter) #expect(components.comparator == .GREATER_THAN_OR_EQUALS) #expect(components.fieldName == "temperature") diff --git a/Tests/MistKitTests/Models/Queries/QueryFilterTests+EdgeCases.swift b/Tests/MistKitTests/Models/Queries/QueryFilterTests+EdgeCases.swift index d89d5697..1f9eff54 100644 --- a/Tests/MistKitTests/Models/Queries/QueryFilterTests+EdgeCases.swift +++ b/Tests/MistKitTests/Models/Queries/QueryFilterTests+EdgeCases.swift @@ -13,7 +13,7 @@ extension QueryFilterTests { Issue.record("QueryFilter is not available on this operating system.") return } - let filter = QueryFilter.equals("emptyField", .string("")) + let filter = QueryFilter.equals("emptyField", .string(.value(""))) let components = Components.Schemas.Filter(from: filter) #expect(components.fieldName == "emptyField") } @@ -24,7 +24,7 @@ extension QueryFilterTests { Issue.record("QueryFilter is not available on this operating system.") return } - let filter = QueryFilter.equals("field_name_123", .string("value")) + let filter = QueryFilter.equals("field_name_123", .string(.value("value"))) let components = Components.Schemas.Filter(from: filter) #expect(components.fieldName == "field_name_123") } @@ -35,11 +35,11 @@ extension QueryFilterTests { Issue.record("QueryFilter is not available on this operating system.") return } - let intFilter = QueryFilter.equals("count", .int64(0)) + let intFilter = QueryFilter.equals("count", .int64(.value(0))) let intComponents = Components.Schemas.Filter(from: intFilter) #expect(intComponents.fieldName == "count") - let doubleFilter = QueryFilter.equals("amount", .double(0.0)) + let doubleFilter = QueryFilter.equals("amount", .double(.value(0.0))) let doubleComponents = Components.Schemas.Filter(from: doubleFilter) #expect(doubleComponents.fieldName == "amount") } @@ -50,7 +50,7 @@ extension QueryFilterTests { Issue.record("QueryFilter is not available on this operating system.") return } - let filter = QueryFilter.lessThan("balance", .int64(-100)) + let filter = QueryFilter.lessThan("balance", .int64(.value(-100))) let components = Components.Schemas.Filter(from: filter) #expect(components.comparator == .LESS_THAN) } @@ -61,7 +61,7 @@ extension QueryFilterTests { Issue.record("QueryFilter is not available on this operating system.") return } - let filter = QueryFilter.greaterThan("views", .int64(1_000_000)) + let filter = QueryFilter.greaterThan("views", .int64(.value(1_000_000))) let components = Components.Schemas.Filter(from: filter) #expect(components.comparator == .GREATER_THAN) } diff --git a/Tests/MistKitTests/Models/Queries/QueryFilterTests+Equality.swift b/Tests/MistKitTests/Models/Queries/QueryFilterTests+Equality.swift index 930671b2..552c1b63 100644 --- a/Tests/MistKitTests/Models/Queries/QueryFilterTests+Equality.swift +++ b/Tests/MistKitTests/Models/Queries/QueryFilterTests+Equality.swift @@ -13,7 +13,7 @@ extension QueryFilterTests { Issue.record("QueryFilter is not available on this operating system.") return } - let filter = QueryFilter.equals("name", .string("Alice")) + let filter = QueryFilter.equals("name", .string(.value("Alice"))) let components = Components.Schemas.Filter(from: filter) #expect(components.comparator == .EQUALS) #expect(components.fieldName == "name") @@ -25,7 +25,7 @@ extension QueryFilterTests { Issue.record("QueryFilter is not available on this operating system.") return } - let filter = QueryFilter.notEquals("status", .string("deleted")) + let filter = QueryFilter.notEquals("status", .string(.value("deleted"))) let components = Components.Schemas.Filter(from: filter) #expect(components.comparator == .NOT_EQUALS) #expect(components.fieldName == "status") diff --git a/Tests/MistKitTests/Models/Queries/QueryFilterTests+List.swift b/Tests/MistKitTests/Models/Queries/QueryFilterTests+List.swift index fcdfd835..7b406c49 100644 --- a/Tests/MistKitTests/Models/Queries/QueryFilterTests+List.swift +++ b/Tests/MistKitTests/Models/Queries/QueryFilterTests+List.swift @@ -13,7 +13,7 @@ extension QueryFilterTests { Issue.record("QueryFilter is not available on this operating system.") return } - let values: [FieldValue] = [.string("draft"), .string("published")] + let values: [FieldValue] = [.string(.value("draft")), .string(.value("published"))] let filter = QueryFilter.in("state", values) let components = Components.Schemas.Filter(from: filter) #expect(components.comparator == .IN) @@ -27,7 +27,7 @@ extension QueryFilterTests { Issue.record("QueryFilter is not available on this operating system.") return } - let values: [FieldValue] = [.int64(0), .int64(-1)] + let values: [FieldValue] = [.int64(.value(0)), .int64(.value(-1))] let filter = QueryFilter.notIn("errorCode", values) let components = Components.Schemas.Filter(from: filter) #expect(components.comparator == .NOT_IN) diff --git a/Tests/MistKitTests/Models/Queries/QueryFilterTests+ListMember.swift b/Tests/MistKitTests/Models/Queries/QueryFilterTests+ListMember.swift index a07413e5..5ca66312 100644 --- a/Tests/MistKitTests/Models/Queries/QueryFilterTests+ListMember.swift +++ b/Tests/MistKitTests/Models/Queries/QueryFilterTests+ListMember.swift @@ -13,7 +13,7 @@ extension QueryFilterTests { Issue.record("QueryFilter is not available on this operating system.") return } - let filter = QueryFilter.listContains("categories", .string("technology")) + let filter = QueryFilter.listContains("categories", .string(.value("technology"))) let components = Components.Schemas.Filter(from: filter) #expect(components.comparator == .LIST_CONTAINS) #expect(components.fieldName == "categories") @@ -25,7 +25,7 @@ extension QueryFilterTests { Issue.record("QueryFilter is not available on this operating system.") return } - let filter = QueryFilter.notListContains("blockedUsers", .string("user-456")) + let filter = QueryFilter.notListContains("blockedUsers", .string(.value("user-456"))) let components = Components.Schemas.Filter(from: filter) #expect(components.comparator == .NOT_LIST_CONTAINS) #expect(components.fieldName == "blockedUsers") diff --git a/Tests/MistKitTests/Models/RecordOperationEncodedSizeTests.swift b/Tests/MistKitTests/Models/RecordOperationEncodedSizeTests.swift index faecc3dd..67c56e47 100644 --- a/Tests/MistKitTests/Models/RecordOperationEncodedSizeTests.swift +++ b/Tests/MistKitTests/Models/RecordOperationEncodedSizeTests.swift @@ -47,7 +47,7 @@ internal struct RecordOperationEncodedSizeTests { internal func createReturnsPositive() throws { let operation = RecordOperation.create( recordType: "Note", - fields: ["body": .string("hello")] + fields: ["body": .string(.value("hello"))] ) #expect(try operation.encodedRecordSize() > 0) } @@ -56,11 +56,11 @@ internal struct RecordOperationEncodedSizeTests { internal func sizeScalesWithContent() throws { let small = RecordOperation.create( recordType: "Note", - fields: ["body": .string("x")] + fields: ["body": .string(.value("x"))] ) let large = RecordOperation.create( recordType: "Note", - fields: ["body": .string(String(repeating: "x", count: 10_000))] + fields: ["body": .string(.value(String(repeating: "x", count: 10_000)))] ) let smallSize = try small.encodedRecordSize() let largeSize = try large.encodedRecordSize() @@ -74,7 +74,7 @@ internal struct RecordOperationEncodedSizeTests { internal func boundaryComparisonCompiles() throws { let operation = RecordOperation.create( recordType: "Note", - fields: ["body": .string("ok")] + fields: ["body": .string(.value("ok"))] ) let size = try operation.encodedRecordSize() #expect(size <= CloudKitService.maxRecordDataBytes) diff --git a/Tests/MistKitTests/Models/Subscriptions/SubscriptionConversionTests.swift b/Tests/MistKitTests/Models/Subscriptions/SubscriptionConversionTests.swift index f0c97e91..4994d9d7 100644 --- a/Tests/MistKitTests/Models/Subscriptions/SubscriptionConversionTests.swift +++ b/Tests/MistKitTests/Models/Subscriptions/SubscriptionConversionTests.swift @@ -56,7 +56,7 @@ internal struct SubscriptionConversionTests { let info = SubscriptionInfo.query( subscriptionID: "sub-1", recordType: "Article", - filters: [.equals("published", .string("true"))], + filters: [.equals("published", .string(.value("true")))], sortBy: [.ascending("title")], firesOn: [.create, .update] ) diff --git a/Tests/MistKitTests/RecordManagement/AltTestRecord.swift b/Tests/MistKitTests/RecordManagement/AltTestRecord.swift index d38fdf73..0d49f2b6 100644 --- a/Tests/MistKitTests/RecordManagement/AltTestRecord.swift +++ b/Tests/MistKitTests/RecordManagement/AltTestRecord.swift @@ -51,6 +51,6 @@ internal struct AltTestRecord: CloudKitRecord { } internal func toCloudKitFields() -> [String: FieldValue] { - ["title": .string(title)] + ["title": .string(.value(title))] } } diff --git a/Tests/MistKitTests/RecordManagement/CloudKitRecordTests+Formatting.swift b/Tests/MistKitTests/RecordManagement/CloudKitRecordTests+Formatting.swift index 9850cc27..b17f636b 100644 --- a/Tests/MistKitTests/RecordManagement/CloudKitRecordTests+Formatting.swift +++ b/Tests/MistKitTests/RecordManagement/CloudKitRecordTests+Formatting.swift @@ -41,8 +41,8 @@ extension CloudKitRecordTests { recordName: "test-7", recordType: "TestRecord", fields: [ - "name": .string("Display Record"), - "count": .int64(99), + "name": .string(.value("Display Record")), + "count": .int64(.value(99)), ] ) diff --git a/Tests/MistKitTests/RecordManagement/CloudKitRecordTests+Parsing.swift b/Tests/MistKitTests/RecordManagement/CloudKitRecordTests+Parsing.swift index b3392962..843c6775 100644 --- a/Tests/MistKitTests/RecordManagement/CloudKitRecordTests+Parsing.swift +++ b/Tests/MistKitTests/RecordManagement/CloudKitRecordTests+Parsing.swift @@ -41,10 +41,10 @@ extension CloudKitRecordTests { recordName: "test-3", recordType: "TestRecord", fields: [ - "name": .string("Parsed Record"), - "count": .int64(25), + "name": .string(.value("Parsed Record")), + "count": .int64(.value(25)), "isActive": FieldValue(booleanValue: true), - "score": .double(75.0), + "score": .double(.value(75.0)), ] ) @@ -63,7 +63,7 @@ extension CloudKitRecordTests { recordName: "test-4", recordType: "TestRecord", fields: [ - "name": .string("Minimal Record"), + "name": .string(.value("Minimal Record")), "isActive": FieldValue(booleanValue: false), ] ) @@ -84,7 +84,7 @@ extension CloudKitRecordTests { recordName: "test-5", recordType: "TestRecord", fields: [ - "count": .int64(10) + "count": .int64(.value(10)) // Missing required "name" and "isActive" fields ] ) @@ -99,8 +99,8 @@ extension CloudKitRecordTests { recordName: "test-6", recordType: "TestRecord", fields: [ - "name": .string("Legacy Record"), - "isActive": .int64(1), // Legacy boolean as int64 + "name": .string(.value("Legacy Record")), + "isActive": .int64(.value(1)), // Legacy boolean as int64 ] ) diff --git a/Tests/MistKitTests/RecordManagement/FieldValueConvenienceTests+LegacyList.swift b/Tests/MistKitTests/RecordManagement/FieldValueConvenienceTests+LegacyList.swift new file mode 100644 index 00000000..107a3490 --- /dev/null +++ b/Tests/MistKitTests/RecordManagement/FieldValueConvenienceTests+LegacyList.swift @@ -0,0 +1,54 @@ +// +// FieldValueConvenienceTests+LegacyList.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Testing + +@testable import MistKit + +/// Protocol shim so deprecated ``FieldValue/listValue`` can be exercised without +/// `DeprecatedDeclaration` warnings (same pattern as FetchZoneChangesAPI). +internal protocol FieldValueLegacyListReading { + var listValue: [FieldValue]? { get } +} + +extension FieldValue: FieldValueLegacyListReading {} + +extension FieldValueConvenienceTests { + @Test("listValue flattens homogeneous list to [FieldValue] of .value elements") + internal func listValueExtraction() { + let value: any FieldValueLegacyListReading = FieldValue.string(.list(["one", "two"])) + #expect(value.listValue == [.string(.value("one")), .string(.value("two"))]) + } + + @Test("listValue returns nil for non-list cases") + internal func listValueReturnsNilForWrongType() { + let value: any FieldValueLegacyListReading = FieldValue.string(.value("[]")) + #expect(value.listValue == nil) + } +} diff --git a/Tests/MistKitTests/RecordManagement/FieldValueConvenienceTests+Lists.swift b/Tests/MistKitTests/RecordManagement/FieldValueConvenienceTests+Lists.swift new file mode 100644 index 00000000..3437956f --- /dev/null +++ b/Tests/MistKitTests/RecordManagement/FieldValueConvenienceTests+Lists.swift @@ -0,0 +1,51 @@ +// +// FieldValueConvenienceTests+Lists.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import Testing + +@testable import MistKit + +extension FieldValueConvenienceTests { + @Test("typed *ListValue accessors unwrap homogeneous lists") + internal func typedListValueAccessors() { + #expect(FieldValue.int64(.list([1, 2])).int64ListValue == [1, 2]) + #expect(FieldValue.double(.list([1.5])).doubleListValue == [1.5]) + let date = Date(timeIntervalSince1970: 0) + #expect(FieldValue.date(.list([date])).dateListValue == [date]) + let data = Data([0x01]) + #expect(FieldValue.bytes(.list([data])).bytesListValue == [data]) + let location = Location(latitude: 1, longitude: 2, horizontalAccuracy: 3) + #expect(FieldValue.location(.list([location])).locationListValue == [location]) + let reference = Reference(recordName: "r") + #expect(FieldValue.reference(.list([reference])).referenceListValue == [reference]) + let asset = Asset(fileChecksum: "c", size: 1, downloadURL: "https://example.com") + #expect(FieldValue.asset(.list([asset])).assetListValue == [asset]) + } +} diff --git a/Tests/MistKitTests/RecordManagement/FieldValueConvenienceTests.swift b/Tests/MistKitTests/RecordManagement/FieldValueConvenienceTests.swift index 9f412ad1..b9ee6450 100644 --- a/Tests/MistKitTests/RecordManagement/FieldValueConvenienceTests.swift +++ b/Tests/MistKitTests/RecordManagement/FieldValueConvenienceTests.swift @@ -37,57 +37,57 @@ internal import Testing internal struct FieldValueConvenienceTests { @Test("stringValue extracts String from .string case") internal func stringValueExtraction() { - let value = FieldValue.string("test") + let value = FieldValue.string(.value("test")) #expect(value.stringValue == "test") } @Test("stringValue returns nil for non-string cases") internal func stringValueReturnsNilForWrongType() { - #expect(FieldValue.int64(42).stringValue == nil) - #expect(FieldValue.double(3.14).stringValue == nil) + #expect(FieldValue.int64(.value(42)).stringValue == nil) + #expect(FieldValue.double(.value(3.14)).stringValue == nil) #expect(FieldValue(booleanValue: true).stringValue == nil) } @Test("intValue extracts Int from .int64 case") internal func intValueExtraction() { - let value = FieldValue.int64(42) + let value = FieldValue.int64(.value(42)) #expect(value.intValue == 42) } @Test("intValue returns nil for non-int cases") internal func intValueReturnsNilForWrongType() { - #expect(FieldValue.string("42").intValue == nil) - #expect(FieldValue.double(42.0).intValue == nil) + #expect(FieldValue.string(.value("42")).intValue == nil) + #expect(FieldValue.double(.value(42.0)).intValue == nil) } @Test("doubleValue extracts Double from .double case") internal func doubleValueExtraction() { - let value = FieldValue.double(3.14) + let value = FieldValue.double(.value(3.14)) #expect(value.doubleValue == 3.14) } @Test("doubleValue returns nil for non-double cases") internal func doubleValueReturnsNilForWrongType() { - #expect(FieldValue.string("3.14").doubleValue == nil) - #expect(FieldValue.int64(3).doubleValue == nil) + #expect(FieldValue.string(.value("3.14")).doubleValue == nil) + #expect(FieldValue.int64(.value(3)).doubleValue == nil) } - @Test("boolValue extracts Bool from .int64(0) as false") + @Test("boolValue extracts Bool from .int64(.value(0)) as false") internal func boolValueFromInt64Zero() { - let value = FieldValue.int64(0) + let value = FieldValue.int64(.value(0)) #expect(value.boolValue == false) } - @Test("boolValue extracts Bool from .int64(1) as true") + @Test("boolValue extracts Bool from .int64(.value(1)) as true") internal func boolValueFromInt64One() { - let value = FieldValue.int64(1) + let value = FieldValue.int64(.value(1)) #expect(value.boolValue == true) } @Test("boolValue asserts for .int64 with values other than 0 or 1") internal func boolValueAssertsForInvalidInt64() async { await confirmation("Assertion handler called", expectedCount: 1) { assertionCalled in - let value2 = FieldValue.int64(2) + let value2 = FieldValue.int64(.value(2)) let value = value2.boolValue { condition, message in assertionCalled() #expect(condition == false) @@ -99,47 +99,47 @@ internal struct FieldValueConvenienceTests { @Test("boolValue returns nil for non-boolean-compatible cases") internal func boolValueReturnsNilForWrongType() { - #expect(FieldValue.string("true").boolValue == nil) - #expect(FieldValue.double(1.0).boolValue == nil) + #expect(FieldValue.string(.value("true")).boolValue == nil) + #expect(FieldValue.double(.value(1.0)).boolValue == nil) } @Test("dateValue extracts Date from .date case") internal func dateValueExtraction() { let date = Date() - let value = FieldValue.date(date) + let value = FieldValue.date(.value(date)) #expect(value.dateValue == date) } @Test("dateValue returns nil for non-date cases") internal func dateValueReturnsNilForWrongType() { - #expect(FieldValue.string("2024-01-01").dateValue == nil) - #expect(FieldValue.int64(1_704_067_200).dateValue == nil) + #expect(FieldValue.string(.value("2024-01-01")).dateValue == nil) + #expect(FieldValue.int64(.value(1_704_067_200)).dateValue == nil) } @Test("bytesValue extracts base64 String from .bytes case") internal func bytesValueExtraction() { let data = Data("Hello World".utf8) - let value = FieldValue.bytes(data) + let value = FieldValue.bytes(.value(data)) #expect(value.bytesValue == data.base64EncodedString()) } @Test("bytesValue returns nil for non-bytes cases") internal func bytesValueReturnsNilForWrongType() { - #expect(FieldValue.string("test").bytesValue == nil) + #expect(FieldValue.string(.value("test")).bytesValue == nil) } @Test("dataValue extracts Data from .bytes case") internal func dataValueExtraction() { let data = Data("Hello World".utf8) - let value = FieldValue.bytes(data) + let value = FieldValue.bytes(.value(data)) #expect(value.dataValue == data) } @Test("dataValue returns nil for non-bytes cases including .string") internal func dataValueReturnsNilForWrongType() { - #expect(FieldValue.string("test").dataValue == nil) - #expect(FieldValue.string("SGVsbG8gV29ybGQ=").dataValue == nil) - #expect(FieldValue.string("Chen").dataValue == nil) + #expect(FieldValue.string(.value("test")).dataValue == nil) + #expect(FieldValue.string(.value("SGVsbG8gV29ybGQ=")).dataValue == nil) + #expect(FieldValue.string(.value("Chen")).dataValue == nil) } @Test("locationValue extracts Location from .location case") @@ -149,25 +149,25 @@ internal struct FieldValueConvenienceTests { longitude: -122.4194, horizontalAccuracy: 10.0 ) - let value = FieldValue.location(location) + let value = FieldValue.location(.value(location)) #expect(value.locationValue == location) } @Test("locationValue returns nil for non-location cases") internal func locationValueReturnsNilForWrongType() { - #expect(FieldValue.string("37.7749,-122.4194").locationValue == nil) + #expect(FieldValue.string(.value("37.7749,-122.4194")).locationValue == nil) } @Test("referenceValue extracts Reference from .reference case") internal func referenceValueExtraction() { let reference = Reference(recordName: "test-record") - let value = FieldValue.reference(reference) + let value = FieldValue.reference(.value(reference)) #expect(value.referenceValue == reference) } @Test("referenceValue returns nil for non-reference cases") internal func referenceValueReturnsNilForWrongType() { - #expect(FieldValue.string("test-record").referenceValue == nil) + #expect(FieldValue.string(.value("test-record")).referenceValue == nil) } @Test("assetValue extracts Asset from .asset case") @@ -177,35 +177,29 @@ internal struct FieldValueConvenienceTests { size: 1_024, downloadURL: "https://example.com/file" ) - let value = FieldValue.asset(asset) + let value = FieldValue.asset(.value(asset)) #expect(value.assetValue == asset) } @Test("assetValue returns nil for non-asset cases") internal func assetValueReturnsNilForWrongType() { - #expect(FieldValue.string("asset").assetValue == nil) + #expect(FieldValue.string(.value("asset")).assetValue == nil) } - @Test("listValue extracts [FieldValue] from .list case") - internal func listValueExtraction() { - let list: [FieldValue] = [.string("one"), .int64(2), .double(3.0)] - let value = FieldValue.list(list) - #expect(value.listValue == list) - } - - @Test("listValue returns nil for non-list cases") - internal func listValueReturnsNilForWrongType() { - #expect(FieldValue.string("[]").listValue == nil) + @Test("stringListValue extracts [String] from .string(.value(.list))") + internal func stringListValueExtraction() { + #expect(FieldValue.string(.list(["a", "b"])).stringListValue == ["a", "b"]) + #expect(FieldValue.string(.value("a")).stringListValue == nil) } @Test("Convenience extractors work in field dictionary") internal func convenienceExtractorsInDictionary() { let fields: [String: FieldValue] = [ - "name": .string("Test"), - "count": .int64(42), + "name": .string(.value("Test")), + "count": .int64(.value(42)), "enabled": FieldValue(booleanValue: true), - "legacyFlag": .int64(1), - "score": .double(98.5), + "legacyFlag": .int64(.value(1)), + "score": .double(.value(98.5)), ] #expect(fields["name"]?.stringValue == "Test") @@ -213,8 +207,6 @@ internal struct FieldValueConvenienceTests { #expect(fields["enabled"]?.boolValue == true) #expect(fields["legacyFlag"]?.boolValue == true) #expect(fields["score"]?.doubleValue == 98.5) - - // Type mismatches return nil #expect(fields["name"]?.intValue == nil) #expect(fields["count"]?.stringValue == nil) } diff --git a/Tests/MistKitTests/RecordManagement/RecordManagingTests+List.swift b/Tests/MistKitTests/RecordManagement/RecordManagingTests+List.swift index d6ed6fec..a4757ed0 100644 --- a/Tests/MistKitTests/RecordManagement/RecordManagingTests+List.swift +++ b/Tests/MistKitTests/RecordManagement/RecordManagingTests+List.swift @@ -49,8 +49,8 @@ extension RecordManagingTests { recordName: "test-1", recordType: "TestRecord", fields: [ - "name": .string("First"), - "count": .int64(1), + "name": .string(.value("First")), + "count": .int64(.value(1)), "isActive": FieldValue(booleanValue: true), ] ) diff --git a/Tests/MistKitTests/RecordManagement/RecordManagingTests+Query.swift b/Tests/MistKitTests/RecordManagement/RecordManagingTests+Query.swift index fd80d850..ff45a58c 100644 --- a/Tests/MistKitTests/RecordManagement/RecordManagingTests+Query.swift +++ b/Tests/MistKitTests/RecordManagement/RecordManagingTests+Query.swift @@ -50,8 +50,8 @@ extension RecordManagingTests { recordName: "test-1", recordType: "TestRecord", fields: [ - "name": .string("First"), - "count": .int64(10), + "name": .string(.value("First")), + "count": .int64(.value(10)), "isActive": FieldValue(booleanValue: true), ] ), @@ -59,8 +59,8 @@ extension RecordManagingTests { recordName: "test-2", recordType: "TestRecord", fields: [ - "name": .string("Second"), - "count": .int64(20), + "name": .string(.value("Second")), + "count": .int64(.value(20)), "isActive": FieldValue(booleanValue: false), ] ), @@ -95,7 +95,7 @@ extension RecordManagingTests { recordName: "test-1", recordType: "TestRecord", fields: [ - "name": .string("Active"), + "name": .string(.value("Active")), "isActive": FieldValue(booleanValue: true), ] ), @@ -103,7 +103,7 @@ extension RecordManagingTests { recordName: "test-2", recordType: "TestRecord", fields: [ - "name": .string("Inactive"), + "name": .string(.value("Inactive")), "isActive": FieldValue(booleanValue: false), ] ), @@ -111,7 +111,7 @@ extension RecordManagingTests { recordName: "test-3", recordType: "TestRecord", fields: [ - "name": .string("Also Active"), + "name": .string(.value("Also Active")), "isActive": FieldValue(booleanValue: true), ] ), @@ -143,7 +143,7 @@ extension RecordManagingTests { recordName: "test-1", recordType: "TestRecord", fields: [ - "name": .string("Valid"), + "name": .string(.value("Valid")), "isActive": FieldValue(booleanValue: true), ] ), @@ -152,14 +152,14 @@ extension RecordManagingTests { recordType: "TestRecord", fields: [ // Missing required "name" and "isActive" fields - "count": .int64(10) + "count": .int64(.value(10)) ] ), RecordInfo( recordName: "test-3", recordType: "TestRecord", fields: [ - "name": .string("Also Valid"), + "name": .string(.value("Also Valid")), "isActive": FieldValue(booleanValue: false), ] ), diff --git a/Tests/MistKitTests/RecordManagement/TestRecord.swift b/Tests/MistKitTests/RecordManagement/TestRecord.swift index 7892f967..75474138 100644 --- a/Tests/MistKitTests/RecordManagement/TestRecord.swift +++ b/Tests/MistKitTests/RecordManagement/TestRecord.swift @@ -75,17 +75,17 @@ internal struct TestRecord: CloudKitRecord { internal func toCloudKitFields() -> [String: FieldValue] { var fields: [String: FieldValue] = [ - "name": .string(name), - "count": .int64(count), + "name": .string(.value(name)), + "count": .int64(.value(count)), "isActive": FieldValue(booleanValue: isActive), ] if let score { - fields["score"] = .double(score) + fields["score"] = .double(.value(score)) } if let lastUpdated { - fields["lastUpdated"] = .date(lastUpdated) + fields["lastUpdated"] = .date(.value(lastUpdated)) } return fields diff --git a/openapi.yaml b/openapi.yaml index 31635f91..eab40113 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1556,6 +1556,8 @@ components: description: | A CloudKit field value from API responses. May include optional type field for explicit type information. + List fields carry the granular *_LIST family (e.g. STRING_LIST), matching + live CloudKit responses — not a flat LIST tag. properties: value: oneOf: @@ -1570,8 +1572,10 @@ components: - $ref: '#/components/schemas/ListValue' type: type: string - enum: [STRING, INT64, DOUBLE, BYTES, REFERENCE, ASSET, ASSETID, LOCATION, TIMESTAMP, LIST] - description: The CloudKit field type (optional, may be inferred from value) + enum: [STRING, INT64, DOUBLE, BYTES, TIMESTAMP, REFERENCE, ASSET, ASSETID, LOCATION, STRING_LIST, INT64_LIST, DOUBLE_LIST, BYTES_LIST, TIMESTAMP_LIST, REFERENCE_LIST, LOCATION_LIST, ASSET_LIST] + description: | + The CloudKit field type (optional). List responses use STRING_LIST, + INT64_LIST, etc. (verified live against MistDemo, 2026-09-09). required: - value From 88d009b9d222e87cd9bd150d9988d7e88adcc981 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Wed, 9 Sep 2026 19:57:53 +0100 Subject: [PATCH 3/4] test(#481): cover homogeneous list conversions; gate Win 6.2 tip-over MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand list request/response/accessor tests so Codecov patch/project clear the #481 rewrite, and omit those bodies on Windows × Swift 6.2 to stay under the MistKitTests emit abort. Co-authored-by: Cursor --- .../FieldValueConversionTests+Lists.swift | 790 ++++++++++++++---- ...ieldValueConvenienceTests+LegacyList.swift | 56 +- .../FieldValueConvenienceTests+Lists.swift | 38 +- 3 files changed, 719 insertions(+), 165 deletions(-) diff --git a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+Lists.swift b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+Lists.swift index 44a8c95e..04790d1c 100644 --- a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+Lists.swift +++ b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+Lists.swift @@ -1,102 +1,372 @@ +// +// FieldValueConversionTests+Lists.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + internal import Foundation internal import MistKitOpenAPI internal import Testing @testable import MistKit +// swiftlint:disable file_length type_body_length extension FieldValueConversionTests { - @Suite("List Conversions") + /// Homogeneous-list request/response conversions (issue #481). + /// + /// Bodies are omitted on Windows × Swift 6.2 to stay under the MistKitTests + /// emit tip-over (see `.claude/memory/reference_windows_62_mistkittests_emit_abort.md`). + @Suite("List Conversions", .disabled(if: Platform.isWindowsSwift62)) internal struct Lists { + private static let windowsTipOverMessage = + "Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over)." + + private static func withSuppressedConversionAssert( + _ body: () throws -> Void + ) rethrows { + try ConversionFailureReporter.$assertionHandler.withValue( + { _, _, _ in }, + operation: body + ) + } + + // MARK: - Request encoding (*_LIST tags) + @Test("Convert STRING_LIST FieldValue with strings tags STRING_LIST") internal func convertListWithStrings() { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("FieldValue is not available on this operating system.") - return - } - let fieldValue = FieldValue.string(.list(["one", "two", "three"])) - let components = Components.Schemas.FieldValueRequest(from: fieldValue) + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + let fieldValue = FieldValue.string(.list(["one", "two", "three"])) + let components = Components.Schemas.FieldValueRequest(from: fieldValue) - #expect(components._type == .STRING_LIST) - if case .ListValue(let values) = components.value { - #expect(values.count == 3) - } else { - Issue.record("Expected listValue") - } + #expect(components._type == .STRING_LIST) + if case .ListValue(let values) = components.value { + #expect(values.count == 3) + } else { + Issue.record("Expected listValue") + } + #else + Issue.record(Self.windowsTipOverMessage) + #endif } @Test("Convert INT64_LIST FieldValue with numbers tags INT64_LIST") internal func convertListWithNumbers() { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("FieldValue is not available on this operating system.") - return - } - let fieldValue = FieldValue.int64(.list([1, 2, 3])) - let components = Components.Schemas.FieldValueRequest(from: fieldValue) + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + let fieldValue = FieldValue.int64(.list([1, 2, 3])) + let components = Components.Schemas.FieldValueRequest(from: fieldValue) - #expect(components._type == .INT64_LIST) - if case .ListValue(let values) = components.value { - #expect(values.count == 3) - } else { - Issue.record("Expected listValue") - } + #expect(components._type == .INT64_LIST) + if case .ListValue(let values) = components.value { + #expect(values.count == 3) + } else { + Issue.record("Expected listValue") + } + #else + Issue.record(Self.windowsTipOverMessage) + #endif + } + + @Test("Convert DOUBLE_LIST FieldValue tags DOUBLE_LIST") + internal func convertDoubleList() { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + let components = Components.Schemas.FieldValueRequest( + from: .double(.list([1.5, 2.25])) + ) + #expect(components._type == .DOUBLE_LIST) + guard case .ListValue(let values) = components.value else { + Issue.record("Expected listValue") + return + } + #expect(values.count == 2) + #else + Issue.record(Self.windowsTipOverMessage) + #endif + } + + @Test("Convert BYTES_LIST FieldValue tags BYTES_LIST") + internal func convertBytesList() { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + let payload = Data("hello".utf8) + let components = Components.Schemas.FieldValueRequest( + from: .bytes(.list([payload])) + ) + #expect(components._type == .BYTES_LIST) + guard case .ListValue(let values) = components.value, + case .BytesValue(let encoded) = values.first + else { + Issue.record("Expected BytesValue list element") + return + } + #expect(encoded == payload.base64EncodedString()) + #else + Issue.record(Self.windowsTipOverMessage) + #endif } @Test("Convert empty STRING_LIST tags STRING_LIST") internal func convertEmptyList() { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("FieldValue is not available on this operating system.") - return - } - let fieldValue = FieldValue.string(.list([])) - let components = Components.Schemas.FieldValueRequest(from: fieldValue) + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + let fieldValue = FieldValue.string(.list([])) + let components = Components.Schemas.FieldValueRequest(from: fieldValue) + + #expect(components._type == .STRING_LIST) + if case .ListValue(let values) = components.value { + #expect(values.isEmpty) + } else { + Issue.record("Expected listValue") + } + #else + Issue.record(Self.windowsTipOverMessage) + #endif + } + + @Test("Convert REFERENCE_LIST FieldValue tags REFERENCE_LIST") + internal func convertReferenceList() { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + let references = [ + Reference(recordName: "a", action: .deleteSelf), + Reference(recordName: "b", action: Reference.Action.none), + Reference(recordName: "c", action: .validate), + Reference(recordName: "d"), + ] + let components = Components.Schemas.FieldValueRequest( + from: .reference(.list(references)) + ) + #expect(components._type == .REFERENCE_LIST) + guard case .ListValue(let values) = components.value else { + Issue.record("Expected listValue") + return + } + #expect(values.count == 4) + #else + Issue.record(Self.windowsTipOverMessage) + #endif + } - #expect(components._type == .STRING_LIST) - if case .ListValue(let values) = components.value { - #expect(values.isEmpty) - } else { - Issue.record("Expected listValue") - } + @Test("Convert ASSET_LIST FieldValue tags ASSET_LIST") + internal func convertAssetList() { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + let asset = Asset(fileChecksum: "c", size: 1, downloadURL: "https://example.com") + let components = Components.Schemas.FieldValueRequest( + from: .asset(.list([asset])) + ) + #expect(components._type == .ASSET_LIST) + guard case .ListValue(let values) = components.value, + case .AssetValue(let value) = values.first + else { + Issue.record("Expected AssetValue list element") + return + } + #expect(value.fileChecksum == "c") + #else + Issue.record(Self.windowsTipOverMessage) + #endif } + /// A fractional millisecond inside a list must be rounded, exactly as the scalar + /// `.date` case is. CloudKit rejects a fractional TIMESTAMP with + /// `BAD_REQUEST "Invalid value, expected type TIMESTAMP"`. + @Test("List .date elements round to whole milliseconds") + internal func convertListWithDatesRoundsMilliseconds() { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + let date = Date(timeIntervalSince1970: 1_747_999_812.3478923) + let components = Components.Schemas.FieldValueRequest( + from: .date(.list([date])) + ) + + #expect(components._type == .TIMESTAMP_LIST) + guard case .ListValue(let values) = components.value, let first = values.first else { + Issue.record("Expected a ListValue with one element") + return + } + guard case .DateValue(let milliseconds) = first else { + Issue.record("Expected a DateValue element") + return + } + #expect(milliseconds == 1_747_999_812_348) + #expect(milliseconds == milliseconds.rounded()) + #else + Issue.record(Self.windowsTipOverMessage) + #endif + } + + /// `Location.timestamp` nested inside a list element is a second millisecond field + /// under the same constraint. + @Test("List .location elements round their timestamp to whole milliseconds") + internal func convertListWithLocationRoundsTimestamp() { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + let location = Location( + latitude: 37.7749, + longitude: -122.4194, + timestamp: Date(timeIntervalSince1970: 1_747_999_812.3478923) + ) + let components = Components.Schemas.FieldValueRequest( + from: .location(.list([location])) + ) + + #expect(components._type == .LOCATION_LIST) + guard case .ListValue(let values) = components.value, let first = values.first else { + Issue.record("Expected a ListValue with one element") + return + } + guard case .LocationValue(let locationValue) = first else { + Issue.record("Expected a LocationValue element") + return + } + guard let timestamp = locationValue.timestamp else { + Issue.record("Expected a timestamp on the LocationValue") + return + } + #expect(timestamp == 1_747_999_812_348) + #expect(timestamp == timestamp.rounded()) + #else + Issue.record(Self.windowsTipOverMessage) + #endif + } + + // MARK: - Tagged response decoding + @Test("STRING_LIST response empty array decodes as .string(.list([]))") internal func decodeEmptyStringList() throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("FieldValue is not available on this operating system.") - return - } - let data = Data(#"{"value": [], "type": "STRING_LIST"}"#.utf8) - let response = try JSONDecoder().decode( - Components.Schemas.FieldValueResponse.self, - from: data - ) - let value = try FieldValue(response, fieldName: "tags") - #expect(value == .string(.list([]))) + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + let data = Data(#"{"value": [], "type": "STRING_LIST"}"#.utf8) + let response = try JSONDecoder().decode( + Components.Schemas.FieldValueResponse.self, + from: data + ) + let value = try FieldValue(response, fieldName: "tags") + #expect(value == .string(.list([]))) + #else + Issue.record(Self.windowsTipOverMessage) + #endif } - @Test("STRING_LIST response filled array decodes as .string(.value(.list))") + @Test("STRING_LIST response filled array decodes as .string(.list)") internal func decodeFilledStringList() throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("FieldValue is not available on this operating system.") - return - } - let data = Data(#"{"value": ["a", "b"], "type": "STRING_LIST"}"#.utf8) - let response = try JSONDecoder().decode( - Components.Schemas.FieldValueResponse.self, - from: data - ) - let value = try FieldValue(response, fieldName: "tags") - #expect(value == .string(.list(["a", "b"]))) + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + let data = Data(#"{"value": ["a", "b"], "type": "STRING_LIST"}"#.utf8) + let response = try JSONDecoder().decode( + Components.Schemas.FieldValueResponse.self, + from: data + ) + let value = try FieldValue(response, fieldName: "tags") + #expect(value == .string(.list(["a", "b"]))) + #else + Issue.record(Self.windowsTipOverMessage) + #endif + } + + @Test("INT64_LIST response decodes as .int64(.list)") + internal func decodeInt64List() throws { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + let value = try FieldValue( + listValue: [.Int64Value(1), .Int64Value(2)], + elementKind: .int64, + fieldName: "nums" + ) + #expect(value == .int64(.list([1, 2]))) + #else + Issue.record(Self.windowsTipOverMessage) + #endif + } + + @Test("DOUBLE_LIST accepts DoubleValue and Int64Value elements") + internal func decodeDoubleListCoercingInt64() throws { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + let value = try FieldValue( + listValue: [.DoubleValue(1.5), .Int64Value(3)], + elementKind: .double, + fieldName: "scores" + ) + #expect(value == .double(.list([1.5, 3.0]))) + #else + Issue.record(Self.windowsTipOverMessage) + #endif } @Test("BYTES_LIST element that is not valid base64 throws typeValueMismatch") internal func malformedBytesListElementThrows() { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("FieldValue is not available on this operating system.") - return - } - ConversionFailureReporter.$assertionHandler.withValue( - { _, _, _ in }, - operation: { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + Self.withSuppressedConversionAssert { #expect( throws: ConversionError.typeValueMismatch( fieldName: "field", @@ -111,93 +381,225 @@ extension FieldValueConversionTests { ) } } - ) + #else + Issue.record(Self.windowsTipOverMessage) + #endif } - @Test("BYTES_LIST element with valid base64 reads as .bytes(.value(.list))") + @Test("BYTES_LIST accepts BytesValue and StringValue base64 elements") internal func validBytesListElement() throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("FieldValue is not available on this operating system.") - return - } - let value = try FieldValue( - listValue: [.BytesValue("aGVsbG8=")], - elementKind: .bytes, - fieldName: "field" - ) - #expect(value == .bytes(.list([Data("hello".utf8)]))) + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + let hello = Data("hello".utf8) + let value = try FieldValue( + listValue: [ + .BytesValue("aGVsbG8="), + .StringValue(hello.base64EncodedString()), + ], + elementKind: .bytes, + fieldName: "field" + ) + #expect(value == .bytes(.list([hello, hello]))) + #else + Issue.record(Self.windowsTipOverMessage) + #endif } - /// A fractional millisecond inside a list must be rounded, exactly as the scalar - /// `.date` case is. CloudKit rejects a fractional TIMESTAMP with - /// `BAD_REQUEST "Invalid value, expected type TIMESTAMP"`. - @Test("List .date elements round to whole milliseconds") - internal func convertListWithDatesRoundsMilliseconds() { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("FieldValue is not available on this operating system.") - return - } - let date = Date(timeIntervalSince1970: 1_747_999_812.3478923) - let components = Components.Schemas.FieldValueRequest( - from: .date(.list([date])) - ) + @Test("TIMESTAMP_LIST accepts DateValue, Int64Value, and DoubleValue elements") + internal func decodeTimestampListNumericShapes() throws { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + let value = try FieldValue( + listValue: [ + .DateValue(1_000), + .Int64Value(2_000), + .DoubleValue(3_000), + ], + elementKind: .date, + fieldName: "dates" + ) + #expect( + value + == .date( + .list([ + Date(timeIntervalSince1970: 1), + Date(timeIntervalSince1970: 2), + Date(timeIntervalSince1970: 3), + ]) + ) + ) + #else + Issue.record(Self.windowsTipOverMessage) + #endif + } + + @Test("LOCATION_LIST / REFERENCE_LIST / ASSET_LIST decode matching payloads") + internal func decodeComplexLists() throws { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + let locationPayload = Components.Schemas.LocationValue( + latitude: 1, + longitude: 2 + ) + let locationValue = try FieldValue( + listValue: [.LocationValue(locationPayload)], + elementKind: .location, + fieldName: "locs" + ) + #expect(locationValue.locationListValue?.first?.latitude == 1) - #expect(components._type == .TIMESTAMP_LIST) - guard case .ListValue(let values) = components.value, let first = values.first else { - Issue.record("Expected a ListValue with one element") - return - } - guard case .DateValue(let milliseconds) = first else { - Issue.record("Expected a DateValue element") - return - } - #expect(milliseconds == 1_747_999_812_348) - #expect(milliseconds == milliseconds.rounded()) + let referenceValue = try FieldValue( + listValue: [ + .ReferenceValue(.init(recordName: "r1", action: .DELETE_SELF)) + ], + elementKind: .reference, + fieldName: "refs" + ) + #expect(referenceValue.referenceListValue?.first?.recordName == "r1") + + let assetValue = try FieldValue( + listValue: [ + .AssetValue( + .init(fileChecksum: "chk", size: 9, downloadURL: "https://example.com") + ) + ], + elementKind: .asset, + fieldName: "assets" + ) + #expect(assetValue.assetListValue?.first?.fileChecksum == "chk") + #else + Issue.record(Self.windowsTipOverMessage) + #endif } - /// `Location.timestamp` nested inside a list element is a second millisecond field - /// under the same constraint. - @Test("List .location elements round their timestamp to whole milliseconds") - internal func convertListWithLocationRoundsTimestamp() { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("FieldValue is not available on this operating system.") - return - } - let location = Location( - latitude: 37.7749, - longitude: -122.4194, - timestamp: Date(timeIntervalSince1970: 1_747_999_812.3478923) - ) - let components = Components.Schemas.FieldValueRequest( - from: .location(.list([location])) - ) + // MARK: - Untagged inference + mismatches - #expect(components._type == .LOCATION_LIST) - guard case .ListValue(let values) = components.value, let first = values.first else { - Issue.record("Expected a ListValue with one element") - return - } - guard case .LocationValue(let locationValue) = first else { - Issue.record("Expected a LocationValue element") - return - } - guard let timestamp = locationValue.timestamp else { - Issue.record("Expected a timestamp on the LocationValue") - return - } - #expect(timestamp == 1_747_999_812_348) - #expect(timestamp == timestamp.rounded()) + @Test("Empty untagged list becomes .string(.list([]))") + internal func emptyUntaggedListDefaultsToString() throws { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + let value = try FieldValue( + listValue: [], + elementKind: nil, + fieldName: "empty" + ) + #expect(value == .string(.list([]))) + #else + Issue.record(Self.windowsTipOverMessage) + #endif + } + + @Test("Untagged list infers element kind from the first payload") + internal func untaggedListInfersKind() throws { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + #expect( + try FieldValue( + listValue: [.StringValue("a")], + elementKind: nil, + fieldName: "s" + ) == .string(.list(["a"])) + ) + #expect( + try FieldValue( + listValue: [.Int64Value(9)], + elementKind: nil, + fieldName: "i" + ) == .int64(.list([9])) + ) + #expect( + try FieldValue( + listValue: [.DoubleValue(1.25)], + elementKind: nil, + fieldName: "d" + ) == .double(.list([1.25])) + ) + #expect( + try FieldValue( + listValue: [.BytesValue("aGVsbG8=")], + elementKind: nil, + fieldName: "b" + ) == .bytes(.list([Data("hello".utf8)])) + ) + #expect( + try FieldValue( + listValue: [.DateValue(5_000)], + elementKind: nil, + fieldName: "t" + ) == .date(.list([Date(timeIntervalSince1970: 5)])) + ) + #expect( + try FieldValue( + listValue: [.LocationValue(.init(latitude: 3, longitude: 4))], + elementKind: nil, + fieldName: "l" + ).locationListValue?.first?.longitude == 4 + ) + #expect( + try FieldValue( + listValue: [.ReferenceValue(.init(recordName: "x"))], + elementKind: nil, + fieldName: "r" + ).referenceListValue?.first?.recordName == "x" + ) + #expect( + try FieldValue( + listValue: [ + .AssetValue(.init(fileChecksum: "z", size: 1, downloadURL: "https://e.com")) + ], + elementKind: nil, + fieldName: "a" + ).assetListValue?.first?.fileChecksum == "z" + ) + #else + Issue.record(Self.windowsTipOverMessage) + #endif + } + + @Test("Nested ListValue payload is unmappable") + internal func nestedListValueThrows() { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + Self.withSuppressedConversionAssert { + #expect(throws: ConversionError.self) { + _ = try FieldValue( + listValue: [.ListValue([.StringValue("nested")])], + elementKind: nil, + fieldName: "bad" + ) + } + } + #else + Issue.record(Self.windowsTipOverMessage) + #endif } @Test("STRING_LIST tag over non-list value throws typeValueMismatch") internal func stringListOverScalarThrows() { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("FieldValue is not available on this operating system.") - return - } - ConversionFailureReporter.$assertionHandler.withValue( - { _, _, _ in }, - operation: { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + Self.withSuppressedConversionAssert { #expect(throws: ConversionError.self) { let data = Data(#"{"value": "plain", "type": "STRING_LIST"}"#.utf8) let response = try JSONDecoder().decode( @@ -207,7 +609,111 @@ extension FieldValueConversionTests { _ = try FieldValue(response, fieldName: "field") } } - ) + #else + Issue.record(Self.windowsTipOverMessage) + #endif + } + + @Test("Element kind mismatch throws typeValueMismatch for each require*") + internal func elementKindMismatchThrows() { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + // STRING is accepted by BYTES_LIST (wire base64), so use a numeric wrong + // payload for the bytes mismatch and a string wrong payload for the rest. + Self.withSuppressedConversionAssert { + #expect(throws: ConversionError.self) { + _ = try FieldValue( + listValue: [.Int64Value(1)], + elementKind: .string, + fieldName: "field" + ) + } + #expect(throws: ConversionError.self) { + _ = try FieldValue( + listValue: [.StringValue("nope")], + elementKind: .int64, + fieldName: "field" + ) + } + #expect(throws: ConversionError.self) { + _ = try FieldValue( + listValue: [.StringValue("nope")], + elementKind: .double, + fieldName: "field" + ) + } + #expect(throws: ConversionError.self) { + _ = try FieldValue( + listValue: [.Int64Value(1)], + elementKind: .bytes, + fieldName: "field" + ) + } + #expect(throws: ConversionError.self) { + _ = try FieldValue( + listValue: [.StringValue("nope")], + elementKind: .date, + fieldName: "field" + ) + } + #expect(throws: ConversionError.self) { + _ = try FieldValue( + listValue: [.StringValue("nope")], + elementKind: .location, + fieldName: "field" + ) + } + #expect(throws: ConversionError.self) { + _ = try FieldValue( + listValue: [.StringValue("nope")], + elementKind: .reference, + fieldName: "field" + ) + } + #expect(throws: ConversionError.self) { + _ = try FieldValue( + listValue: [.StringValue("nope")], + elementKind: .asset, + fieldName: "field" + ) + } + } + #else + Issue.record(Self.windowsTipOverMessage) + #endif + } + + @Test("Homogeneous list JSON Codable round-trips for non-bytes kinds") + internal func listCodableRoundTrip() throws { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + let cases: [FieldValue] = [ + .string(.list(["a", "b"])), + .int64(.list([1, 2])), + .double(.list([1.5, 2.5])), + .location(.list([Location(latitude: 1, longitude: 2)])), + .reference(.list([Reference(recordName: "r")])), + .asset( + .list([ + Asset(fileChecksum: "c", size: 1, downloadURL: "https://example.com") + ]) + ), + ] + for value in cases { + let data = try JSONEncoder().encode(value) + let decoded = try JSONDecoder().decode(FieldValue.self, from: data) + #expect(decoded == value) + } + #else + Issue.record(Self.windowsTipOverMessage) + #endif } } } +// swiftlint:enable file_length type_body_length diff --git a/Tests/MistKitTests/RecordManagement/FieldValueConvenienceTests+LegacyList.swift b/Tests/MistKitTests/RecordManagement/FieldValueConvenienceTests+LegacyList.swift index 107a3490..b95e7b42 100644 --- a/Tests/MistKitTests/RecordManagement/FieldValueConvenienceTests+LegacyList.swift +++ b/Tests/MistKitTests/RecordManagement/FieldValueConvenienceTests+LegacyList.swift @@ -27,6 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // +internal import Foundation internal import Testing @testable import MistKit @@ -40,15 +41,52 @@ internal protocol FieldValueLegacyListReading { extension FieldValue: FieldValueLegacyListReading {} extension FieldValueConvenienceTests { - @Test("listValue flattens homogeneous list to [FieldValue] of .value elements") - internal func listValueExtraction() { - let value: any FieldValueLegacyListReading = FieldValue.string(.list(["one", "two"])) - #expect(value.listValue == [.string(.value("one")), .string(.value("two"))]) - } + @Suite("Legacy listValue", .disabled(if: Platform.isWindowsSwift62)) + internal struct LegacyList { + @Test("listValue flattens every homogeneous list kind to [FieldValue] of .value") + internal func listValueExtraction() { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + let string: any FieldValueLegacyListReading = FieldValue.string(.list(["one", "two"])) + #expect(string.listValue == [.string(.value("one")), .string(.value("two"))]) + + let ints: any FieldValueLegacyListReading = FieldValue.int64(.list([1, 2])) + #expect(ints.listValue == [.int64(.value(1)), .int64(.value(2))]) + + let doubles: any FieldValueLegacyListReading = FieldValue.double(.list([1.5])) + #expect(doubles.listValue == [.double(.value(1.5))]) + + let date = Date(timeIntervalSince1970: 0) + let dates: any FieldValueLegacyListReading = FieldValue.date(.list([date])) + #expect(dates.listValue == [.date(.value(date))]) + + let data = Data([0x01]) + let bytes: any FieldValueLegacyListReading = FieldValue.bytes(.list([data])) + #expect(bytes.listValue == [.bytes(.value(data))]) + + let location = Location(latitude: 1, longitude: 2) + let locations: any FieldValueLegacyListReading = FieldValue.location(.list([location])) + #expect(locations.listValue == [.location(.value(location))]) + + let reference = Reference(recordName: "r") + let references: any FieldValueLegacyListReading = FieldValue.reference(.list([reference])) + #expect(references.listValue == [.reference(.value(reference))]) + + let asset = Asset(fileChecksum: "c", size: 1, downloadURL: "https://example.com") + let assets: any FieldValueLegacyListReading = FieldValue.asset(.list([asset])) + #expect(assets.listValue == [.asset(.value(asset))]) + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif + } - @Test("listValue returns nil for non-list cases") - internal func listValueReturnsNilForWrongType() { - let value: any FieldValueLegacyListReading = FieldValue.string(.value("[]")) - #expect(value.listValue == nil) + @Test("listValue returns nil for non-list cases") + internal func listValueReturnsNilForWrongType() { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + let value: any FieldValueLegacyListReading = FieldValue.string(.value("[]")) + #expect(value.listValue == nil) + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif + } } } diff --git a/Tests/MistKitTests/RecordManagement/FieldValueConvenienceTests+Lists.swift b/Tests/MistKitTests/RecordManagement/FieldValueConvenienceTests+Lists.swift index 3437956f..c2b1ec71 100644 --- a/Tests/MistKitTests/RecordManagement/FieldValueConvenienceTests+Lists.swift +++ b/Tests/MistKitTests/RecordManagement/FieldValueConvenienceTests+Lists.swift @@ -33,19 +33,29 @@ internal import Testing @testable import MistKit extension FieldValueConvenienceTests { - @Test("typed *ListValue accessors unwrap homogeneous lists") - internal func typedListValueAccessors() { - #expect(FieldValue.int64(.list([1, 2])).int64ListValue == [1, 2]) - #expect(FieldValue.double(.list([1.5])).doubleListValue == [1.5]) - let date = Date(timeIntervalSince1970: 0) - #expect(FieldValue.date(.list([date])).dateListValue == [date]) - let data = Data([0x01]) - #expect(FieldValue.bytes(.list([data])).bytesListValue == [data]) - let location = Location(latitude: 1, longitude: 2, horizontalAccuracy: 3) - #expect(FieldValue.location(.list([location])).locationListValue == [location]) - let reference = Reference(recordName: "r") - #expect(FieldValue.reference(.list([reference])).referenceListValue == [reference]) - let asset = Asset(fileChecksum: "c", size: 1, downloadURL: "https://example.com") - #expect(FieldValue.asset(.list([asset])).assetListValue == [asset]) + @Suite("List Accessors", .disabled(if: Platform.isWindowsSwift62)) + internal struct ListAccessors { + @Test("typed *ListValue accessors unwrap homogeneous lists") + internal func typedListValueAccessors() { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + #expect(FieldValue.string(.list(["a"])).stringListValue == ["a"]) + #expect(FieldValue.string(.value("a")).stringListValue == nil) + #expect(FieldValue.int64(.list([1, 2])).int64ListValue == [1, 2]) + #expect(FieldValue.double(.list([1.5])).doubleListValue == [1.5]) + let date = Date(timeIntervalSince1970: 0) + #expect(FieldValue.date(.list([date])).dateListValue == [date]) + let data = Data([0x01]) + #expect(FieldValue.bytes(.list([data])).bytesListValue == [data]) + let location = Location(latitude: 1, longitude: 2, horizontalAccuracy: 3) + #expect(FieldValue.location(.list([location])).locationListValue == [location]) + let reference = Reference(recordName: "r") + #expect(FieldValue.reference(.list([reference])).referenceListValue == [reference]) + let asset = Asset(fileChecksum: "c", size: 1, downloadURL: "https://example.com") + #expect(FieldValue.asset(.list([asset])).assetListValue == [asset]) + #expect(FieldValue.string(.value("x")).assetListValue == nil) + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif + } } } From 57a407afffc845ad03889367338f2eb468f5794d Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Wed, 9 Sep 2026 20:47:36 +0100 Subject: [PATCH 4/4] fix(#481): use Comment literals for Win 6.2 Issue.record stubs String variables do not satisfy Issue.record's Comment parameter on Windows Swift 6.2; also silence function/closure body length on the expanded list conversion suite. Co-authored-by: Cursor --- .../FieldValueConversionTests+Lists.swift | 53 +++++++++---------- 1 file changed, 25 insertions(+), 28 deletions(-) diff --git a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+Lists.swift b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+Lists.swift index 04790d1c..baa33fa0 100644 --- a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+Lists.swift +++ b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+Lists.swift @@ -33,7 +33,7 @@ internal import Testing @testable import MistKit -// swiftlint:disable file_length type_body_length +// swiftlint:disable file_length type_body_length function_body_length closure_body_length extension FieldValueConversionTests { /// Homogeneous-list request/response conversions (issue #481). /// @@ -41,9 +41,6 @@ extension FieldValueConversionTests { /// emit tip-over (see `.claude/memory/reference_windows_62_mistkittests_emit_abort.md`). @Suite("List Conversions", .disabled(if: Platform.isWindowsSwift62)) internal struct Lists { - private static let windowsTipOverMessage = - "Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over)." - private static func withSuppressedConversionAssert( _ body: () throws -> Void ) rethrows { @@ -72,7 +69,7 @@ extension FieldValueConversionTests { Issue.record("Expected listValue") } #else - Issue.record(Self.windowsTipOverMessage) + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") #endif } @@ -93,7 +90,7 @@ extension FieldValueConversionTests { Issue.record("Expected listValue") } #else - Issue.record(Self.windowsTipOverMessage) + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") #endif } @@ -114,7 +111,7 @@ extension FieldValueConversionTests { } #expect(values.count == 2) #else - Issue.record(Self.windowsTipOverMessage) + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") #endif } @@ -138,7 +135,7 @@ extension FieldValueConversionTests { } #expect(encoded == payload.base64EncodedString()) #else - Issue.record(Self.windowsTipOverMessage) + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") #endif } @@ -159,7 +156,7 @@ extension FieldValueConversionTests { Issue.record("Expected listValue") } #else - Issue.record(Self.windowsTipOverMessage) + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") #endif } @@ -186,7 +183,7 @@ extension FieldValueConversionTests { } #expect(values.count == 4) #else - Issue.record(Self.windowsTipOverMessage) + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") #endif } @@ -210,7 +207,7 @@ extension FieldValueConversionTests { } #expect(value.fileChecksum == "c") #else - Issue.record(Self.windowsTipOverMessage) + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") #endif } @@ -241,7 +238,7 @@ extension FieldValueConversionTests { #expect(milliseconds == 1_747_999_812_348) #expect(milliseconds == milliseconds.rounded()) #else - Issue.record(Self.windowsTipOverMessage) + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") #endif } @@ -279,7 +276,7 @@ extension FieldValueConversionTests { #expect(timestamp == 1_747_999_812_348) #expect(timestamp == timestamp.rounded()) #else - Issue.record(Self.windowsTipOverMessage) + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") #endif } @@ -300,7 +297,7 @@ extension FieldValueConversionTests { let value = try FieldValue(response, fieldName: "tags") #expect(value == .string(.list([]))) #else - Issue.record(Self.windowsTipOverMessage) + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") #endif } @@ -319,7 +316,7 @@ extension FieldValueConversionTests { let value = try FieldValue(response, fieldName: "tags") #expect(value == .string(.list(["a", "b"]))) #else - Issue.record(Self.windowsTipOverMessage) + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") #endif } @@ -337,7 +334,7 @@ extension FieldValueConversionTests { ) #expect(value == .int64(.list([1, 2]))) #else - Issue.record(Self.windowsTipOverMessage) + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") #endif } @@ -355,7 +352,7 @@ extension FieldValueConversionTests { ) #expect(value == .double(.list([1.5, 3.0]))) #else - Issue.record(Self.windowsTipOverMessage) + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") #endif } @@ -382,7 +379,7 @@ extension FieldValueConversionTests { } } #else - Issue.record(Self.windowsTipOverMessage) + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") #endif } @@ -404,7 +401,7 @@ extension FieldValueConversionTests { ) #expect(value == .bytes(.list([hello, hello]))) #else - Issue.record(Self.windowsTipOverMessage) + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") #endif } @@ -435,7 +432,7 @@ extension FieldValueConversionTests { ) ) #else - Issue.record(Self.windowsTipOverMessage) + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") #endif } @@ -477,7 +474,7 @@ extension FieldValueConversionTests { ) #expect(assetValue.assetListValue?.first?.fileChecksum == "chk") #else - Issue.record(Self.windowsTipOverMessage) + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") #endif } @@ -497,7 +494,7 @@ extension FieldValueConversionTests { ) #expect(value == .string(.list([]))) #else - Issue.record(Self.windowsTipOverMessage) + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") #endif } @@ -567,7 +564,7 @@ extension FieldValueConversionTests { ).assetListValue?.first?.fileChecksum == "z" ) #else - Issue.record(Self.windowsTipOverMessage) + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") #endif } @@ -588,7 +585,7 @@ extension FieldValueConversionTests { } } #else - Issue.record(Self.windowsTipOverMessage) + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") #endif } @@ -610,7 +607,7 @@ extension FieldValueConversionTests { } } #else - Issue.record(Self.windowsTipOverMessage) + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") #endif } @@ -682,7 +679,7 @@ extension FieldValueConversionTests { } } #else - Issue.record(Self.windowsTipOverMessage) + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") #endif } @@ -711,9 +708,9 @@ extension FieldValueConversionTests { #expect(decoded == value) } #else - Issue.record(Self.windowsTipOverMessage) + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") #endif } } } -// swiftlint:enable file_length type_body_length +// swiftlint:enable file_length type_body_length function_body_length closure_body_length