Push Notifications & Subscriptions epic (#379)#381
Conversation
Add curated CloudKitService support for the five CloudKit Web Services push-notification endpoints, plus MistDemo CLI, integration-phase, and web-server wiring. MistKit core (#49/#50/#51/#52/#53): - listSubscriptions / lookupSubscriptions / modifySubscriptions (+ create/ delete convenience) and createAPNsToken / registerAPNsToken - New domain types under Models/Subscriptions and Models/Tokens; errors route through CloudKitServiceError; CloudKitResponseType conformances per op - openapi.yaml: promote the records/query inline query shape into a shared named `Query` schema, referenced from both records/query and Subscription.query, so query subscriptions reuse the same query model; regenerated MistKitOpenAPI MistDemo: - Real list/lookup/create-token/register-token commands + new modify-subscriptions command; SubscriptionRoundtripPhase + TokenRoundtripPhase wired into the private pipeline - Web server: WebBackend methods + routes for subscriptions/* and tokens/*, replacing the 501 pending stubs; tokens.js feeds the minted token into register Native app left informational (per #52/#53). Adds unit + service + web-route tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…p ci]
Surfaced by running the live MistDemo integration suite against a real
container.
- modifySubscriptions: CloudKit echoes a deleted subscription as a bare
{ subscriptionID } with no subscriptionType. Filter those deletion
acknowledgements instead of fatal-erroring in SubscriptionInfo(from:).
- tokens/create + tokens/register: the endpoints are container-scoped
(/database/{version}/{container}/{environment}/tokens/...) with no
{database} segment. Corrected openapi.yaml + regenerated; added
ContainerOperationInputPath for the database-less path init. (The live
endpoints still return 405 server-side — tracked in #379.)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Cross-checked against Apple's authoritative reference:
- developer.apple.com/library/archive/.../CreateTokens.html
- developer.apple.com/library/archive/.../RegisterTokens.html
Two real spec bugs surfaced, independent of the ongoing 405 mystery:
1. tokens/register was missing apnsEnvironment in the request body.
Apple lists both apnsEnvironment + apnsToken as Required. Updated
openapi.yaml; registerAPNsToken now takes (token, environment, db).
2. TokenResponse shape was wrong. Apple returns { apnsEnvironment,
apnsToken, webcourierURL } (long-poll URL for browser/SW callers).
Ours had a speculative `webcAuthToken` and no webcourierURL.
Reshaped TokenResponse + APNsTokenResult; webcourierURL is now a
URL on the domain type, environment is echoed back.
Regenerated MistKitOpenAPI; updated CLI (--apns-environment flag on
register-token), MistDemo integration phase, web routes + JS panel,
and tests. 498 MistKit + 941 MistDemo unit tests pass; lint clean.
Live test-private still fails at Phase 16 (TokenRoundtripPhase) —
createAPNsToken returns 405 before we ever reach the corrected
registerAPNsToken. Skip CI: integration scope didn't change, and
the live live-suite failure is by design pending the next diagnostic
pass (see #379 comment).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CloudKit JS calls setApiModuleName("device") on token requests, producing
POST /device/1/{container}/{environment}/tokens/{create,register}. The
archived REST reference documents these under /database/... but the live
service only routes OPTIONS there, hence the persistent 405 with
Allow: OPTIONS. Switching to /device/ unblocks server-side calls.
Also surfaces clientId on both request bodies (CloudKit JS always sends
one, defaulting to a UUID) and exposes it as an optional caller-supplied
parameter so callers can pin both halves of the round-trip to one
logical client.
Live test-private phase 16 (createToken+registerToken) now returns 200
with the expected {apnsToken, apnsEnvironment, webcourierURL} body —
previously it failed at the first call with HTTP 405.
Adds MistDemoLoggingBootstrap so --verbose on the integration commands
turns on swift-log at .debug for com.brightdigit.MistKit.middleware,
letting the existing LoggingMiddleware actually emit the wire trace
that made this diagnosis possible.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Code Review: Push Notifications & Subscriptions epic (#379)Overall this is a solid, well-structured implementation that follows the project's established patterns faithfully. The architecture mirrors Moderate1. Web server silently forwards empty strings for missing subscription fields subscriptionID: input.subscriptionID ?? "",
recordType: input.query?.recordType ?? "",
zoneID: ZoneID(zoneName: input.zoneID?.zoneName ?? "")If the browser omits 2. self.apnsToken =
try container.decodeIfPresent(String.self, forKey: .apnsToken) ?? ""A missing 3. await #expect(throws: CloudKitError.self) {This catches any Minor / Informational4. APNsEnvironment has two near-identical 5. 6. Public memberwise init allows structurally invalid 7. 8. Nit
Overall the PR is in good shape. The two web-server input-validation issues (#1 and #2) are the most worth fixing before merge since they result in confusing errors for the browser demo; the rest are polish items. |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## v1.0.0-beta.2 #381 +/- ##
================================================
Coverage ? 68.51%
================================================
Files ? 162
Lines ? 3725
Branches ? 0
================================================
Hits ? 2552
Misses ? 1173
Partials ? 0
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
…ip ci]
Close the loop on subscription notifications with a headless, receive-side
test path and the matching web-app reception, using the verified CloudKit
web-courier wire format.
MistDemoKit (Swift):
- WebCourierPoller: long-polls a webcourierURL on a dedicated URLSession;
exposes pollOnce/waitForFrame plus a notifications() AsyncThrowingStream
(the addNotificationListener mirror). No de-dup: the courier is
consume-on-delivery and nid is shared across subscriptions for one change.
- CourierFrame / CourierNotification: typed model mapping the {aps, ck}
payload to the documented CloudKit.QueryNotification fields.
- NotificationRoundtripPhase: create subscription (create/update/delete on
Note) -> mint+register courier token -> trigger a record change -> await
the push filtered by subscriptionID -> self-clean. Soft on non-arrival
(delivery is eventual); registered in the private-DB pipeline.
Web app (tokens.js): MistKit mode now long-polls the courier URL returned by
/api/tokens and renders incoming notifications, mirroring CloudKit JS mode's
registerForNotifications()/addNotificationListener.
Docs: WEB_COURIER_SPIKE.md records the resolved wire format (request shape,
{aps, ck} framing, consume-on-delivery / no-cursor). CLAUDE.md corrects the
stale --config-file reference to the real Swift Configuration precedence.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…skip ci] Pin the three real web-courier payload shapes captured against a live container — an alerting push, a bare ck-only frame, and a silent content-available push — plus the fo→reason mapping, as a decoding regression for CourierNotification. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… [skip ci] modifySubscriptions silently dropped CloudKit's inline per-subscription errors (e.g. INTERNAL_ERROR "could not find subscription we just created"), returning an empty array where CloudKit JS shows a visible failure. Apply the records' RecordResult success-or-failure pattern to subscriptions so failures are surfaced consistently across the API. - openapi.yaml: add SubscriptionOperationFailure; make SubscriptionsModifyResponse items oneOf:[SubscriptionOperationFailure, Subscription] (mirrors ModifyResponse). Regenerated MistKitOpenAPI. - Hoist the server-error-code enum to a shared CloudKitServerErrorCode (RecordOperationFailure.ServerErrorCode kept as a typealias) so record and subscription failures share one code type. - Add SubscriptionOperationFailure + SubscriptionResult (.success/.failure/.get()) and CloudKitError.subscriptionOperationFailed. - modifySubscriptions now returns [SubscriptionResult] (surfaces failures, still skips deletion acks); createSubscription throws on failure; deleteSubscription surfaces a failed delete. Web demo maps .get() so the panel shows the error. Note: this surfaces the failure; it does not make the create succeed — that CloudKit-side cause (likely a non-queryable record type) is unchanged. Breaking: modifySubscriptions return type [SubscriptionInfo] -> [SubscriptionResult]. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| internal struct WebCourierPoller { | ||
| private let courierURL: URL | ||
| private let perPollTimeout: TimeInterval | ||
| private let session: URLSession |
There was a problem hiding this comment.
rather then using URLSession it should use the same client as the MistKit client. See how Client transport work
There was a problem hiding this comment.
Deliberately not the CloudKit ClientTransport here: the webcourierURL is a different host from api.apple-cloudkit.com, so reusing that transport's HTTP/2 connection pool across the two hosts risks 421 Misdirected Request — the same hazard documented in CLAUDE.md ("Asset Upload Transport Design") that makes MistKit upload assets through a separate AssetUploader closure instead of the shared transport.
To address the underlying intent (don't hard-code URLSession), 9806678 makes the transport an injectable @Sendable closure that defaults to the dedicated ephemeral URLSession. That keeps host/connection-pool separation while making the poller testable without a live courier. Open to revisiting if we later add a transport abstraction that pools per-host.
…387) [skip ci] Restructure SubscriptionInfo around a Kind enum (.query/.zone/.database) mirroring native CKSubscription subclasses; add NotificationInfo, firesOnce, and zoneWide per the CloudKit JS reference. firesOn and firesOnce are query-only — they live inside Kind.query, matching native CKQuerySubscriptionOptions. zone subscriptions no longer carry firesOn. SubscriptionFireEvents OptionSet replaces [SubscriptionFireEvent] array; init traps and the schema decoder throws ConversionError.subscriptionQueryMissingFiresOn when a query subscription has no fire events (a subscription that would never trigger is a programmer error). Unify CloudKit query representation in a new public Query type used by both queryRecords and SubscriptionInfo.Kind.query. SubscriptionQuery becomes a deprecated typealias. queryRecords(_ query: Query, ...) is the new canonical overload; the flat-param queryRecords is now @available(*, deprecated). Add isLikelyDuplicate on SubscriptionOperationFailure and CloudKitError.subscriptionLikelyDuplicate, thrown from createSubscription on the canonical INTERNAL_ERROR / "could not find subscription we just created" payload. Wording is hedged — the wire code is still INTERNAL_ERROR. Empirically verified via a new `mistdemo probe-duplicate-subscription` diagnostic command (uniqueness is by (recordType, firesOn), not subscriptionID — see #387). Consolidate RecordOperationFailure/SubscriptionOperationFailure into a generic OperationFailure<Target> with phantom-typed RecordTarget / SubscriptionTarget. The old per-target types become typealiases; .recordName / .subscriptionID become .identifier across callers. RecordResult / SubscriptionResult fold into OperationResult<Value, Target>. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolve all swiftlint violations surfaced by the lint scripts: split CloudKitError errorDescription and SubscriptionInfo Codable into extension files to meet file_length, reorder type contents to satisfy type_contents_order, reduce cyclomatic complexity in makeTypedScalar and SubscriptionInfo init by extracting numeric/string branches and query/zone kind helpers, and drop the unused OpenAPIRuntime import. MistDemo: split AtomicBool, ProbeSubscriptionTemplate, MockBackend helpers, and the CloudKitService: WebBackend conformance into their own files; replace @unchecked Sendable with proper Sendable + lock; introduce concrete @objc shims on PushNotificationDelegate so APNs selectors land on the class (Swift forbids @objc on protocol-extension defaults); refactor ModifyCommand / ModifySubscriptionsCommand / ProbeDuplicateSubscriptionCommand to satisfy function_body_length, cyclomatic_complexity, type_contents_order, and large_tuple. Disable the discouraged_optional_boolean opt-in in both .swiftlint.yml — Bool? is used intentionally on subscription fields where nil means "let CloudKit apply its default". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…anup (#379) - Move CourierNotification decoder into MistKit as public API (Models/Notifications/); it models generic CloudKit push payloads. Keep WebCourierPoller/CourierFrame as MistDemo glue; relocate decoder tests to MistKitTests. - Make WebCourierPoller's transport injectable (Sendable closure) for testing while keeping the dedicated ephemeral URLSession default; document the 421 Misdirected Request hazard that rules out reusing the CloudKit ClientTransport. - Delete WEB_COURIER_SPIKE.md and its dangling doc reference. - Add doc comments for 8 swift-format AllPublicDeclarationsHaveDocumentation warnings (Query, SubscriptionFireEvents, SubscriptionInfo). - Replace MockBackend force-unwrap with guard/throw (NeverForceUnwrap); the prior swiftlint:disable didn't apply to swift-format. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code Review: Push Notifications & Subscriptions Epic (#379)A large, well-scoped PR that completes the server-side push-notification surface for CloudKit Web Services. The key discovery — that the Correctness
guard let created = results.first else {
throw CloudKitError.invalidResponse
}
if case .failure(let failure) = created, failure.isLikelyDuplicate {
throw CloudKitError.subscriptionLikelyDuplicate(failure)
}
return try created.get() // ← throws subscriptionOperationFailed for non-duplicate failuresIf
let results = try await modifySubscriptions([.delete(subscriptionID: id)], database: database)
for result in results { _ = try result.get() }
The Design
The doc comment says "deletions are typically omitted" but actually they are always silently skipped by the
The docs say "reuse the value passed to Code QualitySwiftLint rule removal scope is too broad
The doc comment at line 62 references
public var firesOn: SubscriptionFireEvents {
if case .query(_, let firesOn, _) = kind { return firesOn }
return []
}Returning Test CoverageNo test covers The failure test matrix ( Token tests lack wire-format assertions The PR notes this in the known follow-ups: No test for If the precondition is changed to a throw in a future refactor, there's no test asserting it fires. Tests use the factory methods ( Known Follow-ups (acknowledged in PR)The PR description already calls out several of these — registering them here for completeness:
SummaryThe core subscription and token implementations are correct and well-structured. The main actionable items before merging:
Everything else is lower priority. The live verification story (17/17 phases green) and the existing 498 + 941 unit tests give good confidence in the implementation. 🤖 Generated with Claude Code |
…ors (#379) - Move web-courier long-poll into MistKit: new `Courier` namespace (`Courier.Transport` typealias + static `pollOnce`/`notifications`), `WebCourierPoller` convenience wrapper, and `URLSession.pollCourier` default transport. Transport is now an injectable closure (AsyncHTTPClient pluggable) mirroring `AssetUploader`. - Drop `CourierFrame` and the unused `waitForFrame()`; `pollOnce` returns `CourierNotification?` directly. - Restore `recordName` accessor on `RecordOperationFailure` and add `subscriptionID` on `SubscriptionOperationFailure` (named aliases for `identifier`). - Remove the `SubscriptionQuery` deprecated alias; repoint its doc reference to `Query`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| internal static var duplicateMarker: String { | ||
| "could not find subscription we just created" | ||
| } |
There was a problem hiding this comment.
why not make this a let
…er let (#379) - `Courier.Transport` is no longer defaulted to nil. The `Courier` statics take a required transport; convenience/defaulting moves into a three-step `WebCourierPoller` initializer chain: transport -> session -> configuration (default `ephemeralConfiguration`: ephemeral, waitsForConnectivity false). - Default courier transport now uses a dedicated ephemeral URLSession rather than URLSession.shared, isolating held-open long-poll connections. - `Courier` statics drop the macOS 12 gate (no longer touch URLSession), so a custom transport works on any platform; only the URLSession-based inits gate. - `SubscriptionTarget.duplicateMarker` is now `static let` instead of a computed `var`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code Review: Push Notifications & Subscriptions epic (#379)Overall: This is a well-architected addition. The A few items worth addressing before merge: Bugs / Correctness1. Stale DocC links in
/// `subscription` with ``SubscriptionInfo/query(subscriptionID:recordType:filters:sortBy:firesOn:)``
/// or ``SubscriptionInfo/zone(subscriptionID:zoneID:firesOn:)``.Both links are wrong:
DocC will silently fail to resolve these. Correct links are: /// ``SubscriptionInfo/query(subscriptionID:recordType:filters:sortBy:firesOn:firesOnce:notificationInfo:)``
/// or ``SubscriptionInfo/zone(subscriptionID:zoneID:notificationInfo:)``Missing Test Coverage2. Delete-acknowledgement path in
The 3. The
4. Token operations have no failure-case tests The Design Concern (not a blocker)5.
let (_, data) = try await transport(courierURL, perPollTimeout)
return try? CourierNotification(data: data)The transport returns Minor / Nits
Summary
🤖 Generated with Claude Code |
Review fixes: - Fix stale DocC links in `createSubscription` (query/zone/database factory signatures now resolve). - `Courier.pollOnce` throws `CloudKitError.httpError` on a non-2xx status instead of returning nil forever, so a persistent courier failure surfaces rather than hammering the endpoint. - Add tests: subscription delete-acknowledgement filtering, `.database` subscription schema + Codable round-trips, token 400/401 failure mapping, and `Courier.pollOnce` status-code behavior. Courier tidy-ups (this branch, pre-review): - `URLSession.courierTransport` accessor vending a `Courier.Transport`. - Guard `waitsForConnectivity` behind `#if !canImport(FoundationNetworking)` (get-only on swift-corelibs-foundation). - Add `Returns:`/`Throws:` doc sections to the `Courier` long-poll APIs. Also trim MockBackend.swift to satisfy the file_length limit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
subrepo: subdir: "Examples/BushelCloud" merged: "66bbe46" upstream: origin: "git@github.com:brightdigit/BushelCloud.git" branch: "mistkit" commit: "66bbe46" git-subrepo: version: "0.4.9" origin: "https://github.com/Homebrew/brew" commit: "6f293daa9f"
Code Review — PR #381: Push Notifications & Subscriptions epicOverviewThis PR delivers the complete server-side push-notification workflow: subscriptions (list/lookup/modify) and APNs tokens (create/register), closing issues #49–53 and epic #379. The key non-obvious discovery — that Files changed: 111, +8372/−900 lines. Code Quality and Style ✅Overall quality is high and patterns follow the codebase's conventions well:
Potential Bugs / Issues1. Silent fallback in } else if let single = try? SubscriptionInput(from: decoder),
single.subscriptionType != nil
{
self.create = [single]
} else {
self.create = []
}The 2. self.apnsToken =
try container.decodeIfPresent(String.self, forKey: .apnsToken) ?? ""
3.
Known Follow-ups (acknowledged in PR, confirming they're tracked)
Test CoverageGood:
Gap (already noted): request-body wire format is not covered at unit level; the live integration test fills this for now. SecurityNo concerns. APNs tokens are treated as opaque hex strings with no logging at non-debug levels. SwiftLint Change
SummaryWell-structured, thoroughly verified feature addition. The phantom-type |
Code Review — Push Notifications & Subscriptions epic (#381)Great PR overall. The implementation is well-structured, the key discovery (token endpoints live at Breaking API change:
|
| 498/498 unit tests ✅ | 941/941 MistDemo tests ✅ |
| lint clean ✅ | 17/17 live phases green ✅ |
The architecture — OperationFailure<Target>, ContainerOperationInputPath, WebCourierPoller — is solid and the endpoint-path discovery is a genuinely useful piece of reverse-engineering. The issues above are all minor or acknowledged follow-ups. The PR is in good shape to merge after the dead code cleanup and comment alignment on discouraged_optional_boolean.
Summary
Delivers the full server-side push-notification workflow for CloudKit Web Services — subscriptions (the triggers) plus APNs tokens (the delivery path) — closing epic #379 and sub-issues #49, #50, #51, #52, #53.
subscriptions/list,/lookup,/modify): newCloudKitServicemethods, CLI commands (list-subscriptions,lookup-subscription,modify-subscriptions),SubscriptionRoundtripPhasein the live runner. Working live since the first commit on this branch.tokens/create,tokens/register): newCloudKitServicemethods, CLI commands (create-token,register-token),TokenRoundtripPhase. The endpoint route turned out to be/device/1/…(CloudKit JS usessetApiModuleName("device")), not/database/1/…as Apple's archived REST reference documents — that detail was the missing piece that resolved the persistent HTTP 405 on every server-side call. TheclientIdfield CloudKit JS always sends is now part of both bodies, exposed as an optional caller-supplied parameter (defaults to a fresh UUID per call).--verboseontest-private/test-publicnow bootstrapsLoggingSystemso MistKit's existingLoggingMiddlewareactually emits the wire trace — necessary for diagnosing the 405 and useful for anyone reverse-engineering CloudKit JS in the future.Live verification
swift run mistdemo test-private --verboseagainstiCloud.com.brightdigit.MistDemo— all 17 phases green, including:SubscriptionRoundtripPhase(create / list / lookup / delete)TokenRoundtripPhase(POST /device/1/.../tokens/create→ 200, thentokens/register→ 200)test-public --verboseblocked on an unrelated pre-existing config bug (S2S key path has escaped backslashes —com\\~apple\\~CloudDocsinstead ofcom~apple~CloudDocs). Filing as a separate follow-up; not in scope here.Test plan
swift test— 498/498 MistKit unit testscd Examples/MistDemo && swift test— 941/941 MistDemo unit tests./Scripts/lint.sh— clean (only 4 pre-existing warnings unrelated to this branch)swift run mistdemo test-private --verbose— 17/17 phases green livetest-private --verbosefrom a clean checkout if you want to see the wire traceKnown follow-ups (out of scope for this PR)
tokens/registerresponse spec — live server actually returns the same{apnsToken, apnsEnvironment, webcourierURL}body astokens/create, not the empty 200 our spec assumes. Behaviorally harmless (we ignore the body) but accurate.CloudKitService+TokenOperations.swiftstill cites Apple's archivedRegisterTokens.htmlas the source of truth; CloudKit JS is the actual authority now.MockTransportdoesn't capture the request body, so we can't assertclientIdlands in the JSON from unit tests. A capturing transport variant would let us regression-pin the wire shape.Resources/js/tokens.jsdoesn't yet surface aclientIdinput. Backend accepts requests without it (auto-UUID), so the panel works; adding a field is pure UX polish.test-publicconfig loading.Closes #49, #50, #51, #52, #53, #379.