Skip to content

Commit 799aafa

Browse files
tmmampagent
andauthored
feat: Swift client for Zero (iOS and macOS) (#1)
* docs(swift): add design for native Swift client Record the scoping decisions for a native Swift Zero client on iOS 26 and macOS 26: architecture, API, storage, lifecycle, conformance suite, drift harness, distribution, and milestones M0-M8. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * build(swift): scaffold Swift package, conformance package, and CI Add the swift/ package with Zero, ZeroUI, and ZeroTesting targets, the private zero-conformance package that emits vectors from the JS implementation, and workflows for Linux, macOS, and the SwiftPM mirror. The orb setup installs the Swift 6.2.4 toolchain. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * ci(swift): run Swift workflows on the swift-client branch Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * feat(swift): port JSON, hashing, AST normalization, and value primitives Add the Swift JSONValue model with a JSON.parse-compatible parser and a JSON.stringify-compatible serializer, JS number formatting and parsing, UTF-8 and UTF-16 string orders, xxHash32 (h32/h64/h128), the AST with normalization and query hashing, value comparison, primary-key storage keys, LIKE/ILIKE, and TTL. Each is checked against vectors that the zero-conformance package generates from the JS implementation; seeded fuzz corpora are generated in CI and shared with the Swift job. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * feat(swift): port IVM operators, pipeline builder, views, and query builder Port the client IVM engine 1:1 from packages/zql: MemorySource with its B+tree indexes and push overlays, Filter/Exists/FanOut/FanIn, Join, FlippedJoin, UnionFanOut/In, Take, Skip, Cap, TakeGate, the pipeline builder with correlated predicate pushdown, ArrayView change application, and the untyped AnyQuery builder. The zero-conformance package gains IVM vectors generated by running the JS builder and engine: 34 curated programs and a seeded fuzz corpus, each replaying a push sequence and recording the view and the changes it receives. Swift replays the same programs through its builder and engine and must match the AST, format, view, and changes at every step. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * feat(swift): port the zero-protocol wire messages Add Swift types for every upstream and downstream message of packages/zero-protocol, with their bodies, the error kind, origin and reason enums, binary poke chunk decoding, the Sec-WebSocket-Protocol encoding, and normalizeClientSchema. Decoding accepts and rejects what valita does in passthrough mode. The types drop unknown keys, as strip mode does. Valita behaviors that the types reproduce: - Upstream is an open tagged tuple union. A message with an unknown string tag decodes to .unrecognized, kept as sent. Downstream is closed. - ASTs sent by clients use strictASTSchema: no unknown keys, and at most 50 levels deep. ASTs from zero-cache are lenient. - Mutation results follow the JS client: it parses in passthrough mode and then checks 'error' in result. Valita returns just {data} once data is present, so a result with data is OK even when it also has an error key. - pushResponse tries the OK variant first. jsonObjectSchema (rows patch merge) also accepts arrays. - Inspect bodies are checked by a small valita interpreter and kept as stripped JSON. The new zero-conformance generator emits vectors for downstream and upstream messages (hand-written, seeded random, and randomly edited), poke chunks split at seeded byte boundaries, sec protocols, and client schema normalization. Larger fuzz corpora go to generated/. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * fix(conformance): generate vectors on the pinned Node, guard JSON.parse Node 24.22's V8 misreads the escaped key in `{"\n":2}` as a backslash once it has parsed an object keyed by a backslash, so the poke-chunk fuzz corpus CI generated on Node 24 held wrong expectations. Generate vectors with the Node in .nvmrc, and refuse to emit vectors on a runtime whose JSON.parse fails the probe. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * feat(swift): walking skeleton against a real zero-cache Add the transport protocol the engine talks through, the sync connect URL, client-to-server name mapping, and the client schema the handshake sends. A Linux end-to-end suite connects to zero-cache over a test-only SwiftNIO WebSocket transport, asks for a named query, and checks the rows it receives through binary poke chunks. packages/zero-conformance/src/e2e runs the server side: a Postgres database loaded from a fixture, an API server for the app's named queries and mutators, and zero-cache from source. CI runs it on the Ubuntu runner's Postgres. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * feat(swift): typed key-path query builder and zero-swift codegen Add the typed layer over AnyQuery: generated row structs conform to ZeroRow and name their columns and relationships by key path, Query<Row, Result> builds with key paths and a `$0.column >= value` expression DSL, and views decode into the row structs. Column values go through ZeroValue, with string enums, integer columns, and typed JSON objects. packages/zero-swift-cli is the `zero-swift codegen` command. It loads the TS schema, reads enumerations and json<T>() types with the TypeScript checker, and emits the row structs, their enum and JSON types, the ZeroSchema, and the `zql` builder. The Swift tests compile the Swift it generates for the zql test schema and a fixture that covers keywords, reserved names, shared enums, nested JSON, integer overrides, compound keys, and two-hop relationships; CI checks the checked-in output is current. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * test(conformance): client scenario vectors from the JS zero-client Runs 127 scenarios (connection, errors, pokes, queries, mutations) against TestZero over a MockSocket with fake timers and memory storage, and records what the app and the server observe after every step. The Swift client replays them in M4. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * feat(swift): client engine on memory storage (M4) Port the zero-client engine: the connection run loop and state machine, the replica with its pending mutations and rebase, the pusher, the query manager with TTLs and got queries, the poke handler, the mutation tracker, auth and recovery. It runs synchronously on the main actor: an event loop reproduces JS microtask order, timers come from an injected Scheduler, and sockets are callback-based. ZeroTesting gains ManualScheduler (fake timers) and MockSocket. The public API is Zero<Context> with defineQuery/defineMutator; mutator bodies are plain throws. A view is destroyed when its last reference goes. All 127 client scenario vectors from the JS client pass. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * test(conformance): persistence scenarios with a restart step The client harness gains `restart`: close the client and start a new one on the same in-memory storage, optionally later or with other options or mutators. New scenarios cover what a relaunched client starts from (rows, cookie, got queries, pending mutations of earlier clients), client group selection by mutator names, storage names, client GC with deleteClients, and the server errors that disable a client group or drop the store. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * feat(swift): SQLite persistence and the client lifecycle (M5) The replica is stored per JS database name in SQLite (or in process memory with ZeroStorage.memory): client groups, clients and their heartbeats, deleted clients, and each group's snapshot and pending mutations. A new client joins the group with its mutator names and replays the pending mutations; writes go through on a background queue in order. Heartbeats, client and client group GC, deleteClients, collecting unused databases, disabling a group or dropping the store on server errors, and the memory fallback with onStorageFailure follow Replicache. All 144 client scenarios pass on memory and on SQLite. Definition overloads now let a context-free app write `(tx, args)`; NoArgs marks a no-argument definition that reads the context. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * test(conformance): visibility scenarios with a fake document The harness stubs document with one whose visibility a step sets, and passes hiddenTabDisconnectDelay through, so the client's background and foreground behavior is recorded from the JS client like the rest. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * feat(swift): app lifecycle, live queries, and the Apple layer (M6) - Logging: LogSink and logLevel (default warn) at the JS client's log points; os.Logger on Apple platforms, stderr elsewhere. - Views: snapshots with row diffs (listener and AsyncStream); LiveQuery, an @Observable typed query; Connection is @Observable with updateNeeded. - Recovery: without onClientStateNotFound, Zero replaces its client in place and moves views, live queries, and preloads to it. - Visibility: disconnect in the background, reconnect in the foreground (hiddenTabDisconnectDelay semantics); reconnect without the backoff when the network is back; wait for pending mutations before suspension. - Apple: URLSessionTransport (the default socket factory), the UIKit/AppKit lifecycle and NWPathMonitor hooks, a SwiftUI @ZeroQuery and environment, and a diffable data source adapter. - Transport frames are written by one task, so they keep their order. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * fix(swift): build the SwiftUI and AppKit helpers on macOS DynamicProperty is not main-actor isolated, so ZeroQuery conforms with @preconcurrency; AppKit snapshots reload changed rows, having no reconfigure. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * test(swift): the whole client against zero-cache end to end Syncs a named query, runs mutations optimistically until the API server confirms them, sees them from a second client, and relaunches on the same SQLite store. The test schema is generated for the E2E target too. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * fix(swift): compile the Apple tests on macOS Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * feat(swift): drift harness for Swift ports of TS queries and mutators zero-swift drift record runs an app's TS queries and mutators on samples in offline clients and records their ASTs, outcomes, and row changes. Drift.failures (ZeroTesting) replays the record against the Swift port. Query gains a partial-row start, a where that takes nil as JS takes undefined, and and/or over optional conditions; escapeLike is ported. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * feat(zbugs): zbugs' schema, queries, and mutators in Swift (M7) apps/zbugs-swift ports apps/zbugs/shared to the Swift client and checks the port against the TS definitions on 37 query and 38 mutator samples. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * feat(zero-swift): flag mutators that read the clock or make up IDs The drift recorder runs each mutator sample again with the clock a day later and fails when the runs differ. DESIGN.md records the M6 and M7 decisions. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * feat(swift): read nulls the schema rules out, and share the NIO transport zbugs' Postgres allows nulls in columns its schema says can't be null, and JS shows those rows. Swift failed to decode them, which emptied the whole view. A null in a non-optional String, Double, Int, or Bool column now reads as "", 0, or false; enums and JSON types still fail. NIOTransport moves from the E2E tests into a ZeroNIOTesting product, so apps can run their own end-to-end tests on Linux, where corelibs' URLSessionWebSocketTask never completes a connection. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * feat(zbugs): native app login redirect and a dev token script The GitHub login sets the JWT as a cookie, which the Swift app's web authentication session can't read. With redirect=zbugs-swift://auth the API redirects there with the JWT in the query instead. `pnpm dev-token <userID> [crew|user]` signs a JWT with the dev key, for clients that can't log in through GitHub in development and tests. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * feat(zbugs-swift): the zbugs app for iOS and macOS ZBugsUI has the app's screens: the issue list with zbugs' filters and sorts, issue detail with comments, and creating, editing, closing, and commenting, all through the ported queries and mutators. The list also comes in UIKit and AppKit through DiffableAdapter, picked in the sidebar. Login takes a zbugs JWT, from the GitHub login through a web authentication session or from `pnpm dev-token`, and keeps it in the keychain. App/ is an XcodeGen project with one multiplatform target, which macOS CI builds for the iOS simulator and macOS. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * test(zbugs-swift): the Swift port against zbugs' own backend server/server.ts runs zbugs' backend from this repository's sources: a fresh database with zbugs' migrations and seed data, the API server, and zero-cache. BackendTests lists, creates, and comments through it, so the API server runs the TS mutators and queries next to the Swift ones, and checks that a second client sees the result and that both sides report zbugs' errors alike. The Linux end-to-end job runs it. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * fix(zbugs-swift): read the project name off the main actor Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * fix(zbugs-swift): name the app's module apart from the ZBugs library Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * perf(swift): what the benchmarks found - The mutation tracker found a mutation by scanning the outstanding ones, so mutations slowed down as they piled up (229 µs each over a second of them, now 21 µs). It keeps them by ID, as the JS Map does. - A live query read every row again on every change. It now reuses the rows of unchanged entries, matched in order (92 µs instead of about 640 µs per update of a 1000-row list). - B-tree nodes skip dynamic exclusivity checks (the tree copies shared nodes before writing them), and the forward iterator reads a leaf's keys once rather than per element: a 1000-element scan went from 23 µs to 2.7 µs, and views hydrate faster. The tree is inlinable, so other modules get it specialized. - A view's hydration takes its row list out once, not once per row. - UTF-8 string comparison and code unit equality compare bytes. - Reading a column is inlinable and specialized into the row structs. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * feat(swift): benchmarks, next to the JS ones ZeroBenchmarks ports zero.bench.ts and the shared B-tree and hash benchmarks under their names, and adds the Swift client's own: memory for 100k rows, hydration of up to 100k rows (as structs too), pokes, and mutations including the client's side of a round trip. `swift run -c release ZeroBenchmarks [filter…]`. packages/zero-conformance has the JS side of those that have one (`pnpm --filter zero-conformance bench`). BENCHMARKS.md has both sets of numbers from one machine: Swift is faster on all the client benchmarks and B-tree scans, and slower on B-tree lookups. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * refactor(swift): keep the engine out of the public API The IVM operators, wire-protocol bodies, B-tree, hashes, and JSON machinery were public, so apps (and the docs) saw ~1,700 symbols. They are package now: the package's tests, ZeroTesting, and the benchmarks still see them; apps see what they and the generated code use (~720). ErroredQuery and escapeLike stay public (views report the one; zbugs uses the other). zbugs reads JWT claims with JSONDecoder instead of the engine's parser. A package type with @inlinable members has to be @usableFromInline, or other modules quietly get unspecialized calls: BTreeSet<Int> scans from the benchmarks were 5x slower until it was. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * docs(swift): DocC catalogs, a README, and the JS parity matrix A catalog per library product: Zero (getting started, and what each JS option, method, and React hook is called in Swift), ZeroUI, and ZeroTesting. swift/README.md is the mirror's front page, with the compatibility table. scripts/docs.sh builds the catalogs with warnings as errors; CI runs it for Zero and ZeroTesting on Linux, and for all three on macOS (ZeroUI's symbols exist only there). Oxfmt skips the catalogs, since it turns DocC's double-backtick symbol links into code voice, and the recorded drift vectors, like the conformance vectors. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * build(zero-swift): bundle the CLI for npm The CLI ran from its TypeScript sources and imported other workspace packages' sources by relative path, so it couldn't be published. `pnpm --filter zero-swift build` now bundles bin.ts with esbuild into dist/, leaving out typescript and @rocicorp/zero. publishConfig points the bin at the bundle. bin.ts is the entry point because cli.ts's own check (its URL against argv[1]) fails behind npm's .bin symlink. @rocicorp/zero is a peer dependency (>=1.11.0, the first release with sync protocol 53): drift record must run the app's own copy. In the workspace, a workspace:* devDependency satisfies it. Checked by packing the CLI and running codegen, codegen --check, and drift record in a scratch app against @rocicorp/zero 1.11.0 from npm. The package stays private, at 0.1.0, until the publish is approved. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * test(zbugs-swift): screenshots of each screen against zbugs' backend A UI test (App/UITests) drives the app through the issue list, an issue, the new issue sheet, the filters, and the UIKit list, keeping a screenshot of each. A new workflow runs it in the iOS Simulator against server.ts on the macOS runner and uploads them. The app reads ZBUGS_TOKEN, ZBUGS_CACHE_URL, and ZBUGS_API_URL from its environment, so the test logs in without the login sheet. On iPhone, the split view now starts at the issue list rather than the filters, which had no way forward; the filters get an Issues button, and a selection from the UIKit list shows the issue. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * fix(zbugs-swift): make the app model once, in the App RootView made its AppModel in a @State initializer, which SwiftUI runs each time it re-creates the view; each throwaway model started a client. In the simulator against zbugs' backend that came to about 830 clients in three minutes, each closing before it synced, so the list never loaded. The App makes the model now and passes it in. ZeroUI's docs say where to make a client. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * fix(zbugs-swift): let zero-cache forward to either loopback name server.ts allowed zero-cache to forward queries and mutations only to http://127.0.0.1:<port>, but the app named the API server http://localhost:5173 by default, so zero-cache refused to transform its queries (TransformFailed) and the list never loaded. server.ts now allows both names on a loopback host, and the app defaults to 127.0.0.1, the address server.ts prints. The screenshots workflow also keeps the client's log. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * fix(zbugs-swift): issue numbers without separators, a badge that fits, and the picker the test taps The navigation title formatted the issue number for the locale ("#3,000"). On an iPhone the connection badge shows only its dot. The UI test finds the filters' List picker by its label, which is its title and its value. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * test(zbugs-swift): screenshots on iPad and macOS too The UI test runs on an iPhone and an iPad simulator and on macOS, where the native list is AppKit. It finds issues by accessibility identifier, not as the first cell, which on an iPad or a Mac could be a filter. When a screen doesn't appear it also keeps the accessibility tree. The split view starts with all three columns, so an iPad or a Mac shows the filters, and the window has a default size. A SwiftUI row reads as one element. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * feat(zbugs-swift): run the app on a device against a backend on the Mac With ZBUGS_HOST=0.0.0.0, server.ts serves the network and zero-cache accepts the API server at any of the machine's addresses. The UI test passes ZBUGS_CACHE_URL and ZBUGS_API_URL on to the app, and the app says why it asks for the local network. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * fix(zbugs-swift): room for the filters on macOS, and the picker the test clicks there On macOS the sidebar was too narrow for the pickers' values, and the list column for its titles. The List pop-up button has no label of its own, so the UI test finds it by its value. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * fix(zbugs-swift): sheets that fit an iPhone, and an AppKit list like the SwiftUI one From running the app on a Mac and an iPhone: - The sheets' minimum size, for macOS, made them wider than an iPhone, clipping Create. On macOS the issue forms are grouped, like the others, not labels in a column beside the fields. - The AppKit list truncates its titles instead of clipping them, shows the status icon, and highlights the issue the detail shows, as the UIKit list now does on an iPad. - Tapping the issue the detail shows opens it again from the UIKit list, which doesn't change the selection. - A row's meta line doesn't wrap before its labels. - On a device, the UI test allows local network access. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * fix(zbugs-swift): keep a row's labels whole in a narrow list On a Mac the list column is narrow enough that the labels were squeezed to a letter or two. They keep their size, and go below the subtitle when they don't fit beside it. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * fix(zbugs-swift): highlight the open issue in the UIKit list on an iPad The list checked its own size class, which a split view's column reports as compact on an iPad too. The root view, which sees the window's, decides. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * fix(zbugs-swift): give the Mac's list column its width `searchable` drops a column width set before it, so the list was 200pt whatever the minimum. Set after it, the list opens 340pt wide. Found by running the app on a Mac. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * fix(zbugs-swift): keep the UIKit list's highlight when it appears UITableViewController clears its table's selection as the view appears, which on an iPad is after the root view set it. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * perf(zero-swift): rows keyed by column index A row was a [String: JSONValue], so every column read hashed the name: comparing rows by key and reading rows as structs mostly hashed. `Row` is now a struct that reads and writes like that dictionary but keeps its values in an array, in the order of a layout the rows of a table share. A source puts the rows it gets in its table's layout. Its comparators read the columns they sort by by index, and the generated init(entry:) passes each column's position in the table, so decoding reads it there. A row in another layout falls back to its names. Rows on the wire and in storage stay dictionaries (`RowRecord`), as does a query's start bound. 100,000 rows of 10 columns, on this machine: materialize 25.0 ms -> 15.3 ms materialize as structs 85.9 ms -> 28.3 ms The table mutators also take a [String: JSONValue], so code that built one for them still compiles. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * perf(zero-swift): a row in one allocation, shared with the main branch A row was a layout and an array of values: two references to retain on every copy, where the dictionary it replaced had one. Its layout and values now share one buffer. A mutation's fork and the main branch each made their own layout for a table they hadn't read yet, so the main branch copied every row the mutation wrote. Layouts now come from the branch and its forks share them, and the main branch takes the rows the transaction wrote. 100,000 rows of 10 columns, against the commit before rows keyed by column index: materialize 24.6 ms -> 11.5 ms materialize as structs 91.2 ms -> 29.7 ms memory, with a view 98 MB -> 128 MB (JS: 164 MB) The memory is a source's rows, which no longer share their storage with the local store's dictionaries. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * perf(zero-swift): B-tree lookups without retaining each node get() walked the tree with strong references, retaining and releasing each node it passed. The tree holds its nodes for the walk, so it no longer does: a lookup in a tree of 1000 went from about 64 ns to 43 ns. BENCHMARKS.md has the numbers after rows keyed by column index. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * docs(zero-swift): say Linux needs a socket of your own Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * fix(zbugs-swift): the list's large title on an iPhone when it has rows at launch A compact split view that starts at the content column lays out the list before its navigation bar is set up. A list that already has rows then (from the local store, as when the app opens offline) keeps its large title collapsed. Start at the filters and move to the list as the view appears. Amp-Thread-ID: https://ampcode.com/threads/T-01a1557f-dff8-761e-ad04-7fc6c1bd83db Co-authored-by: Amp <amp@ampcode.com> * test(zbugs-swift): screenshots offline too, from what the app stored After the screenshots against the backend, the workflow stops it and runs each platform again. The issue list can then only come from the app's local store, so a launch that doesn't read it fails the job. Amp-Thread-ID: https://ampcode.com/threads/T-01a1557f-dff8-761e-ad04-7fc6c1bd83db Co-authored-by: Amp <amp@ampcode.com> * feat(zbugs-swift): a backend that keeps its database across restarts server.ts recreates the database on every start and signs tokens with new keys, so each restart sends every app that synced back to an empty store and logs it out. With ZBUGS_KEEP=1 it keeps the database (where zero-cache keeps the client groups), the replica, and the keys in ZBUGS_DIR, and seeds only a database that doesn't exist yet. Amp-Thread-ID: https://ampcode.com/threads/T-01a1557f-dff8-761e-ad04-7fc6c1bd83db Co-authored-by: Amp <amp@ampcode.com> * docs(zbugs-swift): running the app on a device The token and servers an app gets in its environment aren't saved, so on a device opened from the home screen it goes back to the saved ones: set Servers… and log in once. Amp-Thread-ID: https://ampcode.com/threads/T-01a1557f-dff8-761e-ad04-7fc6c1bd83db Co-authored-by: Amp <amp@ampcode.com> * fix(zero-swift): ZeroTesting no longer links Testing.framework Drift.expectNoDrift imported Testing, so everything that linked ZeroTesting linked Testing.framework, which on Apple platforms only test bundles find: `swift run ZeroBenchmarks` died at launch on macOS. Tests now expect `Drift.failures` to be empty, which lists each difference just the same. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * perf(zero-swift): zero.bench checks its sum by column index The check summed column `a` by name, on the main actor, which on macOS checks its executor for every row: about 45% of "All 1000 rows" there. JS reads `row.a` off the object's shape; the Swift check now reads `a` by index, off the main actor. On Linux, All 1000 rows went from 197 to 122 µs. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * docs(zero-swift): benchmarks on the iPhone and an M2 Max first The results were from one Linux Xeon, where JS is slowest, and overstated Swift's lead. They now lead with an iPhone 15 Pro, where JS in WebKit is ahead on the read path and Swift on optimistic updates, memory, hashing, and B-tree scans, then an M2 Max next to Linux. The shared benchmarks' command runs Node only, without headless Chromium beside it. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * feat(zero-swift): ZeroVirtual, lists that page their rows in The paging core of @rocicorp/zero-virtual for Swift, without its views: a window of at most three queries (a page, or a permalink's row and a page either side of it) that moves as the list scrolls, keeping what's on screen in place as rows are added or removed above it or change height. It has permalinks, scrollToItem with alignment, the first and last visible rows, scroll state to save and restore, settling, and following the bottom while the list is parked there. ZeroVirtualizer runs the core's queries on a Zero client. The page and row queries take the list context, so a new filter queries the new list. Scroll view adapters come next. zero-virtual's behavior tests are ported by hand with their names; Tests/ZeroVirtualTests/UPSTREAM.md lists each upstream case at f89c468 as ported, adapted, or not applicable. Restores are a call, restore(_:), rather than an option the core compares: a state is a value, so restoring the same one twice looked like no change. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * feat(zero-swift): VirtualList, ZeroVirtual's SwiftUI scroll view VirtualList draws a ZeroVirtualizer's window of rows in a ScrollView, between spacers, in a plain VStack: the window is already what to draw, and each row must be measured. ScrollGeometryHost turns SwiftUI's after- the-fact geometry (row frames, the scroll geometry) into the core's VirtualHost, and tells the core of an update once every row is measured and the content reaches them. The core's own scrolls go through a ScrollPosition and are reported back as they land. LoopbackServer (ZeroTesting) is a server in the app's process: it sends its rows, confirms every query, and syncs rows added later. The new test app, swift/TestHost, runs a chat that sticks to the bottom (rows of many heights) and a long list on it, with UI tests in the screenshots workflow. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * perf(zero-swift): ContiguousArray B-trees, struct scans, appending hydration Five changes, measured one at a time on an iPhone 15 Pro and an M2 Max: - B-tree nodes hold ContiguousArrays: Darwin's Array checks for NSArray bridging on every read. Scans 3.6-4.5x faster, lookups about 2x. - Row reads its layout and slot count through the header pointer, not the stored class property, which checked exclusivity each time. - MemorySource's scans and overlays are struct iterators, not closures over captured variables behind AnyIterator. - Hydration appends at the end of the list rather than inserting, which copied each entry. - Column predicates look their column's index up once per row layout. On the iPhone, "all 1000 rows" went from 95.6 to 41.8 us and materializing 2.0-2.5x faster: Swift is now ahead of JS in WebKit on the read path. BENCHMARKS.md has the new numbers. Co-authored-by: tmm <tmm@tmm.dev> Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 * fix(zero-swift): VirtualList re-renders every snapshot The core waits for afterUpdate after each snapshot, even one with the same rows, as after scrollToItem. VirtualList only re-measured when the row keys changed, so a scroll to a row far away never landed. The virtualizer now tells the adapter of every snapshot (onSnapshot). An AppleTests case checks a scroll far below in a real window, and the test app keeps its lists off the safe area so the UI tests measure rows against the viewport. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * fix(zero-swift): VirtualList updates when content rounds below its rows ScrollGeometryHost held afterUpdate until the scroll view's content reached the last row. SwiftUI rounds the content size to pixels while rows of text end on fractions, so with no space after the rows the update could wait forever: scrollToItem never landed and paging never started (the chat opened at its top on the iPhone). It allows a point. The test app's rows are one accessibility element with the padded row's frame, which the UI tests measure, and the AppleTests case waits without a nested run loop, which didn't run the main queue on macOS. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * fix(zero-swift): a scroll that lands at the bottom re-arms stick-to-bottom Only the user's scrolls measured whether the list was at its bottom, so scrolling to the latest row with scrollToItem(align: .end) after scrolling up left following off. Landing now measures it too, as zero-virtual's stick-to-bottom does on every scroll. The test app's rows take their padded frame as their accessibility shape (the UI tests measure it), and the AppleTests case expects the row where the virtualizer's window puts it, after the space standing in for rows before the window. Both found on the iPhone and the Mac. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * test(zero-swift): the loopback paging test waits for the last page's query to complete On macOS CI it stopped when the last row arrived, before its query was confirmed. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * test(zero-swift): find the scrolled-to row by its place in the window The expectation counted rows from the list's start, but under a permalink anchor the window's indexes count from its anchor. The scroll view was where it should be (checked on a Mac). Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * test(zero-swift): virtual list UI tests read rows from one snapshot Reading each row element's identifier and frame was a round trip to the device, about 6 s per call on an iPhone. The issues test now swipes until it reaches row 400 (about 14 rows a swipe), up to 60 swipes. Found on the iPhone. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * fix(zero-swift): VirtualList no longer takes the scroll view's insets off twice ScrollGeometry.containerSize already leaves out the content insets, and ScrollGeometryHost took them off again, so under a navigation bar the viewport was one inset short (113 pt on an iPhone): rows aligned wrong and the visible range and the gap to the bottom were off. The adapter now passes the full height (visibleRect). Found on an iPhone simulator and the iPhone, which checked the fix. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * fix(zero-swift): pages loading mid-fling no longer stop it To keep what's on screen in place as rows above it loaded, went, or measured another height, the core scrolled by the difference. On iOS, ScrollPosition.scrollTo stops a fling, and growth that landed between the request and the next geometry report was never compensated (an 80 pt jump on the iPhone's chat). The space before the rows now takes up the difference instead, so the scroll view isn't touched while it may be moving. New rows carry the space through paging's renumbering, and a permalink window gets a reserve to take changes out of. Space that falls short of its estimate is given back with a scroll once the list has been still for 150 ms; space over it stays until the rows reach the start. Near the start, where the space can't go below zero, the core still scrolls. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * fix(zero-swift): VirtualList keeps row frames through a new space before SwiftUI reports a row's frame only when it changes. ScrollGeometryHost dropped every frame when the space before the rows changed, so rows that didn't move were never measured again and the update waited for good: on the iPhone, the chat stopped at its first page (found by the device check). It now keeps them, shifted by the space's change when the first row is the same; over a new first row the core sized the space to keep them in place. The tests' SwiftUI fake now reports only frames that change, and a row's first frame when it comes into the window, as SwiftUI does. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * fix(zero-swift): flings through a list opened mid-way never scroll under From the iPhone's traces at 25def68: - A list opened at a permalink kept its reserve of space only while its anchor was the permalink. Once paging renumbered the window, the space estimated one row, and every older page of the chat fell back to a scroll that stopped the fling. The reserve now stays until the list reaches its start: its indexes count from where it opened. - Reaching the start dropped the space left over from above at once, scrolling to make up for it mid-fling, and the scroll was lost (a 143 pt jump). The space now stands before the first row until the list rests, then is given back. - ScrollGeometryHost took any report after a requested scroll as the scroll landing. A fling's deceleration writing over the scroll left the rows where the core didn't think they were (a 2,300 pt jump). A contradicted scroll is now asked for again, against the content as it is, for up to three reports. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * test(zero-swift): a cheaper fling test, and room for VirtualListTests to wait On macOS CI every test target runs in one process, and the fling test's seconds of main-actor work kept VirtualListTests' real window from loading within its 5 s wait (found from the CI logs on a Mac). The fling test moves in coarser frames (about 2.5x cheaper; it still fails on the reserve regression it guards), and the window test waits up to 30 s. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * test(zero-swift): the chat UI test swipes up to 70 times to reach its first message A swipe covers about four messages, and the list no longer skips any on the way up, so 40 swipes stopped short of m0000 on the iPhone. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * fix(zero-swift): the give-back at the start scrolls with the space it removes Removing leftover space at rest used to wait for the rows to report and compensate a pass later, so the view drew one pass of the shorter content at the old offset: a visible flash at the top of the chat. The scroll now goes out in the same update as the new space. Space put back still compensates after layout, as the view can't scroll past content it doesn't have yet. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * revert(zero-swift): the give-back at the start scrolls with the space it removes This reverts 9e2e1fe. On the iPhone, SwiftUI applies the shorter content before the scroll, so the wrong pass stayed and landing took a retry (19 ms instead of 7-9 ms); on macOS CI the chat showed no rows after a swipe. The fix moves to the native scroll view. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * test(zero-swift): a frame probe checks every display frame of the virtual list With -probe, TestHost's rows carry marker views, and on every display frame (CADisplayLink; NSView.displayLink on macOS) the probe reads where they are on screen. A frame is wrong when the rows move by anything but the scroll: a drive the probe makes itself must move them by exactly its step, a finger's momentum must stay smooth, nothing may move at rest, and a jump (Latest) may show only where the list was or where it lands. -probe-script chat|issues has the app drive the transitions itself: older pages of many heights loading, the space given back at each rest and at the top, and Latest after scrolling far up, with and without a message received. FrameProbeUITests runs the scripts and real flings (iOS) and asserts no wrong frame and full-length flings. TestHost now runs at 120 Hz on ProMotion iPhones. At bf41176 the probe fails every transition: on the iPhone the issues page-up has 40 wrong frames and the chat's older pages 40, far up 20. * fix(zero-swift): paging waits for the space that keeps rows in place When rows above the anchor change height mid-scroll, the core takes the change up in the space before the rows, and the next layout moves them back. Until then the rows aren't where paging reads them, so evaluating paging in the same update could move the window to rows that only seemed to be on screen. On macOS that swung the chat between two windows forever (m0171…, m0182…), one update each way, until AppKit gave up on the layout. An update that respaces the rows now leaves paging to the next update, which reads the rows where they are. * fix(zero-swift): VirtualList lays its rows out in a native scroll view ScrollPosition.scrollTo(y:) lands at least one frame after the content changes, so every compensation (a page loading above, space given back, Latest) showed a frame with the rows moved and the offset not yet. Setting the offset on the SwiftUI ScrollView's own UIScrollView/NSScrollView didn't help: SwiftUI lays the content out in its own pass, and the probe still found 55 wrong frames in the chat on the iPhone and 9 on macOS Issues. VirtualList now draws into a UIScrollView (iOS) or NSScrollView (macOS) that lays out the snapshot's rows itself. Each row is its own hosting controller, measured with sizeThatFits. LayoutHost runs the core inside the scroll view's layout pass: it reports the user's scroll, places the rows, and runs afterUpdate until the snapshot stops changing, so the rows' frames, the content size and the offset are applied together before the frame is drawn. Rows stay hidden until the first layout that isn't on its way to a jump or restore. VirtualList's API is unchanged, but rows no longer inherit the list's custom environment values: copying the whole environment into a hosting controller replaces its own, and the rows lose their accessibility. They get the platform's traits, and custom values can be applied in the row closure. With the frame probe at the same transitions, wrong frames go from 40 (Issues) and 161 (chat) to 0 on the iPhone (Release), and from 62 (Issues) and 681 (chat) to 0 on macOS. Fling lengths are unchanged (about 2.4 s / 575 pt per UI-test fling, before and after). * test(zero-swift): the frame probe counts dropped frames and opens the chat at its latest message The probe's summary now counts frames while the list moves, and those that came late, and carries the last wrong frames' reasons for a failing test's message. Its DONE line printed the summary before the last counts were in. A finger's momentum is checked over pairs of frames, not single ones: near the end of a fling UIKit moves the content every other frame, and after a late frame it catches up in one step, which the single-frame check called a jump. It still finds the jumps bf41176 makes under real flings. The chat screen shows its list once it knows the latest message, which the list opens at: shown before, it drew the top of the chat for a frame. * fix(zero-swift): no gap stands above the first row after a fling to the top Flung up to the top of Issues, the first page arriving at the start left space before i0000 (up to 49 pt), carried over from the estimate. The fling or its bounce came to rest at offset 0 with the gap showing, and 150 ms later the give-back took the space out and i0000 jumped up by it. On a host that applies a scroll and a snapshot in one layout pass (VirtualHost.scrollsWithLayout, LayoutHost), space left over at the start now comes out at once, with a scroll by as much in the same pass: the rows stay where they are on screen and nothing stands above the first row. Momentum carries on through the scroll. Hosts that can't (ScrollGeometryHost) keep it until rest, as before. The frame probe now flags a frame at rest with a gap above the list's first row, and no longer reads a bounce past the top as uneven momentum. testIssuesFlingsToTheTopLeaveNoGap flings to the top from six distances at four speeds: on the iPhone at ce30a2f it found 23 wrong frames (a 49 pt gap at rest for 150 ms, then the jump), and 0 now. * fix(zero-swift): rows on macOS stay on the content's pixels as it scrolls The macOS scroll view aligned each row with backingAlignedRect, which aligns in the window: as the content scrolled by a fraction of a pixel, each row snapped anew and moved by up to a pixel against it. At 1x (CI's display) the frame probe counted that as 1254 wrong frames in the chat and 555 on Issues ("moved 1.0, the drive 0.2"). Rows are now rounded to whole pixels in content coordinates, as on iOS, so they move with the content exactly. Run at 1x (NSHighResolutionCapable off), the probe went from 172 wrong frames on Issues to 0, and 0 on the chat. * test(zero-swift): the frame probe waits for a slow jump to move, and CI runs each suite in its own job A jump (Latest) settled half a second after it last moved, counting from when it began. On the iOS simulator, Latest from the top takes longer than that to load its rows, so the jump settled where it began and its landing was checked as a frame at rest ("no row in both frames ... was m0000"). It now waits for its first move, for up to 5 s; any frame between where it was and where it lands still fails. The virtual list UI tests timed out at 45 minutes with the probe's scripts on both platforms in one job; each platform and suite now runs in its own. * ci(zero-swift): UI tests boot their simulator before launching the app xcodebuild booted each simulator itself, and the first app launch on one still starting up timed out (TestHost at 461a9b4, zbugs on the iPad at ce30a2f). Each now waits for simctl bootstatus first; the screenshots job runs one simulator at a time. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * fix(zero-swift): VirtualList rows read the list's Zero client Each row is hosted in its own hosting view, cut off from the list's environment, so a @ZeroQuery in a row had no client and stayed empty without an error. Rows now get the list's .zero(_:) client. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * test(zero-swift): the frame probe's momentum check allows a simulator's frame timing On the iOS simulator, real-fling tests failed the momentum check in their first frames ("speed 4590 between 10413.9 and 4290"), while no row was out of place: the scroll offset and the rows moved together in every frame. The simulator's display link fires on time, but its callbacks come late by varying amounts, so a frame's sample can be one frame ahead of its timestamp, and the next pair's speed spikes. Under a fast fling, deceleration alone also moves a pair's speed by more than the fixed 300 pt/s band. A pair's speed is now compared with the mean of its neighbors', within 300 pt/s or 15 % of it, and frames within two of one whose callback came more than half a frame later than the last are skipped, as late frames already were. Replayed on the iPhone traces, it still finds bf41176's jump under a real fling. * fix(zero-swift): VirtualList rows inherit the list's environment as they are Rows' hosting views already inherit the list's environment, Zero client and custom values alike (checked on macOS and the iOS 26.5 simulator), so 0ff3f12's explicit .environment(\.zero, zero) was redundant. It goes, and so does ZeroVirtual's dependency on ZeroUI; the doc comment now says rows inherit. The test stays, guarding the inheritance. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * test(zero-swift): the core's scrolling tests run on LayoutHost; ScrollGeometryHost goes VirtualList lays its rows out with LayoutHost since a018d98, and nothing used ScrollGeometryHost. Its scenario tests (paging, taller rows, stick to bottom, a far jump, flings, a fling to the top from a permalink) now drive LayoutHost through a fake view that runs its layout passes, and check every pass, not only where the list settles: a row the test holds is never drawn moved, the core never scrolls under a fling away from the start, and no gap stands above the first row at the top. A new case checks LayoutHost takes leftover space at the start out at once. The tests of ScrollGeometryHost's own reporting go with it. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * test(zero-swift): the LayoutHost tests themselves 93af68b committed the renamed ScrollGeometryHost tests, not their port to LayoutHost, so nothing built. This is the port it describes. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * feat(zero-swift): VirtualListView, the virtual list for UIKit and AppKit VirtualListView is VirtualList for UIKit and AppKit apps, with rows that are the app's own views. It takes the same ZeroVirtualizer and a row closure, (item, reusing) -> UIView/NSView, which makes a row's view or shows the row in one another row left. Views of rows that leave are kept hidden in one pool. A row whose data changes is shown again in its own view. Heights come from Auto Layout at the list's width (systemLayoutSizeFitting; on macOS, fittingSize with a width constraint, after laying out at that width so wrapping labels wrap there), and rowHeightDidChange(_:) is for rows that change height on their own. It's the same native scroll view as VirtualList's, exposed as scrollView for insets and delegates, so the rows' frames, the content size and the offset still change in one layout pass. It follows the virtualizer while it's in a window. AppleTests open it in a window and scroll to a row far below, and check that a reused view shows its new row; each fails when its part is undone (not attaching; reusing a view without showing the new row in it). Underneath, RowsLayout no longer hosts SwiftUI itself: how a row's view is made, shown again when its row changes, and measured is a RowViews (HostedRowViews for VirtualList, unchanged in behavior; AppRowViews here), and the rest of the layout is the same for both. * test(zero-swift): the UI tests and frame probe run on VirtualListView too With -native 1, TestHost's chat and issues lists are VirtualListViews of UIKit or AppKit rows (NativeRowView: two labels and the probe's marker, one accessibility element like the SwiftUI rows). NativeVirtualListUITests and NativeFrameProbeUITests run the same tests on them, and CI runs each in its own job. * ci: key the Playwright cache by the OS release ubuntu-latest moved to Ubuntu 26.04, and the cache, keyed by Playwright's version alone, restored a WebKit built for 24.04 that can't load libicudata.so.74 there: Public Test failed on main at b39e85c. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * ci: the Swift tooling passes Public Checks, and Public Test runs on Ubuntu 24.04 Public JS runs on pull requests into main, so PR #1 was the first to check zero-swift's CLI: - syncpack: its esbuild follows the workspace's 0.28.2. It stays on TypeScript 6, as it uses the compiler API at runtime, which TypeScript 7 doesn't have, and its @rocicorp/zero peer range is the app's, not the workspace's version; both are recorded in .syncpackrc. - oxfmt: zbugs-screenshots.yml formatted. Public Test moves to ubuntu-24.04: ubuntu-latest is now 26.04, which Playwright 1.60 has no browsers for. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * feat(zero-swift): virtual lists get a header, a footer, and a selection VirtualList and VirtualListView lay out a header and a footer in the same pass as the rows: they're part of the content, before the space before the rows and after the space after them, so scrollToItem and the anchoring account for them. A selection of one row by key (a Binding for VirtualList; selection and onSelect for VirtualListView): a tap selects a row, even the selected one, and while the list has focus the arrow keys and j and k move the selection and scroll it into view. The list highlights the selected row in the platform's colors, passes isSelected to the row, and marks it selected for accessibility. * test(zero-swift): the Issues screen has a header, a footer, and a selection The UI tests check the header pushing the rows at the top and leaving them in place above the screen, the footer after the last row, and selecting by a tap and (on macOS) by the arrow keys and j and k. The frame probe's Issues script grows the header at the top and above the screen and, on macOS, moves the selection by the arrow keys past the screen's bottom. * fix(zero-swift): a list leaving doesn't detach the list that took its virtualizer over Switching between two lists on one virtualizer (zbugs' SwiftUI and native issue lists) can attach the new list before the old one leaves; the old one then detached the virtualizer from the new list. * feat(zbugs-swift): the issue list and an issue's comments page in as they scroll The issue list is a VirtualList of issueListV2 pages, set up as zbugs' list page sets up useZeroVirtualizer, in place of its first 200 issues; the UIKit and AppKit list is a VirtualListView in place of the DiffableAdapter table. Both use the virtual list's selection for the split view's highlight, and open an issue on a tap, even the selected one. An issue's page is one VirtualList: the header and the description above its comments, which page in as zbugs' comments do, and the composer below. The selected issue and the list's scroll state are kept in SceneStorage: a relaunch opens the list at the issue, as the web app's list permalink does, or else where it was. A UI test scrolls the list of all issues past 200 of them. * docs(zero-swift): VirtualListView's header, footer, and selection, by example * fix(zero-swift): a VirtualList header or footer that goes lets go of its hosting controller * fix(zbugs-swift): the UIKit issue cell keeps its icon beside a wrapping title, and a new filter opens at its top The cell's nested stack views squeezed the icon out beside a long title, and measured some titles at one line: plain constraints instead. A filter change cleared the selected issue's permalink, so the new list opened at its top, not at an issue it may not have. * fix(zero-swift): a list opened at a permalink or where it was shows its rows offline The list held off drawing its rows until it had landed at its target, and it lands once the rows' queries complete. Offline, those queries never complete, so the list stayed blank. It now draws once the viewport is placed at the target, whether or not the queries have completed. The offline zbugs screenshot runs skip the paging test, since the list doesn't page past its first page offline. * docs(zbugs): why the Swift app's login redirect can carry the token Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * ci(zero-swift): the Swift release also publishes the zero-swift CLI to npm swift-mirror.yml becomes swift-release.yml. After the mirror job, it packs packages/zero-swift-cli without credentials and publishes the tarball with npm trusted publishing, in the swift-release environment. The npm jobs run once the ZERO_SWIFT_NPM_PUBLISH variable is 'true', and the package version must match swift/VERSION. The package is no longer private, and the mirror is public. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * test(zero-swift): the frame probe lets a stalled frame scroll past every row When no row is on screen in both frames, the probe flagged a jump. A stalled main thread (a slow CI simulator's) lets a fling run on by a screenful or more between two frames, and UIScrollView catches up on the frame after it, so neither frame shares a row with the one before. The probe now compares the rows laid out in both frames by their content positions: a scroll leaves them where they were, less what the list compensated in that pass; a jump moves them. Up to a quarter of the offset's change, or 50 pt, counts as a scroll. The flag reports dt, the callback latency and the offset. * fix(zero-swift): rows are selected by keys on macOS only On iOS the arrow keys and j and k needed a hardware keyboard, and the list took first responder on a tap only for them. The iOS list now has no key commands and doesn't take first responder; a tap still selects a row, highlights it, and calls onSelect, and the selection binding and the accessibility traits are as they were. macOS keeps its keys. * build(zero-swift): the CLI's package names its repository, which npm provenance checks Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * ci(zero-swift): Changesets version the Swift client, and releases publish to npm by default A changeset for zero-swift versions the CLI and the Swift package together: pnpm changeset:version writes the CLI's version to swift/VERSION. On main, swift-release.yml keeps a version PR open. With no changesets pending, it releases the version when its mirror tag or npm package is missing, after approval in swift-release. Each step skips what's already released, so a failed run can be rerun. The ZERO_SWIFT_NPM_PUBLISH and mirror-token gates are gone; a missing token or trusted publisher now fails the job. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * chore(zero-swift): keep the fork's root package.json, lockfile, and .prettierignore as upstream has them .prettierignore goes back to upstream's: appending to it had joined its last line to ours. Changesets runs through pnpm dlx, pinned at 3.0.3, from zero-swift's version-packages script, so it's no longer a root dependency. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> --------- Co-authored-by: Amp <amp@ampcode.com>
1 parent b39e85c commit 799aafa

276 files changed

Lines changed: 112229 additions & 7 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.agents/setup‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ export DEBIAN_FRONTEND=noninteractive
77

88
echo '==> Checking native build prerequisites'
99
missing=()
10-
for package in build-essential python3 pkg-config curl ca-certificates xz-utils; do
10+
# postgresql backs the Swift end-to-end tests (packages/zero-conformance/src/e2e).
11+
for package in build-essential python3 pkg-config curl ca-certificates xz-utils postgresql; do
1112
if [[ "$(dpkg-query -W -f='${Status}' "$package" 2>/dev/null || true)" != 'install ok installed' ]]; then
1213
missing+=("$package")
1314
fi
@@ -17,6 +18,26 @@ if ((${#missing[@]})); then
1718
time sudo apt-get install -y "${missing[@]}"
1819
fi
1920

21+
echo '==> Installing the Swift toolchain for swift/'
22+
swift_version='6.2.4'
23+
if [[ "$(/opt/swift/usr/bin/swift --version 2>/dev/null || true)" != *"swift-${swift_version}-RELEASE"* ]]; then
24+
time sudo apt-get update
25+
time sudo apt-get install -y binutils-gold libicu-dev libcurl4-openssl-dev \
26+
libedit-dev libsqlite3-dev libncurses-dev libpython3-dev libxml2-dev \
27+
tzdata uuid-dev gnupg2 libstdc++-12-dev zlib1g-dev
28+
swift_tmp="$(mktemp -d)"
29+
time curl -sSfL -o "$swift_tmp/swift.tar.gz" \
30+
"https://download.swift.org/swift-${swift_version}-release/debian12/swift-${swift_version}-RELEASE/swift-${swift_version}-RELEASE-debian12.tar.gz"
31+
sudo rm -rf /opt/swift
32+
sudo mkdir -p /opt/swift
33+
sudo tar -xzf "$swift_tmp/swift.tar.gz" -C /opt/swift --strip-components=1
34+
rm -rf "$swift_tmp"
35+
fi
36+
for tool in swift swiftc sourcekit-lsp docc; do
37+
sudo ln -sf "/opt/swift/usr/bin/$tool" "/usr/local/bin/$tool"
38+
done
39+
swift --version
40+
2041
echo '==> Selecting the repository Node and pnpm versions'
2142
node_version="$(tr -d '[:space:]' < .nvmrc)"
2243
if [[ "$(node -p 'process.versions.node')" != "$node_version" && "$(node -p 'process.versions.node')" != "$node_version".* ]]; then
@@ -40,6 +61,14 @@ pnpm --version
4061
PATH="${PNPM_HOME:-$HOME/.local/share/pnpm}/bin:$PATH" \
4162
pnpm config set --global verify-deps-before-run false
4263

64+
# zero-cache runs from source only on Node 24 (`using`), which the Swift
65+
# end-to-end server needs; the .nvmrc Node stays the default.
66+
echo '==> Installing Node 24 for zero-cache into ~/.local/node24'
67+
if [[ "$("$HOME/.local/node24/bin/node" --version 2>/dev/null || true)" != v24.* ]]; then
68+
time N_PREFIX="$HOME/.local/node24" n install 24
69+
fi
70+
"$HOME/.local/node24/bin/node" --version
71+
4372
echo '==> Installing workspace dependencies from the lockfile'
4473
time pnpm install --frozen-lockfile
4574

‎.changeset/README.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Changesets
2+
3+
Changesets version the Swift client: the `zero-swift` npm package
4+
(`packages/zero-swift-cli`) and the Swift package (`swift/`), which share one
5+
version. Upstream Zero's packages are released separately and are ignored here.
6+
7+
To release a change to either, add a changeset for `zero-swift`:
8+
9+
```sh
10+
pnpm dlx @changesets/cli@3.0.3
11+
```
12+
13+
Changesets isn't a workspace dependency, so the root `package.json` and
14+
`pnpm-lock.yaml` stay as upstream has them and syncing upstream doesn't
15+
conflict there.
16+
17+
On `main`, `.github/workflows/swift-release.yml` keeps a "Version Packages" pull
18+
request open with the pending version and changelog. Merging it sets the
19+
version in `packages/zero-swift-cli/package.json` and `swift/VERSION`, and the
20+
workflow then publishes the npm package and tags the SwiftPM mirror after
21+
approval in the `swift-release` environment.

‎.changeset/config.json‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"$schema": "https://unpkg.com/@changesets/config@4.0.1/schema.json",
3+
"baseBranch": "main",
4+
"access": "public",
5+
"changelog": "@changesets/cli/changelog",
6+
"commit": false,
7+
"format": "oxfmt",
8+
"ignore": ["@rocicorp/zero-events", "replicache", "otel-proxy"],
9+
"privatePackages": {"version": false, "tag": false}
10+
}

‎.github/actions/playwright-install/action.yml‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,18 @@ runs:
66
steps:
77
- name: Get Playwright version
88
id: playwright-version
9-
run: echo "version=$(pnpm exec playwright --version | awk '{print $NF}')" >> "$GITHUB_OUTPUT"
9+
# The OS release too: browsers built for one Ubuntu release don't run
10+
# on the next, which ubuntu-latest moves to.
11+
run: |
12+
echo "version=$(pnpm exec playwright --version | awk '{print $NF}')" >> "$GITHUB_OUTPUT"
13+
echo "os=$(. /etc/os-release && echo "$ID-$VERSION_ID")" >> "$GITHUB_OUTPUT"
1014
shell: bash
1115
- name: Cache Playwright browsers
1216
id: playwright-cache
1317
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
1418
with:
1519
path: ~/.cache/ms-playwright
16-
key: playwright-${{ runner.os }}-${{ runner.arch }}-${{ steps.playwright-version.outputs.version }}
20+
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.os }}-${{ runner.arch }}-${{ steps.playwright-version.outputs.version }}
1721
- name: Install Playwright browsers and system deps
1822
if: steps.playwright-cache.outputs.cache-hit != 'true'
1923
run: timeout -k 30s 20m pnpm exec playwright install --with-deps

‎.github/workflows/public-js.yml‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,9 @@ jobs:
7979
test:
8080
name: Public Test
8181
if: github.repository == 'rocicorp/mono' || github.repository == 'rocicorp/mono-internal' || github.repository == 'wevm/zero'
82-
# See Public Checks. x64, since this runs WebKit and Firefox too.
83-
runs-on: ${{ github.repository == 'rocicorp/mono-internal' && 'ubuntu-x64-8-core' || 'ubuntu-latest' }}
82+
# See Public Checks. x64, since this runs WebKit and Firefox too. Ubuntu
83+
# 24.04, not ubuntu-latest: Playwright 1.60 has no browsers for 26.04.
84+
runs-on: ${{ github.repository == 'rocicorp/mono-internal' && 'ubuntu-x64-8-core' || 'ubuntu-24.04' }}
8485
steps:
8586
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
8687
with:

‎.github/workflows/swift-macos.yml‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
name: Swift (macOS)
2+
3+
# The Apple-only layer (ZeroUI, the URLSession transport) and iOS simulator
4+
# tests. Path-filtered because macOS runners are slow to start.
5+
6+
on:
7+
push:
8+
# swift-client is the development branch until the client lands on main.
9+
branches: [main, swift-client]
10+
paths:
11+
- 'swift/**'
12+
- 'packages/zero-conformance/**'
13+
- 'apps/zbugs-swift/**'
14+
- '.github/workflows/swift-macos.yml'
15+
pull_request:
16+
branches: [main]
17+
paths:
18+
- 'swift/**'
19+
- 'packages/zero-conformance/**'
20+
- 'apps/zbugs-swift/**'
21+
- '.github/workflows/swift-macos.yml'
22+
23+
concurrency:
24+
group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}'
25+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
26+
27+
permissions:
28+
contents: read
29+
30+
jobs:
31+
macos:
32+
name: Swift (macOS)
33+
runs-on: macos-26
34+
timeout-minutes: 45
35+
defaults:
36+
run:
37+
working-directory: swift
38+
steps:
39+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
40+
with:
41+
persist-credentials: false
42+
- run: swift --version
43+
- name: Build and test (macOS)
44+
run: swift test
45+
- name: Docs
46+
run: scripts/docs.sh
47+
- name: Test (iOS Simulator)
48+
run: |
49+
device="$(xcrun simctl list devices available -j |
50+
python3 -c 'import json,sys; d=json.load(sys.stdin)["devices"]; print(next(x["udid"] for k,v in d.items() if "iOS" in k for x in v if x["name"].startswith("iPhone")))')"
51+
xcodebuild test -scheme Zero-Package -destination "id=$device" -skipPackagePluginValidation
52+
53+
zbugs:
54+
name: zbugs app (iOS and macOS)
55+
runs-on: macos-26
56+
timeout-minutes: 45
57+
defaults:
58+
run:
59+
working-directory: apps/zbugs-swift
60+
steps:
61+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
62+
with:
63+
persist-credentials: false
64+
# The drift test again, and ZBugsUI (empty on Linux) for macOS.
65+
- name: Build and test (macOS)
66+
run: swift test
67+
- name: Generate the Xcode project
68+
working-directory: apps/zbugs-swift/App
69+
run: brew install xcodegen && xcodegen generate
70+
- name: Build the app (iOS Simulator)
71+
working-directory: apps/zbugs-swift/App
72+
run: >-
73+
xcodebuild build -project ZBugs.xcodeproj -scheme ZBugsApp
74+
-destination 'generic/platform=iOS Simulator'
75+
-skipPackagePluginValidation CODE_SIGNING_ALLOWED=NO
76+
- name: Build the app (macOS)
77+
working-directory: apps/zbugs-swift/App
78+
run: >-
79+
xcodebuild build -project ZBugs.xcodeproj -scheme ZBugsApp
80+
-destination 'generic/platform=macOS'
81+
-skipPackagePluginValidation CODE_SIGNING_ALLOWED=NO
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
name: Swift Release
2+
3+
# Releases the Swift client, versioned with Changesets (see .changeset/README.md).
4+
# The zero-swift npm package (packages/zero-swift-cli) and the Swift package
5+
# (swift/) share one version, kept in step by `pnpm --filter zero-swift version-packages`.
6+
#
7+
# On each push to main:
8+
# - With pending changesets, opens or updates the "Version Packages" PR.
9+
# - Otherwise, if the version in swift/VERSION isn't released yet, after
10+
# approval in the swift-release environment:
11+
# - publishes swift/ to the SwiftPM mirror (wevm/zero-swift) and tags the
12+
# version there. SwiftPM needs Package.swift at a repository root and
13+
# clones whole repositories, so consumers install from the mirror.
14+
# - publishes the zero-swift CLI to npm with trusted publishing.
15+
# Each step is skipped when its release already exists, so a failed run can be
16+
# rerun.
17+
18+
on:
19+
push:
20+
branches: [main]
21+
workflow_dispatch:
22+
23+
permissions:
24+
contents: read
25+
26+
concurrency:
27+
group: swift-release
28+
cancel-in-progress: false
29+
30+
jobs:
31+
version:
32+
name: Version PR
33+
runs-on: ubuntu-latest
34+
permissions:
35+
contents: write
36+
pull-requests: write
37+
outputs:
38+
has-changesets: ${{ steps.changesets.outputs.has-changesets }}
39+
steps:
40+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
41+
with:
42+
persist-credentials: false
43+
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
44+
with:
45+
version: 11.11.0
46+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
47+
with:
48+
node-version-file: .nvmrc
49+
package-manager-cache: false
50+
- run: pnpm install --frozen-lockfile
51+
- id: changesets
52+
uses: changesets/action@ae32849d5ba541f9ae29e40e22a623bc13562f51 # v2.1.2
53+
with:
54+
version-script: pnpm --filter zero-swift version-packages
55+
commit-message: 'chore(zero-swift): version packages'
56+
pr-title: 'chore(zero-swift): version packages'
57+
58+
plan:
59+
name: Plan release
60+
runs-on: ubuntu-latest
61+
needs: version
62+
if: needs.version.outputs.has-changesets == 'false'
63+
outputs:
64+
version: ${{ steps.plan.outputs.version }}
65+
mirror: ${{ steps.plan.outputs.mirror }}
66+
npm: ${{ steps.plan.outputs.npm }}
67+
steps:
68+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
69+
with:
70+
persist-credentials: false
71+
sparse-checkout: |
72+
swift/VERSION
73+
packages/zero-swift-cli/package.json
74+
sparse-checkout-cone-mode: false
75+
- id: plan
76+
run: |
77+
set -euo pipefail
78+
version="$(tr -d '[:space:]' < swift/VERSION)"
79+
package="$(jq -r .version packages/zero-swift-cli/package.json)"
80+
if [ "$package" != "$version" ]; then
81+
echo "packages/zero-swift-cli is $package but swift/VERSION is $version." >&2
82+
exit 1
83+
fi
84+
echo "version=$version" >> "$GITHUB_OUTPUT"
85+
if git ls-remote --exit-code --tags https://github.com/wevm/zero-swift.git "refs/tags/$version" >/dev/null; then
86+
echo "mirror=false" >> "$GITHUB_OUTPUT"
87+
else
88+
echo "mirror=true" >> "$GITHUB_OUTPUT"
89+
fi
90+
if npm view "zero-swift@$version" version >/dev/null 2>&1; then
91+
echo "npm=false" >> "$GITHUB_OUTPUT"
92+
else
93+
echo "npm=true" >> "$GITHUB_OUTPUT"
94+
fi
95+
96+
mirror:
97+
name: Publish mirror
98+
runs-on: ubuntu-latest
99+
needs: plan
100+
if: needs.plan.outputs.mirror == 'true'
101+
environment: swift-release
102+
env:
103+
MIRROR_TOKEN: ${{ secrets.ZERO_SWIFT_MIRROR_TOKEN }}
104+
MIRROR_REPO: wevm/zero-swift
105+
VERSION: ${{ needs.plan.outputs.version }}
106+
steps:
107+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
108+
with:
109+
fetch-depth: 0
110+
persist-credentials: false
111+
- name: Split and push
112+
run: |
113+
set -euo pipefail
114+
git subtree split --prefix=swift -b mirror
115+
remote="https://x-access-token:${MIRROR_TOKEN}@github.com/${MIRROR_REPO}.git"
116+
git push "$remote" mirror:main
117+
git tag "$VERSION" mirror
118+
git push "$remote" "refs/tags/$VERSION"
119+
120+
npm-build:
121+
name: Build npm package
122+
runs-on: ubuntu-latest
123+
needs: plan
124+
if: needs.plan.outputs.npm == 'true'
125+
steps:
126+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
127+
with:
128+
persist-credentials: false
129+
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
130+
with:
131+
version: 11.11.0
132+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
133+
with:
134+
node-version-file: .nvmrc
135+
package-manager-cache: false
136+
- run: pnpm install --frozen-lockfile
137+
- name: Build and pack
138+
run: |
139+
pnpm --filter zero-swift build
140+
pnpm --filter zero-swift pack --pack-destination "$GITHUB_WORKSPACE/dist/npm"
141+
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
142+
with:
143+
name: zero-swift-npm
144+
path: dist/npm/*.tgz
145+
if-no-files-found: error
146+
147+
npm-publish:
148+
name: Publish npm package
149+
runs-on: ubuntu-latest
150+
needs: [plan, npm-build]
151+
environment: swift-release
152+
permissions:
153+
contents: read
154+
id-token: write # For npm trusted publishing.
155+
env:
156+
VERSION: ${{ needs.plan.outputs.version }}
157+
steps:
158+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
159+
with:
160+
node-version: 24.x
161+
registry-url: 'https://registry.npmjs.org'
162+
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
163+
with:
164+
name: zero-swift-npm
165+
path: dist/npm
166+
- name: Publish
167+
run: npm publish "dist/npm/zero-swift-$VERSION.tgz" --provenance --access public

0 commit comments

Comments
 (0)