From 0f6e1dc581460a2d7f3c44271af9a5feca04d711 Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 19 Oct 2026 04:45:22 +0000 Subject: [PATCH 001/104] 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 --- swift/DESIGN.md | 442 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 442 insertions(+) create mode 100644 swift/DESIGN.md diff --git a/swift/DESIGN.md b/swift/DESIGN.md new file mode 100644 index 0000000000..e53a48e9eb --- /dev/null +++ b/swift/DESIGN.md @@ -0,0 +1,442 @@ +# Zero Swift client — design + +A native Swift client for Zero on iOS and macOS, with behavioral parity with +the JS client (`packages/zero-client`). Parity is enforced by a conformance +suite whose expected values come from running the JS implementation. + +The server side (`zero-cache`, the app's API server, Postgres) is unchanged. +The Swift client speaks the existing sync protocol to `zero-cache`. + +This document records the decisions made while scoping the project. Each +decision is listed with the reason that settled it. + +## Goals and non-goals + +Goals: + +- Behavioral parity with the JS client for every non-deprecated public API. +- Swift-idiomatic API that keeps the JS concept names (`query`, `mutate`, + `materialize`, `preload`, `connection`, TTL) so the JS docs map 1:1. +- Works with SwiftUI, UIKit, and AppKit (Mac Catalyst through UIKit). +- A conformance suite that the JS implementation generates and both + implementations pass. +- Public-SDK quality, driven by a real app (`apps/zbugs-swift`). + +Non-goals: + +- Running or configuring servers from Swift. +- Web-only features: IndexedDB, BroadcastChannel and Web Locks multi-tab + coordination, `hiddenTabDisconnectDelay`, page reloads. +- Deprecated JS APIs (see [Scope](#scope)). +- A production transport on Linux. The core builds and tests on Linux, but only + the Apple layer ships a real WebSocket transport. + +## Platforms and toolchain + +| Item | Decision | +| --- | --- | +| OS floor | iOS 26, macOS 26 | +| Toolchain | Swift 6.2 or newer (`swift-tools-version: 6.2`), Swift 6 language mode, strict concurrency | +| Linux | `Zero` (core) and `ZeroTesting` build and test on Linux; `ZeroUI` is Apple-only | +| Other Apple OSes | Not promised; nothing in the core should block them | +| Dependencies | No third-party runtime dependencies. Test-only dependencies are allowed (for example SwiftNIO for the Linux end-to-end transport) | +| Tests | Swift Testing; XCTest only where Apple requires it (UI tests) | +| Formatting | `swift-format` (ships with the toolchain), enforced in CI. No SwiftLint | + +iOS 26 and macOS 26 give `Observations` and default UIKit/AppKit observation +tracking (`updateProperties()`), so one `@Observable` type drives SwiftUI, +UIKit, and AppKit. Only the UI layer depends on the OS version, so the floor can +be lowered later with `@available` shims. + +## Repository layout and distribution + +``` +swift/ Swift package (source of truth) + Package.swift + Sources/Zero/ core engine, platform-agnostic + Sources/ZeroUI/ SwiftUI, UIKit, AppKit helpers; URLSession transport + Sources/ZeroTesting/ drift-harness replay API and test helpers + Tests/... + VERSION +packages/zero-conformance/ private JS package: fixtures, vector generators, JS runners +packages/zero-swift-cli/ npm package `zero-swift`: `codegen`, `drift record` +apps/zbugs-swift/ dogfood app (iOS + macOS) +``` + +- SwiftPM needs `Package.swift` at a repository root and clones the whole + repository. This monorepo's `.git` is about 436 MB, so the Swift package is + distributed through a mirror repository, `wevm/zero-swift`. CI fills it with a + `git subtree split` of `swift/` and tags releases there. Nobody commits to the + mirror directly. +- The mirror is private until launch, then public. The Swift source is visible + in this public monorepo either way. +- SwiftPM products: `Zero`, `ZeroUI`, `ZeroTesting`. +- The CLI ships as the unscoped npm package `zero-swift` (we don't own the + `@rocicorp` scope) with a peer dependency on `@rocicorp/zero`. It is + versioned with the Swift releases. +- License: Apache-2.0, like this repository. +- Docs: DocC, plus a JS→Swift parity matrix. + +### Versioning and releases + +- Independent semver starting at `0.1.0`. `1.0` once zbugs dogfooding shows the + API is stable. +- A PR bumps `swift/VERSION`. On merge, CI pushes the subtree split to the + mirror and tags it. +- The README carries a compatibility table: Swift version → sync protocol + version → minimum `zero-cache` version → the Zero JS release whose vectors + it passes. + +### Fork hygiene and CI policy + +- JS-side changes are additive only: new packages and files, no edits to + existing tests. This keeps upstream merges painless and the work upstreamable. +- The Swift client tracks this fork's `main`. +- CI fails when the checked-in conformance vectors are stale, or when Swift + fails them. Those are signs of real behavioral divergence. +- CI does not fail on a JS protocol bump. `zero-cache` must keep supporting + every sync protocol from `MIN_SERVER_SUPPORTED_SYNC_PROTOCOL` (30) through + `PROTOCOL_VERSION` indefinitely + (`packages/zero-protocol/src/protocol-version.ts`), so an older Swift client + keeps working. The Swift client declares its own protocol version, starting + at 53. + +## Architecture + +The JS client has three layers, and the Swift client ports them as follows. + +| JS subsystem | Size (non-test LOC) | Swift approach | +| --- | --- | --- | +| `zql` IVM operators and pipeline builder (`zql/src/ivm`, `zql/src/builder`) | ~10.7k | 1:1 operator port | +| `zero-client` logic: connection state machine, query manager, poke handler, mutation tracker, mutator proxy | ~11k total | Faithful behavioral port | +| Replicache (dag, B-tree, memdag, persist, refresh, GC) | ~18.6k | Keep the semantics, replace the storage machinery | + +Rationale: + +- **IVM runs on the client.** The server sends rows, not query results. The + client keeps every synced row in in-memory `MemorySource`s + (`zero-client/src/client/context.ts`), builds an operator pipeline for every + materialized query, and updates it incrementally on pokes and optimistic + mutations. Rebase forks the sources (`ivm-branch.ts`). A 1:1 port keeps the + edge-case behavior (Take boundaries, Exists with limits, change ordering) + identical. +- **Replicache semantics stay.** Client and client-group IDs, the pending + mutation queue, last mutation IDs, cookies, rebase on poke, `cached` result + tracking, client GC, and client-state-not-found all behave as in JS. +- **Replicache storage machinery goes.** The dag, B-tree, memdag, and refresh + stack exist mostly for IndexedDB performance and multi-tab sharing. Swift + uses SQLite tables for persistence and in-memory IVM sources for reads. + +### Threading + +- The engine (IVM, views, mutator execution) runs on `@MainActor`, as the JS + client runs on the main thread. `materialize` returns data synchronously, so + there is no empty first frame, and the vectors' ordering semantics carry over + directly. +- SQLite I/O, the socket, and poke JSON decoding run off the main actor. + +### Storage + +- SQLite (system `libsqlite3`), WAL mode. +- Pending mutations are written durably before they are pushed. Synced-row + writes are coalesced. +- Default location `Application Support/Zero/.sqlite`, configurable (for + example an App Group container). +- iOS Data Protection default `completeUntilFirstUserAuthentication`, + configurable. `complete` would make the database unreadable while the device + is locked and break the background flush. +- No database-level encryption. +- A Swift-side storage version. On a mismatch, drop the store and resync, as JS + does. +- If the store can't be opened, fall back to memory and call + `onStorageFailure`, as JS does. +- One process per store in v1: one `Zero` instance per user and storage key per + process. Concurrent cross-process access (app extensions) is deferred. + +### Transport + +- The core defines a transport protocol and never touches a socket directly. +- The Apple layer implements it with `URLSessionWebSocketTask`. +- Tests use an in-memory transport that replays scenario vectors, and, on Linux + end-to-end runs, a test-only SwiftNIO WebSocket transport. +- Wire details follow `zero-protocol`: connect to `/sync/v53/connect`, put + `initConnection` and the auth token in `Sec-WebSocket-Protocol` while they fit + under `maxHeaderLength`, and accept binary poke chunks (protocol 52 and later). + +### App lifecycle and recovery + +- iOS: on background, flush pending pushes inside a short background task, then + disconnect. Reconnect on foreground. No background sync in v1. +- macOS: stay connected while the app runs. Reconnect after wake. +- Both: reconnect immediately when `NWPathMonitor` reports the network is back. + This is a platform adaptation in the Apple layer; JS relies on ping/pong only. +- New client group and client-state-not-found: the SDK transparently recreates + its internal client. Active queries re-materialize and the `@Observable` + objects keep their identity. +- Version-not-supported and schema-not-supported can't be fixed by reloading a + native app. They surface as a callback plus an `.updateRequired` connection + state so the app can prompt for an update. +- Documentation must say that schema changes stay additive while old app + binaries are in the wild. The server rejects a client whose schema references + a dropped or retyped column + (`zero-cache/src/services/view-syncer/client-schema.ts`). + +## API + +### Scope + +In scope for v1 (everything non-deprecated): + +- Named queries and mutators, with CRUD inside mutators (`insert`, `upsert`, + `update`, `delete`). +- The full ZQL builder: `where` (all operators), `whereExists` (including + `flip` and `scalar`), `related`, `orderBy`, `limit`, `start`, `one`. +- `run` with `unknown`, `cached`, and `complete`; `materialize`; `preload`; + TTL. +- Connection state; `connect(auth:)`; the `needs-auth` flow. +- `close`, `delete`, `context`. +- `mutateURL` and `queryURL` with their headers; `getTraceparent`. +- `onUpdateNeeded`, `onClientStateNotFound`, `onStorageFailure`. +- Storage options (SQLite file, memory, custom store), logging, + `maxHeaderLength`, and binary poke chunks. + +Out of scope: + +- Deprecated: legacy AST queries (`enableLegacyQueries`), legacy CRUD on + `zero.mutate`, `mutateBatch`, and `_zero_crud` (`enableLegacyMutators`), + map-style mutators, `syncedQuery*`, the `server` and `getQueriesURL` aliases, + `online`/`onOnline`, `maxRecentQueries`, `tx.query`. +- Web-only features (see [Non-goals](#goals-and-non-goals)). +- Analytics. The JS client's metrics reporting is disabled + (`zero-client/src/client/zero.ts`), so there is nothing to match. + +Deferred to v1.x: the inspector. + +### Schema + +The TS schema (`schema.ts`) is the source of truth. `zero-swift codegen` loads +it and emits plain Swift: table descriptors (client and server names, column +types, optional, primary and unique keys, relationships, enums), `Codable` row +structs, and typed column references for the builder. + +| TS | Swift | +| --- | --- | +| `string()` | `String` | +| `boolean()` | `Bool` | +| `number()` | `Double`; per-column override to `Int` in the codegen config (checked, exact up to 2^53) | +| `enumeration<…>()` | `enum: String, Codable` | +| `json()` | A generated `Codable` type when `T` is a plain object or literal union, otherwise `JSONValue` | +| `.optional()` | Swift optional | + +Row struct names are the table names in PascalCase. The generated code stays +readable enough to hand-write. + +### Queries and mutators + +Named queries and mutators are hand-written in Swift, including their argument +types. Names are explicit dotted strings that match the TS registries. + +```swift +enum IssueQueries { + static let byStatus = defineQuery("issues.byStatus") { (args: ByStatus, ctx: AuthData?) in + zql.issue.where(\.status, args.status).orderBy(\.modified, .desc) + } +} + +enum IssueMutators { + static let create = defineMutator("issue.create") { (tx, args: CreateIssue, ctx: AuthData?) async throws in + try await tx.mutate.issue.insert(.init(id: args.id, title: args.title)) + } + static let notify = defineMutator.serverOnly("issue.notify", args: Notify.self) +} + +let zero = Zero(schema: .app, context: auth, mutators: [IssueMutators.create, IssueMutators.notify]) +let view = zero.materialize(IssueQueries.byStatus(.init(status: .open))) +zero.mutate(IssueMutators.create(.init(id: newID(), title: "…"))) +``` + +- `Zero` is generic over the schema and the context type. +- Mutators need a registry: pending mutations are stored as name plus args and + replayed by name on rebase, including after a restart. Registering two + mutators under one name throws at init. A pending mutation with an unknown + name is reported as an error, not a crash. +- Queries don't need a registry. Every named query needs a Swift body, because + the client builds the AST locally to compute results. +- `.serverOnly` mutators have no optimistic effect; they only push name and + args. +- Argument validation is structural, through the `Codable` arg types, with an + optional hand-written `validate(args)` hook per definition. The server's + Standard Schema validator stays the authority. + +### Query builder + +```swift +zql.issue + .where(\.status, .open) // ≈ where('status', 'open') + .where { $0.priority >= 2 && ($0.assignee == me || $0.assignee == nil) } // ≈ where(({cmp, and, or}) => …) + .whereExists(\.labels) { $0.where(\.name, "bug") } + .related(\.comments) { $0.orderBy(\.created, .desc).limit(10) } + .orderBy(\.modified, .desc) + .limit(50) +``` + +- Key paths map to columns through the generated table descriptors. +- Generated row structs carry optional relationship properties (for example + `comments: [Comment]?`) that are filled only when `related` requested them. +- `one()` returns `Row?`. + +### Mutate, errors, auth, logging + +- `zero.mutate(request)` is `@discardableResult` and returns a handle with + `client` and `server` async throwing properties, the same split as JS + `{client, server}`. +- One `ZeroError` enum mirrors the JS kinds: application (with message), input + validation, storage failure, transaction closed, rejected while disconnected + or closed, and Zero/server errors. The public API uses plain `throws` because + mutator bodies can throw arbitrary errors. +- Auth: an `auth` token option and a `.needsAuth` connection state on 401/403. + The app calls `connection.connect(auth:)`, which sends `updateAuth` when + already connected. An async token-provider closure is deferred. +- Logging: a `LogSink` protocol and a `logLevel` option defaulting to `.warn` + (what the JS code does). The default sink is `os.Logger` (subsystem `zero`) + on Apple platforms and stderr on Linux. No telemetry. + +### UI integration + +1. **Core view.** `materialize(query)` returns a view with listener callbacks + and an `AsyncSequence` of immutable snapshots plus per-row diffs. + Framework-agnostic; builds on Linux. +2. **`@Observable` query object** with `data`, `resultType`, and `error`. + Works in SwiftUI and is tracked automatically by UIKit and AppKit. +3. **SwiftUI.** Environment injection, a `@Query`-style property wrapper (≈ + `useQuery`), and connection state. +4. **UIKit and AppKit.** A diffable-data-source snapshot adapter keyed by + primary key. + +No Combine publishers. + +## Conformance + +The public repository has no JS tests for `zql`, `zero-protocol`, `zqlite`, or +`zero-cache`; upstream `rocicorp/mono` doesn't either. Only `replicache`, +`shared`, and `zero-client` have tests. Vectors are therefore generated by +running the JS implementation as the oracle. + +`packages/zero-conformance` (private, additive) holds the fixtures, the +generators, and the JS-side runners. Vectors come in three layers: + +1. **Primitives.** The xxHash32-based hashes (`hashOfAST`, + `hashOfNameAndArgs`), primary-key to storage-key encoding, value comparison + (JS compares strings by UTF-16 code unit; Swift `String <` differs), LIKE + and ILIKE (reusing `zql/src/builder/like-test-cases.ts`), TTL parsing and + clamping, AST normalization, and protocol message decode/encode including + binary poke chunks. +2. **Query semantics.** IVM results plus the per-push sequence of view changes + (add, remove, edit, child) for queries over fixture data under push + sequences. Internal operator traces are not recorded, so upstream refactors + don't churn the vectors. +3. **Client behavior.** Scenarios in a JSON step format: open with options, + connect, inject a downstream message, advance a fake clock, mutate, + materialize or destroy, then expect an upstream message, view data or result + type, or connection state. A JS runner executes each scenario against the + real `TestZero` and `MockSocket` to fill in and verify expectations; a Swift + runner replays it. Scenarios are seeded from the existing `zero-client` + tests (`zero.test.ts`, the poke handler, query manager, mutation tracker, and + connection manager). + +Fixtures: + +- `zql/src/query/test/test-schemas.ts`. +- The zbugs schema. +- A synthetic edge-case schema: every column type, compound primary keys, + optional columns, enums, json, self-relations, and junction tables. + +Datasets include empty strings, nulls, Unicode (emoji and surrogate pairs), +`-0`, fractional numbers, integers above 2^31 and near 2^53, and nested JSON. + +Vector storage and fuzzing: + +- Curated vectors are checked in. CI regenerates them from JS and fails if they + are stale. +- Seeded random query and push fuzz cases are generated in CI from fixed seeds + (not checked in) and run against Swift. +- A live differential harness (JS drives a Swift CLI over stdio with random + seeds and shrinks failures) is deferred to v1.x. + +### Drift harness for apps + +Hand-written Swift queries and mutators can drift from their TS counterparts. +The drift harness catches it in the app's own tests. + +- **TS side.** The app author writes a samples file: argument sets per query, + and argument sets plus seed rows per mutator. `zero-swift drift record` runs + the TS definitions and writes `drift-vectors.json`. +- **Swift side.** `ZeroTesting` exposes + `assertNoDrift(vectors:queries:mutators:)` for Swift Testing or XCTest. + +Checks: + +- Each query sample's normalized AST hash matches. +- Each mutator sample produces the same row operations. +- Every name in the TS registries has a Swift definition, and the other way + round. +- Each sample's args JSON decodes into the Swift args type and re-encodes to + identical JSON. This catches renamed fields and `nil`-versus-omitted + mismatches (zod's `.optional()` rejects `null`). + +A mutator that calls `Date.now()` or generates IDs internally can't be +compared, so the harness flags it. The docs recommend the Zero convention of +passing IDs and timestamps as arguments. + +### CI + +- **Linux, every PR.** Swift build, unit tests, and conformance vectors; the + vector staleness check; an end-to-end run against a real `zero-cache` and + Postgres with the zbugs schema, including the drift harness. +- **macOS, path-filtered to `swift/` and `packages/zero-conformance/`.** The + Apple layer (URLSession transport against a local mock WebSocket server, + SwiftUI, UIKit, and AppKit helpers), iOS simulator tests, and the + `apps/zbugs-swift` build. +- **Benchmarks.** Ports of the key JS benchmarks + (`zero-client/src/client/zero.bench.ts`, the shared B-tree and hash + benchmarks) plus Swift benchmarks for query hydration over N rows, poke + apply, mutation round trip, and memory per 100k rows. Tracked next to the JS + numbers, non-blocking in v1, with a goal of no slower than JS. + +## Dogfood app: `apps/zbugs-swift` + +- A SwiftUI multiplatform app (iOS and macOS), plus one UIKit screen and one + AppKit screen (the issue list through the diffable adapter), so every UI + layer gets exercised. +- Features: issue list with filters, issue detail, comments, labels, create + and edit issue. +- Built in macOS CI; not shipped to TestFlight or the App Store in v1. +- Auth: zbugs login sets the JWT as a browser cookie and redirects + (`apps/zbugs/api/index.ts`), which a native app can't read. An additive + change to the zbugs API allows a `zbugs-swift://` redirect that carries the + JWT, used through `ASWebAuthenticationSession`. A dev script mints a JWT + with the dev key for local and CI end-to-end runs. + +## Milestones + +Each milestone is done when its conformance layer passes. + +| # | Milestone | Done when | +| --- | --- | --- | +| M0 | Scaffolding: `swift/` with the three targets, Linux and macOS CI, `swift-format`, the `packages/zero-conformance` skeleton, the mirror job | CI green on an empty package | +| M1 | Primitives: JSON value model, hashes, key encoding, UTF-16 value comparison, LIKE, TTL, AST and normalization, protocol codecs including binary poke chunks | Primitive vectors pass | +| M1.5 | Protocol-only walking skeleton: connect to a real `zero-cache`, handshake, receive pokes, log rows | Talks to a real server | +| M2 | IVM: `MemorySource`, all client operators, pipeline builder, change application, the builder API | Curated and seeded-fuzz IVM vectors pass | +| M3 | Schema codegen CLI; generate the zbugs and fixture schemas | Generated code compiles and drives M2 | +| M4 | Client engine on memory storage: connection state machine, query manager (named queries, TTL, cached/complete), poke handler, mutators, `tx`, pending queue, rebase, mutation tracker, auth, recovery | Client scenario vectors pass | +| M5 | SQLite persistence, client and client-group lifecycle and GC, storage version, memory fallback | Scenarios pass on SQLite | +| M6 | Apple layer: URLSession transport, lifecycle, `NWPathMonitor`, `os.Logger`, `@Observable` queries, SwiftUI wrapper and environment, UIKit and AppKit diffable adapters | macOS CI green | +| M7 | Linux end-to-end against real `zero-cache` and Postgres; drift harness; zbugs queries and mutators ported to Swift | End-to-end and drift checks pass | +| M8 | `apps/zbugs-swift` (iOS and macOS, UIKit and AppKit screens, auth), benchmarks, DocC, parity matrix | `0.1.0` ready to tag on the mirror | + +Deferred to v1.x: the inspector, the live differential fuzzer, an async auth +provider, cross-process access through App Groups, validator-derived drift +samples, and making the mirror public at launch. + +Creating the mirror repository, its CI token, the `0.1.0` tag, and the npm +`zero-swift` publish are external actions that need explicit approval. From b173358b697cce2dd871d65af9b9f7c7649076f4 Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 19 Oct 2026 04:49:25 +0000 Subject: [PATCH 002/104] 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 --- .agents/setup | 20 ++++++ .github/workflows/swift-macos.yml | 47 +++++++++++++ .github/workflows/swift-mirror.yml | 44 ++++++++++++ .github/workflows/swift.yml | 58 ++++++++++++++++ packages/zero-conformance/README.md | 11 +++ packages/zero-conformance/package.json | 30 ++++++++ packages/zero-conformance/src/meta.test.ts | 20 ++++++ packages/zero-conformance/src/vectors.ts | 58 ++++++++++++++++ packages/zero-conformance/tsconfig.json | 7 ++ packages/zero-conformance/vectors/meta.json | 11 +++ packages/zero-conformance/vitest.config.ts | 13 ++++ pnpm-lock.yaml | 36 ++++++++++ swift/.gitignore | 4 ++ swift/.swift-format | 17 +++++ swift/DESIGN.md | 68 +++++++++---------- swift/Package.swift | 20 ++++++ swift/Sources/Zero/Zero.swift | 7 ++ swift/Sources/ZeroTesting/ZeroTesting.swift | 1 + swift/Sources/ZeroUI/ZeroUI.swift | 1 + swift/Tests/ConformanceTests/MetaTests.swift | 18 +++++ swift/Tests/ConformanceTests/Vectors.swift | 63 +++++++++++++++++ .../ZeroTests/ProtocolVersionTests.swift | 6 ++ swift/VERSION | 1 + 23 files changed, 527 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/swift-macos.yml create mode 100644 .github/workflows/swift-mirror.yml create mode 100644 .github/workflows/swift.yml create mode 100644 packages/zero-conformance/README.md create mode 100644 packages/zero-conformance/package.json create mode 100644 packages/zero-conformance/src/meta.test.ts create mode 100644 packages/zero-conformance/src/vectors.ts create mode 100644 packages/zero-conformance/tsconfig.json create mode 100644 packages/zero-conformance/vectors/meta.json create mode 100644 packages/zero-conformance/vitest.config.ts create mode 100644 swift/.gitignore create mode 100644 swift/.swift-format create mode 100644 swift/Package.swift create mode 100644 swift/Sources/Zero/Zero.swift create mode 100644 swift/Sources/ZeroTesting/ZeroTesting.swift create mode 100644 swift/Sources/ZeroUI/ZeroUI.swift create mode 100644 swift/Tests/ConformanceTests/MetaTests.swift create mode 100644 swift/Tests/ConformanceTests/Vectors.swift create mode 100644 swift/Tests/ZeroTests/ProtocolVersionTests.swift create mode 100644 swift/VERSION diff --git a/.agents/setup b/.agents/setup index f397615d65..41c9afe0e9 100755 --- a/.agents/setup +++ b/.agents/setup @@ -17,6 +17,26 @@ if ((${#missing[@]})); then time sudo apt-get install -y "${missing[@]}" fi +echo '==> Installing the Swift toolchain for swift/' +swift_version='6.2.4' +if [[ "$(/opt/swift/usr/bin/swift --version 2>/dev/null || true)" != *"swift-${swift_version}-RELEASE"* ]]; then + time sudo apt-get update + time sudo apt-get install -y binutils-gold libicu-dev libcurl4-openssl-dev \ + libedit-dev libsqlite3-dev libncurses-dev libpython3-dev libxml2-dev \ + tzdata uuid-dev gnupg2 libstdc++-12-dev zlib1g-dev + swift_tmp="$(mktemp -d)" + time curl -sSfL -o "$swift_tmp/swift.tar.gz" \ + "https://download.swift.org/swift-${swift_version}-release/debian12/swift-${swift_version}-RELEASE/swift-${swift_version}-RELEASE-debian12.tar.gz" + sudo rm -rf /opt/swift + sudo mkdir -p /opt/swift + sudo tar -xzf "$swift_tmp/swift.tar.gz" -C /opt/swift --strip-components=1 + rm -rf "$swift_tmp" +fi +for tool in swift swiftc sourcekit-lsp docc; do + sudo ln -sf "/opt/swift/usr/bin/$tool" "/usr/local/bin/$tool" +done +swift --version + echo '==> Selecting the repository Node and pnpm versions' node_version="$(tr -d '[:space:]' < .nvmrc)" if [[ "$(node -p 'process.versions.node')" != "$node_version" && "$(node -p 'process.versions.node')" != "$node_version".* ]]; then diff --git a/.github/workflows/swift-macos.yml b/.github/workflows/swift-macos.yml new file mode 100644 index 0000000000..967e5813e3 --- /dev/null +++ b/.github/workflows/swift-macos.yml @@ -0,0 +1,47 @@ +name: Swift (macOS) + +# The Apple-only layer (ZeroUI, the URLSession transport) and iOS simulator +# tests. Path-filtered because macOS runners are slow to start. + +on: + push: + branches: [main] + paths: + - 'swift/**' + - 'packages/zero-conformance/**' + - 'apps/zbugs-swift/**' + - '.github/workflows/swift-macos.yml' + pull_request: + branches: [main] + paths: + - 'swift/**' + - 'packages/zero-conformance/**' + - 'apps/zbugs-swift/**' + - '.github/workflows/swift-macos.yml' + +concurrency: + group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + macos: + name: Swift (macOS) + runs-on: macos-26 + defaults: + run: + working-directory: swift + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - run: swift --version + - name: Build and test (macOS) + run: swift test + - name: Test (iOS Simulator) + run: | + device="$(xcrun simctl list devices available -j | + 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")))')" + xcodebuild test -scheme Zero-Package -destination "id=$device" -skipPackagePluginValidation diff --git a/.github/workflows/swift-mirror.yml b/.github/workflows/swift-mirror.yml new file mode 100644 index 0000000000..f903196c0c --- /dev/null +++ b/.github/workflows/swift-mirror.yml @@ -0,0 +1,44 @@ +name: Swift Mirror + +# Publishes swift/ to the SwiftPM mirror (wevm/zero-swift) and tags the version +# in swift/VERSION. SwiftPM needs Package.swift at a repository root and clones +# whole repositories, so consumers install from the mirror, not this monorepo. +# +# The swift-release environment requires approval, and the job is skipped +# until the ZERO_SWIFT_MIRROR_TOKEN secret (push access to the mirror) exists. + +on: + push: + branches: [main] + paths: ['swift/VERSION'] + workflow_dispatch: + +permissions: + contents: read + +jobs: + mirror: + name: Publish mirror + runs-on: ubuntu-latest + environment: swift-release + env: + MIRROR_TOKEN: ${{ secrets.ZERO_SWIFT_MIRROR_TOKEN }} + MIRROR_REPO: wevm/zero-swift + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + - name: Split and push + if: env.MIRROR_TOKEN != '' + run: | + version="$(tr -d '[:space:]' < swift/VERSION)" + git subtree split --prefix=swift -b mirror + remote="https://x-access-token:${MIRROR_TOKEN}@github.com/${MIRROR_REPO}.git" + git push "$remote" mirror:main + if git ls-remote --exit-code --tags "$remote" "refs/tags/$version" >/dev/null; then + echo "Tag $version already exists on the mirror; not retagging." + else + git tag "$version" mirror + git push "$remote" "refs/tags/$version" + fi diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml new file mode 100644 index 0000000000..8a13a543f7 --- /dev/null +++ b/.github/workflows/swift.yml @@ -0,0 +1,58 @@ +name: Swift + +# CI for the Swift client in swift/ and the conformance vectors it replays +# (packages/zero-conformance). Linux runs on every change because the vectors +# can go stale from any JS change. The Apple-only layer runs in swift-macos.yml. + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + vectors: + name: Conformance Vectors + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + version: 11.11.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.x + package-manager-cache: false + - run: pnpm install --frozen-lockfile + # Fails when the checked-in vectors no longer match the JS behavior. + - name: Check vectors are current + run: pnpm --filter zero-conformance run test + + linux: + name: Swift (Linux) + runs-on: ubuntu-latest + container: swift:6.2.4-bookworm + defaults: + run: + working-directory: swift + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Install system libraries + run: apt-get update && apt-get install -y --no-install-recommends libsqlite3-dev + - name: Format + run: swift format lint --strict --recursive Sources Tests Package.swift + - name: Build + run: swift build --build-tests + - name: Test + run: swift test --skip-build diff --git a/packages/zero-conformance/README.md b/packages/zero-conformance/README.md new file mode 100644 index 0000000000..e1d0e58908 --- /dev/null +++ b/packages/zero-conformance/README.md @@ -0,0 +1,11 @@ +# zero-conformance + +Conformance vectors for non-JS Zero clients, generated by running the JS +implementation. The Swift client (`swift/`) replays every file in `vectors/`. + +- `pnpm run test` checks that the checked-in vectors match the current JS + behavior. +- `pnpm run generate` rewrites them. + +Each generator is a `*.test.ts` file under `src/` that calls `emitVectors`. +See `swift/DESIGN.md` for the vector layers. diff --git a/packages/zero-conformance/package.json b/packages/zero-conformance/package.json new file mode 100644 index 0000000000..4a266a688c --- /dev/null +++ b/packages/zero-conformance/package.json @@ -0,0 +1,30 @@ +{ + "name": "zero-conformance", + "version": "0.0.0", + "private": true, + "description": "Conformance vectors generated from the JS Zero client, consumed by the Swift client.", + "type": "module", + "scripts": { + "test": "vitest run", + "generate": "UPDATE_VECTORS=1 vitest run", + "check-types": "tsc", + "format": "oxfmt .", + "check-format": "oxfmt --check .", + "lint": "oxlint --quiet --config ../../oxlint.config.ts src/", + "fmt": "oxfmt .", + "check-fmt": "oxfmt --check ." + }, + "devDependencies": { + "@types/node": "^22.10.5", + "oxfmt": "^0.65.0", + "replicache": "workspace:*", + "shared": "workspace:*", + "typescript": "~7.0.2", + "vitest": "^5.0.0", + "zero-client": "workspace:*", + "zero-protocol": "workspace:*", + "zero-schema": "workspace:*", + "zero-types": "workspace:*", + "zql": "workspace:*" + } +} diff --git a/packages/zero-conformance/src/meta.test.ts b/packages/zero-conformance/src/meta.test.ts new file mode 100644 index 0000000000..40f2e40200 --- /dev/null +++ b/packages/zero-conformance/src/meta.test.ts @@ -0,0 +1,20 @@ +import {test} from 'vitest'; +import { + MIN_SERVER_SUPPORTED_SYNC_PROTOCOL, + PROTOCOL_VERSION, +} from '../../zero-protocol/src/protocol-version.ts'; +import {emitVectors} from './vectors.ts'; + +test('meta', () => { + emitVectors( + 'meta', + 'meta', + 'Protocol versions of the JS implementation the vectors were generated from.', + [ + { + protocolVersion: PROTOCOL_VERSION, + minServerSupportedSyncProtocol: MIN_SERVER_SUPPORTED_SYNC_PROTOCOL, + }, + ], + ); +}); diff --git a/packages/zero-conformance/src/vectors.ts b/packages/zero-conformance/src/vectors.ts new file mode 100644 index 0000000000..5172ba6277 --- /dev/null +++ b/packages/zero-conformance/src/vectors.ts @@ -0,0 +1,58 @@ +import {readFileSync, writeFileSync} from 'node:fs'; +import {fileURLToPath} from 'node:url'; +import {expect} from 'vitest'; + +/** + * Bumped when the shape of every vector file changes. Each file also carries + * its own `kind`, so readers can reject vectors they don't understand. + */ +export const VECTOR_FORMAT_VERSION = 1; + +const vectorsDir = fileURLToPath(new URL('../vectors/', import.meta.url)); + +export const updateVectors = process.env['UPDATE_VECTORS'] === '1'; + +export type VectorFile = { + formatVersion: number; + kind: string; + description: string; + cases: T[]; +}; + +/** + * Writes `vectors/.json` when `UPDATE_VECTORS=1`, otherwise checks that + * the checked-in file matches what the JS implementation produces now. A + * mismatch means the JS behavior changed and the Swift client must follow + * (regenerate with `pnpm run generate`). + */ +export function emitVectors( + name: string, + kind: string, + description: string, + cases: T[], +): void { + const file: VectorFile = { + formatVersion: VECTOR_FORMAT_VERSION, + kind, + description, + cases, + }; + const text = JSON.stringify(file, null, 2) + '\n'; + const path = vectorsDir + name + '.json'; + if (updateVectors) { + writeFileSync(path, text); + return; + } + let existing: string; + try { + existing = readFileSync(path, 'utf8'); + } catch { + throw new Error( + `Missing vectors/${name}.json. Run \`pnpm run generate\` in packages/zero-conformance.`, + ); + } + expect( + existing, + `vectors/${name}.json is stale. Run \`pnpm run generate\` in packages/zero-conformance.`, + ).toBe(text); +} diff --git a/packages/zero-conformance/tsconfig.json b/packages/zero-conformance/tsconfig.json new file mode 100644 index 0000000000..9bafd523af --- /dev/null +++ b/packages/zero-conformance/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/zero-conformance/vectors/meta.json b/packages/zero-conformance/vectors/meta.json new file mode 100644 index 0000000000..b272673529 --- /dev/null +++ b/packages/zero-conformance/vectors/meta.json @@ -0,0 +1,11 @@ +{ + "formatVersion": 1, + "kind": "meta", + "description": "Protocol versions of the JS implementation the vectors were generated from.", + "cases": [ + { + "protocolVersion": 53, + "minServerSupportedSyncProtocol": 30 + } + ] +} diff --git a/packages/zero-conformance/vitest.config.ts b/packages/zero-conformance/vitest.config.ts new file mode 100644 index 0000000000..81f1a70d74 --- /dev/null +++ b/packages/zero-conformance/vitest.config.ts @@ -0,0 +1,13 @@ +import {mergeConfig} from 'vitest/config'; +import config from '../shared/src/tool/vitest-config.ts'; + +// Vectors are written to disk, so the generators run in Node rather than in +// the browser like most zero-client tests. +export default mergeConfig(config, { + test: { + name: 'zero-conformance', + browser: { + enabled: false, + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24f8a6e38f..3ac2b35a52 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1296,6 +1296,42 @@ importers: specifier: workspace:* version: link:../zql + packages/zero-conformance: + devDependencies: + '@types/node': + specifier: ^22.10.5 + version: 22.19.19 + oxfmt: + specifier: ^0.65.0 + version: 0.65.0 + replicache: + specifier: workspace:* + version: link:../replicache + shared: + specifier: workspace:* + version: link:../shared + typescript: + specifier: ~7.0.2 + version: 7.0.2 + vitest: + specifier: ^5.0.0 + version: 5.0.0(@opentelemetry/api@1.9.1)(@types/node@22.19.19)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(vite@8.0.13(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.47.1)(tsx@4.22.0)(yaml@2.9.0)) + zero-client: + specifier: workspace:* + version: link:../zero-client + zero-protocol: + specifier: workspace:* + version: link:../zero-protocol + zero-schema: + specifier: workspace:* + version: link:../zero-schema + zero-types: + specifier: workspace:* + version: link:../zero-types + zql: + specifier: workspace:* + version: link:../zql + packages/zero-events: devDependencies: esbuild: diff --git a/swift/.gitignore b/swift/.gitignore new file mode 100644 index 0000000000..77b280c420 --- /dev/null +++ b/swift/.gitignore @@ -0,0 +1,4 @@ +.build/ +.swiftpm/ +*.xcodeproj/xcuserdata/ +Package.resolved diff --git a/swift/.swift-format b/swift/.swift-format new file mode 100644 index 0000000000..a7f178ecfa --- /dev/null +++ b/swift/.swift-format @@ -0,0 +1,17 @@ +{ + "version": 1, + "indentation": { "spaces": 2 }, + "lineLength": 100, + "maximumBlankLines": 1, + "respectsExistingLineBreaks": true, + "lineBreakBeforeEachArgument": false, + "prioritizeKeepingFunctionOutputTogether": true, + "rules": { + "AlwaysUseLowerCamelCase": false, + "NeverForceUnwrap": false, + "NeverUseForceTry": false, + "OrderedImports": true, + "UseLetInEveryBoundCaseVariable": false, + "ValidateDocumentationComments": false + } +} diff --git a/swift/DESIGN.md b/swift/DESIGN.md index e53a48e9eb..cf1dbe98ad 100644 --- a/swift/DESIGN.md +++ b/swift/DESIGN.md @@ -33,15 +33,15 @@ Non-goals: ## Platforms and toolchain -| Item | Decision | -| --- | --- | -| OS floor | iOS 26, macOS 26 | -| Toolchain | Swift 6.2 or newer (`swift-tools-version: 6.2`), Swift 6 language mode, strict concurrency | -| Linux | `Zero` (core) and `ZeroTesting` build and test on Linux; `ZeroUI` is Apple-only | -| Other Apple OSes | Not promised; nothing in the core should block them | -| Dependencies | No third-party runtime dependencies. Test-only dependencies are allowed (for example SwiftNIO for the Linux end-to-end transport) | -| Tests | Swift Testing; XCTest only where Apple requires it (UI tests) | -| Formatting | `swift-format` (ships with the toolchain), enforced in CI. No SwiftLint | +| Item | Decision | +| ---------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| OS floor | iOS 26, macOS 26 | +| Toolchain | Swift 6.2 or newer (`swift-tools-version: 6.2`), Swift 6 language mode, strict concurrency | +| Linux | `Zero` (core) and `ZeroTesting` build and test on Linux; `ZeroUI` is Apple-only | +| Other Apple OSes | Not promised; nothing in the core should block them | +| Dependencies | No third-party runtime dependencies. Test-only dependencies are allowed (for example SwiftNIO for the Linux end-to-end transport) | +| Tests | Swift Testing; XCTest only where Apple requires it (UI tests) | +| Formatting | `swift-format` (ships with the toolchain), enforced in CI. No SwiftLint | iOS 26 and macOS 26 give `Observations` and default UIKit/AppKit observation tracking (`updateProperties()`), so one `@Observable` type drives SwiftUI, @@ -105,11 +105,11 @@ apps/zbugs-swift/ dogfood app (iOS + macOS) The JS client has three layers, and the Swift client ports them as follows. -| JS subsystem | Size (non-test LOC) | Swift approach | -| --- | --- | --- | -| `zql` IVM operators and pipeline builder (`zql/src/ivm`, `zql/src/builder`) | ~10.7k | 1:1 operator port | -| `zero-client` logic: connection state machine, query manager, poke handler, mutation tracker, mutator proxy | ~11k total | Faithful behavioral port | -| Replicache (dag, B-tree, memdag, persist, refresh, GC) | ~18.6k | Keep the semantics, replace the storage machinery | +| JS subsystem | Size (non-test LOC) | Swift approach | +| ----------------------------------------------------------------------------------------------------------- | ------------------- | ------------------------------------------------- | +| `zql` IVM operators and pipeline builder (`zql/src/ivm`, `zql/src/builder`) | ~10.7k | 1:1 operator port | +| `zero-client` logic: connection state machine, query manager, poke handler, mutation tracker, mutator proxy | ~11k total | Faithful behavioral port | +| Replicache (dag, B-tree, memdag, persist, refresh, GC) | ~18.6k | Keep the semantics, replace the storage machinery | Rationale: @@ -219,14 +219,14 @@ it and emits plain Swift: table descriptors (client and server names, column types, optional, primary and unique keys, relationships, enums), `Codable` row structs, and typed column references for the builder. -| TS | Swift | -| --- | --- | -| `string()` | `String` | -| `boolean()` | `Bool` | -| `number()` | `Double`; per-column override to `Int` in the codegen config (checked, exact up to 2^53) | -| `enumeration<…>()` | `enum: String, Codable` | -| `json()` | A generated `Codable` type when `T` is a plain object or literal union, otherwise `JSONValue` | -| `.optional()` | Swift optional | +| TS | Swift | +| ------------------ | --------------------------------------------------------------------------------------------- | +| `string()` | `String` | +| `boolean()` | `Bool` | +| `number()` | `Double`; per-column override to `Int` in the codegen config (checked, exact up to 2^53) | +| `enumeration<…>()` | `enum: String, Codable` | +| `json()` | A generated `Codable` type when `T` is a plain object or literal union, otherwise `JSONValue` | +| `.optional()` | Swift optional | Row struct names are the table names in PascalCase. The generated code stays readable enough to hand-write. @@ -421,18 +421,18 @@ passing IDs and timestamps as arguments. Each milestone is done when its conformance layer passes. -| # | Milestone | Done when | -| --- | --- | --- | -| M0 | Scaffolding: `swift/` with the three targets, Linux and macOS CI, `swift-format`, the `packages/zero-conformance` skeleton, the mirror job | CI green on an empty package | -| M1 | Primitives: JSON value model, hashes, key encoding, UTF-16 value comparison, LIKE, TTL, AST and normalization, protocol codecs including binary poke chunks | Primitive vectors pass | -| M1.5 | Protocol-only walking skeleton: connect to a real `zero-cache`, handshake, receive pokes, log rows | Talks to a real server | -| M2 | IVM: `MemorySource`, all client operators, pipeline builder, change application, the builder API | Curated and seeded-fuzz IVM vectors pass | -| M3 | Schema codegen CLI; generate the zbugs and fixture schemas | Generated code compiles and drives M2 | -| M4 | Client engine on memory storage: connection state machine, query manager (named queries, TTL, cached/complete), poke handler, mutators, `tx`, pending queue, rebase, mutation tracker, auth, recovery | Client scenario vectors pass | -| M5 | SQLite persistence, client and client-group lifecycle and GC, storage version, memory fallback | Scenarios pass on SQLite | -| M6 | Apple layer: URLSession transport, lifecycle, `NWPathMonitor`, `os.Logger`, `@Observable` queries, SwiftUI wrapper and environment, UIKit and AppKit diffable adapters | macOS CI green | -| M7 | Linux end-to-end against real `zero-cache` and Postgres; drift harness; zbugs queries and mutators ported to Swift | End-to-end and drift checks pass | -| M8 | `apps/zbugs-swift` (iOS and macOS, UIKit and AppKit screens, auth), benchmarks, DocC, parity matrix | `0.1.0` ready to tag on the mirror | +| # | Milestone | Done when | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | +| M0 | Scaffolding: `swift/` with the three targets, Linux and macOS CI, `swift-format`, the `packages/zero-conformance` skeleton, the mirror job | CI green on an empty package | +| M1 | Primitives: JSON value model, hashes, key encoding, UTF-16 value comparison, LIKE, TTL, AST and normalization, protocol codecs including binary poke chunks | Primitive vectors pass | +| M1.5 | Protocol-only walking skeleton: connect to a real `zero-cache`, handshake, receive pokes, log rows | Talks to a real server | +| M2 | IVM: `MemorySource`, all client operators, pipeline builder, change application, the builder API | Curated and seeded-fuzz IVM vectors pass | +| M3 | Schema codegen CLI; generate the zbugs and fixture schemas | Generated code compiles and drives M2 | +| M4 | Client engine on memory storage: connection state machine, query manager (named queries, TTL, cached/complete), poke handler, mutators, `tx`, pending queue, rebase, mutation tracker, auth, recovery | Client scenario vectors pass | +| M5 | SQLite persistence, client and client-group lifecycle and GC, storage version, memory fallback | Scenarios pass on SQLite | +| M6 | Apple layer: URLSession transport, lifecycle, `NWPathMonitor`, `os.Logger`, `@Observable` queries, SwiftUI wrapper and environment, UIKit and AppKit diffable adapters | macOS CI green | +| M7 | Linux end-to-end against real `zero-cache` and Postgres; drift harness; zbugs queries and mutators ported to Swift | End-to-end and drift checks pass | +| M8 | `apps/zbugs-swift` (iOS and macOS, UIKit and AppKit screens, auth), benchmarks, DocC, parity matrix | `0.1.0` ready to tag on the mirror | Deferred to v1.x: the inspector, the live differential fuzzer, an async auth provider, cross-process access through App Groups, validator-derived drift diff --git a/swift/Package.swift b/swift/Package.swift new file mode 100644 index 0000000000..145e27e45a --- /dev/null +++ b/swift/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version: 6.2 +import PackageDescription + +let package = Package( + name: "Zero", + platforms: [.iOS(.v26), .macOS(.v26)], + products: [ + .library(name: "Zero", targets: ["Zero"]), + .library(name: "ZeroUI", targets: ["ZeroUI"]), + .library(name: "ZeroTesting", targets: ["ZeroTesting"]), + ], + targets: [ + .target(name: "Zero"), + .target(name: "ZeroUI", dependencies: ["Zero"]), + .target(name: "ZeroTesting", dependencies: ["Zero"]), + .testTarget(name: "ZeroTests", dependencies: ["Zero", "ZeroTesting"]), + .testTarget(name: "ConformanceTests", dependencies: ["Zero", "ZeroTesting"]), + ], + swiftLanguageModes: [.v6] +) diff --git a/swift/Sources/Zero/Zero.swift b/swift/Sources/Zero/Zero.swift new file mode 100644 index 0000000000..c3c2155791 --- /dev/null +++ b/swift/Sources/Zero/Zero.swift @@ -0,0 +1,7 @@ +/// The sync protocol version this client speaks. +/// +/// `zero-cache` supports every protocol version from +/// `MIN_SERVER_SUPPORTED_SYNC_PROTOCOL` through its own `PROTOCOL_VERSION` +/// (see `packages/zero-protocol/src/protocol-version.ts`), so this can lag the +/// JS client. +public let protocolVersion = 53 diff --git a/swift/Sources/ZeroTesting/ZeroTesting.swift b/swift/Sources/ZeroTesting/ZeroTesting.swift new file mode 100644 index 0000000000..f7bc15f500 --- /dev/null +++ b/swift/Sources/ZeroTesting/ZeroTesting.swift @@ -0,0 +1 @@ +@_exported import Zero diff --git a/swift/Sources/ZeroUI/ZeroUI.swift b/swift/Sources/ZeroUI/ZeroUI.swift new file mode 100644 index 0000000000..f7bc15f500 --- /dev/null +++ b/swift/Sources/ZeroUI/ZeroUI.swift @@ -0,0 +1 @@ +@_exported import Zero diff --git a/swift/Tests/ConformanceTests/MetaTests.swift b/swift/Tests/ConformanceTests/MetaTests.swift new file mode 100644 index 0000000000..aefbe62ab6 --- /dev/null +++ b/swift/Tests/ConformanceTests/MetaTests.swift @@ -0,0 +1,18 @@ +import Testing +import Zero + +@Suite(.enabled(if: Vectors.available)) +struct MetaTests { + struct Meta: Decodable { + let protocolVersion: Int + let minServerSupportedSyncProtocol: Int + } + + /// The server keeps supporting every protocol from its historical floor, so + /// the Swift client may lag the JS one but must stay inside that window. + @Test func protocolVersionIsSupportedByTheServer() throws { + let meta = try #require(try Vectors.load("meta", as: Meta.self).first) + #expect(protocolVersion >= meta.minServerSupportedSyncProtocol) + #expect(protocolVersion <= meta.protocolVersion) + } +} diff --git a/swift/Tests/ConformanceTests/Vectors.swift b/swift/Tests/ConformanceTests/Vectors.swift new file mode 100644 index 0000000000..2c10efc129 --- /dev/null +++ b/swift/Tests/ConformanceTests/Vectors.swift @@ -0,0 +1,63 @@ +import Foundation + +/// Locates and decodes the vector files in `packages/zero-conformance/vectors`. +/// +/// The vectors live in the monorepo, outside this package. The mirror +/// repository doesn't carry them, so every conformance suite is skipped when +/// they aren't found. +enum Vectors { + static let directory: URL? = { + if let override = ProcessInfo.processInfo.environment["ZERO_CONFORMANCE_VECTORS"] { + return URL(fileURLWithPath: override, isDirectory: true) + } + let url = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // ConformanceTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // swift + .deletingLastPathComponent() // repo root + .appendingPathComponent("packages/zero-conformance/vectors", isDirectory: true) + return FileManager.default.fileExists(atPath: url.path) ? url : nil + }() + + static var available: Bool { directory != nil } + + struct File: Decodable { + let formatVersion: Int + let kind: String + let description: String + let cases: [Case] + } + + static let supportedFormatVersion = 1 + + static func load(_ name: String, as _: Case.Type = Case.self) throws -> [Case] { + guard let directory else { throw VectorError.missingDirectory } + let data = try Data(contentsOf: directory.appendingPathComponent("\(name).json")) + let file = try JSONDecoder().decode(File.self, from: data) + guard file.formatVersion == supportedFormatVersion else { + throw VectorError.unsupportedFormat(name: name, version: file.formatVersion) + } + return file.cases + } + + /// Raw bytes of a vector file, for suites that decode it with the client's + /// own JSON parser. + static func data(_ name: String) throws -> Data { + guard let directory else { throw VectorError.missingDirectory } + return try Data(contentsOf: directory.appendingPathComponent("\(name).json")) + } +} + +enum VectorError: Error, CustomStringConvertible { + case missingDirectory + case unsupportedFormat(name: String, version: Int) + + var description: String { + switch self { + case .missingDirectory: + "packages/zero-conformance/vectors not found; set ZERO_CONFORMANCE_VECTORS" + case .unsupportedFormat(let name, let version): + "\(name).json has format version \(version); this runner supports \(Vectors.supportedFormatVersion)" + } + } +} diff --git a/swift/Tests/ZeroTests/ProtocolVersionTests.swift b/swift/Tests/ZeroTests/ProtocolVersionTests.swift new file mode 100644 index 0000000000..341f8b1c87 --- /dev/null +++ b/swift/Tests/ZeroTests/ProtocolVersionTests.swift @@ -0,0 +1,6 @@ +import Testing +import Zero + +@Test func speaksProtocol53() { + #expect(protocolVersion == 53) +} diff --git a/swift/VERSION b/swift/VERSION new file mode 100644 index 0000000000..6e8bf73aa5 --- /dev/null +++ b/swift/VERSION @@ -0,0 +1 @@ +0.1.0 From b113f73de63eee965a4dd64f521d8d7de84480d0 Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 19 Oct 2026 04:50:29 +0000 Subject: [PATCH 003/104] 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 --- .github/workflows/swift-macos.yml | 3 ++- .github/workflows/swift.yml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/swift-macos.yml b/.github/workflows/swift-macos.yml index 967e5813e3..dfcfcb5018 100644 --- a/.github/workflows/swift-macos.yml +++ b/.github/workflows/swift-macos.yml @@ -5,7 +5,8 @@ name: Swift (macOS) on: push: - branches: [main] + # swift-client is the development branch until the client lands on main. + branches: [main, swift-client] paths: - 'swift/**' - 'packages/zero-conformance/**' diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 8a13a543f7..11da00a2ce 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -6,7 +6,8 @@ name: Swift on: push: - branches: [main] + # swift-client is the development branch until the client lands on main. + branches: [main, swift-client] pull_request: branches: [main] From 7c86b2c303dbddf9751c3fbf20b95543d7b53ff4 Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 19 Oct 2026 05:03:58 +0000 Subject: [PATCH 004/104] 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 --- .github/workflows/swift.yml | 13 +- packages/zero-conformance/.gitignore | 1 + packages/zero-conformance/src/env.d.ts | 3 + .../src/primitives/ast.test.ts | 342 + .../src/primitives/compare.test.ts | 72 + .../src/primitives/hash.test.ts | 28 + .../src/primitives/json.test.ts | 216 + .../src/primitives/keys.test.ts | 49 + .../src/primitives/like.test.ts | 98 + .../src/primitives/ttl.test.ts | 55 + packages/zero-conformance/src/random.ts | 119 + packages/zero-conformance/src/vectors.ts | 24 +- packages/zero-conformance/tsconfig.json | 2 +- packages/zero-conformance/vectors/ast.json | 8028 ++++++++++++++++ .../vectors/compare-values.json | 1741 ++++ packages/zero-conformance/vectors/hash.json | 1075 +++ .../zero-conformance/vectors/json-parse.json | 237 + .../vectors/json-stringify-strings.json | 275 + packages/zero-conformance/vectors/like.json | 6976 ++++++++++++++ .../vectors/name-args-hash.json | 1180 +++ .../vectors/number-format.json | 1779 ++++ .../vectors/number-parse.json | 187 + .../vectors/primary-key-string.json | 1816 ++++ .../vectors/string-compare.json | 8143 +++++++++++++++++ packages/zero-conformance/vectors/ttl.json | 1080 +++ swift/Sources/Zero/AST/AST.swift | 147 + swift/Sources/Zero/AST/ASTCoding.swift | 187 + swift/Sources/Zero/AST/ASTNormalize.swift | 185 + swift/Sources/Zero/Client/Keys.swift | 61 + swift/Sources/Zero/Hash/QueryHash.swift | 276 + swift/Sources/Zero/Hash/XXHash32.swift | 101 + swift/Sources/Zero/IVM/Data.swift | 92 + swift/Sources/Zero/JSON/JSNumber.swift | 164 + swift/Sources/Zero/JSON/JSONDecoding.swift | 212 + swift/Sources/Zero/JSON/JSONParser.swift | 282 + swift/Sources/Zero/JSON/JSONSerializer.swift | 82 + swift/Sources/Zero/JSON/JSONValue.swift | 193 + swift/Sources/Zero/Query/Like.swift | 159 + swift/Sources/Zero/Query/TTL.swift | 117 + swift/Sources/Zero/Util/JSSort.swift | 64 + swift/Sources/Zero/Util/StringCompare.swift | 56 + .../ASTConformanceTests.swift | 31 + .../JSONConformanceTests.swift | 73 + .../PrimitiveConformanceTests.swift | 106 + swift/Tests/ConformanceTests/Vectors.swift | 30 + 45 files changed, 36154 insertions(+), 3 deletions(-) create mode 100644 packages/zero-conformance/.gitignore create mode 100644 packages/zero-conformance/src/env.d.ts create mode 100644 packages/zero-conformance/src/primitives/ast.test.ts create mode 100644 packages/zero-conformance/src/primitives/compare.test.ts create mode 100644 packages/zero-conformance/src/primitives/hash.test.ts create mode 100644 packages/zero-conformance/src/primitives/json.test.ts create mode 100644 packages/zero-conformance/src/primitives/keys.test.ts create mode 100644 packages/zero-conformance/src/primitives/like.test.ts create mode 100644 packages/zero-conformance/src/primitives/ttl.test.ts create mode 100644 packages/zero-conformance/src/random.ts create mode 100644 packages/zero-conformance/vectors/ast.json create mode 100644 packages/zero-conformance/vectors/compare-values.json create mode 100644 packages/zero-conformance/vectors/hash.json create mode 100644 packages/zero-conformance/vectors/json-parse.json create mode 100644 packages/zero-conformance/vectors/json-stringify-strings.json create mode 100644 packages/zero-conformance/vectors/like.json create mode 100644 packages/zero-conformance/vectors/name-args-hash.json create mode 100644 packages/zero-conformance/vectors/number-format.json create mode 100644 packages/zero-conformance/vectors/number-parse.json create mode 100644 packages/zero-conformance/vectors/primary-key-string.json create mode 100644 packages/zero-conformance/vectors/string-compare.json create mode 100644 packages/zero-conformance/vectors/ttl.json create mode 100644 swift/Sources/Zero/AST/AST.swift create mode 100644 swift/Sources/Zero/AST/ASTCoding.swift create mode 100644 swift/Sources/Zero/AST/ASTNormalize.swift create mode 100644 swift/Sources/Zero/Client/Keys.swift create mode 100644 swift/Sources/Zero/Hash/QueryHash.swift create mode 100644 swift/Sources/Zero/Hash/XXHash32.swift create mode 100644 swift/Sources/Zero/IVM/Data.swift create mode 100644 swift/Sources/Zero/JSON/JSNumber.swift create mode 100644 swift/Sources/Zero/JSON/JSONDecoding.swift create mode 100644 swift/Sources/Zero/JSON/JSONParser.swift create mode 100644 swift/Sources/Zero/JSON/JSONSerializer.swift create mode 100644 swift/Sources/Zero/JSON/JSONValue.swift create mode 100644 swift/Sources/Zero/Query/Like.swift create mode 100644 swift/Sources/Zero/Query/TTL.swift create mode 100644 swift/Sources/Zero/Util/JSSort.swift create mode 100644 swift/Sources/Zero/Util/StringCompare.swift create mode 100644 swift/Tests/ConformanceTests/ASTConformanceTests.swift create mode 100644 swift/Tests/ConformanceTests/JSONConformanceTests.swift create mode 100644 swift/Tests/ConformanceTests/PrimitiveConformanceTests.swift diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 11da00a2ce..975cb2d7df 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -34,12 +34,19 @@ jobs: node-version: 24.x package-manager-cache: false - run: pnpm install --frozen-lockfile - # Fails when the checked-in vectors no longer match the JS behavior. + # Fails when the checked-in vectors no longer match the JS behavior. Also + # writes the seeded fuzz corpora (generated/), which aren't checked in. - name: Check vectors are current run: pnpm --filter zero-conformance run test + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: generated-vectors + path: packages/zero-conformance/generated/ + if-no-files-found: error linux: name: Swift (Linux) + needs: vectors runs-on: ubuntu-latest container: swift:6.2.4-bookworm defaults: @@ -49,6 +56,10 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: generated-vectors + path: packages/zero-conformance/generated/ - name: Install system libraries run: apt-get update && apt-get install -y --no-install-recommends libsqlite3-dev - name: Format diff --git a/packages/zero-conformance/.gitignore b/packages/zero-conformance/.gitignore new file mode 100644 index 0000000000..9ab870da89 --- /dev/null +++ b/packages/zero-conformance/.gitignore @@ -0,0 +1 @@ +generated/ diff --git a/packages/zero-conformance/src/env.d.ts b/packages/zero-conformance/src/env.d.ts new file mode 100644 index 0000000000..b5ce66cbce --- /dev/null +++ b/packages/zero-conformance/src/env.d.ts @@ -0,0 +1,3 @@ +interface ImportMeta { + readonly env?: {readonly VITEST?: string | boolean}; +} diff --git a/packages/zero-conformance/src/primitives/ast.test.ts b/packages/zero-conformance/src/primitives/ast.test.ts new file mode 100644 index 0000000000..e84863b13f --- /dev/null +++ b/packages/zero-conformance/src/primitives/ast.test.ts @@ -0,0 +1,342 @@ +import {test} from 'vitest'; +import type { + AST, + Condition, + CorrelatedSubquery, + SimpleOperator, + ValuePosition, +} from '../../../zero-protocol/src/ast.ts'; +import {normalizeAST} from '../../../zero-protocol/src/ast.ts'; +import { + hashOfAST, + hashOfNameAndArgs, +} from '../../../zero-protocol/src/query-hash.ts'; +import {interestingStrings, Random} from '../random.ts'; +import {emitGeneratedVectors, emitVectors} from '../vectors.ts'; + +const ops: SimpleOperator[] = [ + '=', + '!=', + 'IS', + 'IS NOT', + '<', + '>', + '<=', + '>=', + 'LIKE', + 'NOT LIKE', + 'ILIKE', + 'NOT ILIKE', + 'IN', + 'NOT IN', +]; +const columns = ['id', 'title', 'closed', 'ownerId', 'createdAt', 'é', 'a']; +const tables = ['issue', 'comment', 'user', 'label']; +const scalars = [ + 0, + -0, + 1, + -1, + 1.5, + 2 ** 31, + -(2 ** 31) - 1, + 1e21, + true, + false, + 'a', + 'b', + '', + '1', + '\u{1f600}', + 'é', + 'é', +]; + +function literal(r: Random): ValuePosition { + const kind = r.int(4); + if (kind === 0) { + return {type: 'literal', value: null}; + } + if (kind === 1) { + const n = r.int(4); + const value: (string | number | boolean)[] = []; + for (let i = 0; i < n; i++) { + value.push(r.pick(scalars)); + } + return {type: 'literal', value}; + } + return {type: 'literal', value: r.pick(scalars)}; +} + +function right(r: Random): Exclude { + if (r.bool(0.1)) { + return { + type: 'static', + anchor: r.pick(['authData', 'preMutationRow'] as const), + field: r.bool() ? r.pick(['sub', 'role']) : ['a', r.pick(['b', 'c'])], + }; + } + return literal(r) as Exclude; +} + +function left(r: Random): ValuePosition { + if (r.bool(0.1)) { + return literal(r); + } + if (r.bool(0.05)) { + return {type: 'static', anchor: 'authData', field: 'sub'}; + } + return {type: 'column', name: r.pick(columns)}; +} + +function condition(r: Random, depth: number): Condition { + const kind = depth > 2 ? 0 : r.int(depth === 0 ? 4 : 5); + switch (kind) { + case 0: + case 1: + return {type: 'simple', op: r.pick(ops), left: left(r), right: right(r)}; + case 2: + case 3: { + const n = r.int(5); + const conditions: Condition[] = []; + for (let i = 0; i < n; i++) { + conditions.push(condition(r, depth + 1)); + } + return {type: r.bool() ? 'and' : 'or', conditions}; + } + default: + return { + type: 'correlatedSubquery', + op: r.bool() ? 'EXISTS' : 'NOT EXISTS', + related: correlated(r, depth + 1), + ...(r.bool(0.3) ? {flip: r.bool()} : {}), + ...(r.bool(0.2) ? {scalar: r.bool()} : {}), + }; + } +} + +function correlated(r: Random, depth: number): CorrelatedSubquery { + const compound = r.bool(0.2); + return { + correlation: { + parentField: compound ? ['id', 'ownerId'] : [r.pick(columns)], + childField: compound ? ['issueId', 'authorId'] : [r.pick(columns)], + }, + subquery: ast( + r, + depth, + r.pick(['zsubq_a', 'zsubq_b', 'comments', 'owner']), + ), + ...(r.bool(0.3) + ? {system: r.pick(['client', 'permissions', 'test'] as const)} + : {}), + ...(r.bool(0.2) ? {hidden: r.bool()} : {}), + }; +} + +function ast(r: Random, depth: number, alias?: string): AST { + const result: {-readonly [K in keyof AST]: AST[K]} = {table: r.pick(tables)}; + if (alias !== undefined) { + result.alias = alias; + } + if (r.bool(0.1)) { + result.schema = 'public'; + } + if (r.bool(0.7)) { + result.where = condition(r, depth); + } + if (depth < 2 && r.bool(0.4)) { + const n = 1 + r.int(3); + const related: CorrelatedSubquery[] = []; + for (let i = 0; i < n; i++) { + related.push(correlated(r, depth + 1)); + } + result.related = related; + } + if (r.bool(0.3)) { + result.limit = r.pick([0, 1, 10, 100, 4106, 1.5, 2 ** 32]); + } + if (r.bool(0.5)) { + result.orderBy = [ + [r.pick(columns), r.bool() ? 'asc' : 'desc'], + ...(r.bool() ? ([['id', 'asc']] as const) : []), + ]; + } + if (r.bool(0.2)) { + result.start = { + row: {id: r.pick(scalars), createdAt: r.pick([1, 2.5, null])}, + exclusive: r.bool(), + }; + } + return result; +} + +// Hand-written cases for the parts of normalization random ASTs rarely hit. +const handWritten: AST[] = [ + {table: 'issue'}, + {table: 'issue', where: {type: 'and', conditions: []}}, + {table: 'issue', where: {type: 'or', conditions: []}}, + { + table: 'issue', + where: { + type: 'and', + conditions: [ + { + type: 'simple', + op: '=', + left: {type: 'column', name: 'b'}, + right: {type: 'literal', value: 1}, + }, + ], + }, + }, + { + // Nested conjunctions of the same type flatten; a subquery and an `or` + // exercise the asymmetric branch of the comparator. + table: 'issue', + where: { + type: 'and', + conditions: [ + { + type: 'or', + conditions: [ + { + type: 'simple', + op: '=', + left: {type: 'column', name: 'x'}, + right: {type: 'literal', value: 'b'}, + }, + { + type: 'simple', + op: '=', + left: {type: 'column', name: 'x'}, + right: {type: 'literal', value: 'a'}, + }, + ], + }, + { + type: 'correlatedSubquery', + op: 'EXISTS', + related: { + correlation: {parentField: ['id'], childField: ['issueId']}, + subquery: {table: 'comment', alias: 'zsubq_comments'}, + }, + }, + { + type: 'and', + conditions: [ + { + type: 'simple', + op: '<', + left: {type: 'column', name: 'a'}, + right: {type: 'literal', value: 5}, + }, + { + type: 'correlatedSubquery', + op: 'NOT EXISTS', + related: { + correlation: {parentField: ['id'], childField: ['issueId']}, + subquery: {table: 'label', alias: 'zsubq_labels'}, + }, + }, + ], + }, + { + type: 'simple', + op: 'IN', + left: {type: 'column', name: 'a'}, + right: {type: 'literal', value: ['b', 'a', 1, true]}, + }, + ], + }, + related: [ + { + correlation: {parentField: ['id'], childField: ['issueId']}, + subquery: {table: 'comment', alias: 'comments', limit: 3}, + }, + { + correlation: {parentField: ['ownerId'], childField: ['id']}, + subquery: {table: 'user', alias: 'owner'}, + }, + { + correlation: {parentField: ['id'], childField: ['issueId']}, + subquery: {table: 'issueLabel', alias: 'comments'}, + }, + ], + }, +]; + +function astCase(a: AST) { + return { + ast: a, + normalized: JSON.parse(JSON.stringify(normalizeAST(a))), + hash: hashOfAST(a), + }; +} + +test('ast', () => { + const r = new Random(0xa57); + const asts = [...handWritten]; + for (let i = 0; i < 30; i++) { + asts.push(ast(r, 0)); + } + emitVectors( + 'ast', + 'ast', + 'normalizeAST(ast) (as JSON) and hashOfAST(ast).', + asts.map(astCase), + ); +}); + +test('ast (fuzz)', () => { + const r = new Random(0xa57f); + const asts: AST[] = []; + for (let i = 0; i < 2000; i++) { + asts.push(ast(r, 0)); + } + emitGeneratedVectors( + 'ast-fuzz', + 'ast', + 'Seeded random ASTs: normalizeAST(ast) (as JSON) and hashOfAST(ast).', + asts.map(astCase), + ); +}); + +test('name-args-hash', () => { + const r = new Random(0x4e41); + const cases: {name: string; args: unknown[]}[] = [ + {name: 'issues.byId', args: []}, + {name: 'issues.byId', args: [{id: '1'}]}, + {name: 'issues.byId', args: [{id: 1}]}, + {name: 'issues.byId', args: [{b: 1, a: 2}]}, + {name: 'issues.byId', args: [{a: 2, b: 1}]}, + {name: 'x', args: [null, true, false, 0, -0, 1.5, 'x', [1, [2]], {}]}, + {name: 'x', args: [{nested: {z: 1, a: [true, null]}}]}, + {name: '', args: [{'': ''}]}, + {name: '\u{1f600}', args: [{'\u{1f600}': 'é', 'é': 'é'}]}, + ]; + for (const s of interestingStrings) { + cases.push({name: s, args: [s]}); + } + for (let i = 0; i < 100; i++) { + const args: unknown[] = []; + const n = r.int(3); + for (let j = 0; j < n; j++) { + args.push( + r.bool() + ? { + [r.pick(columns)]: r.pick(scalars), + [r.pick(columns)]: r.pick(scalars), + } + : r.pick(scalars), + ); + } + cases.push({name: r.pick(['a', 'issues.list', 'users.byId']), args}); + } + emitVectors( + 'name-args-hash', + 'name-args-hash', + 'hashOfNameAndArgs(name, args): the ID of a named query.', + cases.map(c => ({...c, hash: hashOfNameAndArgs(c.name, c.args)})), + ); +}); diff --git a/packages/zero-conformance/src/primitives/compare.test.ts b/packages/zero-conformance/src/primitives/compare.test.ts new file mode 100644 index 0000000000..86731f9e9b --- /dev/null +++ b/packages/zero-conformance/src/primitives/compare.test.ts @@ -0,0 +1,72 @@ +import {test} from 'vitest'; +import type {Value} from '../../../zero-protocol/src/data.ts'; +import {compareValues, valuesEqual} from '../../../zql/src/ivm/data.ts'; +import {interestingStrings, Random, randomString} from '../random.ts'; +import {emitVectors} from '../vectors.ts'; + +const sign = (n: number) => (n < 0 ? -1 : n > 0 ? 1 : 0); + +test('string-compare', () => { + const r = new Random(0xc0de); + const pairs: [string, string][] = []; + for (const a of interestingStrings) { + for (const b of interestingStrings) { + pairs.push([a, b]); + } + } + for (let i = 0; i < 200; i++) { + pairs.push([randomString(r), randomString(r)]); + } + emitVectors( + 'string-compare', + 'string-compare', + 'compareUTF8 (code point order) and JS `<` (UTF-16 code unit order) signs.', + pairs.map(([a, b]) => ({ + a, + b, + // compareValues compares strings with compare-utf8. + utf8: sign(compareValues(a, b)), + utf16: a < b ? -1 : a > b ? 1 : 0, + })), + ); +}); + +test('compare-values', () => { + const values: Value[] = [ + null, + true, + false, + 0, + -0, + 1, + -1, + 0.5, + 1e21, + -1e-7, + 2 ** 53, + '', + 'a', + 'B', + 'é', + '\u{1f600}', + '', + ]; + const cases = []; + for (const a of values) { + for (const b of values) { + let result: number | 'throws'; + try { + result = sign(compareValues(a, b)); + } catch { + result = 'throws'; + } + cases.push({a, b, compare: result, equal: valuesEqual(a, b)}); + } + } + emitVectors( + 'compare-values', + 'compare-values', + 'zql compareValues (sign, or "throws" for mismatched types) and valuesEqual.', + cases, + ); +}); diff --git a/packages/zero-conformance/src/primitives/hash.test.ts b/packages/zero-conformance/src/primitives/hash.test.ts new file mode 100644 index 0000000000..e88b2e0109 --- /dev/null +++ b/packages/zero-conformance/src/primitives/hash.test.ts @@ -0,0 +1,28 @@ +import {test} from 'vitest'; +import {h128, h32, h64} from '../../../shared/src/hash.ts'; +import {interestingStrings, Random, randomString} from '../random.ts'; +import {emitVectors} from '../vectors.ts'; + +test('hash', () => { + const r = new Random(0x4a54); + const inputs = [...interestingStrings]; + // Cover every length class of the xxHash32 tail and stripe loops. + for (let n = 0; n <= 40; n++) { + inputs.push('x'.repeat(n)); + } + inputs.push('a'.repeat(4096), 'b'.repeat(4097), '\u{1f600}'.repeat(1500)); + for (let i = 0; i < 100; i++) { + inputs.push(randomString(r, 40)); + } + emitVectors( + 'hash', + 'hash', + 'h32, h64, and h128 (shared/src/hash.ts) of strings; wide hashes in decimal.', + inputs.map(s => ({ + input: s, + h32: h32(s), + h64: h64(s).toString(), + h128: h128(s).toString(), + })), + ); +}); diff --git a/packages/zero-conformance/src/primitives/json.test.ts b/packages/zero-conformance/src/primitives/json.test.ts new file mode 100644 index 0000000000..395e7681cf --- /dev/null +++ b/packages/zero-conformance/src/primitives/json.test.ts @@ -0,0 +1,216 @@ +import {test} from 'vitest'; +import {float64Bits, interestingStrings, Random} from '../random.ts'; +import {emitVectors} from '../vectors.ts'; + +const r = new Random(0x15011); + +test('number-format', () => { + const numbers: number[] = [ + 0, + -0, + 1, + -1, + 0.1, + 0.5, + 1.5, + -1.5, + 100, + 123456789, + 2 ** 31 - 1, + 2 ** 31, + -(2 ** 31), + 2 ** 32, + 2 ** 53 - 1, + 2 ** 53, + 2 ** 53 + 2, + 1e20, + 1e21, + 1.5e21, + 123456789012345680000, + 1e-6, + 1e-7, + 1.5e-7, + 0.000001234, + 0.0000001234, + 5e-324, + Number.MAX_VALUE, + Number.MIN_VALUE, + Number.EPSILON, + Math.PI, + Math.E, + 1 / 3, + 2 / 3, + 0.1 + 0.2, + 1e300, + 1e-300, + NaN, + Infinity, + -Infinity, + 4.35, + 0.000123, + 1234.5678, + ]; + for (let i = 0; i < 300; i++) { + numbers.push(r.float64()); + } + for (let i = 0; i < 100; i++) { + numbers.push(r.int(1e9) / 10 ** r.int(12)); + } + emitVectors( + 'number-format', + 'number-format', + '`String(n)` for float64 values given as big-endian hex bit patterns.', + numbers.map(n => ({bits: float64Bits(n), string: String(n)})), + ); +}); + +test('number-parse', () => { + const inputs = [ + '', + ' ', + '0', + '-0', + '+0', + '1', + '-1', + '+1', + '1.5', + '.5', + '5.', + '1e3', + '1E3', + '1e+3', + '1e-3', + '1e', + 'e3', + ' 42 ', + '\t42\n', + ' 42
', + '0x10', + '0X1f', + '0o17', + '0b101', + '-0x10', + '0x', + '0xg', + 'Infinity', + '-Infinity', + '+Infinity', + 'infinity', + 'NaN', + 'abc', + '1a', + '1_000', + '1,000', + '00012', + '1e400', + '-1e400', + '1e-400', + '123456789012345678901234567890', + '9007199254740993', + '0.1', + '10', + '30', + ]; + emitVectors( + 'number-parse', + 'number-parse', + '`Number(s)` as a big-endian hex float64 bit pattern.', + inputs.map(s => ({input: s, bits: float64Bits(Number(s))})), + ); +}); + +test('json-parse', () => { + const ok = [ + 'null', + 'true', + 'false', + '0', + '-0', + '1', + '-1.5e10', + '1E400', + '123456789012345678901234567890', + '0.1', + '"abc"', + '""', + '"\\u00e9\\n\\t\\"\\\\\\/"', + '"\\ud83d\\ude00"', + '"😀"', + '"\\u0000"', + '[]', + '[1,2,3]', + '{}', + '{"a":1,"b":[true,null,{"c":"d"}]}', + ' \t\n\r{ "a" : 1 } ', + '{"a":1,"a":2}', + '{"__proto__":1}', + '[[[[[]]]]]', + '{"":0}', + '"\\u2028"', + ]; + const bad = [ + '', + ' ', + 'nul', + 'True', + '01', + '-', + '+1', + '.5', + '1.', + '1e', + '0x10', + 'NaN', + 'Infinity', + '"abc', + '"\u0001"', + '"\\x41"', + '"\\u12"', + "'a'", + '[1,]', + '[1 2]', + '{"a":1,}', + '{a:1}', + '{"a" 1}', + '[1]x', + '{"a":1}}', + ]; + emitVectors( + 'json-parse', + 'json-parse', + 'JSON.parse acceptance; accepted inputs carry their JSON.stringify re-encoding.', + [ + ...ok.map(input => ({ + input, + ok: true, + stringify: JSON.stringify(JSON.parse(input)), + })), + ...bad.map(input => { + try { + JSON.parse(input); + throw new Error(`expected ${JSON.stringify(input)} to be rejected`); + } catch (e) { + if (!(e instanceof SyntaxError)) { + throw e; + } + } + return {input, ok: false}; + }), + ], + ); +}); + +test('json-stringify-strings', () => { + const strings = [...interestingStrings]; + for (let i = 0; i < 0x20; i++) { + strings.push(String.fromCharCode(i)); + } + strings.push('a"b\\c\u0007d\u007fe\u0080f'); + emitVectors( + 'json-stringify-strings', + 'json-stringify-strings', + 'JSON.stringify of strings (escaping rules).', + strings.map(s => ({input: s, stringify: JSON.stringify(s)})), + ); +}); diff --git a/packages/zero-conformance/src/primitives/keys.test.ts b/packages/zero-conformance/src/primitives/keys.test.ts new file mode 100644 index 0000000000..0d636f7499 --- /dev/null +++ b/packages/zero-conformance/src/primitives/keys.test.ts @@ -0,0 +1,49 @@ +import {test} from 'vitest'; +import {toPrimaryKeyString} from '../../../zero-client/src/client/keys.ts'; +import type {CompoundKey} from '../../../zero-protocol/src/ast.ts'; +import type {Row} from '../../../zero-protocol/src/data.ts'; +import {interestingStrings, Random} from '../random.ts'; +import {emitVectors} from '../vectors.ts'; + +test('primary-key-string', () => { + const r = new Random(0x6b6579); + const cases: {table: string; primaryKey: CompoundKey; row: Row}[] = []; + const scalars = [ + ...interestingStrings, + 0, + -0, + 1, + -1, + 1.5, + 1e21, + 1e-7, + 2 ** 53, + 123456789, + true, + false, + ]; + for (const v of scalars) { + cases.push({table: 'issue', primaryKey: ['id'], row: {id: v, other: 'x'}}); + } + for (let i = 0; i < 100; i++) { + cases.push({ + table: r.pick(['issueLabel', 'a/b', 'T']), + primaryKey: ['a', 'b'], + row: {a: r.pick(scalars), b: r.pick(scalars), c: 1}, + }); + } + cases.push({ + table: 'three', + primaryKey: ['z', 'a', 'm'], + row: {a: 1, m: 'x', z: true}, + }); + emitVectors( + 'primary-key-string', + 'primary-key-string', + 'toPrimaryKeyString(table, primaryKey, row): the `e/` storage key of a row.', + cases.map(c => ({ + ...c, + key: toPrimaryKeyString(c.table, c.primaryKey, c.row), + })), + ); +}); diff --git a/packages/zero-conformance/src/primitives/like.test.ts b/packages/zero-conformance/src/primitives/like.test.ts new file mode 100644 index 0000000000..147ffd3473 --- /dev/null +++ b/packages/zero-conformance/src/primitives/like.test.ts @@ -0,0 +1,98 @@ +import {test} from 'vitest'; +import {cases as likeCases} from '../../../zql/src/builder/like-test-cases.ts'; +import {getLikePredicate} from '../../../zql/src/builder/like.ts'; +import {emitVectors} from '../vectors.ts'; + +test('like', () => { + const groups: {pattern: string; flags: 'i' | ''; inputs: string[]}[] = [ + ...likeCases.map(c => ({ + pattern: c.pattern, + flags: c.flags, + inputs: c.inputs.map(([input]) => input), + })), + ]; + const extraInputs = [ + '', + 'a', + 'A', + 'ab', + 'a\nb', + '\n', + '\u{1f600}', + 'a\u{1f600}b', + 'é', + 'É', + 'İ', + 'i', + 'I', + 'ı', + 'ß', + 'SS', + 'ss', + 'Σ', + 'σ', + 'ς', + '%', + '_', + '\\', + 'a.b', + 'a+b', + '(x)', + '[x]', + '^$', + ]; + const extraPatterns = [ + '', + '%', + '_', + '__', + 'a_b', + 'a%b', + '\\%', + '\\_', + '\\\\', + 'a\\b', + '%\u{1f600}%', + '_\u{1f600}', + '\u{1f600}', + '__b', + 'a.b', + 'a+b', + '(x)', + '[x]', + '^$', + 'é', + 'e%', + 'İ', + 'i', + 'ß', + 'ss', + 'σ', + '%Σ%', + 'a\\', + ]; + for (const pattern of extraPatterns) { + for (const flags of ['', 'i'] as const) { + groups.push({pattern, flags, inputs: extraInputs}); + } + } + emitVectors( + 'like', + 'like', + 'LIKE / ILIKE (flags "i") predicate results; `throws` when the pattern is invalid.', + groups.map(({pattern, flags, inputs}) => { + let predicate; + try { + predicate = getLikePredicate(pattern, flags); + } catch { + return {pattern, flags, throws: true, results: []}; + } + return { + pattern, + flags, + throws: false, + results: inputs.map(input => ({input, match: predicate(input)})), + }; + }), + ); +}); diff --git a/packages/zero-conformance/src/primitives/ttl.test.ts b/packages/zero-conformance/src/primitives/ttl.test.ts new file mode 100644 index 0000000000..3a748d128d --- /dev/null +++ b/packages/zero-conformance/src/primitives/ttl.test.ts @@ -0,0 +1,55 @@ +import {test} from 'vitest'; +import { + clampTTL, + compareTTL, + normalizeTTL, + parseTTL, + type TTL, +} from '../../../zql/src/query/ttl.ts'; +import {emitVectors} from '../vectors.ts'; + +test('ttl', () => { + const ttls: TTL[] = [ + 'none', + 'forever', + 0, + -1, + -100, + 1, + 999, + 1000, + 1500, + 60_000, + 90_000, + 3_600_000, + 86_400_000, + 31_536_000_000, + 600_000, + 600_001, + '0s', + '1s', + '1.5s', + '30s', + '5m', + '10m', + '11m', + '1h', + '1d', + '1y', + '0.5h', + '-1m', + ' 2m', + ]; + emitVectors( + 'ttl', + 'ttl', + 'parseTTL, normalizeTTL, and clampTTL; plus compareTTL against every other case (signs).', + ttls.map(ttl => ({ + ttl, + parsed: parseTTL(ttl), + normalized: normalizeTTL(ttl), + clamped: clampTTL(ttl), + compare: ttls.map(other => Math.sign(compareTTL(ttl, other))), + })), + ); +}); diff --git a/packages/zero-conformance/src/random.ts b/packages/zero-conformance/src/random.ts new file mode 100644 index 0000000000..b2a98e0fab --- /dev/null +++ b/packages/zero-conformance/src/random.ts @@ -0,0 +1,119 @@ +/** + * A small seeded PRNG (mulberry32) so generated vectors are reproducible. + */ +export class Random { + #state: number; + + constructor(seed: number) { + this.#state = seed >>> 0; + } + + /** Uniform in [0, 1). */ + next(): number { + let t = (this.#state = (this.#state + 0x6d2b79f5) >>> 0); + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + } + + int(maxExclusive: number): number { + return Math.floor(this.next() * maxExclusive); + } + + pick(items: readonly T[]): T { + return items[this.int(items.length)]; + } + + bool(p = 0.5): boolean { + return this.next() < p; + } + + /** A random float64 drawn from its bit pattern, finite only. */ + float64(): number { + const view = new DataView(new ArrayBuffer(8)); + for (;;) { + view.setUint32(0, this.int(2 ** 32)); + view.setUint32(4, this.int(2 ** 32)); + const n = view.getFloat64(0); + if (Number.isFinite(n)) { + return n; + } + } + } +} + +/** Strings that exercise UTF-8 vs UTF-16 ordering and escaping. */ +export const interestingStrings: readonly string[] = [ + '', + 'a', + 'A', + 'abc', + 'ab', + 'b', + 'Z', + 'z', + ' ', + '0', + '9', + '\u0000', + '\u001f', + '"', + '\\', + '\n', + '\u00e9', // é precomposed + 'e\u0301', // é decomposed + '\u00df', // ß + '\u0130', // İ + '\u0131', // ı + '\ufb00', // ff + '\u00a0', // no-break space + '\ue000', // private use, above the surrogates in UTF-16 + '\ufffd', + '\uffff', + '\u{1f600}', // 😀, a surrogate pair + '\u{1f600}a', + 'a\u{1f600}', + '\u{1d49c}', // 𝒜 + '\u4e2d\u6587', // 中文 + '\u03a9', // Ω + '\u03c9', // ω + '\u{10ffff}', +]; + +export function randomString(r: Random, maxLength = 8): string { + const alphabet = [ + 'a', + 'b', + 'A', + 'B', + '_', + '%', + '\\', + ' ', + '\n', + '\u00e9', + '\u00df', + '\u0130', + '\ue000', + '\u{1f600}', + '\u{1d49c}', + '\u4e2d', + '"', + ]; + const n = r.int(maxLength + 1); + let s = ''; + for (let i = 0; i < n; i++) { + s += r.pick(alphabet); + } + return s; +} + +/** The big-endian hex bit pattern of a float64, exact for -0, NaN, etc. */ +export function float64Bits(n: number): string { + const view = new DataView(new ArrayBuffer(8)); + view.setFloat64(0, n); + return ( + view.getUint32(0).toString(16).padStart(8, '0') + + view.getUint32(4).toString(16).padStart(8, '0') + ); +} diff --git a/packages/zero-conformance/src/vectors.ts b/packages/zero-conformance/src/vectors.ts index 5172ba6277..e3cc288f6b 100644 --- a/packages/zero-conformance/src/vectors.ts +++ b/packages/zero-conformance/src/vectors.ts @@ -1,4 +1,4 @@ -import {readFileSync, writeFileSync} from 'node:fs'; +import {mkdirSync, readFileSync, writeFileSync} from 'node:fs'; import {fileURLToPath} from 'node:url'; import {expect} from 'vitest'; @@ -9,6 +9,7 @@ import {expect} from 'vitest'; export const VECTOR_FORMAT_VERSION = 1; const vectorsDir = fileURLToPath(new URL('../vectors/', import.meta.url)); +const generatedDir = fileURLToPath(new URL('../generated/', import.meta.url)); export const updateVectors = process.env['UPDATE_VECTORS'] === '1'; @@ -56,3 +57,24 @@ export function emitVectors( `vectors/${name}.json is stale. Run \`pnpm run generate\` in packages/zero-conformance.`, ).toBe(text); } + +/** + * Writes `generated/.json`, which is not checked in. For large seeded + * fuzz corpora: CI regenerates them before running the Swift suites, so + * they are always current and never need a staleness check. + */ +export function emitGeneratedVectors( + name: string, + kind: string, + description: string, + cases: T[], +): void { + const file: VectorFile = { + formatVersion: VECTOR_FORMAT_VERSION, + kind, + description, + cases, + }; + mkdirSync(generatedDir, {recursive: true}); + writeFileSync(generatedDir + name + '.json', JSON.stringify(file) + '\n'); +} diff --git a/packages/zero-conformance/tsconfig.json b/packages/zero-conformance/tsconfig.json index 9bafd523af..6fd1da5082 100644 --- a/packages/zero-conformance/tsconfig.json +++ b/packages/zero-conformance/tsconfig.json @@ -3,5 +3,5 @@ "compilerOptions": { "types": ["node"] }, - "include": ["src/**/*.ts"] + "include": ["src"] } diff --git a/packages/zero-conformance/vectors/ast.json b/packages/zero-conformance/vectors/ast.json new file mode 100644 index 0000000000..1b937639fa --- /dev/null +++ b/packages/zero-conformance/vectors/ast.json @@ -0,0 +1,8028 @@ +{ + "formatVersion": 1, + "kind": "ast", + "description": "normalizeAST(ast) (as JSON) and hashOfAST(ast).", + "cases": [ + { + "ast": { + "table": "issue" + }, + "normalized": { + "table": "issue" + }, + "hash": "12g9y6w1iwk9ih" + }, + { + "ast": { + "table": "issue", + "where": { + "type": "and", + "conditions": [] + } + }, + "normalized": { + "table": "issue", + "where": { + "type": "and", + "conditions": [] + } + }, + "hash": "0e2g78x0lx1n83" + }, + { + "ast": { + "table": "issue", + "where": { + "type": "or", + "conditions": [] + } + }, + "normalized": { + "table": "issue", + "where": { + "type": "or", + "conditions": [] + } + }, + "hash": "14k0td01rmlyl2" + }, + { + "ast": { + "table": "issue", + "where": { + "type": "and", + "conditions": [ + { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "b" + }, + "right": { + "type": "literal", + "value": 1 + } + } + ] + } + }, + "normalized": { + "table": "issue", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "b" + }, + "right": { + "type": "literal", + "value": 1 + } + } + }, + "hash": "0osqhwr1bafp5e" + }, + { + "ast": { + "table": "issue", + "where": { + "type": "and", + "conditions": [ + { + "type": "or", + "conditions": [ + { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "x" + }, + "right": { + "type": "literal", + "value": "b" + } + }, + { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "x" + }, + "right": { + "type": "literal", + "value": "a" + } + } + ] + }, + { + "type": "correlatedSubquery", + "op": "EXISTS", + "related": { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueId" + ] + }, + "subquery": { + "table": "comment", + "alias": "zsubq_comments" + } + } + }, + { + "type": "and", + "conditions": [ + { + "type": "simple", + "op": "<", + "left": { + "type": "column", + "name": "a" + }, + "right": { + "type": "literal", + "value": 5 + } + }, + { + "type": "correlatedSubquery", + "op": "NOT EXISTS", + "related": { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueId" + ] + }, + "subquery": { + "table": "label", + "alias": "zsubq_labels" + } + } + } + ] + }, + { + "type": "simple", + "op": "IN", + "left": { + "type": "column", + "name": "a" + }, + "right": { + "type": "literal", + "value": [ + "b", + "a", + 1, + true + ] + } + } + ] + }, + "related": [ + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueId" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "limit": 3 + } + }, + { + "correlation": { + "parentField": [ + "ownerId" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "user", + "alias": "owner" + } + }, + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueId" + ] + }, + "subquery": { + "table": "issueLabel", + "alias": "comments" + } + } + ] + }, + "normalized": { + "table": "issue", + "where": { + "type": "and", + "conditions": [ + { + "type": "simple", + "op": "<", + "left": { + "type": "column", + "name": "a" + }, + "right": { + "type": "literal", + "value": 5 + } + }, + { + "type": "simple", + "op": "IN", + "left": { + "type": "column", + "name": "a" + }, + "right": { + "type": "literal", + "value": [ + "b", + "a", + 1, + true + ] + } + }, + { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueId" + ] + }, + "subquery": { + "table": "comment", + "alias": "zsubq_comments" + } + }, + "op": "EXISTS" + }, + { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueId" + ] + }, + "subquery": { + "table": "label", + "alias": "zsubq_labels" + } + }, + "op": "NOT EXISTS" + }, + { + "type": "or", + "conditions": [ + { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "x" + }, + "right": { + "type": "literal", + "value": "a" + } + }, + { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "x" + }, + "right": { + "type": "literal", + "value": "b" + } + } + ] + } + ] + }, + "related": [ + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueId" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "limit": 3 + } + }, + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueId" + ] + }, + "subquery": { + "table": "issueLabel", + "alias": "comments" + } + }, + { + "correlation": { + "parentField": [ + "ownerId" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "user", + "alias": "owner" + } + } + ] + }, + "hash": "1p4ynu31v095cu" + }, + { + "ast": { + "table": "user", + "orderBy": [ + [ + "createdAt", + "asc" + ] + ] + }, + "normalized": { + "table": "user", + "orderBy": [ + [ + "createdAt", + "asc" + ] + ] + }, + "hash": "070q3fw0fnyvec" + }, + { + "ast": { + "table": "comment", + "related": [ + { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "table": "user", + "alias": "owner", + "where": { + "type": "correlatedSubquery", + "op": "NOT EXISTS", + "related": { + "correlation": { + "parentField": [ + "createdAt" + ], + "childField": [ + "ownerId" + ] + }, + "subquery": { + "table": "comment", + "alias": "zsubq_b", + "where": { + "type": "and", + "conditions": [ + { + "type": "simple", + "op": "IN", + "left": { + "type": "static", + "anchor": "authData", + "field": "sub" + }, + "right": { + "type": "literal", + "value": null + } + }, + { + "type": "simple", + "op": "ILIKE", + "left": { + "type": "column", + "name": "a" + }, + "right": { + "type": "static", + "anchor": "authData", + "field": [ + "a", + "c" + ] + } + } + ] + }, + "orderBy": [ + [ + "closed", + "desc" + ] + ] + } + }, + "flip": false + } + }, + "system": "client" + }, + { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "table": "user", + "alias": "zsubq_b", + "where": { + "type": "or", + "conditions": [ + { + "type": "or", + "conditions": [ + { + "type": "simple", + "op": "IS NOT", + "left": { + "type": "column", + "name": "ownerId" + }, + "right": { + "type": "literal", + "value": null + } + }, + { + "type": "simple", + "op": "NOT LIKE", + "left": { + "type": "literal", + "value": "é" + }, + "right": { + "type": "literal", + "value": "a" + } + }, + { + "type": "simple", + "op": "IS NOT", + "left": { + "type": "column", + "name": "ownerId" + }, + "right": { + "type": "literal", + "value": [ + "", + "", + -2147483649 + ] + } + }, + { + "type": "simple", + "op": "NOT LIKE", + "left": { + "type": "column", + "name": "id" + }, + "right": { + "type": "literal", + "value": [ + "é" + ] + } + } + ] + } + ] + }, + "orderBy": [ + [ + "ownerId", + "desc" + ] + ] + }, + "system": "client" + } + ], + "limit": 0, + "orderBy": [ + [ + "ownerId", + "asc" + ], + [ + "id", + "asc" + ] + ] + }, + "normalized": { + "table": "comment", + "related": [ + { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "table": "user", + "alias": "owner", + "where": { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "createdAt" + ], + "childField": [ + "ownerId" + ] + }, + "subquery": { + "table": "comment", + "alias": "zsubq_b", + "where": { + "type": "and", + "conditions": [ + { + "type": "simple", + "op": "ILIKE", + "left": { + "type": "column", + "name": "a" + }, + "right": { + "type": "static", + "anchor": "authData", + "field": [ + "a", + "c" + ] + } + }, + { + "type": "simple", + "op": "IN", + "left": { + "type": "static", + "anchor": "authData", + "field": "sub" + }, + "right": { + "type": "literal", + "value": null + } + } + ] + }, + "orderBy": [ + [ + "closed", + "desc" + ] + ] + } + }, + "op": "NOT EXISTS", + "flip": false + } + }, + "system": "client" + }, + { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "table": "user", + "alias": "zsubq_b", + "where": { + "type": "or", + "conditions": [ + { + "type": "simple", + "op": "NOT LIKE", + "left": { + "type": "column", + "name": "id" + }, + "right": { + "type": "literal", + "value": [ + "é" + ] + } + }, + { + "type": "simple", + "op": "IS NOT", + "left": { + "type": "column", + "name": "ownerId" + }, + "right": { + "type": "literal", + "value": null + } + }, + { + "type": "simple", + "op": "IS NOT", + "left": { + "type": "column", + "name": "ownerId" + }, + "right": { + "type": "literal", + "value": [ + "", + "", + -2147483649 + ] + } + }, + { + "type": "simple", + "op": "NOT LIKE", + "left": { + "type": "literal", + "value": "é" + }, + "right": { + "type": "literal", + "value": "a" + } + } + ] + }, + "orderBy": [ + [ + "ownerId", + "desc" + ] + ] + }, + "system": "client" + } + ], + "limit": 0, + "orderBy": [ + [ + "ownerId", + "asc" + ], + [ + "id", + "asc" + ] + ] + }, + "hash": "19m01ea0docht0" + }, + { + "ast": { + "table": "user" + }, + "normalized": { + "table": "user" + }, + "hash": "1nt443e1orliwy" + }, + { + "ast": { + "table": "user", + "where": { + "type": "and", + "conditions": [ + { + "type": "correlatedSubquery", + "op": "EXISTS", + "related": { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "a" + ] + }, + "subquery": { + "table": "label", + "alias": "comments", + "where": { + "type": "simple", + "op": "IS NOT", + "left": { + "type": "literal", + "value": true + }, + "right": { + "type": "literal", + "value": 0 + } + }, + "orderBy": [ + [ + "a", + "asc" + ] + ], + "start": { + "row": { + "id": "a", + "createdAt": null + }, + "exclusive": false + } + } + }, + "flip": true, + "scalar": true + }, + { + "type": "and", + "conditions": [ + { + "type": "and", + "conditions": [ + { + "type": "simple", + "op": "LIKE", + "left": { + "type": "literal", + "value": [] + }, + "right": { + "type": "literal", + "value": -2147483649 + } + }, + { + "type": "simple", + "op": "NOT LIKE", + "left": { + "type": "column", + "name": "é" + }, + "right": { + "type": "literal", + "value": [ + -2147483649 + ] + } + }, + { + "type": "simple", + "op": ">", + "left": { + "type": "column", + "name": "createdAt" + }, + "right": { + "type": "static", + "anchor": "preMutationRow", + "field": [ + "a", + "c" + ] + } + } + ] + }, + { + "type": "correlatedSubquery", + "op": "NOT EXISTS", + "related": { + "correlation": { + "parentField": [ + "a" + ], + "childField": [ + "closed" + ] + }, + "subquery": { + "table": "label", + "alias": "comments", + "where": { + "type": "simple", + "op": "NOT LIKE", + "left": { + "type": "column", + "name": "a" + }, + "right": { + "type": "literal", + "value": 1e+21 + } + }, + "orderBy": [ + [ + "a", + "desc" + ] + ] + } + } + }, + { + "type": "simple", + "op": "IS NOT", + "left": { + "type": "literal", + "value": -1 + }, + "right": { + "type": "literal", + "value": -2147483649 + } + } + ] + }, + { + "type": "simple", + "op": "ILIKE", + "left": { + "type": "column", + "name": "a" + }, + "right": { + "type": "literal", + "value": [ + 1.5, + "a", + -1 + ] + } + }, + { + "type": "correlatedSubquery", + "op": "NOT EXISTS", + "related": { + "correlation": { + "parentField": [ + "ownerId" + ], + "childField": [ + "closed" + ] + }, + "subquery": { + "table": "comment", + "alias": "zsubq_a", + "where": { + "type": "or", + "conditions": [ + { + "type": "simple", + "op": "NOT IN", + "left": { + "type": "column", + "name": "id" + }, + "right": { + "type": "literal", + "value": "" + } + }, + { + "type": "simple", + "op": "NOT IN", + "left": { + "type": "column", + "name": "a" + }, + "right": { + "type": "static", + "anchor": "authData", + "field": "role" + } + }, + { + "type": "simple", + "op": "IS", + "left": { + "type": "column", + "name": "createdAt" + }, + "right": { + "type": "literal", + "value": 1.5 + } + }, + { + "type": "simple", + "op": "IS", + "left": { + "type": "column", + "name": "ownerId" + }, + "right": { + "type": "literal", + "value": "" + } + } + ] + }, + "limit": 1, + "orderBy": [ + [ + "id", + "asc" + ] + ], + "start": { + "row": { + "id": true, + "createdAt": null + }, + "exclusive": true + } + }, + "hidden": true + } + } + ] + } + }, + "normalized": { + "table": "user", + "where": { + "type": "and", + "conditions": [ + { + "type": "simple", + "op": "ILIKE", + "left": { + "type": "column", + "name": "a" + }, + "right": { + "type": "literal", + "value": [ + 1.5, + "a", + -1 + ] + } + }, + { + "type": "simple", + "op": ">", + "left": { + "type": "column", + "name": "createdAt" + }, + "right": { + "type": "static", + "anchor": "preMutationRow", + "field": [ + "a", + "c" + ] + } + }, + { + "type": "simple", + "op": "NOT LIKE", + "left": { + "type": "column", + "name": "é" + }, + "right": { + "type": "literal", + "value": [ + -2147483649 + ] + } + }, + { + "type": "simple", + "op": "IS NOT", + "left": { + "type": "literal", + "value": -1 + }, + "right": { + "type": "literal", + "value": -2147483649 + } + }, + { + "type": "simple", + "op": "LIKE", + "left": { + "type": "literal", + "value": [] + }, + "right": { + "type": "literal", + "value": -2147483649 + } + }, + { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "a" + ] + }, + "subquery": { + "table": "label", + "alias": "comments", + "where": { + "type": "simple", + "op": "IS NOT", + "left": { + "type": "literal", + "value": true + }, + "right": { + "type": "literal", + "value": 0 + } + }, + "start": { + "row": { + "id": "a", + "createdAt": null + }, + "exclusive": false + }, + "orderBy": [ + [ + "a", + "asc" + ] + ] + } + }, + "op": "EXISTS", + "flip": true, + "scalar": true + }, + { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "a" + ], + "childField": [ + "closed" + ] + }, + "subquery": { + "table": "label", + "alias": "comments", + "where": { + "type": "simple", + "op": "NOT LIKE", + "left": { + "type": "column", + "name": "a" + }, + "right": { + "type": "literal", + "value": 1e+21 + } + }, + "orderBy": [ + [ + "a", + "desc" + ] + ] + } + }, + "op": "NOT EXISTS" + }, + { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "ownerId" + ], + "childField": [ + "closed" + ] + }, + "hidden": true, + "subquery": { + "table": "comment", + "alias": "zsubq_a", + "where": { + "type": "or", + "conditions": [ + { + "type": "simple", + "op": "NOT IN", + "left": { + "type": "column", + "name": "a" + }, + "right": { + "type": "static", + "anchor": "authData", + "field": "role" + } + }, + { + "type": "simple", + "op": "IS", + "left": { + "type": "column", + "name": "createdAt" + }, + "right": { + "type": "literal", + "value": 1.5 + } + }, + { + "type": "simple", + "op": "NOT IN", + "left": { + "type": "column", + "name": "id" + }, + "right": { + "type": "literal", + "value": "" + } + }, + { + "type": "simple", + "op": "IS", + "left": { + "type": "column", + "name": "ownerId" + }, + "right": { + "type": "literal", + "value": "" + } + } + ] + }, + "start": { + "row": { + "id": true, + "createdAt": null + }, + "exclusive": true + }, + "limit": 1, + "orderBy": [ + [ + "id", + "asc" + ] + ] + } + }, + "op": "NOT EXISTS" + } + ] + } + }, + "hash": "0ut1n3s01sngpv" + }, + { + "ast": { + "table": "label", + "where": { + "type": "or", + "conditions": [] + }, + "limit": 100, + "orderBy": [ + [ + "é", + "asc" + ], + [ + "id", + "asc" + ] + ] + }, + "normalized": { + "table": "label", + "where": { + "type": "or", + "conditions": [] + }, + "limit": 100, + "orderBy": [ + [ + "é", + "asc" + ], + [ + "id", + "asc" + ] + ] + }, + "hash": "1x1mjz50klihlz" + }, + { + "ast": { + "table": "user", + "schema": "public", + "where": { + "type": "and", + "conditions": [ + { + "type": "or", + "conditions": [ + { + "type": "or", + "conditions": [ + { + "type": "simple", + "op": "<", + "left": { + "type": "column", + "name": "é" + }, + "right": { + "type": "literal", + "value": -1 + } + }, + { + "type": "simple", + "op": "IS", + "left": { + "type": "column", + "name": "é" + }, + "right": { + "type": "literal", + "value": [ + 2147483648, + "😀" + ] + } + }, + { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "title" + }, + "right": { + "type": "literal", + "value": -1 + } + }, + { + "type": "simple", + "op": "NOT IN", + "left": { + "type": "column", + "name": "title" + }, + "right": { + "type": "static", + "anchor": "authData", + "field": "role" + } + } + ] + }, + { + "type": "or", + "conditions": [ + { + "type": "simple", + "op": "IN", + "left": { + "type": "column", + "name": "a" + }, + "right": { + "type": "literal", + "value": null + } + }, + { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "closed" + }, + "right": { + "type": "literal", + "value": [ + 1e+21, + 2147483648, + 1 + ] + } + }, + { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "é" + }, + "right": { + "type": "literal", + "value": null + } + }, + { + "type": "simple", + "op": "NOT IN", + "left": { + "type": "column", + "name": "a" + }, + "right": { + "type": "literal", + "value": false + } + } + ] + }, + { + "type": "or", + "conditions": [ + { + "type": "simple", + "op": "NOT LIKE", + "left": { + "type": "column", + "name": "a" + }, + "right": { + "type": "static", + "anchor": "preMutationRow", + "field": [ + "a", + "c" + ] + } + }, + { + "type": "simple", + "op": ">", + "left": { + "type": "column", + "name": "ownerId" + }, + "right": { + "type": "literal", + "value": 1 + } + } + ] + }, + { + "type": "correlatedSubquery", + "op": "NOT EXISTS", + "related": { + "correlation": { + "parentField": [ + "closed" + ], + "childField": [ + "a" + ] + }, + "subquery": { + "table": "issue", + "alias": "comments", + "schema": "public", + "where": { + "type": "simple", + "op": "ILIKE", + "left": { + "type": "column", + "name": "closed" + }, + "right": { + "type": "literal", + "value": null + } + }, + "orderBy": [ + [ + "createdAt", + "asc" + ] + ] + } + }, + "flip": true, + "scalar": true + } + ] + } + ] + }, + "related": [ + { + "correlation": { + "parentField": [ + "closed" + ], + "childField": [ + "createdAt" + ] + }, + "subquery": { + "table": "label", + "alias": "zsubq_a", + "where": { + "type": "or", + "conditions": [] + }, + "limit": 100 + }, + "system": "permissions", + "hidden": true + } + ], + "start": { + "row": { + "id": -2147483649, + "createdAt": 2.5 + }, + "exclusive": true + } + }, + "normalized": { + "schema": "public", + "table": "user", + "where": { + "type": "or", + "conditions": [ + { + "type": "simple", + "op": "IN", + "left": { + "type": "column", + "name": "a" + }, + "right": { + "type": "literal", + "value": null + } + }, + { + "type": "simple", + "op": "NOT IN", + "left": { + "type": "column", + "name": "a" + }, + "right": { + "type": "literal", + "value": false + } + }, + { + "type": "simple", + "op": "NOT LIKE", + "left": { + "type": "column", + "name": "a" + }, + "right": { + "type": "static", + "anchor": "preMutationRow", + "field": [ + "a", + "c" + ] + } + }, + { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "closed" + }, + "right": { + "type": "literal", + "value": [ + 1e+21, + 2147483648, + 1 + ] + } + }, + { + "type": "simple", + "op": ">", + "left": { + "type": "column", + "name": "ownerId" + }, + "right": { + "type": "literal", + "value": 1 + } + }, + { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "title" + }, + "right": { + "type": "literal", + "value": -1 + } + }, + { + "type": "simple", + "op": "NOT IN", + "left": { + "type": "column", + "name": "title" + }, + "right": { + "type": "static", + "anchor": "authData", + "field": "role" + } + }, + { + "type": "simple", + "op": "<", + "left": { + "type": "column", + "name": "é" + }, + "right": { + "type": "literal", + "value": -1 + } + }, + { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "é" + }, + "right": { + "type": "literal", + "value": null + } + }, + { + "type": "simple", + "op": "IS", + "left": { + "type": "column", + "name": "é" + }, + "right": { + "type": "literal", + "value": [ + 2147483648, + "😀" + ] + } + }, + { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "closed" + ], + "childField": [ + "a" + ] + }, + "subquery": { + "schema": "public", + "table": "issue", + "alias": "comments", + "where": { + "type": "simple", + "op": "ILIKE", + "left": { + "type": "column", + "name": "closed" + }, + "right": { + "type": "literal", + "value": null + } + }, + "orderBy": [ + [ + "createdAt", + "asc" + ] + ] + } + }, + "op": "NOT EXISTS", + "flip": true, + "scalar": true + } + ] + }, + "related": [ + { + "correlation": { + "parentField": [ + "closed" + ], + "childField": [ + "createdAt" + ] + }, + "hidden": true, + "subquery": { + "table": "label", + "alias": "zsubq_a", + "where": { + "type": "or", + "conditions": [] + }, + "limit": 100 + }, + "system": "permissions" + } + ], + "start": { + "row": { + "id": -2147483649, + "createdAt": 2.5 + }, + "exclusive": true + } + }, + "hash": "1nznjxw0j9wwx0" + }, + { + "ast": { + "table": "issue", + "schema": "public", + "where": { + "type": "simple", + "op": "NOT IN", + "left": { + "type": "column", + "name": "closed" + }, + "right": { + "type": "literal", + "value": null + } + } + }, + "normalized": { + "schema": "public", + "table": "issue", + "where": { + "type": "simple", + "op": "NOT IN", + "left": { + "type": "column", + "name": "closed" + }, + "right": { + "type": "literal", + "value": null + } + } + }, + "hash": "0e3uq5k1k8ey0l" + }, + { + "ast": { + "table": "issue", + "where": { + "type": "simple", + "op": "NOT LIKE", + "left": { + "type": "column", + "name": "id" + }, + "right": { + "type": "literal", + "value": -2147483649 + } + }, + "related": [ + { + "correlation": { + "parentField": [ + "a" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "issue", + "alias": "zsubq_a", + "where": { + "type": "and", + "conditions": [] + }, + "related": [ + { + "correlation": { + "parentField": [ + "a" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "issue", + "alias": "owner", + "limit": 0, + "orderBy": [ + [ + "a", + "asc" + ], + [ + "id", + "asc" + ] + ], + "start": { + "row": { + "id": "b", + "createdAt": null + }, + "exclusive": true + } + }, + "hidden": true + }, + { + "correlation": { + "parentField": [ + "é" + ], + "childField": [ + "é" + ] + }, + "subquery": { + "table": "label", + "alias": "zsubq_b", + "orderBy": [ + [ + "createdAt", + "desc" + ], + [ + "id", + "asc" + ] + ] + } + }, + { + "correlation": { + "parentField": [ + "ownerId" + ], + "childField": [ + "a" + ] + }, + "subquery": { + "table": "comment", + "alias": "owner", + "where": { + "type": "correlatedSubquery", + "op": "NOT EXISTS", + "related": { + "correlation": { + "parentField": [ + "é" + ], + "childField": [ + "createdAt" + ] + }, + "subquery": { + "table": "issue", + "alias": "zsubq_b", + "where": { + "type": "simple", + "op": "NOT ILIKE", + "left": { + "type": "column", + "name": "id" + }, + "right": { + "type": "literal", + "value": null + } + } + } + }, + "scalar": true + }, + "limit": 1.5 + }, + "system": "client" + } + ] + } + } + ], + "orderBy": [ + [ + "é", + "asc" + ] + ] + }, + "normalized": { + "table": "issue", + "where": { + "type": "simple", + "op": "NOT LIKE", + "left": { + "type": "column", + "name": "id" + }, + "right": { + "type": "literal", + "value": -2147483649 + } + }, + "related": [ + { + "correlation": { + "parentField": [ + "a" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "issue", + "alias": "zsubq_a", + "where": { + "type": "and", + "conditions": [] + }, + "related": [ + { + "correlation": { + "parentField": [ + "a" + ], + "childField": [ + "id" + ] + }, + "hidden": true, + "subquery": { + "table": "issue", + "alias": "owner", + "start": { + "row": { + "id": "b", + "createdAt": null + }, + "exclusive": true + }, + "limit": 0, + "orderBy": [ + [ + "a", + "asc" + ], + [ + "id", + "asc" + ] + ] + } + }, + { + "correlation": { + "parentField": [ + "ownerId" + ], + "childField": [ + "a" + ] + }, + "subquery": { + "table": "comment", + "alias": "owner", + "where": { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "é" + ], + "childField": [ + "createdAt" + ] + }, + "subquery": { + "table": "issue", + "alias": "zsubq_b", + "where": { + "type": "simple", + "op": "NOT ILIKE", + "left": { + "type": "column", + "name": "id" + }, + "right": { + "type": "literal", + "value": null + } + } + } + }, + "op": "NOT EXISTS", + "scalar": true + }, + "limit": 1.5 + }, + "system": "client" + }, + { + "correlation": { + "parentField": [ + "é" + ], + "childField": [ + "é" + ] + }, + "subquery": { + "table": "label", + "alias": "zsubq_b", + "orderBy": [ + [ + "createdAt", + "desc" + ], + [ + "id", + "asc" + ] + ] + } + } + ] + } + } + ], + "orderBy": [ + [ + "é", + "asc" + ] + ] + }, + "hash": "1tb26rp02dx2cr" + }, + { + "ast": { + "table": "user", + "related": [ + { + "correlation": { + "parentField": [ + "createdAt" + ], + "childField": [ + "é" + ] + }, + "subquery": { + "table": "comment", + "alias": "owner", + "where": { + "type": "correlatedSubquery", + "op": "NOT EXISTS", + "related": { + "correlation": { + "parentField": [ + "a" + ], + "childField": [ + "title" + ] + }, + "subquery": { + "table": "comment", + "alias": "zsubq_a", + "schema": "public", + "where": { + "type": "simple", + "op": "NOT ILIKE", + "left": { + "type": "column", + "name": "title" + }, + "right": { + "type": "literal", + "value": -1 + } + } + }, + "system": "client", + "hidden": false + }, + "scalar": true + }, + "orderBy": [ + [ + "title", + "asc" + ], + [ + "id", + "asc" + ] + ] + } + }, + { + "correlation": { + "parentField": [ + "a" + ], + "childField": [ + "title" + ] + }, + "subquery": { + "table": "user", + "alias": "zsubq_b", + "where": { + "type": "or", + "conditions": [] + }, + "related": [ + { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "table": "label", + "alias": "zsubq_a", + "where": { + "type": "correlatedSubquery", + "op": "EXISTS", + "related": { + "correlation": { + "parentField": [ + "a" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "orderBy": [ + [ + "a", + "asc" + ] + ] + }, + "system": "permissions" + }, + "flip": false + } + }, + "system": "client" + } + ], + "orderBy": [ + [ + "closed", + "desc" + ], + [ + "id", + "asc" + ] + ] + }, + "system": "client" + } + ] + }, + "normalized": { + "table": "user", + "related": [ + { + "correlation": { + "parentField": [ + "createdAt" + ], + "childField": [ + "é" + ] + }, + "subquery": { + "table": "comment", + "alias": "owner", + "where": { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "a" + ], + "childField": [ + "title" + ] + }, + "hidden": false, + "subquery": { + "schema": "public", + "table": "comment", + "alias": "zsubq_a", + "where": { + "type": "simple", + "op": "NOT ILIKE", + "left": { + "type": "column", + "name": "title" + }, + "right": { + "type": "literal", + "value": -1 + } + } + }, + "system": "client" + }, + "op": "NOT EXISTS", + "scalar": true + }, + "orderBy": [ + [ + "title", + "asc" + ], + [ + "id", + "asc" + ] + ] + } + }, + { + "correlation": { + "parentField": [ + "a" + ], + "childField": [ + "title" + ] + }, + "subquery": { + "table": "user", + "alias": "zsubq_b", + "where": { + "type": "or", + "conditions": [] + }, + "related": [ + { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "table": "label", + "alias": "zsubq_a", + "where": { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "a" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "orderBy": [ + [ + "a", + "asc" + ] + ] + }, + "system": "permissions" + }, + "op": "EXISTS", + "flip": false + } + }, + "system": "client" + } + ], + "orderBy": [ + [ + "closed", + "desc" + ], + [ + "id", + "asc" + ] + ] + }, + "system": "client" + } + ] + }, + "hash": "0w9y5t6075eior" + }, + { + "ast": { + "table": "comment", + "where": { + "type": "simple", + "op": "ILIKE", + "left": { + "type": "column", + "name": "createdAt" + }, + "right": { + "type": "literal", + "value": 0 + } + }, + "related": [ + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "closed" + ] + }, + "subquery": { + "table": "comment", + "alias": "owner", + "where": { + "type": "or", + "conditions": [ + { + "type": "simple", + "op": "!=", + "left": { + "type": "column", + "name": "closed" + }, + "right": { + "type": "literal", + "value": null + } + } + ] + }, + "limit": 10, + "start": { + "row": { + "id": 1.5, + "createdAt": 2.5 + }, + "exclusive": true + } + }, + "system": "client" + }, + { + "correlation": { + "parentField": [ + "createdAt" + ], + "childField": [ + "é" + ] + }, + "subquery": { + "table": "label", + "alias": "zsubq_a", + "where": { + "type": "or", + "conditions": [ + { + "type": "simple", + "op": ">", + "left": { + "type": "column", + "name": "ownerId" + }, + "right": { + "type": "literal", + "value": null + } + }, + { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "é" + }, + "right": { + "type": "literal", + "value": [] + } + }, + { + "type": "simple", + "op": "=", + "left": { + "type": "literal", + "value": 2147483648 + }, + "right": { + "type": "literal", + "value": 0 + } + } + ] + }, + "related": [ + { + "correlation": { + "parentField": [ + "closed" + ], + "childField": [ + "createdAt" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "where": { + "type": "correlatedSubquery", + "op": "NOT EXISTS", + "related": { + "correlation": { + "parentField": [ + "é" + ], + "childField": [ + "title" + ] + }, + "subquery": { + "table": "comment", + "alias": "zsubq_b", + "orderBy": [ + [ + "title", + "desc" + ], + [ + "id", + "asc" + ] + ] + } + } + }, + "orderBy": [ + [ + "a", + "asc" + ], + [ + "id", + "asc" + ] + ], + "start": { + "row": { + "id": "é", + "createdAt": 1 + }, + "exclusive": false + } + }, + "system": "test" + }, + { + "correlation": { + "parentField": [ + "createdAt" + ], + "childField": [ + "createdAt" + ] + }, + "subquery": { + "table": "issue", + "alias": "owner", + "where": { + "type": "or", + "conditions": [ + { + "type": "simple", + "op": "<", + "left": { + "type": "literal", + "value": null + }, + "right": { + "type": "static", + "anchor": "preMutationRow", + "field": "sub" + } + }, + { + "type": "simple", + "op": ">=", + "left": { + "type": "column", + "name": "title" + }, + "right": { + "type": "literal", + "value": [] + } + } + ] + } + }, + "system": "permissions" + }, + { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "table": "issue", + "alias": "zsubq_a", + "where": { + "type": "and", + "conditions": [ + { + "type": "simple", + "op": "NOT LIKE", + "left": { + "type": "column", + "name": "a" + }, + "right": { + "type": "literal", + "value": [ + -1, + 2147483648, + 1 + ] + } + }, + { + "type": "simple", + "op": "!=", + "left": { + "type": "column", + "name": "title" + }, + "right": { + "type": "static", + "anchor": "preMutationRow", + "field": [ + "a", + "b" + ] + } + }, + { + "type": "simple", + "op": "<", + "left": { + "type": "column", + "name": "é" + }, + "right": { + "type": "literal", + "value": false + } + } + ] + }, + "orderBy": [ + [ + "title", + "desc" + ], + [ + "id", + "asc" + ] + ] + } + } + ], + "orderBy": [ + [ + "ownerId", + "desc" + ] + ] + }, + "system": "test" + } + ], + "limit": 4294967296, + "orderBy": [ + [ + "id", + "asc" + ], + [ + "id", + "asc" + ] + ], + "start": { + "row": { + "id": "é", + "createdAt": null + }, + "exclusive": true + } + }, + "normalized": { + "table": "comment", + "where": { + "type": "simple", + "op": "ILIKE", + "left": { + "type": "column", + "name": "createdAt" + }, + "right": { + "type": "literal", + "value": 0 + } + }, + "related": [ + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "closed" + ] + }, + "subquery": { + "table": "comment", + "alias": "owner", + "where": { + "type": "simple", + "op": "!=", + "left": { + "type": "column", + "name": "closed" + }, + "right": { + "type": "literal", + "value": null + } + }, + "start": { + "row": { + "id": 1.5, + "createdAt": 2.5 + }, + "exclusive": true + }, + "limit": 10 + }, + "system": "client" + }, + { + "correlation": { + "parentField": [ + "createdAt" + ], + "childField": [ + "é" + ] + }, + "subquery": { + "table": "label", + "alias": "zsubq_a", + "where": { + "type": "or", + "conditions": [ + { + "type": "simple", + "op": ">", + "left": { + "type": "column", + "name": "ownerId" + }, + "right": { + "type": "literal", + "value": null + } + }, + { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "é" + }, + "right": { + "type": "literal", + "value": [] + } + }, + { + "type": "simple", + "op": "=", + "left": { + "type": "literal", + "value": 2147483648 + }, + "right": { + "type": "literal", + "value": 0 + } + } + ] + }, + "related": [ + { + "correlation": { + "parentField": [ + "closed" + ], + "childField": [ + "createdAt" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "where": { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "é" + ], + "childField": [ + "title" + ] + }, + "subquery": { + "table": "comment", + "alias": "zsubq_b", + "orderBy": [ + [ + "title", + "desc" + ], + [ + "id", + "asc" + ] + ] + } + }, + "op": "NOT EXISTS" + }, + "start": { + "row": { + "id": "é", + "createdAt": 1 + }, + "exclusive": false + }, + "orderBy": [ + [ + "a", + "asc" + ], + [ + "id", + "asc" + ] + ] + }, + "system": "test" + }, + { + "correlation": { + "parentField": [ + "createdAt" + ], + "childField": [ + "createdAt" + ] + }, + "subquery": { + "table": "issue", + "alias": "owner", + "where": { + "type": "or", + "conditions": [ + { + "type": "simple", + "op": ">=", + "left": { + "type": "column", + "name": "title" + }, + "right": { + "type": "literal", + "value": [] + } + }, + { + "type": "simple", + "op": "<", + "left": { + "type": "literal", + "value": null + }, + "right": { + "type": "static", + "anchor": "preMutationRow", + "field": "sub" + } + } + ] + } + }, + "system": "permissions" + }, + { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "table": "issue", + "alias": "zsubq_a", + "where": { + "type": "and", + "conditions": [ + { + "type": "simple", + "op": "NOT LIKE", + "left": { + "type": "column", + "name": "a" + }, + "right": { + "type": "literal", + "value": [ + -1, + 2147483648, + 1 + ] + } + }, + { + "type": "simple", + "op": "!=", + "left": { + "type": "column", + "name": "title" + }, + "right": { + "type": "static", + "anchor": "preMutationRow", + "field": [ + "a", + "b" + ] + } + }, + { + "type": "simple", + "op": "<", + "left": { + "type": "column", + "name": "é" + }, + "right": { + "type": "literal", + "value": false + } + } + ] + }, + "orderBy": [ + [ + "title", + "desc" + ], + [ + "id", + "asc" + ] + ] + } + } + ], + "orderBy": [ + [ + "ownerId", + "desc" + ] + ] + }, + "system": "test" + } + ], + "start": { + "row": { + "id": "é", + "createdAt": null + }, + "exclusive": true + }, + "limit": 4294967296, + "orderBy": [ + [ + "id", + "asc" + ], + [ + "id", + "asc" + ] + ] + }, + "hash": "1kz9bdm0m0vnoi" + }, + { + "ast": { + "table": "comment", + "where": { + "type": "or", + "conditions": [ + { + "type": "and", + "conditions": [] + } + ] + }, + "related": [ + { + "correlation": { + "parentField": [ + "é" + ], + "childField": [ + "a" + ] + }, + "subquery": { + "table": "user", + "alias": "owner", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "ownerId" + }, + "right": { + "type": "literal", + "value": "1" + } + } + }, + "system": "permissions", + "hidden": false + } + ], + "limit": 4294967296, + "start": { + "row": { + "id": "😀", + "createdAt": 1 + }, + "exclusive": true + } + }, + "normalized": { + "table": "comment", + "where": { + "type": "and", + "conditions": [] + }, + "related": [ + { + "correlation": { + "parentField": [ + "é" + ], + "childField": [ + "a" + ] + }, + "hidden": false, + "subquery": { + "table": "user", + "alias": "owner", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "ownerId" + }, + "right": { + "type": "literal", + "value": "1" + } + } + }, + "system": "permissions" + } + ], + "start": { + "row": { + "id": "😀", + "createdAt": 1 + }, + "exclusive": true + }, + "limit": 4294967296 + }, + "hash": "1djy6190pccoxe" + }, + { + "ast": { + "table": "label", + "where": { + "type": "simple", + "op": "<=", + "left": { + "type": "literal", + "value": 1.5 + }, + "right": { + "type": "literal", + "value": [ + "" + ] + } + }, + "related": [ + { + "correlation": { + "parentField": [ + "ownerId" + ], + "childField": [ + "closed" + ] + }, + "subquery": { + "table": "issue", + "alias": "owner", + "where": { + "type": "correlatedSubquery", + "op": "NOT EXISTS", + "related": { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "table": "user", + "alias": "owner", + "where": { + "type": "simple", + "op": "NOT IN", + "left": { + "type": "column", + "name": "createdAt" + }, + "right": { + "type": "literal", + "value": true + } + }, + "start": { + "row": { + "id": 1.5, + "createdAt": 1 + }, + "exclusive": false + } + }, + "system": "permissions" + }, + "scalar": true + }, + "related": [ + { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "table": "label", + "alias": "comments", + "where": { + "type": "or", + "conditions": [] + } + }, + "system": "test" + } + ], + "limit": 0, + "start": { + "row": { + "id": 1, + "createdAt": 1 + }, + "exclusive": true + } + }, + "system": "client" + }, + { + "correlation": { + "parentField": [ + "é" + ], + "childField": [ + "title" + ] + }, + "subquery": { + "table": "label", + "alias": "zsubq_b", + "where": { + "type": "or", + "conditions": [ + { + "type": "or", + "conditions": [] + }, + { + "type": "and", + "conditions": [ + { + "type": "simple", + "op": "LIKE", + "left": { + "type": "column", + "name": "createdAt" + }, + "right": { + "type": "literal", + "value": null + } + }, + { + "type": "simple", + "op": ">=", + "left": { + "type": "column", + "name": "é" + }, + "right": { + "type": "literal", + "value": null + } + }, + { + "type": "simple", + "op": "NOT IN", + "left": { + "type": "column", + "name": "id" + }, + "right": { + "type": "literal", + "value": "😀" + } + } + ] + }, + { + "type": "correlatedSubquery", + "op": "EXISTS", + "related": { + "correlation": { + "parentField": [ + "é" + ], + "childField": [ + "closed" + ] + }, + "subquery": { + "table": "comment", + "alias": "zsubq_b", + "where": { + "type": "simple", + "op": "NOT IN", + "left": { + "type": "column", + "name": "é" + }, + "right": { + "type": "literal", + "value": null + } + }, + "orderBy": [ + [ + "title", + "asc" + ], + [ + "id", + "asc" + ] + ] + }, + "system": "test" + } + }, + { + "type": "or", + "conditions": [] + } + ] + }, + "related": [ + { + "correlation": { + "parentField": [ + "createdAt" + ], + "childField": [ + "a" + ] + }, + "subquery": { + "table": "comment", + "alias": "zsubq_b", + "where": { + "type": "simple", + "op": "!=", + "left": { + "type": "column", + "name": "closed" + }, + "right": { + "type": "literal", + "value": null + } + }, + "orderBy": [ + [ + "createdAt", + "desc" + ] + ] + }, + "system": "client" + }, + { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "table": "issue", + "alias": "zsubq_a", + "orderBy": [ + [ + "createdAt", + "asc" + ] + ], + "start": { + "row": { + "id": "é", + "createdAt": null + }, + "exclusive": false + } + }, + "hidden": true + }, + { + "correlation": { + "parentField": [ + "a" + ], + "childField": [ + "é" + ] + }, + "subquery": { + "table": "label", + "alias": "zsubq_b", + "where": { + "type": "simple", + "op": ">", + "left": { + "type": "static", + "anchor": "authData", + "field": "sub" + }, + "right": { + "type": "static", + "anchor": "preMutationRow", + "field": [ + "a", + "b" + ] + } + }, + "orderBy": [ + [ + "ownerId", + "desc" + ], + [ + "id", + "asc" + ] + ] + } + } + ] + } + }, + { + "correlation": { + "parentField": [ + "title" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "issue", + "alias": "zsubq_a", + "where": { + "type": "correlatedSubquery", + "op": "NOT EXISTS", + "related": { + "correlation": { + "parentField": [ + "ownerId" + ], + "childField": [ + "é" + ] + }, + "subquery": { + "table": "comment", + "alias": "zsubq_b", + "orderBy": [ + [ + "closed", + "desc" + ], + [ + "id", + "asc" + ] + ] + } + }, + "scalar": true + }, + "related": [ + { + "correlation": { + "parentField": [ + "ownerId" + ], + "childField": [ + "a" + ] + }, + "subquery": { + "table": "user", + "alias": "zsubq_b", + "where": { + "type": "correlatedSubquery", + "op": "EXISTS", + "related": { + "correlation": { + "parentField": [ + "ownerId" + ], + "childField": [ + "é" + ] + }, + "subquery": { + "table": "issue", + "alias": "zsubq_a", + "where": { + "type": "simple", + "op": "LIKE", + "left": { + "type": "column", + "name": "id" + }, + "right": { + "type": "static", + "anchor": "preMutationRow", + "field": "sub" + } + }, + "limit": 1.5, + "orderBy": [ + [ + "é", + "asc" + ], + [ + "id", + "asc" + ] + ] + }, + "hidden": true + } + }, + "orderBy": [ + [ + "closed", + "desc" + ], + [ + "id", + "asc" + ] + ] + } + }, + { + "correlation": { + "parentField": [ + "title" + ], + "childField": [ + "ownerId" + ] + }, + "subquery": { + "table": "label", + "alias": "owner", + "where": { + "type": "and", + "conditions": [ + { + "type": "simple", + "op": "<=", + "left": { + "type": "column", + "name": "é" + }, + "right": { + "type": "static", + "anchor": "preMutationRow", + "field": "sub" + } + } + ] + }, + "start": { + "row": { + "id": 0, + "createdAt": 2.5 + }, + "exclusive": true + } + }, + "system": "test" + }, + { + "correlation": { + "parentField": [ + "a" + ], + "childField": [ + "closed" + ] + }, + "subquery": { + "table": "user", + "alias": "comments", + "where": { + "type": "or", + "conditions": [ + { + "type": "simple", + "op": "<", + "left": { + "type": "column", + "name": "ownerId" + }, + "right": { + "type": "literal", + "value": [ + 2147483648, + "é", + 0 + ] + } + }, + { + "type": "simple", + "op": "ILIKE", + "left": { + "type": "column", + "name": "id" + }, + "right": { + "type": "literal", + "value": [ + false, + "" + ] + } + } + ] + } + } + } + ], + "orderBy": [ + [ + "closed", + "asc" + ], + [ + "id", + "asc" + ] + ], + "start": { + "row": { + "id": -1, + "createdAt": 2.5 + }, + "exclusive": true + } + }, + "system": "permissions" + } + ], + "limit": 1.5 + }, + "normalized": { + "table": "label", + "where": { + "type": "simple", + "op": "<=", + "left": { + "type": "literal", + "value": 1.5 + }, + "right": { + "type": "literal", + "value": [ + "" + ] + } + }, + "related": [ + { + "correlation": { + "parentField": [ + "ownerId" + ], + "childField": [ + "closed" + ] + }, + "subquery": { + "table": "issue", + "alias": "owner", + "where": { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "table": "user", + "alias": "owner", + "where": { + "type": "simple", + "op": "NOT IN", + "left": { + "type": "column", + "name": "createdAt" + }, + "right": { + "type": "literal", + "value": true + } + }, + "start": { + "row": { + "id": 1.5, + "createdAt": 1 + }, + "exclusive": false + } + }, + "system": "permissions" + }, + "op": "NOT EXISTS", + "scalar": true + }, + "related": [ + { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "table": "label", + "alias": "comments", + "where": { + "type": "or", + "conditions": [] + } + }, + "system": "test" + } + ], + "start": { + "row": { + "id": 1, + "createdAt": 1 + }, + "exclusive": true + }, + "limit": 0 + }, + "system": "client" + }, + { + "correlation": { + "parentField": [ + "title" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "issue", + "alias": "zsubq_a", + "where": { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "ownerId" + ], + "childField": [ + "é" + ] + }, + "subquery": { + "table": "comment", + "alias": "zsubq_b", + "orderBy": [ + [ + "closed", + "desc" + ], + [ + "id", + "asc" + ] + ] + } + }, + "op": "NOT EXISTS", + "scalar": true + }, + "related": [ + { + "correlation": { + "parentField": [ + "a" + ], + "childField": [ + "closed" + ] + }, + "subquery": { + "table": "user", + "alias": "comments", + "where": { + "type": "or", + "conditions": [ + { + "type": "simple", + "op": "ILIKE", + "left": { + "type": "column", + "name": "id" + }, + "right": { + "type": "literal", + "value": [ + false, + "" + ] + } + }, + { + "type": "simple", + "op": "<", + "left": { + "type": "column", + "name": "ownerId" + }, + "right": { + "type": "literal", + "value": [ + 2147483648, + "é", + 0 + ] + } + } + ] + } + } + }, + { + "correlation": { + "parentField": [ + "title" + ], + "childField": [ + "ownerId" + ] + }, + "subquery": { + "table": "label", + "alias": "owner", + "where": { + "type": "simple", + "op": "<=", + "left": { + "type": "column", + "name": "é" + }, + "right": { + "type": "static", + "anchor": "preMutationRow", + "field": "sub" + } + }, + "start": { + "row": { + "id": 0, + "createdAt": 2.5 + }, + "exclusive": true + } + }, + "system": "test" + }, + { + "correlation": { + "parentField": [ + "ownerId" + ], + "childField": [ + "a" + ] + }, + "subquery": { + "table": "user", + "alias": "zsubq_b", + "where": { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "ownerId" + ], + "childField": [ + "é" + ] + }, + "hidden": true, + "subquery": { + "table": "issue", + "alias": "zsubq_a", + "where": { + "type": "simple", + "op": "LIKE", + "left": { + "type": "column", + "name": "id" + }, + "right": { + "type": "static", + "anchor": "preMutationRow", + "field": "sub" + } + }, + "limit": 1.5, + "orderBy": [ + [ + "é", + "asc" + ], + [ + "id", + "asc" + ] + ] + } + }, + "op": "EXISTS" + }, + "orderBy": [ + [ + "closed", + "desc" + ], + [ + "id", + "asc" + ] + ] + } + } + ], + "start": { + "row": { + "id": -1, + "createdAt": 2.5 + }, + "exclusive": true + }, + "orderBy": [ + [ + "closed", + "asc" + ], + [ + "id", + "asc" + ] + ] + }, + "system": "permissions" + }, + { + "correlation": { + "parentField": [ + "é" + ], + "childField": [ + "title" + ] + }, + "subquery": { + "table": "label", + "alias": "zsubq_b", + "where": { + "type": "or", + "conditions": [ + { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "é" + ], + "childField": [ + "closed" + ] + }, + "subquery": { + "table": "comment", + "alias": "zsubq_b", + "where": { + "type": "simple", + "op": "NOT IN", + "left": { + "type": "column", + "name": "é" + }, + "right": { + "type": "literal", + "value": null + } + }, + "orderBy": [ + [ + "title", + "asc" + ], + [ + "id", + "asc" + ] + ] + }, + "system": "test" + }, + "op": "EXISTS" + }, + { + "type": "and", + "conditions": [ + { + "type": "simple", + "op": "LIKE", + "left": { + "type": "column", + "name": "createdAt" + }, + "right": { + "type": "literal", + "value": null + } + }, + { + "type": "simple", + "op": "NOT IN", + "left": { + "type": "column", + "name": "id" + }, + "right": { + "type": "literal", + "value": "😀" + } + }, + { + "type": "simple", + "op": ">=", + "left": { + "type": "column", + "name": "é" + }, + "right": { + "type": "literal", + "value": null + } + } + ] + } + ] + }, + "related": [ + { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "hidden": true, + "subquery": { + "table": "issue", + "alias": "zsubq_a", + "start": { + "row": { + "id": "é", + "createdAt": null + }, + "exclusive": false + }, + "orderBy": [ + [ + "createdAt", + "asc" + ] + ] + } + }, + { + "correlation": { + "parentField": [ + "createdAt" + ], + "childField": [ + "a" + ] + }, + "subquery": { + "table": "comment", + "alias": "zsubq_b", + "where": { + "type": "simple", + "op": "!=", + "left": { + "type": "column", + "name": "closed" + }, + "right": { + "type": "literal", + "value": null + } + }, + "orderBy": [ + [ + "createdAt", + "desc" + ] + ] + }, + "system": "client" + }, + { + "correlation": { + "parentField": [ + "a" + ], + "childField": [ + "é" + ] + }, + "subquery": { + "table": "label", + "alias": "zsubq_b", + "where": { + "type": "simple", + "op": ">", + "left": { + "type": "static", + "anchor": "authData", + "field": "sub" + }, + "right": { + "type": "static", + "anchor": "preMutationRow", + "field": [ + "a", + "b" + ] + } + }, + "orderBy": [ + [ + "ownerId", + "desc" + ], + [ + "id", + "asc" + ] + ] + } + } + ] + } + } + ], + "limit": 1.5 + }, + "hash": "0k3vd1z1pne2y9" + }, + { + "ast": { + "table": "issue", + "where": { + "type": "simple", + "op": "NOT IN", + "left": { + "type": "column", + "name": "createdAt" + }, + "right": { + "type": "literal", + "value": [ + 1 + ] + } + }, + "related": [ + { + "correlation": { + "parentField": [ + "createdAt" + ], + "childField": [ + "title" + ] + }, + "subquery": { + "table": "user", + "alias": "zsubq_b", + "where": { + "type": "simple", + "op": "NOT LIKE", + "left": { + "type": "column", + "name": "closed" + }, + "right": { + "type": "literal", + "value": 1 + } + }, + "related": [ + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "é" + ] + }, + "subquery": { + "table": "user", + "alias": "zsubq_a", + "schema": "public", + "where": { + "type": "simple", + "op": "<=", + "left": { + "type": "column", + "name": "createdAt" + }, + "right": { + "type": "literal", + "value": null + } + }, + "start": { + "row": { + "id": 0, + "createdAt": 1 + }, + "exclusive": true + } + }, + "hidden": true + } + ] + }, + "hidden": true + }, + { + "correlation": { + "parentField": [ + "createdAt" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "comment", + "alias": "zsubq_b", + "where": { + "type": "simple", + "op": ">", + "left": { + "type": "column", + "name": "createdAt" + }, + "right": { + "type": "static", + "anchor": "authData", + "field": [ + "a", + "b" + ] + } + }, + "limit": 100 + } + }, + { + "correlation": { + "parentField": [ + "createdAt" + ], + "childField": [ + "createdAt" + ] + }, + "subquery": { + "table": "user", + "alias": "owner", + "related": [ + { + "correlation": { + "parentField": [ + "title" + ], + "childField": [ + "é" + ] + }, + "subquery": { + "table": "label", + "alias": "zsubq_b", + "where": { + "type": "and", + "conditions": [] + } + }, + "system": "client" + }, + { + "correlation": { + "parentField": [ + "title" + ], + "childField": [ + "a" + ] + }, + "subquery": { + "table": "comment", + "alias": "zsubq_a", + "schema": "public", + "orderBy": [ + [ + "é", + "asc" + ], + [ + "id", + "asc" + ] + ], + "start": { + "row": { + "id": "1", + "createdAt": 1 + }, + "exclusive": false + } + }, + "system": "test" + }, + { + "correlation": { + "parentField": [ + "é" + ], + "childField": [ + "ownerId" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "where": { + "type": "correlatedSubquery", + "op": "NOT EXISTS", + "related": { + "correlation": { + "parentField": [ + "title" + ], + "childField": [ + "closed" + ] + }, + "subquery": { + "table": "issue", + "alias": "zsubq_a", + "where": { + "type": "simple", + "op": "IS NOT", + "left": { + "type": "column", + "name": "ownerId" + }, + "right": { + "type": "literal", + "value": null + } + }, + "orderBy": [ + [ + "title", + "desc" + ], + [ + "id", + "asc" + ] + ] + } + }, + "flip": false + } + } + } + ] + } + } + ] + }, + "normalized": { + "table": "issue", + "where": { + "type": "simple", + "op": "NOT IN", + "left": { + "type": "column", + "name": "createdAt" + }, + "right": { + "type": "literal", + "value": [ + 1 + ] + } + }, + "related": [ + { + "correlation": { + "parentField": [ + "createdAt" + ], + "childField": [ + "createdAt" + ] + }, + "subquery": { + "table": "user", + "alias": "owner", + "related": [ + { + "correlation": { + "parentField": [ + "é" + ], + "childField": [ + "ownerId" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "where": { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "title" + ], + "childField": [ + "closed" + ] + }, + "subquery": { + "table": "issue", + "alias": "zsubq_a", + "where": { + "type": "simple", + "op": "IS NOT", + "left": { + "type": "column", + "name": "ownerId" + }, + "right": { + "type": "literal", + "value": null + } + }, + "orderBy": [ + [ + "title", + "desc" + ], + [ + "id", + "asc" + ] + ] + } + }, + "op": "NOT EXISTS", + "flip": false + } + } + }, + { + "correlation": { + "parentField": [ + "title" + ], + "childField": [ + "a" + ] + }, + "subquery": { + "schema": "public", + "table": "comment", + "alias": "zsubq_a", + "start": { + "row": { + "id": "1", + "createdAt": 1 + }, + "exclusive": false + }, + "orderBy": [ + [ + "é", + "asc" + ], + [ + "id", + "asc" + ] + ] + }, + "system": "test" + }, + { + "correlation": { + "parentField": [ + "title" + ], + "childField": [ + "é" + ] + }, + "subquery": { + "table": "label", + "alias": "zsubq_b", + "where": { + "type": "and", + "conditions": [] + } + }, + "system": "client" + } + ] + } + }, + { + "correlation": { + "parentField": [ + "createdAt" + ], + "childField": [ + "title" + ] + }, + "hidden": true, + "subquery": { + "table": "user", + "alias": "zsubq_b", + "where": { + "type": "simple", + "op": "NOT LIKE", + "left": { + "type": "column", + "name": "closed" + }, + "right": { + "type": "literal", + "value": 1 + } + }, + "related": [ + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "é" + ] + }, + "hidden": true, + "subquery": { + "schema": "public", + "table": "user", + "alias": "zsubq_a", + "where": { + "type": "simple", + "op": "<=", + "left": { + "type": "column", + "name": "createdAt" + }, + "right": { + "type": "literal", + "value": null + } + }, + "start": { + "row": { + "id": 0, + "createdAt": 1 + }, + "exclusive": true + } + } + } + ] + } + }, + { + "correlation": { + "parentField": [ + "createdAt" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "comment", + "alias": "zsubq_b", + "where": { + "type": "simple", + "op": ">", + "left": { + "type": "column", + "name": "createdAt" + }, + "right": { + "type": "static", + "anchor": "authData", + "field": [ + "a", + "b" + ] + } + }, + "limit": 100 + } + } + ] + }, + "hash": "1ibest71bemjr9" + }, + { + "ast": { + "table": "comment", + "related": [ + { + "correlation": { + "parentField": [ + "createdAt" + ], + "childField": [ + "a" + ] + }, + "subquery": { + "table": "user", + "alias": "zsubq_b", + "where": { + "type": "correlatedSubquery", + "op": "NOT EXISTS", + "related": { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "table": "user", + "alias": "owner", + "limit": 1.5 + } + } + }, + "related": [ + { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "table": "label", + "alias": "zsubq_a", + "schema": "public", + "where": { + "type": "or", + "conditions": [ + { + "type": "simple", + "op": "<", + "left": { + "type": "column", + "name": "a" + }, + "right": { + "type": "literal", + "value": 1.5 + } + }, + { + "type": "simple", + "op": "IS NOT", + "left": { + "type": "column", + "name": "id" + }, + "right": { + "type": "literal", + "value": "é" + } + }, + { + "type": "simple", + "op": ">", + "left": { + "type": "column", + "name": "a" + }, + "right": { + "type": "literal", + "value": null + } + }, + { + "type": "simple", + "op": ">", + "left": { + "type": "column", + "name": "title" + }, + "right": { + "type": "literal", + "value": 1e+21 + } + } + ] + } + }, + "hidden": true + }, + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "title" + ] + }, + "subquery": { + "table": "user", + "alias": "zsubq_b", + "where": { + "type": "simple", + "op": "<", + "left": { + "type": "column", + "name": "a" + }, + "right": { + "type": "literal", + "value": [ + "é", + true + ] + } + }, + "limit": 100 + }, + "system": "permissions" + } + ], + "orderBy": [ + [ + "title", + "asc" + ] + ] + }, + "hidden": true + }, + { + "correlation": { + "parentField": [ + "closed" + ], + "childField": [ + "a" + ] + }, + "subquery": { + "table": "issue", + "alias": "zsubq_b" + } + }, + { + "correlation": { + "parentField": [ + "é" + ], + "childField": [ + "title" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "related": [ + { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "table": "comment", + "alias": "zsubq_a" + }, + "hidden": false + }, + { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "table": "user", + "alias": "zsubq_b", + "where": { + "type": "simple", + "op": "!=", + "left": { + "type": "literal", + "value": 1.5 + }, + "right": { + "type": "literal", + "value": null + } + }, + "limit": 100, + "orderBy": [ + [ + "ownerId", + "asc" + ] + ] + } + } + ], + "orderBy": [ + [ + "title", + "asc" + ] + ] + } + } + ], + "start": { + "row": { + "id": "😀", + "createdAt": 1 + }, + "exclusive": false + } + }, + "normalized": { + "table": "comment", + "related": [ + { + "correlation": { + "parentField": [ + "é" + ], + "childField": [ + "title" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "related": [ + { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "hidden": false, + "subquery": { + "table": "comment", + "alias": "zsubq_a" + } + }, + { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "table": "user", + "alias": "zsubq_b", + "where": { + "type": "simple", + "op": "!=", + "left": { + "type": "literal", + "value": 1.5 + }, + "right": { + "type": "literal", + "value": null + } + }, + "limit": 100, + "orderBy": [ + [ + "ownerId", + "asc" + ] + ] + } + } + ], + "orderBy": [ + [ + "title", + "asc" + ] + ] + } + }, + { + "correlation": { + "parentField": [ + "createdAt" + ], + "childField": [ + "a" + ] + }, + "hidden": true, + "subquery": { + "table": "user", + "alias": "zsubq_b", + "where": { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "table": "user", + "alias": "owner", + "limit": 1.5 + } + }, + "op": "NOT EXISTS" + }, + "related": [ + { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "hidden": true, + "subquery": { + "schema": "public", + "table": "label", + "alias": "zsubq_a", + "where": { + "type": "or", + "conditions": [ + { + "type": "simple", + "op": "<", + "left": { + "type": "column", + "name": "a" + }, + "right": { + "type": "literal", + "value": 1.5 + } + }, + { + "type": "simple", + "op": ">", + "left": { + "type": "column", + "name": "a" + }, + "right": { + "type": "literal", + "value": null + } + }, + { + "type": "simple", + "op": "IS NOT", + "left": { + "type": "column", + "name": "id" + }, + "right": { + "type": "literal", + "value": "é" + } + }, + { + "type": "simple", + "op": ">", + "left": { + "type": "column", + "name": "title" + }, + "right": { + "type": "literal", + "value": 1e+21 + } + } + ] + } + } + }, + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "title" + ] + }, + "subquery": { + "table": "user", + "alias": "zsubq_b", + "where": { + "type": "simple", + "op": "<", + "left": { + "type": "column", + "name": "a" + }, + "right": { + "type": "literal", + "value": [ + "é", + true + ] + } + }, + "limit": 100 + }, + "system": "permissions" + } + ], + "orderBy": [ + [ + "title", + "asc" + ] + ] + } + }, + { + "correlation": { + "parentField": [ + "closed" + ], + "childField": [ + "a" + ] + }, + "subquery": { + "table": "issue", + "alias": "zsubq_b" + } + } + ], + "start": { + "row": { + "id": "😀", + "createdAt": 1 + }, + "exclusive": false + } + }, + "hash": "1aytcnf0xcf73f" + }, + { + "ast": { + "table": "label", + "start": { + "row": { + "id": 0, + "createdAt": 1 + }, + "exclusive": true + } + }, + "normalized": { + "table": "label", + "start": { + "row": { + "id": 0, + "createdAt": 1 + }, + "exclusive": true + } + }, + "hash": "1ieuxxd1m6n6xy" + }, + { + "ast": { + "table": "issue", + "where": { + "type": "simple", + "op": "IS", + "left": { + "type": "column", + "name": "a" + }, + "right": { + "type": "literal", + "value": null + } + }, + "related": [ + { + "correlation": { + "parentField": [ + "title" + ], + "childField": [ + "createdAt" + ] + }, + "subquery": { + "table": "comment", + "alias": "zsubq_b", + "related": [ + { + "correlation": { + "parentField": [ + "createdAt" + ], + "childField": [ + "closed" + ] + }, + "subquery": { + "table": "comment", + "alias": "zsubq_b", + "where": { + "type": "and", + "conditions": [ + { + "type": "simple", + "op": "NOT ILIKE", + "left": { + "type": "static", + "anchor": "authData", + "field": "sub" + }, + "right": { + "type": "static", + "anchor": "preMutationRow", + "field": "sub" + } + } + ] + }, + "orderBy": [ + [ + "title", + "asc" + ] + ] + }, + "hidden": true + }, + { + "correlation": { + "parentField": [ + "closed" + ], + "childField": [ + "ownerId" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "schema": "public", + "where": { + "type": "simple", + "op": "<", + "left": { + "type": "column", + "name": "closed" + }, + "right": { + "type": "literal", + "value": "é" + } + } + } + }, + { + "correlation": { + "parentField": [ + "é" + ], + "childField": [ + "closed" + ] + }, + "subquery": { + "table": "comment", + "alias": "zsubq_a", + "where": { + "type": "simple", + "op": "IS", + "left": { + "type": "column", + "name": "ownerId" + }, + "right": { + "type": "literal", + "value": null + } + }, + "orderBy": [ + [ + "title", + "desc" + ] + ] + } + } + ], + "limit": 100 + }, + "hidden": true + }, + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "é" + ] + }, + "subquery": { + "table": "comment", + "alias": "owner", + "schema": "public", + "where": { + "type": "correlatedSubquery", + "op": "NOT EXISTS", + "related": { + "correlation": { + "parentField": [ + "createdAt" + ], + "childField": [ + "createdAt" + ] + }, + "subquery": { + "table": "user", + "alias": "owner", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "é" + }, + "right": { + "type": "literal", + "value": [ + -1, + 2147483648 + ] + } + }, + "limit": 100, + "orderBy": [ + [ + "closed", + "asc" + ] + ] + } + }, + "scalar": true + }, + "start": { + "row": { + "id": 1.5, + "createdAt": 2.5 + }, + "exclusive": false + } + }, + "hidden": false + } + ], + "limit": 100, + "orderBy": [ + [ + "closed", + "desc" + ], + [ + "id", + "asc" + ] + ], + "start": { + "row": { + "id": "é", + "createdAt": 2.5 + }, + "exclusive": false + } + }, + "normalized": { + "table": "issue", + "where": { + "type": "simple", + "op": "IS", + "left": { + "type": "column", + "name": "a" + }, + "right": { + "type": "literal", + "value": null + } + }, + "related": [ + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "é" + ] + }, + "hidden": false, + "subquery": { + "schema": "public", + "table": "comment", + "alias": "owner", + "where": { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "createdAt" + ], + "childField": [ + "createdAt" + ] + }, + "subquery": { + "table": "user", + "alias": "owner", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "é" + }, + "right": { + "type": "literal", + "value": [ + -1, + 2147483648 + ] + } + }, + "limit": 100, + "orderBy": [ + [ + "closed", + "asc" + ] + ] + } + }, + "op": "NOT EXISTS", + "scalar": true + }, + "start": { + "row": { + "id": 1.5, + "createdAt": 2.5 + }, + "exclusive": false + } + } + }, + { + "correlation": { + "parentField": [ + "title" + ], + "childField": [ + "createdAt" + ] + }, + "hidden": true, + "subquery": { + "table": "comment", + "alias": "zsubq_b", + "related": [ + { + "correlation": { + "parentField": [ + "closed" + ], + "childField": [ + "ownerId" + ] + }, + "subquery": { + "schema": "public", + "table": "comment", + "alias": "comments", + "where": { + "type": "simple", + "op": "<", + "left": { + "type": "column", + "name": "closed" + }, + "right": { + "type": "literal", + "value": "é" + } + } + } + }, + { + "correlation": { + "parentField": [ + "é" + ], + "childField": [ + "closed" + ] + }, + "subquery": { + "table": "comment", + "alias": "zsubq_a", + "where": { + "type": "simple", + "op": "IS", + "left": { + "type": "column", + "name": "ownerId" + }, + "right": { + "type": "literal", + "value": null + } + }, + "orderBy": [ + [ + "title", + "desc" + ] + ] + } + }, + { + "correlation": { + "parentField": [ + "createdAt" + ], + "childField": [ + "closed" + ] + }, + "hidden": true, + "subquery": { + "table": "comment", + "alias": "zsubq_b", + "where": { + "type": "simple", + "op": "NOT ILIKE", + "left": { + "type": "static", + "anchor": "authData", + "field": "sub" + }, + "right": { + "type": "static", + "anchor": "preMutationRow", + "field": "sub" + } + }, + "orderBy": [ + [ + "title", + "asc" + ] + ] + } + } + ], + "limit": 100 + } + } + ], + "start": { + "row": { + "id": "é", + "createdAt": 2.5 + }, + "exclusive": false + }, + "limit": 100, + "orderBy": [ + [ + "closed", + "desc" + ], + [ + "id", + "asc" + ] + ] + }, + "hash": "1w1wk041uy30i2" + }, + { + "ast": { + "table": "comment", + "where": { + "type": "simple", + "op": "NOT LIKE", + "left": { + "type": "column", + "name": "ownerId" + }, + "right": { + "type": "literal", + "value": null + } + }, + "related": [ + { + "correlation": { + "parentField": [ + "title" + ], + "childField": [ + "a" + ] + }, + "subquery": { + "table": "comment", + "alias": "zsubq_b", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "id" + }, + "right": { + "type": "literal", + "value": 2147483648 + } + }, + "related": [ + { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "table": "issue", + "alias": "zsubq_b", + "start": { + "row": { + "id": "😀", + "createdAt": 1 + }, + "exclusive": true + } + }, + "system": "test", + "hidden": false + } + ], + "start": { + "row": { + "id": -2147483649, + "createdAt": 1 + }, + "exclusive": false + } + } + }, + { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "table": "issue", + "alias": "owner", + "where": { + "type": "simple", + "op": "ILIKE", + "left": { + "type": "column", + "name": "closed" + }, + "right": { + "type": "literal", + "value": "é" + } + }, + "related": [ + { + "correlation": { + "parentField": [ + "é" + ], + "childField": [ + "createdAt" + ] + }, + "subquery": { + "table": "label", + "alias": "comments", + "where": { + "type": "simple", + "op": "NOT ILIKE", + "left": { + "type": "column", + "name": "closed" + }, + "right": { + "type": "literal", + "value": 0 + } + }, + "orderBy": [ + [ + "é", + "asc" + ] + ] + }, + "system": "test", + "hidden": true + } + ], + "orderBy": [ + [ + "é", + "asc" + ] + ], + "start": { + "row": { + "id": 1e+21, + "createdAt": 1 + }, + "exclusive": false + } + } + }, + { + "correlation": { + "parentField": [ + "createdAt" + ], + "childField": [ + "ownerId" + ] + }, + "subquery": { + "table": "user", + "alias": "zsubq_a", + "where": { + "type": "or", + "conditions": [ + { + "type": "correlatedSubquery", + "op": "EXISTS", + "related": { + "correlation": { + "parentField": [ + "title" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "label", + "alias": "zsubq_b", + "where": { + "type": "simple", + "op": "ILIKE", + "left": { + "type": "column", + "name": "createdAt" + }, + "right": { + "type": "literal", + "value": false + } + } + } + } + } + ] + }, + "orderBy": [ + [ + "a", + "asc" + ], + [ + "id", + "asc" + ] + ] + } + } + ] + }, + "normalized": { + "table": "comment", + "where": { + "type": "simple", + "op": "NOT LIKE", + "left": { + "type": "column", + "name": "ownerId" + }, + "right": { + "type": "literal", + "value": null + } + }, + "related": [ + { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "table": "issue", + "alias": "owner", + "where": { + "type": "simple", + "op": "ILIKE", + "left": { + "type": "column", + "name": "closed" + }, + "right": { + "type": "literal", + "value": "é" + } + }, + "related": [ + { + "correlation": { + "parentField": [ + "é" + ], + "childField": [ + "createdAt" + ] + }, + "hidden": true, + "subquery": { + "table": "label", + "alias": "comments", + "where": { + "type": "simple", + "op": "NOT ILIKE", + "left": { + "type": "column", + "name": "closed" + }, + "right": { + "type": "literal", + "value": 0 + } + }, + "orderBy": [ + [ + "é", + "asc" + ] + ] + }, + "system": "test" + } + ], + "start": { + "row": { + "id": 1e+21, + "createdAt": 1 + }, + "exclusive": false + }, + "orderBy": [ + [ + "é", + "asc" + ] + ] + } + }, + { + "correlation": { + "parentField": [ + "createdAt" + ], + "childField": [ + "ownerId" + ] + }, + "subquery": { + "table": "user", + "alias": "zsubq_a", + "where": { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "title" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "label", + "alias": "zsubq_b", + "where": { + "type": "simple", + "op": "ILIKE", + "left": { + "type": "column", + "name": "createdAt" + }, + "right": { + "type": "literal", + "value": false + } + } + } + }, + "op": "EXISTS" + }, + "orderBy": [ + [ + "a", + "asc" + ], + [ + "id", + "asc" + ] + ] + } + }, + { + "correlation": { + "parentField": [ + "title" + ], + "childField": [ + "a" + ] + }, + "subquery": { + "table": "comment", + "alias": "zsubq_b", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "id" + }, + "right": { + "type": "literal", + "value": 2147483648 + } + }, + "related": [ + { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "hidden": false, + "subquery": { + "table": "issue", + "alias": "zsubq_b", + "start": { + "row": { + "id": "😀", + "createdAt": 1 + }, + "exclusive": true + } + }, + "system": "test" + } + ], + "start": { + "row": { + "id": -2147483649, + "createdAt": 1 + }, + "exclusive": false + } + } + } + ] + }, + "hash": "1768q120uy23wu" + }, + { + "ast": { + "table": "issue", + "orderBy": [ + [ + "id", + "asc" + ] + ], + "start": { + "row": { + "id": 2147483648, + "createdAt": null + }, + "exclusive": true + } + }, + "normalized": { + "table": "issue", + "start": { + "row": { + "id": 2147483648, + "createdAt": null + }, + "exclusive": true + }, + "orderBy": [ + [ + "id", + "asc" + ] + ] + }, + "hash": "04w3ayh1tp9wxr" + }, + { + "ast": { + "table": "user", + "where": { + "type": "and", + "conditions": [] + }, + "related": [ + { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "table": "user", + "alias": "zsubq_a", + "where": { + "type": "simple", + "op": "NOT IN", + "left": { + "type": "column", + "name": "createdAt" + }, + "right": { + "type": "literal", + "value": "é" + } + }, + "related": [ + { + "correlation": { + "parentField": [ + "ownerId" + ], + "childField": [ + "a" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "where": { + "type": "or", + "conditions": [ + { + "type": "simple", + "op": "<", + "left": { + "type": "column", + "name": "a" + }, + "right": { + "type": "literal", + "value": null + } + } + ] + } + }, + "system": "test", + "hidden": true + }, + { + "correlation": { + "parentField": [ + "closed" + ], + "childField": [ + "a" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "where": { + "type": "simple", + "op": "<", + "left": { + "type": "column", + "name": "closed" + }, + "right": { + "type": "literal", + "value": 0 + } + }, + "limit": 4294967296, + "orderBy": [ + [ + "closed", + "desc" + ] + ], + "start": { + "row": { + "id": "", + "createdAt": null + }, + "exclusive": true + } + } + } + ], + "start": { + "row": { + "id": "😀", + "createdAt": 2.5 + }, + "exclusive": true + } + }, + "hidden": true + }, + { + "correlation": { + "parentField": [ + "createdAt" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "comment", + "alias": "owner", + "where": { + "type": "or", + "conditions": [ + { + "type": "simple", + "op": "NOT ILIKE", + "left": { + "type": "column", + "name": "createdAt" + }, + "right": { + "type": "literal", + "value": null + } + }, + { + "type": "correlatedSubquery", + "op": "NOT EXISTS", + "related": { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "table": "comment", + "alias": "zsubq_b", + "where": { + "type": "simple", + "op": "<=", + "left": { + "type": "column", + "name": "title" + }, + "right": { + "type": "literal", + "value": null + } + } + } + } + }, + { + "type": "correlatedSubquery", + "op": "NOT EXISTS", + "related": { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "table": "comment", + "alias": "owner" + }, + "system": "test" + } + }, + { + "type": "or", + "conditions": [] + } + ] + } + }, + "system": "client" + }, + { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "table": "issue", + "alias": "zsubq_a", + "schema": "public", + "where": { + "type": "or", + "conditions": [ + { + "type": "simple", + "op": "NOT ILIKE", + "left": { + "type": "column", + "name": "closed" + }, + "right": { + "type": "literal", + "value": 1 + } + }, + { + "type": "or", + "conditions": [ + { + "type": "simple", + "op": "IS NOT", + "left": { + "type": "column", + "name": "id" + }, + "right": { + "type": "literal", + "value": null + } + } + ] + }, + { + "type": "simple", + "op": "<", + "left": { + "type": "column", + "name": "title" + }, + "right": { + "type": "literal", + "value": false + } + }, + { + "type": "correlatedSubquery", + "op": "EXISTS", + "related": { + "correlation": { + "parentField": [ + "title" + ], + "childField": [ + "closed" + ] + }, + "subquery": { + "table": "label", + "alias": "zsubq_b", + "where": { + "type": "simple", + "op": ">=", + "left": { + "type": "column", + "name": "closed" + }, + "right": { + "type": "literal", + "value": [ + 2147483648 + ] + } + } + }, + "system": "client", + "hidden": true + } + } + ] + } + } + } + ], + "orderBy": [ + [ + "createdAt", + "desc" + ] + ] + }, + "normalized": { + "table": "user", + "where": { + "type": "and", + "conditions": [] + }, + "related": [ + { + "correlation": { + "parentField": [ + "createdAt" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "comment", + "alias": "owner", + "where": { + "type": "or", + "conditions": [ + { + "type": "simple", + "op": "NOT ILIKE", + "left": { + "type": "column", + "name": "createdAt" + }, + "right": { + "type": "literal", + "value": null + } + }, + { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "table": "comment", + "alias": "owner" + }, + "system": "test" + }, + "op": "NOT EXISTS" + }, + { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "table": "comment", + "alias": "zsubq_b", + "where": { + "type": "simple", + "op": "<=", + "left": { + "type": "column", + "name": "title" + }, + "right": { + "type": "literal", + "value": null + } + } + } + }, + "op": "NOT EXISTS" + } + ] + } + }, + "system": "client" + }, + { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "hidden": true, + "subquery": { + "table": "user", + "alias": "zsubq_a", + "where": { + "type": "simple", + "op": "NOT IN", + "left": { + "type": "column", + "name": "createdAt" + }, + "right": { + "type": "literal", + "value": "é" + } + }, + "related": [ + { + "correlation": { + "parentField": [ + "ownerId" + ], + "childField": [ + "a" + ] + }, + "hidden": true, + "subquery": { + "table": "comment", + "alias": "comments", + "where": { + "type": "simple", + "op": "<", + "left": { + "type": "column", + "name": "a" + }, + "right": { + "type": "literal", + "value": null + } + } + }, + "system": "test" + }, + { + "correlation": { + "parentField": [ + "closed" + ], + "childField": [ + "a" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "where": { + "type": "simple", + "op": "<", + "left": { + "type": "column", + "name": "closed" + }, + "right": { + "type": "literal", + "value": 0 + } + }, + "start": { + "row": { + "id": "", + "createdAt": null + }, + "exclusive": true + }, + "limit": 4294967296, + "orderBy": [ + [ + "closed", + "desc" + ] + ] + } + } + ], + "start": { + "row": { + "id": "😀", + "createdAt": 2.5 + }, + "exclusive": true + } + } + }, + { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "schema": "public", + "table": "issue", + "alias": "zsubq_a", + "where": { + "type": "or", + "conditions": [ + { + "type": "simple", + "op": "NOT ILIKE", + "left": { + "type": "column", + "name": "closed" + }, + "right": { + "type": "literal", + "value": 1 + } + }, + { + "type": "simple", + "op": "IS NOT", + "left": { + "type": "column", + "name": "id" + }, + "right": { + "type": "literal", + "value": null + } + }, + { + "type": "simple", + "op": "<", + "left": { + "type": "column", + "name": "title" + }, + "right": { + "type": "literal", + "value": false + } + }, + { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "title" + ], + "childField": [ + "closed" + ] + }, + "hidden": true, + "subquery": { + "table": "label", + "alias": "zsubq_b", + "where": { + "type": "simple", + "op": ">=", + "left": { + "type": "column", + "name": "closed" + }, + "right": { + "type": "literal", + "value": [ + 2147483648 + ] + } + } + }, + "system": "client" + }, + "op": "EXISTS" + } + ] + } + } + } + ], + "orderBy": [ + [ + "createdAt", + "desc" + ] + ] + }, + "hash": "125gqff0ekkerv" + }, + { + "ast": { + "table": "user", + "where": { + "type": "and", + "conditions": [ + { + "type": "simple", + "op": "<", + "left": { + "type": "column", + "name": "ownerId" + }, + "right": { + "type": "literal", + "value": 1 + } + }, + { + "type": "or", + "conditions": [ + { + "type": "and", + "conditions": [ + { + "type": "simple", + "op": "IN", + "left": { + "type": "column", + "name": "é" + }, + "right": { + "type": "literal", + "value": 0 + } + }, + { + "type": "simple", + "op": "LIKE", + "left": { + "type": "column", + "name": "é" + }, + "right": { + "type": "literal", + "value": 0 + } + } + ] + } + ] + } + ] + }, + "start": { + "row": { + "id": "b", + "createdAt": null + }, + "exclusive": false + } + }, + "normalized": { + "table": "user", + "where": { + "type": "and", + "conditions": [ + { + "type": "simple", + "op": "<", + "left": { + "type": "column", + "name": "ownerId" + }, + "right": { + "type": "literal", + "value": 1 + } + }, + { + "type": "simple", + "op": "IN", + "left": { + "type": "column", + "name": "é" + }, + "right": { + "type": "literal", + "value": 0 + } + }, + { + "type": "simple", + "op": "LIKE", + "left": { + "type": "column", + "name": "é" + }, + "right": { + "type": "literal", + "value": 0 + } + } + ] + }, + "start": { + "row": { + "id": "b", + "createdAt": null + }, + "exclusive": false + } + }, + "hash": "1rga5ov0we5cjk" + }, + { + "ast": { + "table": "comment", + "where": { + "type": "or", + "conditions": [] + }, + "related": [ + { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "schema": "public", + "where": { + "type": "correlatedSubquery", + "op": "NOT EXISTS", + "related": { + "correlation": { + "parentField": [ + "a" + ], + "childField": [ + "title" + ] + }, + "subquery": { + "table": "comment", + "alias": "zsubq_a", + "where": { + "type": "simple", + "op": "IS", + "left": { + "type": "column", + "name": "a" + }, + "right": { + "type": "literal", + "value": 1.5 + } + }, + "limit": 0 + } + }, + "flip": true, + "scalar": false + }, + "related": [ + { + "correlation": { + "parentField": [ + "ownerId" + ], + "childField": [ + "ownerId" + ] + }, + "subquery": { + "table": "user", + "alias": "comments", + "where": { + "type": "correlatedSubquery", + "op": "EXISTS", + "related": { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "where": { + "type": "simple", + "op": "NOT IN", + "left": { + "type": "column", + "name": "createdAt" + }, + "right": { + "type": "literal", + "value": 0 + } + }, + "limit": 0, + "orderBy": [ + [ + "createdAt", + "asc" + ] + ], + "start": { + "row": { + "id": -2147483649, + "createdAt": 1 + }, + "exclusive": true + } + } + }, + "scalar": true + }, + "start": { + "row": { + "id": true, + "createdAt": null + }, + "exclusive": false + } + }, + "system": "permissions" + } + ], + "orderBy": [ + [ + "ownerId", + "asc" + ], + [ + "id", + "asc" + ] + ] + }, + "system": "test" + } + ] + }, + "normalized": { + "table": "comment", + "where": { + "type": "or", + "conditions": [] + }, + "related": [ + { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "schema": "public", + "table": "comment", + "alias": "comments", + "where": { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "a" + ], + "childField": [ + "title" + ] + }, + "subquery": { + "table": "comment", + "alias": "zsubq_a", + "where": { + "type": "simple", + "op": "IS", + "left": { + "type": "column", + "name": "a" + }, + "right": { + "type": "literal", + "value": 1.5 + } + }, + "limit": 0 + } + }, + "op": "NOT EXISTS", + "flip": true, + "scalar": false + }, + "related": [ + { + "correlation": { + "parentField": [ + "ownerId" + ], + "childField": [ + "ownerId" + ] + }, + "subquery": { + "table": "user", + "alias": "comments", + "where": { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "where": { + "type": "simple", + "op": "NOT IN", + "left": { + "type": "column", + "name": "createdAt" + }, + "right": { + "type": "literal", + "value": 0 + } + }, + "start": { + "row": { + "id": -2147483649, + "createdAt": 1 + }, + "exclusive": true + }, + "limit": 0, + "orderBy": [ + [ + "createdAt", + "asc" + ] + ] + } + }, + "op": "EXISTS", + "scalar": true + }, + "start": { + "row": { + "id": true, + "createdAt": null + }, + "exclusive": false + } + }, + "system": "permissions" + } + ], + "orderBy": [ + [ + "ownerId", + "asc" + ], + [ + "id", + "asc" + ] + ] + }, + "system": "test" + } + ] + }, + "hash": "0eiw42f0ub19r7" + }, + { + "ast": { + "table": "user", + "where": { + "type": "simple", + "op": ">=", + "left": { + "type": "literal", + "value": "a" + }, + "right": { + "type": "literal", + "value": "é" + } + }, + "limit": 1.5 + }, + "normalized": { + "table": "user", + "where": { + "type": "simple", + "op": ">=", + "left": { + "type": "literal", + "value": "a" + }, + "right": { + "type": "literal", + "value": "é" + } + }, + "limit": 1.5 + }, + "hash": "1qv1iby11gox3p" + }, + { + "ast": { + "table": "comment", + "start": { + "row": { + "id": "😀", + "createdAt": 1 + }, + "exclusive": false + } + }, + "normalized": { + "table": "comment", + "start": { + "row": { + "id": "😀", + "createdAt": 1 + }, + "exclusive": false + } + }, + "hash": "1ywpqcx1ipwu9n" + }, + { + "ast": { + "table": "issue", + "where": { + "type": "or", + "conditions": [] + }, + "orderBy": [ + [ + "title", + "desc" + ] + ] + }, + "normalized": { + "table": "issue", + "where": { + "type": "or", + "conditions": [] + }, + "orderBy": [ + [ + "title", + "desc" + ] + ] + }, + "hash": "0dl9q1m1u909tf" + }, + { + "ast": { + "table": "label", + "where": { + "type": "and", + "conditions": [] + } + }, + "normalized": { + "table": "label", + "where": { + "type": "and", + "conditions": [] + } + }, + "hash": "0inu4th1461bxb" + }, + { + "ast": { + "table": "label", + "where": { + "type": "and", + "conditions": [ + { + "type": "and", + "conditions": [ + { + "type": "correlatedSubquery", + "op": "EXISTS", + "related": { + "correlation": { + "parentField": [ + "title" + ], + "childField": [ + "createdAt" + ] + }, + "subquery": { + "table": "comment", + "alias": "owner", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "title" + }, + "right": { + "type": "literal", + "value": 0 + } + } + }, + "system": "client" + } + }, + { + "type": "and", + "conditions": [] + }, + { + "type": "correlatedSubquery", + "op": "EXISTS", + "related": { + "correlation": { + "parentField": [ + "createdAt" + ], + "childField": [ + "createdAt" + ] + }, + "subquery": { + "table": "label", + "alias": "owner", + "schema": "public", + "where": { + "type": "simple", + "op": ">=", + "left": { + "type": "literal", + "value": null + }, + "right": { + "type": "literal", + "value": -2147483649 + } + }, + "limit": 1, + "orderBy": [ + [ + "id", + "asc" + ] + ] + } + }, + "scalar": true + } + ] + } + ] + }, + "orderBy": [ + [ + "a", + "desc" + ], + [ + "id", + "asc" + ] + ] + }, + "normalized": { + "table": "label", + "where": { + "type": "and", + "conditions": [ + { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "title" + ], + "childField": [ + "createdAt" + ] + }, + "subquery": { + "table": "comment", + "alias": "owner", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "title" + }, + "right": { + "type": "literal", + "value": 0 + } + } + }, + "system": "client" + }, + "op": "EXISTS" + }, + { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "createdAt" + ], + "childField": [ + "createdAt" + ] + }, + "subquery": { + "schema": "public", + "table": "label", + "alias": "owner", + "where": { + "type": "simple", + "op": ">=", + "left": { + "type": "literal", + "value": null + }, + "right": { + "type": "literal", + "value": -2147483649 + } + }, + "limit": 1, + "orderBy": [ + [ + "id", + "asc" + ] + ] + } + }, + "op": "EXISTS", + "scalar": true + } + ] + }, + "orderBy": [ + [ + "a", + "desc" + ], + [ + "id", + "asc" + ] + ] + }, + "hash": "1kxroes1xz1sh9" + }, + { + "ast": { + "table": "label", + "where": { + "type": "simple", + "op": "!=", + "left": { + "type": "column", + "name": "title" + }, + "right": { + "type": "literal", + "value": null + } + }, + "related": [ + { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "table": "issue", + "alias": "owner", + "where": { + "type": "simple", + "op": "NOT LIKE", + "left": { + "type": "column", + "name": "title" + }, + "right": { + "type": "literal", + "value": null + } + } + } + }, + { + "correlation": { + "parentField": [ + "a" + ], + "childField": [ + "ownerId" + ] + }, + "subquery": { + "table": "label", + "alias": "owner", + "where": { + "type": "correlatedSubquery", + "op": "NOT EXISTS", + "related": { + "correlation": { + "parentField": [ + "ownerId" + ], + "childField": [ + "title" + ] + }, + "subquery": { + "table": "comment", + "alias": "zsubq_b" + }, + "hidden": true + }, + "scalar": true + } + } + } + ], + "orderBy": [ + [ + "a", + "desc" + ] + ] + }, + "normalized": { + "table": "label", + "where": { + "type": "simple", + "op": "!=", + "left": { + "type": "column", + "name": "title" + }, + "right": { + "type": "literal", + "value": null + } + }, + "related": [ + { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "table": "issue", + "alias": "owner", + "where": { + "type": "simple", + "op": "NOT LIKE", + "left": { + "type": "column", + "name": "title" + }, + "right": { + "type": "literal", + "value": null + } + } + } + }, + { + "correlation": { + "parentField": [ + "a" + ], + "childField": [ + "ownerId" + ] + }, + "subquery": { + "table": "label", + "alias": "owner", + "where": { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "ownerId" + ], + "childField": [ + "title" + ] + }, + "hidden": true, + "subquery": { + "table": "comment", + "alias": "zsubq_b" + } + }, + "op": "NOT EXISTS", + "scalar": true + } + } + } + ], + "orderBy": [ + [ + "a", + "desc" + ] + ] + }, + "hash": "0hycl2h0anwsd1" + }, + { + "ast": { + "table": "issue", + "related": [ + { + "correlation": { + "parentField": [ + "a" + ], + "childField": [ + "title" + ] + }, + "subquery": { + "table": "user", + "alias": "zsubq_a", + "related": [ + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "é" + ] + }, + "subquery": { + "table": "user", + "alias": "zsubq_a", + "where": { + "type": "and", + "conditions": [] + }, + "orderBy": [ + [ + "title", + "asc" + ], + [ + "id", + "asc" + ] + ] + }, + "hidden": true + }, + { + "correlation": { + "parentField": [ + "ownerId" + ], + "childField": [ + "é" + ] + }, + "subquery": { + "table": "issue", + "alias": "owner", + "orderBy": [ + [ + "id", + "asc" + ], + [ + "id", + "asc" + ] + ] + } + }, + { + "correlation": { + "parentField": [ + "ownerId" + ], + "childField": [ + "a" + ] + }, + "subquery": { + "table": "label", + "alias": "comments", + "where": { + "type": "or", + "conditions": [ + { + "type": "simple", + "op": "NOT LIKE", + "left": { + "type": "column", + "name": "ownerId" + }, + "right": { + "type": "literal", + "value": null + } + }, + { + "type": "simple", + "op": "<", + "left": { + "type": "column", + "name": "title" + }, + "right": { + "type": "literal", + "value": 2147483648 + } + }, + { + "type": "simple", + "op": "IS", + "left": { + "type": "column", + "name": "id" + }, + "right": { + "type": "static", + "anchor": "preMutationRow", + "field": [ + "a", + "b" + ] + } + }, + { + "type": "simple", + "op": ">", + "left": { + "type": "column", + "name": "closed" + }, + "right": { + "type": "literal", + "value": -2147483649 + } + } + ] + } + } + } + ] + } + }, + { + "correlation": { + "parentField": [ + "createdAt" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "label", + "alias": "zsubq_a", + "where": { + "type": "simple", + "op": "<", + "left": { + "type": "column", + "name": "id" + }, + "right": { + "type": "literal", + "value": 0 + } + } + } + } + ], + "limit": 4106, + "orderBy": [ + [ + "closed", + "desc" + ] + ] + }, + "normalized": { + "table": "issue", + "related": [ + { + "correlation": { + "parentField": [ + "a" + ], + "childField": [ + "title" + ] + }, + "subquery": { + "table": "user", + "alias": "zsubq_a", + "related": [ + { + "correlation": { + "parentField": [ + "ownerId" + ], + "childField": [ + "a" + ] + }, + "subquery": { + "table": "label", + "alias": "comments", + "where": { + "type": "or", + "conditions": [ + { + "type": "simple", + "op": ">", + "left": { + "type": "column", + "name": "closed" + }, + "right": { + "type": "literal", + "value": -2147483649 + } + }, + { + "type": "simple", + "op": "IS", + "left": { + "type": "column", + "name": "id" + }, + "right": { + "type": "static", + "anchor": "preMutationRow", + "field": [ + "a", + "b" + ] + } + }, + { + "type": "simple", + "op": "NOT LIKE", + "left": { + "type": "column", + "name": "ownerId" + }, + "right": { + "type": "literal", + "value": null + } + }, + { + "type": "simple", + "op": "<", + "left": { + "type": "column", + "name": "title" + }, + "right": { + "type": "literal", + "value": 2147483648 + } + } + ] + } + } + }, + { + "correlation": { + "parentField": [ + "ownerId" + ], + "childField": [ + "é" + ] + }, + "subquery": { + "table": "issue", + "alias": "owner", + "orderBy": [ + [ + "id", + "asc" + ], + [ + "id", + "asc" + ] + ] + } + }, + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "é" + ] + }, + "hidden": true, + "subquery": { + "table": "user", + "alias": "zsubq_a", + "where": { + "type": "and", + "conditions": [] + }, + "orderBy": [ + [ + "title", + "asc" + ], + [ + "id", + "asc" + ] + ] + } + } + ] + } + }, + { + "correlation": { + "parentField": [ + "createdAt" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "label", + "alias": "zsubq_a", + "where": { + "type": "simple", + "op": "<", + "left": { + "type": "column", + "name": "id" + }, + "right": { + "type": "literal", + "value": 0 + } + } + } + } + ], + "limit": 4106, + "orderBy": [ + [ + "closed", + "desc" + ] + ] + }, + "hash": "02jt33k0v9l742" + }, + { + "ast": { + "table": "label", + "where": { + "type": "and", + "conditions": [ + { + "type": "correlatedSubquery", + "op": "EXISTS", + "related": { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "subquery": { + "table": "label", + "alias": "owner", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "static", + "anchor": "authData", + "field": "sub" + }, + "right": { + "type": "literal", + "value": "" + } + } + }, + "system": "permissions", + "hidden": false + }, + "scalar": true + } + ] + }, + "orderBy": [ + [ + "a", + "asc" + ], + [ + "id", + "asc" + ] + ] + }, + "normalized": { + "table": "label", + "where": { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "id", + "ownerId" + ], + "childField": [ + "issueId", + "authorId" + ] + }, + "hidden": false, + "subquery": { + "table": "label", + "alias": "owner", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "static", + "anchor": "authData", + "field": "sub" + }, + "right": { + "type": "literal", + "value": "" + } + } + }, + "system": "permissions" + }, + "op": "EXISTS", + "scalar": true + }, + "orderBy": [ + [ + "a", + "asc" + ], + [ + "id", + "asc" + ] + ] + }, + "hash": "0i0mgf71lcb3iq" + }, + { + "ast": { + "table": "label", + "orderBy": [ + [ + "é", + "asc" + ] + ] + }, + "normalized": { + "table": "label", + "orderBy": [ + [ + "é", + "asc" + ] + ] + }, + "hash": "1tzt75n0m05sor" + } + ] +} diff --git a/packages/zero-conformance/vectors/compare-values.json b/packages/zero-conformance/vectors/compare-values.json new file mode 100644 index 0000000000..998d814039 --- /dev/null +++ b/packages/zero-conformance/vectors/compare-values.json @@ -0,0 +1,1741 @@ +{ + "formatVersion": 1, + "kind": "compare-values", + "description": "zql compareValues (sign, or \"throws\" for mismatched types) and valuesEqual.", + "cases": [ + { + "a": null, + "b": null, + "compare": 0, + "equal": false + }, + { + "a": null, + "b": true, + "compare": -1, + "equal": false + }, + { + "a": null, + "b": false, + "compare": -1, + "equal": false + }, + { + "a": null, + "b": 0, + "compare": -1, + "equal": false + }, + { + "a": null, + "b": 0, + "compare": -1, + "equal": false + }, + { + "a": null, + "b": 1, + "compare": -1, + "equal": false + }, + { + "a": null, + "b": -1, + "compare": -1, + "equal": false + }, + { + "a": null, + "b": 0.5, + "compare": -1, + "equal": false + }, + { + "a": null, + "b": 1e+21, + "compare": -1, + "equal": false + }, + { + "a": null, + "b": -1e-7, + "compare": -1, + "equal": false + }, + { + "a": null, + "b": 9007199254740992, + "compare": -1, + "equal": false + }, + { + "a": null, + "b": "", + "compare": -1, + "equal": false + }, + { + "a": null, + "b": "a", + "compare": -1, + "equal": false + }, + { + "a": null, + "b": "B", + "compare": -1, + "equal": false + }, + { + "a": null, + "b": "é", + "compare": -1, + "equal": false + }, + { + "a": null, + "b": "😀", + "compare": -1, + "equal": false + }, + { + "a": null, + "b": "", + "compare": -1, + "equal": false + }, + { + "a": true, + "b": null, + "compare": 1, + "equal": false + }, + { + "a": true, + "b": true, + "compare": 0, + "equal": true + }, + { + "a": true, + "b": false, + "compare": 1, + "equal": false + }, + { + "a": true, + "b": 0, + "compare": "throws", + "equal": false + }, + { + "a": true, + "b": 0, + "compare": "throws", + "equal": false + }, + { + "a": true, + "b": 1, + "compare": "throws", + "equal": false + }, + { + "a": true, + "b": -1, + "compare": "throws", + "equal": false + }, + { + "a": true, + "b": 0.5, + "compare": "throws", + "equal": false + }, + { + "a": true, + "b": 1e+21, + "compare": "throws", + "equal": false + }, + { + "a": true, + "b": -1e-7, + "compare": "throws", + "equal": false + }, + { + "a": true, + "b": 9007199254740992, + "compare": "throws", + "equal": false + }, + { + "a": true, + "b": "", + "compare": "throws", + "equal": false + }, + { + "a": true, + "b": "a", + "compare": "throws", + "equal": false + }, + { + "a": true, + "b": "B", + "compare": "throws", + "equal": false + }, + { + "a": true, + "b": "é", + "compare": "throws", + "equal": false + }, + { + "a": true, + "b": "😀", + "compare": "throws", + "equal": false + }, + { + "a": true, + "b": "", + "compare": "throws", + "equal": false + }, + { + "a": false, + "b": null, + "compare": 1, + "equal": false + }, + { + "a": false, + "b": true, + "compare": -1, + "equal": false + }, + { + "a": false, + "b": false, + "compare": 0, + "equal": true + }, + { + "a": false, + "b": 0, + "compare": "throws", + "equal": false + }, + { + "a": false, + "b": 0, + "compare": "throws", + "equal": false + }, + { + "a": false, + "b": 1, + "compare": "throws", + "equal": false + }, + { + "a": false, + "b": -1, + "compare": "throws", + "equal": false + }, + { + "a": false, + "b": 0.5, + "compare": "throws", + "equal": false + }, + { + "a": false, + "b": 1e+21, + "compare": "throws", + "equal": false + }, + { + "a": false, + "b": -1e-7, + "compare": "throws", + "equal": false + }, + { + "a": false, + "b": 9007199254740992, + "compare": "throws", + "equal": false + }, + { + "a": false, + "b": "", + "compare": "throws", + "equal": false + }, + { + "a": false, + "b": "a", + "compare": "throws", + "equal": false + }, + { + "a": false, + "b": "B", + "compare": "throws", + "equal": false + }, + { + "a": false, + "b": "é", + "compare": "throws", + "equal": false + }, + { + "a": false, + "b": "😀", + "compare": "throws", + "equal": false + }, + { + "a": false, + "b": "", + "compare": "throws", + "equal": false + }, + { + "a": 0, + "b": null, + "compare": 1, + "equal": false + }, + { + "a": 0, + "b": true, + "compare": "throws", + "equal": false + }, + { + "a": 0, + "b": false, + "compare": "throws", + "equal": false + }, + { + "a": 0, + "b": 0, + "compare": 0, + "equal": true + }, + { + "a": 0, + "b": 0, + "compare": 0, + "equal": true + }, + { + "a": 0, + "b": 1, + "compare": -1, + "equal": false + }, + { + "a": 0, + "b": -1, + "compare": 1, + "equal": false + }, + { + "a": 0, + "b": 0.5, + "compare": -1, + "equal": false + }, + { + "a": 0, + "b": 1e+21, + "compare": -1, + "equal": false + }, + { + "a": 0, + "b": -1e-7, + "compare": 1, + "equal": false + }, + { + "a": 0, + "b": 9007199254740992, + "compare": -1, + "equal": false + }, + { + "a": 0, + "b": "", + "compare": "throws", + "equal": false + }, + { + "a": 0, + "b": "a", + "compare": "throws", + "equal": false + }, + { + "a": 0, + "b": "B", + "compare": "throws", + "equal": false + }, + { + "a": 0, + "b": "é", + "compare": "throws", + "equal": false + }, + { + "a": 0, + "b": "😀", + "compare": "throws", + "equal": false + }, + { + "a": 0, + "b": "", + "compare": "throws", + "equal": false + }, + { + "a": 0, + "b": null, + "compare": 1, + "equal": false + }, + { + "a": 0, + "b": true, + "compare": "throws", + "equal": false + }, + { + "a": 0, + "b": false, + "compare": "throws", + "equal": false + }, + { + "a": 0, + "b": 0, + "compare": 0, + "equal": true + }, + { + "a": 0, + "b": 0, + "compare": 0, + "equal": true + }, + { + "a": 0, + "b": 1, + "compare": -1, + "equal": false + }, + { + "a": 0, + "b": -1, + "compare": 1, + "equal": false + }, + { + "a": 0, + "b": 0.5, + "compare": -1, + "equal": false + }, + { + "a": 0, + "b": 1e+21, + "compare": -1, + "equal": false + }, + { + "a": 0, + "b": -1e-7, + "compare": 1, + "equal": false + }, + { + "a": 0, + "b": 9007199254740992, + "compare": -1, + "equal": false + }, + { + "a": 0, + "b": "", + "compare": "throws", + "equal": false + }, + { + "a": 0, + "b": "a", + "compare": "throws", + "equal": false + }, + { + "a": 0, + "b": "B", + "compare": "throws", + "equal": false + }, + { + "a": 0, + "b": "é", + "compare": "throws", + "equal": false + }, + { + "a": 0, + "b": "😀", + "compare": "throws", + "equal": false + }, + { + "a": 0, + "b": "", + "compare": "throws", + "equal": false + }, + { + "a": 1, + "b": null, + "compare": 1, + "equal": false + }, + { + "a": 1, + "b": true, + "compare": "throws", + "equal": false + }, + { + "a": 1, + "b": false, + "compare": "throws", + "equal": false + }, + { + "a": 1, + "b": 0, + "compare": 1, + "equal": false + }, + { + "a": 1, + "b": 0, + "compare": 1, + "equal": false + }, + { + "a": 1, + "b": 1, + "compare": 0, + "equal": true + }, + { + "a": 1, + "b": -1, + "compare": 1, + "equal": false + }, + { + "a": 1, + "b": 0.5, + "compare": 1, + "equal": false + }, + { + "a": 1, + "b": 1e+21, + "compare": -1, + "equal": false + }, + { + "a": 1, + "b": -1e-7, + "compare": 1, + "equal": false + }, + { + "a": 1, + "b": 9007199254740992, + "compare": -1, + "equal": false + }, + { + "a": 1, + "b": "", + "compare": "throws", + "equal": false + }, + { + "a": 1, + "b": "a", + "compare": "throws", + "equal": false + }, + { + "a": 1, + "b": "B", + "compare": "throws", + "equal": false + }, + { + "a": 1, + "b": "é", + "compare": "throws", + "equal": false + }, + { + "a": 1, + "b": "😀", + "compare": "throws", + "equal": false + }, + { + "a": 1, + "b": "", + "compare": "throws", + "equal": false + }, + { + "a": -1, + "b": null, + "compare": 1, + "equal": false + }, + { + "a": -1, + "b": true, + "compare": "throws", + "equal": false + }, + { + "a": -1, + "b": false, + "compare": "throws", + "equal": false + }, + { + "a": -1, + "b": 0, + "compare": -1, + "equal": false + }, + { + "a": -1, + "b": 0, + "compare": -1, + "equal": false + }, + { + "a": -1, + "b": 1, + "compare": -1, + "equal": false + }, + { + "a": -1, + "b": -1, + "compare": 0, + "equal": true + }, + { + "a": -1, + "b": 0.5, + "compare": -1, + "equal": false + }, + { + "a": -1, + "b": 1e+21, + "compare": -1, + "equal": false + }, + { + "a": -1, + "b": -1e-7, + "compare": -1, + "equal": false + }, + { + "a": -1, + "b": 9007199254740992, + "compare": -1, + "equal": false + }, + { + "a": -1, + "b": "", + "compare": "throws", + "equal": false + }, + { + "a": -1, + "b": "a", + "compare": "throws", + "equal": false + }, + { + "a": -1, + "b": "B", + "compare": "throws", + "equal": false + }, + { + "a": -1, + "b": "é", + "compare": "throws", + "equal": false + }, + { + "a": -1, + "b": "😀", + "compare": "throws", + "equal": false + }, + { + "a": -1, + "b": "", + "compare": "throws", + "equal": false + }, + { + "a": 0.5, + "b": null, + "compare": 1, + "equal": false + }, + { + "a": 0.5, + "b": true, + "compare": "throws", + "equal": false + }, + { + "a": 0.5, + "b": false, + "compare": "throws", + "equal": false + }, + { + "a": 0.5, + "b": 0, + "compare": 1, + "equal": false + }, + { + "a": 0.5, + "b": 0, + "compare": 1, + "equal": false + }, + { + "a": 0.5, + "b": 1, + "compare": -1, + "equal": false + }, + { + "a": 0.5, + "b": -1, + "compare": 1, + "equal": false + }, + { + "a": 0.5, + "b": 0.5, + "compare": 0, + "equal": true + }, + { + "a": 0.5, + "b": 1e+21, + "compare": -1, + "equal": false + }, + { + "a": 0.5, + "b": -1e-7, + "compare": 1, + "equal": false + }, + { + "a": 0.5, + "b": 9007199254740992, + "compare": -1, + "equal": false + }, + { + "a": 0.5, + "b": "", + "compare": "throws", + "equal": false + }, + { + "a": 0.5, + "b": "a", + "compare": "throws", + "equal": false + }, + { + "a": 0.5, + "b": "B", + "compare": "throws", + "equal": false + }, + { + "a": 0.5, + "b": "é", + "compare": "throws", + "equal": false + }, + { + "a": 0.5, + "b": "😀", + "compare": "throws", + "equal": false + }, + { + "a": 0.5, + "b": "", + "compare": "throws", + "equal": false + }, + { + "a": 1e+21, + "b": null, + "compare": 1, + "equal": false + }, + { + "a": 1e+21, + "b": true, + "compare": "throws", + "equal": false + }, + { + "a": 1e+21, + "b": false, + "compare": "throws", + "equal": false + }, + { + "a": 1e+21, + "b": 0, + "compare": 1, + "equal": false + }, + { + "a": 1e+21, + "b": 0, + "compare": 1, + "equal": false + }, + { + "a": 1e+21, + "b": 1, + "compare": 1, + "equal": false + }, + { + "a": 1e+21, + "b": -1, + "compare": 1, + "equal": false + }, + { + "a": 1e+21, + "b": 0.5, + "compare": 1, + "equal": false + }, + { + "a": 1e+21, + "b": 1e+21, + "compare": 0, + "equal": true + }, + { + "a": 1e+21, + "b": -1e-7, + "compare": 1, + "equal": false + }, + { + "a": 1e+21, + "b": 9007199254740992, + "compare": 1, + "equal": false + }, + { + "a": 1e+21, + "b": "", + "compare": "throws", + "equal": false + }, + { + "a": 1e+21, + "b": "a", + "compare": "throws", + "equal": false + }, + { + "a": 1e+21, + "b": "B", + "compare": "throws", + "equal": false + }, + { + "a": 1e+21, + "b": "é", + "compare": "throws", + "equal": false + }, + { + "a": 1e+21, + "b": "😀", + "compare": "throws", + "equal": false + }, + { + "a": 1e+21, + "b": "", + "compare": "throws", + "equal": false + }, + { + "a": -1e-7, + "b": null, + "compare": 1, + "equal": false + }, + { + "a": -1e-7, + "b": true, + "compare": "throws", + "equal": false + }, + { + "a": -1e-7, + "b": false, + "compare": "throws", + "equal": false + }, + { + "a": -1e-7, + "b": 0, + "compare": -1, + "equal": false + }, + { + "a": -1e-7, + "b": 0, + "compare": -1, + "equal": false + }, + { + "a": -1e-7, + "b": 1, + "compare": -1, + "equal": false + }, + { + "a": -1e-7, + "b": -1, + "compare": 1, + "equal": false + }, + { + "a": -1e-7, + "b": 0.5, + "compare": -1, + "equal": false + }, + { + "a": -1e-7, + "b": 1e+21, + "compare": -1, + "equal": false + }, + { + "a": -1e-7, + "b": -1e-7, + "compare": 0, + "equal": true + }, + { + "a": -1e-7, + "b": 9007199254740992, + "compare": -1, + "equal": false + }, + { + "a": -1e-7, + "b": "", + "compare": "throws", + "equal": false + }, + { + "a": -1e-7, + "b": "a", + "compare": "throws", + "equal": false + }, + { + "a": -1e-7, + "b": "B", + "compare": "throws", + "equal": false + }, + { + "a": -1e-7, + "b": "é", + "compare": "throws", + "equal": false + }, + { + "a": -1e-7, + "b": "😀", + "compare": "throws", + "equal": false + }, + { + "a": -1e-7, + "b": "", + "compare": "throws", + "equal": false + }, + { + "a": 9007199254740992, + "b": null, + "compare": 1, + "equal": false + }, + { + "a": 9007199254740992, + "b": true, + "compare": "throws", + "equal": false + }, + { + "a": 9007199254740992, + "b": false, + "compare": "throws", + "equal": false + }, + { + "a": 9007199254740992, + "b": 0, + "compare": 1, + "equal": false + }, + { + "a": 9007199254740992, + "b": 0, + "compare": 1, + "equal": false + }, + { + "a": 9007199254740992, + "b": 1, + "compare": 1, + "equal": false + }, + { + "a": 9007199254740992, + "b": -1, + "compare": 1, + "equal": false + }, + { + "a": 9007199254740992, + "b": 0.5, + "compare": 1, + "equal": false + }, + { + "a": 9007199254740992, + "b": 1e+21, + "compare": -1, + "equal": false + }, + { + "a": 9007199254740992, + "b": -1e-7, + "compare": 1, + "equal": false + }, + { + "a": 9007199254740992, + "b": 9007199254740992, + "compare": 0, + "equal": true + }, + { + "a": 9007199254740992, + "b": "", + "compare": "throws", + "equal": false + }, + { + "a": 9007199254740992, + "b": "a", + "compare": "throws", + "equal": false + }, + { + "a": 9007199254740992, + "b": "B", + "compare": "throws", + "equal": false + }, + { + "a": 9007199254740992, + "b": "é", + "compare": "throws", + "equal": false + }, + { + "a": 9007199254740992, + "b": "😀", + "compare": "throws", + "equal": false + }, + { + "a": 9007199254740992, + "b": "", + "compare": "throws", + "equal": false + }, + { + "a": "", + "b": null, + "compare": 1, + "equal": false + }, + { + "a": "", + "b": true, + "compare": "throws", + "equal": false + }, + { + "a": "", + "b": false, + "compare": "throws", + "equal": false + }, + { + "a": "", + "b": 0, + "compare": "throws", + "equal": false + }, + { + "a": "", + "b": 0, + "compare": "throws", + "equal": false + }, + { + "a": "", + "b": 1, + "compare": "throws", + "equal": false + }, + { + "a": "", + "b": -1, + "compare": "throws", + "equal": false + }, + { + "a": "", + "b": 0.5, + "compare": "throws", + "equal": false + }, + { + "a": "", + "b": 1e+21, + "compare": "throws", + "equal": false + }, + { + "a": "", + "b": -1e-7, + "compare": "throws", + "equal": false + }, + { + "a": "", + "b": 9007199254740992, + "compare": "throws", + "equal": false + }, + { + "a": "", + "b": "", + "compare": 0, + "equal": true + }, + { + "a": "", + "b": "a", + "compare": -1, + "equal": false + }, + { + "a": "", + "b": "B", + "compare": -1, + "equal": false + }, + { + "a": "", + "b": "é", + "compare": -1, + "equal": false + }, + { + "a": "", + "b": "😀", + "compare": -1, + "equal": false + }, + { + "a": "", + "b": "", + "compare": -1, + "equal": false + }, + { + "a": "a", + "b": null, + "compare": 1, + "equal": false + }, + { + "a": "a", + "b": true, + "compare": "throws", + "equal": false + }, + { + "a": "a", + "b": false, + "compare": "throws", + "equal": false + }, + { + "a": "a", + "b": 0, + "compare": "throws", + "equal": false + }, + { + "a": "a", + "b": 0, + "compare": "throws", + "equal": false + }, + { + "a": "a", + "b": 1, + "compare": "throws", + "equal": false + }, + { + "a": "a", + "b": -1, + "compare": "throws", + "equal": false + }, + { + "a": "a", + "b": 0.5, + "compare": "throws", + "equal": false + }, + { + "a": "a", + "b": 1e+21, + "compare": "throws", + "equal": false + }, + { + "a": "a", + "b": -1e-7, + "compare": "throws", + "equal": false + }, + { + "a": "a", + "b": 9007199254740992, + "compare": "throws", + "equal": false + }, + { + "a": "a", + "b": "", + "compare": 1, + "equal": false + }, + { + "a": "a", + "b": "a", + "compare": 0, + "equal": true + }, + { + "a": "a", + "b": "B", + "compare": 1, + "equal": false + }, + { + "a": "a", + "b": "é", + "compare": -1, + "equal": false + }, + { + "a": "a", + "b": "😀", + "compare": -1, + "equal": false + }, + { + "a": "a", + "b": "", + "compare": -1, + "equal": false + }, + { + "a": "B", + "b": null, + "compare": 1, + "equal": false + }, + { + "a": "B", + "b": true, + "compare": "throws", + "equal": false + }, + { + "a": "B", + "b": false, + "compare": "throws", + "equal": false + }, + { + "a": "B", + "b": 0, + "compare": "throws", + "equal": false + }, + { + "a": "B", + "b": 0, + "compare": "throws", + "equal": false + }, + { + "a": "B", + "b": 1, + "compare": "throws", + "equal": false + }, + { + "a": "B", + "b": -1, + "compare": "throws", + "equal": false + }, + { + "a": "B", + "b": 0.5, + "compare": "throws", + "equal": false + }, + { + "a": "B", + "b": 1e+21, + "compare": "throws", + "equal": false + }, + { + "a": "B", + "b": -1e-7, + "compare": "throws", + "equal": false + }, + { + "a": "B", + "b": 9007199254740992, + "compare": "throws", + "equal": false + }, + { + "a": "B", + "b": "", + "compare": 1, + "equal": false + }, + { + "a": "B", + "b": "a", + "compare": -1, + "equal": false + }, + { + "a": "B", + "b": "B", + "compare": 0, + "equal": true + }, + { + "a": "B", + "b": "é", + "compare": -1, + "equal": false + }, + { + "a": "B", + "b": "😀", + "compare": -1, + "equal": false + }, + { + "a": "B", + "b": "", + "compare": -1, + "equal": false + }, + { + "a": "é", + "b": null, + "compare": 1, + "equal": false + }, + { + "a": "é", + "b": true, + "compare": "throws", + "equal": false + }, + { + "a": "é", + "b": false, + "compare": "throws", + "equal": false + }, + { + "a": "é", + "b": 0, + "compare": "throws", + "equal": false + }, + { + "a": "é", + "b": 0, + "compare": "throws", + "equal": false + }, + { + "a": "é", + "b": 1, + "compare": "throws", + "equal": false + }, + { + "a": "é", + "b": -1, + "compare": "throws", + "equal": false + }, + { + "a": "é", + "b": 0.5, + "compare": "throws", + "equal": false + }, + { + "a": "é", + "b": 1e+21, + "compare": "throws", + "equal": false + }, + { + "a": "é", + "b": -1e-7, + "compare": "throws", + "equal": false + }, + { + "a": "é", + "b": 9007199254740992, + "compare": "throws", + "equal": false + }, + { + "a": "é", + "b": "", + "compare": 1, + "equal": false + }, + { + "a": "é", + "b": "a", + "compare": 1, + "equal": false + }, + { + "a": "é", + "b": "B", + "compare": 1, + "equal": false + }, + { + "a": "é", + "b": "é", + "compare": 0, + "equal": true + }, + { + "a": "é", + "b": "😀", + "compare": -1, + "equal": false + }, + { + "a": "é", + "b": "", + "compare": -1, + "equal": false + }, + { + "a": "😀", + "b": null, + "compare": 1, + "equal": false + }, + { + "a": "😀", + "b": true, + "compare": "throws", + "equal": false + }, + { + "a": "😀", + "b": false, + "compare": "throws", + "equal": false + }, + { + "a": "😀", + "b": 0, + "compare": "throws", + "equal": false + }, + { + "a": "😀", + "b": 0, + "compare": "throws", + "equal": false + }, + { + "a": "😀", + "b": 1, + "compare": "throws", + "equal": false + }, + { + "a": "😀", + "b": -1, + "compare": "throws", + "equal": false + }, + { + "a": "😀", + "b": 0.5, + "compare": "throws", + "equal": false + }, + { + "a": "😀", + "b": 1e+21, + "compare": "throws", + "equal": false + }, + { + "a": "😀", + "b": -1e-7, + "compare": "throws", + "equal": false + }, + { + "a": "😀", + "b": 9007199254740992, + "compare": "throws", + "equal": false + }, + { + "a": "😀", + "b": "", + "compare": 1, + "equal": false + }, + { + "a": "😀", + "b": "a", + "compare": 1, + "equal": false + }, + { + "a": "😀", + "b": "B", + "compare": 1, + "equal": false + }, + { + "a": "😀", + "b": "é", + "compare": 1, + "equal": false + }, + { + "a": "😀", + "b": "😀", + "compare": 0, + "equal": true + }, + { + "a": "😀", + "b": "", + "compare": 1, + "equal": false + }, + { + "a": "", + "b": null, + "compare": 1, + "equal": false + }, + { + "a": "", + "b": true, + "compare": "throws", + "equal": false + }, + { + "a": "", + "b": false, + "compare": "throws", + "equal": false + }, + { + "a": "", + "b": 0, + "compare": "throws", + "equal": false + }, + { + "a": "", + "b": 0, + "compare": "throws", + "equal": false + }, + { + "a": "", + "b": 1, + "compare": "throws", + "equal": false + }, + { + "a": "", + "b": -1, + "compare": "throws", + "equal": false + }, + { + "a": "", + "b": 0.5, + "compare": "throws", + "equal": false + }, + { + "a": "", + "b": 1e+21, + "compare": "throws", + "equal": false + }, + { + "a": "", + "b": -1e-7, + "compare": "throws", + "equal": false + }, + { + "a": "", + "b": 9007199254740992, + "compare": "throws", + "equal": false + }, + { + "a": "", + "b": "", + "compare": 1, + "equal": false + }, + { + "a": "", + "b": "a", + "compare": 1, + "equal": false + }, + { + "a": "", + "b": "B", + "compare": 1, + "equal": false + }, + { + "a": "", + "b": "é", + "compare": 1, + "equal": false + }, + { + "a": "", + "b": "😀", + "compare": -1, + "equal": false + }, + { + "a": "", + "b": "", + "compare": 0, + "equal": true + } + ] +} diff --git a/packages/zero-conformance/vectors/hash.json b/packages/zero-conformance/vectors/hash.json new file mode 100644 index 0000000000..af828add91 --- /dev/null +++ b/packages/zero-conformance/vectors/hash.json @@ -0,0 +1,1075 @@ +{ + "formatVersion": 1, + "kind": "hash", + "description": "h32, h64, and h128 (shared/src/hash.ts) of strings; wide hashes in decimal.", + "cases": [ + { + "input": "", + "h32": 46947589, + "h64": "201638359568529298", + "h128": "3719571214403283497883493104908516086" + }, + { + "input": "a", + "h32": 1426945110, + "h64": "6128682584748879983", + "h128": "113054239149863338656013733210046359280" + }, + { + "input": "A", + "h32": 275094093, + "h64": "1181520136379610050", + "h128": "21795199573729072896849746215440001737" + }, + { + "input": "abc", + "h32": 852579327, + "h64": "3661800329566857471", + "h128": "67548293528545111057375047383885910287" + }, + { + "input": "ab", + "h32": 1234828371, + "h64": "5303547470388537970", + "h128": "97833182869027046486904590795959279413" + }, + { + "input": "b", + "h32": 2718739903, + "h64": "11676898972004176550", + "h128": "215400766911123199251351360484621795855" + }, + { + "input": "Z", + "h32": 144536474, + "h64": "620779431106008842", + "h128": "11451359291835555502145238511894596724" + }, + { + "input": "z", + "h32": 2804950734, + "h64": "12047171673028654218", + "h128": "222231092664402911292778171937139127959" + }, + { + "input": " ", + "h32": 120460436, + "h64": "517373637092243339", + "h128": "9543859073924896079499808327478153593" + }, + { + "input": "0", + "h32": 1212501170, + "h64": "5207652873615968876", + "h128": "96064239784311890462413639803208153488" + }, + { + "input": "9", + "h32": 493800275, + "h64": "2120856033867429369", + "h128": "39122888473935146813506194566757574178" + }, + { + "input": "\u0000", + "h32": 3479547966, + "h64": "14944544718881249643", + "h128": "275678191727310068899274308667179144507" + }, + { + "input": "\u001f", + "h32": 2050290091, + "h64": "8805928891305891088", + "h128": "162440716589204668740503777272972862648" + }, + { + "input": "\"", + "h32": 2578345570, + "h64": "11073909901333898205", + "h128": "204277581845224612180601860208842698555" + }, + { + "input": "\\", + "h32": 1906917413, + "h64": "8190147928666406131", + "h128": "151081562765931586805445544610899338098" + }, + { + "input": "\n", + "h32": 2177487698, + "h64": "9352238450756091061", + "h128": "172518349217403521068000152632977947466" + }, + { + "input": "é", + "h32": 866461991, + "h64": "3721425915952903948", + "h128": "68648191460853370828026129761628416652" + }, + { + "input": "é", + "h32": 2756231243, + "h64": "11837923048951082859", + "h128": "218371136848268094025665572416038917212" + }, + { + "input": "ß", + "h32": 3617067294, + "h64": "15535185735313888468", + "h128": "286573595396878635048861986030974070783" + }, + { + "input": "İ", + "h32": 2330854336, + "h64": "10010943146792216606", + "h128": "184669306165332571534516685129229310716" + }, + { + "input": "ı", + "h32": 1369852355, + "h64": "5883471065170563511", + "h128": "108530885004276715595772458130503631456" + }, + { + "input": "ff", + "h32": 4225668063, + "h64": "18149106136618485662", + "h128": "334791916068792706251637145729982494802" + }, + { + "input": " ", + "h32": 489771870, + "h64": "2103554164811042542", + "h128": "38803725323455144442831915344271258299" + }, + { + "input": "", + "h32": 319710069, + "h64": "1373144294318139149", + "h128": "25330041373561217680747049806408957790" + }, + { + "input": "�", + "h32": 1973258321, + "h64": "8475079955709316008", + "h128": "156337630947195434262906348206834796013" + }, + { + "input": "￿", + "h32": 1127487448, + "h64": "4842521718114061174", + "h128": "89328758805430353823803323646137721246" + }, + { + "input": "😀", + "h32": 1127825596, + "h64": "4843974051481038868", + "h128": "89355549627360700229774766935676228297" + }, + { + "input": "😀a", + "h32": 1032446750, + "h64": "4434325028368566358", + "h128": "81798858937959790830086099990489128977" + }, + { + "input": "a😀", + "h32": 1679733645, + "h64": "7214401074380399329", + "h128": "133082210264190453419552015768215903143" + }, + { + "input": "𝒜", + "h32": 3702349041, + "h64": "15901468051534883171", + "h128": "293330311542932877046182197306416630085" + }, + { + "input": "中文", + "h32": 4167075137, + "h64": "17897451433939562671", + "h128": "330149706173529144339476792651214810249" + }, + { + "input": "Ω", + "h32": 2204773769, + "h64": "9469431234740186696", + "h128": "174680174510843660873973749386381965100" + }, + { + "input": "ω", + "h32": 2980047985, + "h64": "12799208637219248696", + "h128": "236103726076796282272865994370385679397" + }, + { + "input": "􏿿", + "h32": 2754081772, + "h64": "11828691143820226054", + "h128": "218200838257006412463398607892041056416" + }, + { + "input": "", + "h32": 46947589, + "h64": "201638359568529298", + "h128": "3719571214403283497883493104908516086" + }, + { + "input": "x", + "h32": 784609514, + "h64": "3369872205741831720", + "h128": "62163170140426669133212497609523263792" + }, + { + "input": "xx", + "h32": 147859612, + "h64": "635052199793632595", + "h128": "11714645403039406213657041830526249078" + }, + { + "input": "xxx", + "h32": 3858467804, + "h64": "16571993031695922253", + "h128": "305699314246992739410177282980086648650" + }, + { + "input": "xxxx", + "h32": 1978068271, + "h64": "8495738533509599706", + "h128": "156718714444804085274396383437905703906" + }, + { + "input": "xxxxx", + "h32": 3559223605, + "h64": "15286748986515356809", + "h128": "281990746273287652604944776888154037061" + }, + { + "input": "xxxxxx", + "h32": 3394013617, + "h64": "14577177488768651623", + "h128": "268901462452355408240726287413290470756" + }, + { + "input": "xxxxxxx", + "h32": 30467228, + "h64": "130855749200120000", + "h128": "2413862516068137018923287610946491896" + }, + { + "input": "xxxxxxxx", + "h32": 11697914, + "h64": "50242158744375729", + "h128": "926804244068187512598585300488357905" + }, + { + "input": "xxxxxxxxx", + "h32": 3044067341, + "h64": "13074169679395605709", + "h128": "241175862052063998072916132624204804398" + }, + { + "input": "xxxxxxxxxx", + "h32": 3280233027, + "h64": "14088493577631734094", + "h128": "259886835410673269661744578568836186162" + }, + { + "input": "xxxxxxxxxxx", + "h32": 1180127420, + "h64": "5068608677074642530", + "h128": "93499327075779472557755257782487068329" + }, + { + "input": "xxxxxxxxxxxx", + "h32": 238654662, + "h64": "1025013972174769520", + "h128": "18908170416684416887530675000648146778" + }, + { + "input": "xxxxxxxxxxxxx", + "h32": 4068533980, + "h64": "17474220388850809761", + "h128": "322342471400728291557533998410023498503" + }, + { + "input": "xxxxxxxxxxxxxx", + "h32": 4034851963, + "h64": "17329557226757307754", + "h128": "319673907072695899166704505539725297095" + }, + { + "input": "xxxxxxxxxxxxxxx", + "h32": 775798960, + "h64": "3332031162495114825", + "h128": "61465126100172307400512928739278746496" + }, + { + "input": "xxxxxxxxxxxxxxxx", + "h32": 404385805, + "h64": "1736823807844833832", + "h128": "32038744284439345550627841881067865826" + }, + { + "input": "xxxxxxxxxxxxxxxxx", + "h32": 1212095327, + "h64": "5205909791200776917", + "h128": "96032085589099460854280302154821195774" + }, + { + "input": "xxxxxxxxxxxxxxxxxx", + "h32": 2425307592, + "h64": "10416616790615343714", + "h128": "192152664050387000959808349821268636977" + }, + { + "input": "xxxxxxxxxxxxxxxxxxx", + "h32": 1918637870, + "h64": "8240486906430541329", + "h128": "152010153005678744664413187137346231583" + }, + { + "input": "xxxxxxxxxxxxxxxxxxxx", + "h32": 3597749392, + "h64": "15452215979485677146", + "h128": "285043073545257449311708091751143866552" + }, + { + "input": "xxxxxxxxxxxxxxxxxxxxx", + "h32": 1950877529, + "h64": "8378955187085152077", + "h128": "154564441941240936420426593811863560587" + }, + { + "input": "xxxxxxxxxxxxxxxxxxxxxx", + "h32": 3261358638, + "h64": "14007428693722580953", + "h128": "258391452243836146181744605990887667061" + }, + { + "input": "xxxxxxxxxxxxxxxxxxxxxxx", + "h32": 2074752488, + "h64": "8910994085127036226", + "h128": "164378827330678023222208571608449841844" + }, + { + "input": "xxxxxxxxxxxxxxxxxxxxxxxx", + "h32": 4168937102, + "h64": "17905448512837698057", + "h128": "330297226241300310987485840466751342392" + }, + { + "input": "xxxxxxxxxxxxxxxxxxxxxxxxx", + "h32": 3794197029, + "h64": "16295952155664954748", + "h128": "300607258852966896567299494608620296050" + }, + { + "input": "xxxxxxxxxxxxxxxxxxxxxxxxxx", + "h32": 2559233379, + "h64": "10991823669633066148", + "h128": "202763358137064139304032963158714807811" + }, + { + "input": "xxxxxxxxxxxxxxxxxxxxxxxxxxx", + "h32": 3959739024, + "h64": "17006949612327710139", + "h128": "313722846973083143332330322059312947933" + }, + { + "input": "xxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "h32": 487919779, + "h64": "2095599494043938651", + "h128": "38656987547623760121663697854832233093" + }, + { + "input": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "h32": 4171578245, + "h64": "17916792137970644356", + "h128": "330506479190995870840284397694115081983" + }, + { + "input": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "h32": 3511744226, + "h64": "15082826603845548182", + "h128": "278229042269276628942083730900129532792" + }, + { + "input": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "h32": 1805892444, + "h64": "7756248989758667966", + "h128": "143077540086046405010665296164152129086" + }, + { + "input": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "h32": 634320413, + "h64": "2724385432498411277", + "h128": "50256040831440701901274569913328635344" + }, + { + "input": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "h32": 827008391, + "h64": "3551973994876243027", + "h128": "65522355239953777315381685368733664710" + }, + { + "input": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "h32": 2437475156, + "h64": "10468876080050458819", + "h128": "193116677788070482705384823773830118220" + }, + { + "input": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "h32": 287539582, + "h64": "1234973101450515398", + "h128": "22781232740372999796096807120656931657" + }, + { + "input": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "h32": 1539568550, + "h64": "6612396572517905142", + "h128": "121977187287112018050209378505958365291" + }, + { + "input": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "h32": 1409749493, + "h64": "6054827968071526741", + "h128": "111691861937354282136714057431857958046" + }, + { + "input": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "h32": 403555270, + "h64": "1733256688012604541", + "h128": "31972942537813958050241204095650002964" + }, + { + "input": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "h32": 2935402065, + "h64": "12607455873833226032", + "h128": "232566511925187738786969198583388266596" + }, + { + "input": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "h32": 971545987, + "h64": "4172758242878378813", + "h128": "76973803387839416184469024113239233064" + }, + { + "input": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "h32": 251786041, + "h64": "1081412812359617141", + "h128": "19948545387628346853695927392777230364" + }, + { + "input": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "h32": 1468547551, + "h64": "6307363706963837538", + "h128": "116350324082165879053057789308813434487" + }, + { + "input": "😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀", + "h32": 3937565075, + "h64": "16911713226648394727", + "h128": "311966045739951714546225703544097249364" + }, + { + "input": "é\nİ𝒜Aa_𝒜A 中", + "h32": 3399627430, + "h64": "14601288632918545445", + "h128": "269346234557812818830038302251088742654" + }, + { + "input": "İ A \"B 😀中\n𝒜_😀\naßabA𝒜A\\İ𝒜\n%%b𝒜__\\é", + "h32": 1313192138, + "h64": "5640117287651680975", + "h128": "104041800151015436452470601184502920933" + }, + { + "input": "ß_中😀Aİ中\\%ßb\\ a\\\nAß😀B\"B中中%éßé😀𝒜𝒜\"aBAa\"中AB", + "h32": 1769176975, + "h64": "7598557252235373749", + "h128": "140168640961415615291471647894210270628" + }, + { + "input": "𝒜%%\n\nßİ \\_ 𝒜中\n\"中中İßéé😀", + "h32": 1161259165, + "h64": "4987570138144899057", + "h128": "92004429888035146355473051664468952540" + }, + { + "input": "İ中\n\\%\"b_😀A😀_Aa\\\"", + "h32": 1918873557, + "h64": "8241499172638029104", + "h128": "152028826021342736213854744460950495730" + }, + { + "input": "😀_\n\n\n%Ba", + "h32": 3917411849, + "h64": "16825155777060010309", + "h128": "310369342619721771012541758318447579356" + }, + { + "input": "B中éé\nßa %", + "h32": 1470304614, + "h64": "6314910234236445814", + "h128": "116489532939408953275922030572290290766" + }, + { + "input": "a_ßa中 _a\\a😀BA\\é中İ%", + "h32": 626700626, + "h64": "2691658693678805670", + "h128": "49652339056068301844881026656776641697" + }, + { + "input": "İß", + "h32": 4218360726, + "h64": "18117721363137679933", + "h128": "334212969184580936462135547166212268614" + }, + { + "input": "a\"_\\\\%ß\"_İB \"İ\"Aa%\"a\"", + "h32": 2983009920, + "h64": "12811930051107213478", + "h128": "236338394743043302996065291860207016744" + }, + { + "input": "a\"Béé𝒜", + "h32": 2648986937, + "h64": "11377312263366690340", + "h128": "209874367569002500358811404826066415842" + }, + { + "input": "İ AB\n\nßA😀%aA\\éb😀%中😀", + "h32": 903885035, + "h64": "3882156668562011302", + "h128": "71613150519008297966158191459365460998" + }, + { + "input": "ß\"%中B%😀é中中é中 Aa\n\\\n", + "h32": 3075907135, + "h64": "13210920551947301047", + "h128": "243698470399881594232345029066799940363" + }, + { + "input": "_𝒜aéBéa𝒜b%bAba_éß_Ba😀aa\n__%\"𝒜\\%\n Bb中b中", + "h32": 2795349650, + "h64": "12005935328780030325", + "h128": "221470416375513161547084503417954266829" + }, + { + "input": "%a𝒜😀aa\n_\\Ba😀_中\nA_aé中ßB𝒜中b😀a\\𝒜%\n", + "h32": 82703243, + "h64": "355207725226978874", + "h128": "6552426000266623350243019319616959152" + }, + { + "input": "ß𝒜中_é\\İ__%\nB_𝒜İ中\"İİ%\"😀_中 \\__a😀𝒜A\"中\n", + "h32": 2917963830, + "h64": "12532559222697309931", + "h128": "231184912569705586691998493361752399307" + }, + { + "input": " _中%B𝒜Bß中_\"aß😀ß b", + "h32": 2334543971, + "h64": "10026790007554750332", + "h128": "184961629150190740966752782976715561700" + }, + { + "input": "𝒜b\\_A%b%é😀B\n", + "h32": 3655479708, + "h64": "15700165800513909744", + "h128": "289616940436887342947288547704879301458" + }, + { + "input": "\"𝒜\n\"é_ßéA \"éA%𝒜\n\"ßbİİİ \nA%%ß%中a", + "h32": 1894878408, + "h64": "8138440792885323825", + "h128": "150127734465393411669326434688560267741" + }, + { + "input": "\"B中A", + "h32": 1897414694, + "h64": "8149334061467451050", + "h128": "150328679803054093498496731725386087556" + }, + { + "input": "_éb中\"中\"b\néß%😀aéßé Abé\\A", + "h32": 1133315836, + "h64": "4867554455385052298", + "h128": "89790531303332737553158750850568433003" + }, + { + "input": "\n😀😀😀中%ßA%\\ _ B_%ß\\a𝒜\"é", + "h32": 1341754949, + "h64": "5762793629124694271", + "h128": "106304779226067113760716150211373041222" + }, + { + "input": "\"\"\"Aß\\\nB\\ßaa", + "h32": 2437396994, + "h64": "10468540378453530012", + "h128": "193110485186626801398040928483384452870" + }, + { + "input": "\nİ\\%_\\A😀é𝒜bİé中\"B😀😀%\\中abİ\n _", + "h32": 2454694686, + "h64": "10542833401892174349", + "h128": "194480949576461578635031073381873050237" + }, + { + "input": "\" %baaBBaé😀İb İ𝒜é", + "h32": 2986921718, + "h64": "12828731098407346804", + "h128": "236648319362759151296481865766256843494" + }, + { + "input": " é", + "h32": 2593085755, + "h64": "11137218515062257178", + "h128": "205445419640332985222336366463419520176" + }, + { + "input": "%B _\n\n", + "h32": 336792386, + "h64": "1446512287027567471", + "h128": "26683441958273830165021271076283455716" + }, + { + "input": "İ𝒜A_ A\n😀\n_\"b\né_\n\\𝒜", + "h32": 3341959820, + "h64": "14353608134496516336", + "h128": "264777335791372825424206486799251285701" + }, + { + "input": "中B\"B中", + "h32": 1787891600, + "h64": "7678935952098415246", + "h128": "141651366266766654775255934855154442377" + }, + { + "input": "\\𝒜 b中\"bß\n\"\nb中", + "h32": 116187506, + "h64": "499021539215115666", + "h128": "9205322621169853534499327966649473327" + }, + { + "input": "éß\\\"_%%中𝒜ßß𝒜𝒜BB_b😀béß \"B\né𝒜\\\\İ%a中\"İaİİ\\", + "h32": 228676426, + "h64": "982157771279432148", + "h128": "18117613046796646254012052614332296118" + }, + { + "input": "\\𝒜中中é_𝒜😀😀İB😀%éBAİéé𝒜\"é𝒜\\%%ß", + "h32": 563438336, + "h64": "2419949227527266221", + "h128": "44640184071566605505207966351578966070" + }, + { + "input": "ßB_\n%aaßb\"ßb\"\\ab_A_ 中a", + "h32": 4116930186, + "h64": "17682080510116048292", + "h128": "326176813860838379181488143916561723805" + }, + { + "input": "A_\\中\nBb", + "h32": 1874093456, + "h64": "8049170105741730196", + "h128": "148480980946371346428501762346815036308" + }, + { + "input": "\\A𝒜İ𝒜😀Aaİ😀 é\\ba%😀_\\ß𝒜ßa\nb ß𝒜\"_AA\n", + "h32": 3512392309, + "h64": "15085610100192184339", + "h128": "278280388714013131641832812713009379104" + }, + { + "input": "B\n\"Bb\na_é éb 中A%\n", + "h32": 1001682417, + "h64": "4302193222063805922", + "h128": "79361457323058912880232863843560542668" + }, + { + "input": "Aa_\n\n𝒜_中é \\ Bİ𝒜中 b𝒜B𝒜b😀_ß中\n_𝒜İ中_𝒜", + "h32": 4116868749, + "h64": "17681816640829520655", + "h128": "326171946331640891521391193183988366537" + }, + { + "input": "%\n𝒜aa", + "h32": 1749333306, + "h64": "7513329343038218315", + "h128": "138596463532518332496562335328627001786" + }, + { + "input": "aAİB%A", + "h32": 4247419059, + "h64": "18242525953234460733", + "h128": "336515207517320477491131574046169361668" + }, + { + "input": "𝒜😀\n İ\né", + "h32": 2845918412, + "h64": "12223126509125335347", + "h128": "225476886494409899407796903583885725628" + }, + { + "input": "中𝒜a%ßa_éß_\\\n\nİİ\"\\İB%é\nß%é𝒜é 𝒜\nb𝒜", + "h32": 2678245955, + "h64": "11502978787857112874", + "h128": "212192505784909878479556309836299358905" + }, + { + "input": "%😀", + "h32": 341447480, + "h64": "1466505763864513259", + "h128": "27052256508628609077895188123765783547" + }, + { + "input": "Aab𝒜\n中__中%\"中éBA%\n\n𝒜", + "h32": 1342803158, + "h64": "5767295650104800808", + "h128": "106387826854901610085738265291263181686" + }, + { + "input": "𝒜ßß_a \n b中éb\na\"a中\n𝒜A\"\"𝒜 İ𝒜中% \"%\\_𝒜", + "h32": 2191658095, + "h64": "9413099844175257973", + "h128": "173641043765776243800246275553929415239" + }, + { + "input": "\"İ_İb𝒜𝒜中\\é_é😀é😀", + "h32": 953228944, + "h64": "4094087140930662357", + "h128": "75522577704213177691317961226036497752" + }, + { + "input": "B😀b😀İİ😀a _a%𝒜a中AB\n \né%é%aAB\"_😀a\\\\ 😀b\na", + "h32": 318272771, + "h64": "1366971146538897868", + "h128": "25216166896348365301453137160458090211" + }, + { + "input": "\n中a_a bß béa𝒜%ßİ\nİAé😀a aé\\", + "h32": 4257782109, + "h64": "18287034914652242868", + "h128": "337336252937580917166325804621215073824" + }, + { + "input": "%bßİ中Aß%\"%", + "h32": 2130457153, + "h64": "9150243798623610316", + "h128": "168792205565257659443346574790265189856" + }, + { + "input": "中__%bBb%%\\中", + "h32": 2955721066, + "h64": "12694725318211876684", + "h128": "234176349031095538054772759105420947594" + }, + { + "input": "\"aßA😀 BbßB\"İİ\\a𝒜𝒜A𝒜\n", + "h32": 3927160349, + "h64": "16867025265669681648", + "h128": "311141698360601475363295622211322978161" + }, + { + "input": "%İ中", + "h32": 3141106639, + "h64": "13490950291717580347", + "h128": "248864107342451421850621799362646338541" + }, + { + "input": "", + "h32": 46947589, + "h64": "201638359568529298", + "h128": "3719571214403283497883493104908516086" + }, + { + "input": "b\nß\\%😀😀\"Bß\né%Bb𝒜😀_é😀中ß中", + "h32": 3569451073, + "h64": "15330675626273432182", + "h128": "282801049754922903853566917489433032120" + }, + { + "input": "\"Bİ中ß_ßA中𝒜é\nAéA\n\"\\\"ßAİ\nbAA中é中\n ", + "h32": 710633660, + "h64": "3052148332977905836", + "h128": "56302199173442671709801861030156515882" + }, + { + "input": "A中😀𝒜\"ßİA中%é中", + "h32": 1979960802, + "h64": "8503866894755738604", + "h128": "156868656244450268391848091264666407557" + }, + { + "input": "\\abé😀\\\n\\A中😀中_a\n\n_中İß中𝒜Baßé𝒜ß%\\_\n B\"", + "h32": 2737414483, + "h64": "11757105682223034968", + "h128": "216880319567524665113645459530141959276" + }, + { + "input": "b𝒜ßé\"", + "h32": 4250925200, + "h64": "18257584712192172596", + "h128": "336792992589881069413141793999284495639" + }, + { + "input": "𝒜B_ 𝒜İAİ é𝒜Aéébb", + "h32": 1873260948, + "h64": "8045594510117446742", + "h128": "148415022848979113818175741149483661612" + }, + { + "input": "A😀a\"abB中é😀Aßb_\n ab𝒜𝒜\"中%AA\n😀é_%𝒜%Aß_A\"\"İ", + "h32": 2743525045, + "h64": "11783350344767559040", + "h128": "217364448140784371563684653799307501932" + }, + { + "input": "_A𝒜İ 𝒜é𝒜𝒜_ß中中_ \né\\中😀aİİéé中𝒜中b", + "h32": 2774911138, + "h64": "11918152590948774473", + "h128": "219851110676650443401956591998328040192" + }, + { + "input": "%_éé\n", + "h32": 3016274498, + "h64": "12954800327420064584", + "h128": "238973886165926635268431584373752052191" + }, + { + "input": "\\\\中b😀_% 中😀éaßa%ß_é𝒜İ中B_é😀 _", + "h32": 2435661397, + "h64": "10461086046272333352", + "h128": "192972977028639849518343443499912947332" + }, + { + "input": "𝒜 AB", + "h32": 4206124213, + "h64": "18065165941805016690", + "h128": "333243492777571322251706715821002052523" + }, + { + "input": "ab\n_中B\\İ\"%\\b\nBaßİßa😀ßİ😀中中\\\"\"_éé😀", + "h32": 1494498535, + "h64": "6418822334550769408", + "h128": "118406372860068914466751524862774505305" + }, + { + "input": "𝒜ß\n\"\nßB中İ𝒜\nİ𝒜%😀 ßA𝒜 中ßßABa\\aA\"", + "h32": 957683648, + "h64": "4113219949637951986", + "h128": "75875515729847791168803712406501501711" + }, + { + "input": "AA\"b𝒜aß_Babé\\\\ß\nß%😀 𝒜", + "h32": 1953566671, + "h64": "8390504966451144836", + "h128": "154777497765313216219719277544199287859" + }, + { + "input": "A_中A\" bß %%a ", + "h32": 273780283, + "h64": "1175877363672901957", + "h128": "21691108789742315372814728831566252851" + }, + { + "input": "éß😀İ\n\"A\\_%B \\éİ\"𝒜\\B%aaİ Abé\\", + "h32": 195784990, + "h64": "840890131388299857", + "h128": "15511685047827966603201963772739362448" + }, + { + "input": "😀A中b\"İ中_%%é_\"\nİ_\n\"\n\"A\\\\é", + "h32": 2208192298, + "h64": "9484113703841472802", + "h128": "174951018260725233950011910541758749474" + }, + { + "input": "B\\A 😀\"😀𝒜AB😀\"ééé", + "h32": 228543709, + "h64": "981587759374665199", + "h128": "18107098183070442628488472746370968463" + }, + { + "input": "\\%B\\", + "h32": 10471842, + "h64": "44976222954444857", + "h128": "829664874242745172172195387602952521" + }, + { + "input": "AB\n\"%ß😀 B%中éab é中ßbé\n\n_%ßb", + "h32": 469264619, + "h64": "2015476193254086159", + "h128": "37178973523612500842191616363541095142" + }, + { + "input": "b", + "h32": 2718739903, + "h64": "11676898972004176550", + "h128": "215400766911123199251351360484621795855" + }, + { + "input": "😀\n", + "h32": 3262101978, + "h64": "14010621314339134371", + "h128": "258450345699274155883361944573385607671" + }, + { + "input": "\né\"_\n%中b\"\\éß\n\"\"\\\"%😀bé\\B", + "h32": 770543245, + "h64": "3309458039642643848", + "h128": "61048725479968370741265885320820277286" + }, + { + "input": "\"A\"% ß𝒜𝒜%a", + "h32": 720698375, + "h64": "3095375951661095820", + "h128": "57099607992207382842348475435795750636" + }, + { + "input": "ß😀é𝒜\n中%a𝒜\nİİa", + "h32": 3263515421, + "h64": "14016692005383032189", + "h128": "258562330183311499599773783125209566205" + }, + { + "input": "%\"B😀b\n😀%\n𝒜bb\\\\İé", + "h32": 1116499877, + "h64": "4795330458920454059", + "h128": "88458233724569790378501849673001993851" + }, + { + "input": "𝒜% 😀_中 ßİ中😀\\B\"_\"A%B\"😀", + "h32": 215600628, + "h64": "925997646902249444", + "h128": "17081641605263059882700022020769150031" + }, + { + "input": "中\\%é 𝒜İ\nb😀a", + "h32": 93859310, + "h64": "403122668609858188", + "h128": "7436300698156881036926162321615747152" + }, + { + "input": "_%b\"中İ_İéAbéßBbİBAaßßA𝒜\n", + "h32": 746843290, + "h64": "3207667506961164625", + "h128": "59171021574466555456570234168801321992" + }, + { + "input": "\\中a中\"é\\", + "h32": 3069781744, + "h64": "13184612197849052705", + "h128": "243213166924830679229947541281092525847" + }, + { + "input": "a😀😀ß中\"ß \"_\n😀B%İ\\_\nßß_😀ßé \\bA\\a ß%\\\\é", + "h32": 2686967023, + "h64": "11540435489678727627", + "h128": "212883459977258456184162653942584175410" + }, + { + "input": "中ß\n\n𝒜b éßB😀b\\a\"\néİabA😀%", + "h32": 1742652495, + "h64": "7484635477247643194", + "h128": "138067155133794223645172319365876535423" + }, + { + "input": "A aİaaİ_b\\_ba\nb%😀bB%ba😀", + "h32": 3990190579, + "h64": "17137738044859729187", + "h128": "316135467715802927233636894736815475889" + }, + { + "input": "\"_b\n\nßBA_aB\"中AA\n😀_é𝒜\"", + "h32": 1046162932, + "h64": "4493235582414135132", + "h128": "82885566851678832856407469506156660222" + }, + { + "input": "\\ßa\\ß_B😀a \"é%_\né\\%İ%中B𝒜", + "h32": 1795519016, + "h64": "7711695457193748375", + "h128": "142255672473241649025435763671919008233" + }, + { + "input": "%\n𝒜bİB\\𝒜 A\" A😀_\nA\"\n 中 a\néß AéİA\n\"baé", + "h32": 1521821832, + "h64": "6536175000878374010", + "h128": "120571147462181669112712065379421979854" + }, + { + "input": "𝒜B\\bA\"b%ß \\\"𝒜İ _Bßİ𝒜\n", + "h32": 1270954229, + "h64": "5458706850323743182", + "h128": "100695368241327241951929237476046186505" + }, + { + "input": "𝒜", + "h32": 3702349041, + "h64": "15901468051534883171", + "h128": "293330311542932877046182197306416630085" + }, + { + "input": "%\"𝒜😀%éß𝒜aé_𝒜中", + "h32": 2780208526, + "h64": "11940904696360470225", + "h128": "220270812942318057025315622863557370450" + }, + { + "input": "İßİ😀%中AAé _b中\\é\\_B\"", + "h32": 27475118, + "h64": "118004736676360654", + "h128": "2176803176954312079703362314649385471" + }, + { + "input": "\"\n_𝒜Bßß%ß_中\\\"\"\\ \\__é%", + "h32": 1879938904, + "h64": "8074276111725808186", + "h128": "148944105013472653632961034061797175748" + }, + { + "input": "a\"😀%B𝒜B\"%中\\__a\"AB\"", + "h32": 2830318661, + "h64": "12156126086732739353", + "h128": "224240946849703242515298020540472967932" + }, + { + "input": "A\"\\𝒜😀ß\n\\_\n\n\na A _aAB%𝒜%Bb_ ß", + "h32": 3962245802, + "h64": "17017716140989112599", + "h128": "313921454371862293189197239073928333454" + }, + { + "input": "a\\𝒜\\\né%\na\"中%中 aa \\ ßA\n__\"%𝒜中BABß", + "h32": 3132017394, + "h64": "13451912278596096066", + "h128": "248143983105253285962210629179202343898" + }, + { + "input": " \\%ß_éé%ß中\n𝒜ß\nBA", + "h32": 3814896092, + "h64": "16384853954763958197", + "h128": "302247207588638555601666867979708368893" + }, + { + "input": "𝒜aİB\"B a%\\b", + "h32": 1501241954, + "h64": "6447785099021273112", + "h128": "118940641563923424221304835316589112871" + }, + { + "input": "ß 𝒜İa", + "h32": 1708734135, + "h64": "7338957228914824589", + "h128": "135379865769692413672366829071488175231" + }, + { + "input": "İAİ_A _\\𝒜ß %Bİßb_\nb", + "h32": 2359739838, + "h64": "10135005433966287109", + "h128": "186957851426031709093953994788834149136" + } + ] +} diff --git a/packages/zero-conformance/vectors/json-parse.json b/packages/zero-conformance/vectors/json-parse.json new file mode 100644 index 0000000000..9e2e049f28 --- /dev/null +++ b/packages/zero-conformance/vectors/json-parse.json @@ -0,0 +1,237 @@ +{ + "formatVersion": 1, + "kind": "json-parse", + "description": "JSON.parse acceptance; accepted inputs carry their JSON.stringify re-encoding.", + "cases": [ + { + "input": "null", + "ok": true, + "stringify": "null" + }, + { + "input": "true", + "ok": true, + "stringify": "true" + }, + { + "input": "false", + "ok": true, + "stringify": "false" + }, + { + "input": "0", + "ok": true, + "stringify": "0" + }, + { + "input": "-0", + "ok": true, + "stringify": "0" + }, + { + "input": "1", + "ok": true, + "stringify": "1" + }, + { + "input": "-1.5e10", + "ok": true, + "stringify": "-15000000000" + }, + { + "input": "1E400", + "ok": true, + "stringify": "null" + }, + { + "input": "123456789012345678901234567890", + "ok": true, + "stringify": "1.2345678901234568e+29" + }, + { + "input": "0.1", + "ok": true, + "stringify": "0.1" + }, + { + "input": "\"abc\"", + "ok": true, + "stringify": "\"abc\"" + }, + { + "input": "\"\"", + "ok": true, + "stringify": "\"\"" + }, + { + "input": "\"\\u00e9\\n\\t\\\"\\\\\\/\"", + "ok": true, + "stringify": "\"é\\n\\t\\\"\\\\/\"" + }, + { + "input": "\"\\ud83d\\ude00\"", + "ok": true, + "stringify": "\"😀\"" + }, + { + "input": "\"😀\"", + "ok": true, + "stringify": "\"😀\"" + }, + { + "input": "\"\\u0000\"", + "ok": true, + "stringify": "\"\\u0000\"" + }, + { + "input": "[]", + "ok": true, + "stringify": "[]" + }, + { + "input": "[1,2,3]", + "ok": true, + "stringify": "[1,2,3]" + }, + { + "input": "{}", + "ok": true, + "stringify": "{}" + }, + { + "input": "{\"a\":1,\"b\":[true,null,{\"c\":\"d\"}]}", + "ok": true, + "stringify": "{\"a\":1,\"b\":[true,null,{\"c\":\"d\"}]}" + }, + { + "input": " \t\n\r{ \"a\" : 1 } ", + "ok": true, + "stringify": "{\"a\":1}" + }, + { + "input": "{\"a\":1,\"a\":2}", + "ok": true, + "stringify": "{\"a\":2}" + }, + { + "input": "{\"__proto__\":1}", + "ok": true, + "stringify": "{\"__proto__\":1}" + }, + { + "input": "[[[[[]]]]]", + "ok": true, + "stringify": "[[[[[]]]]]" + }, + { + "input": "{\"\":0}", + "ok": true, + "stringify": "{\"\":0}" + }, + { + "input": "\"\\u2028\"", + "ok": true, + "stringify": "\"
\"" + }, + { + "input": "", + "ok": false + }, + { + "input": " ", + "ok": false + }, + { + "input": "nul", + "ok": false + }, + { + "input": "True", + "ok": false + }, + { + "input": "01", + "ok": false + }, + { + "input": "-", + "ok": false + }, + { + "input": "+1", + "ok": false + }, + { + "input": ".5", + "ok": false + }, + { + "input": "1.", + "ok": false + }, + { + "input": "1e", + "ok": false + }, + { + "input": "0x10", + "ok": false + }, + { + "input": "NaN", + "ok": false + }, + { + "input": "Infinity", + "ok": false + }, + { + "input": "\"abc", + "ok": false + }, + { + "input": "\"\u0001\"", + "ok": false + }, + { + "input": "\"\\x41\"", + "ok": false + }, + { + "input": "\"\\u12\"", + "ok": false + }, + { + "input": "'a'", + "ok": false + }, + { + "input": "[1,]", + "ok": false + }, + { + "input": "[1 2]", + "ok": false + }, + { + "input": "{\"a\":1,}", + "ok": false + }, + { + "input": "{a:1}", + "ok": false + }, + { + "input": "{\"a\" 1}", + "ok": false + }, + { + "input": "[1]x", + "ok": false + }, + { + "input": "{\"a\":1}}", + "ok": false + } + ] +} diff --git a/packages/zero-conformance/vectors/json-stringify-strings.json b/packages/zero-conformance/vectors/json-stringify-strings.json new file mode 100644 index 0000000000..e49aba761d --- /dev/null +++ b/packages/zero-conformance/vectors/json-stringify-strings.json @@ -0,0 +1,275 @@ +{ + "formatVersion": 1, + "kind": "json-stringify-strings", + "description": "JSON.stringify of strings (escaping rules).", + "cases": [ + { + "input": "", + "stringify": "\"\"" + }, + { + "input": "a", + "stringify": "\"a\"" + }, + { + "input": "A", + "stringify": "\"A\"" + }, + { + "input": "abc", + "stringify": "\"abc\"" + }, + { + "input": "ab", + "stringify": "\"ab\"" + }, + { + "input": "b", + "stringify": "\"b\"" + }, + { + "input": "Z", + "stringify": "\"Z\"" + }, + { + "input": "z", + "stringify": "\"z\"" + }, + { + "input": " ", + "stringify": "\" \"" + }, + { + "input": "0", + "stringify": "\"0\"" + }, + { + "input": "9", + "stringify": "\"9\"" + }, + { + "input": "\u0000", + "stringify": "\"\\u0000\"" + }, + { + "input": "\u001f", + "stringify": "\"\\u001f\"" + }, + { + "input": "\"", + "stringify": "\"\\\"\"" + }, + { + "input": "\\", + "stringify": "\"\\\\\"" + }, + { + "input": "\n", + "stringify": "\"\\n\"" + }, + { + "input": "é", + "stringify": "\"é\"" + }, + { + "input": "é", + "stringify": "\"é\"" + }, + { + "input": "ß", + "stringify": "\"ß\"" + }, + { + "input": "İ", + "stringify": "\"İ\"" + }, + { + "input": "ı", + "stringify": "\"ı\"" + }, + { + "input": "ff", + "stringify": "\"ff\"" + }, + { + "input": " ", + "stringify": "\" \"" + }, + { + "input": "", + "stringify": "\"\"" + }, + { + "input": "�", + "stringify": "\"�\"" + }, + { + "input": "￿", + "stringify": "\"￿\"" + }, + { + "input": "😀", + "stringify": "\"😀\"" + }, + { + "input": "😀a", + "stringify": "\"😀a\"" + }, + { + "input": "a😀", + "stringify": "\"a😀\"" + }, + { + "input": "𝒜", + "stringify": "\"𝒜\"" + }, + { + "input": "中文", + "stringify": "\"中文\"" + }, + { + "input": "Ω", + "stringify": "\"Ω\"" + }, + { + "input": "ω", + "stringify": "\"ω\"" + }, + { + "input": "􏿿", + "stringify": "\"􏿿\"" + }, + { + "input": "\u0000", + "stringify": "\"\\u0000\"" + }, + { + "input": "\u0001", + "stringify": "\"\\u0001\"" + }, + { + "input": "\u0002", + "stringify": "\"\\u0002\"" + }, + { + "input": "\u0003", + "stringify": "\"\\u0003\"" + }, + { + "input": "\u0004", + "stringify": "\"\\u0004\"" + }, + { + "input": "\u0005", + "stringify": "\"\\u0005\"" + }, + { + "input": "\u0006", + "stringify": "\"\\u0006\"" + }, + { + "input": "\u0007", + "stringify": "\"\\u0007\"" + }, + { + "input": "\b", + "stringify": "\"\\b\"" + }, + { + "input": "\t", + "stringify": "\"\\t\"" + }, + { + "input": "\n", + "stringify": "\"\\n\"" + }, + { + "input": "\u000b", + "stringify": "\"\\u000b\"" + }, + { + "input": "\f", + "stringify": "\"\\f\"" + }, + { + "input": "\r", + "stringify": "\"\\r\"" + }, + { + "input": "\u000e", + "stringify": "\"\\u000e\"" + }, + { + "input": "\u000f", + "stringify": "\"\\u000f\"" + }, + { + "input": "\u0010", + "stringify": "\"\\u0010\"" + }, + { + "input": "\u0011", + "stringify": "\"\\u0011\"" + }, + { + "input": "\u0012", + "stringify": "\"\\u0012\"" + }, + { + "input": "\u0013", + "stringify": "\"\\u0013\"" + }, + { + "input": "\u0014", + "stringify": "\"\\u0014\"" + }, + { + "input": "\u0015", + "stringify": "\"\\u0015\"" + }, + { + "input": "\u0016", + "stringify": "\"\\u0016\"" + }, + { + "input": "\u0017", + "stringify": "\"\\u0017\"" + }, + { + "input": "\u0018", + "stringify": "\"\\u0018\"" + }, + { + "input": "\u0019", + "stringify": "\"\\u0019\"" + }, + { + "input": "\u001a", + "stringify": "\"\\u001a\"" + }, + { + "input": "\u001b", + "stringify": "\"\\u001b\"" + }, + { + "input": "\u001c", + "stringify": "\"\\u001c\"" + }, + { + "input": "\u001d", + "stringify": "\"\\u001d\"" + }, + { + "input": "\u001e", + "stringify": "\"\\u001e\"" + }, + { + "input": "\u001f", + "stringify": "\"\\u001f\"" + }, + { + "input": "a\"b\\c\u0007de€f", + "stringify": "\"a\\\"b\\\\c\\u0007de€f\"" + } + ] +} diff --git a/packages/zero-conformance/vectors/like.json b/packages/zero-conformance/vectors/like.json new file mode 100644 index 0000000000..2d2b345d5e --- /dev/null +++ b/packages/zero-conformance/vectors/like.json @@ -0,0 +1,6976 @@ +{ + "formatVersion": 1, + "kind": "like", + "description": "LIKE / ILIKE (flags \"i\") predicate results; `throws` when the pattern is invalid.", + "cases": [ + { + "pattern": "foo", + "flags": "", + "throws": false, + "results": [ + { + "input": "foo", + "match": true + }, + { + "input": "bar", + "match": false + }, + { + "input": "Foo", + "match": false + }, + { + "input": "FOO", + "match": false + }, + { + "input": "fo", + "match": false + }, + { + "input": "fooa", + "match": false + }, + { + "input": "afoo", + "match": false + }, + { + "input": "afoob", + "match": false + } + ] + }, + { + "pattern": "foo", + "flags": "i", + "throws": false, + "results": [ + { + "input": "foo", + "match": true + }, + { + "input": "bar", + "match": false + }, + { + "input": "Foo", + "match": true + }, + { + "input": "FOO", + "match": true + }, + { + "input": "fo", + "match": false + }, + { + "input": "fooa", + "match": false + }, + { + "input": "afoo", + "match": false + }, + { + "input": "afoob", + "match": false + } + ] + }, + { + "pattern": "foo%", + "flags": "", + "throws": false, + "results": [ + { + "input": "foo", + "match": true + }, + { + "input": "foobar", + "match": true + }, + { + "input": "bar", + "match": false + }, + { + "input": "Foo", + "match": false + }, + { + "input": "FOO", + "match": false + }, + { + "input": "fo", + "match": false + }, + { + "input": "fooa", + "match": true + }, + { + "input": "afoo", + "match": false + }, + { + "input": "afoob", + "match": false + } + ] + }, + { + "pattern": "foo%", + "flags": "i", + "throws": false, + "results": [ + { + "input": "foo", + "match": true + }, + { + "input": "foobar", + "match": true + }, + { + "input": "bar", + "match": false + }, + { + "input": "Foo", + "match": true + }, + { + "input": "FOO", + "match": true + }, + { + "input": "fo", + "match": false + }, + { + "input": "fooa", + "match": true + }, + { + "input": "afoo", + "match": false + }, + { + "input": "afoob", + "match": false + }, + { + "input": "foo\nbar", + "match": true + }, + { + "input": "foobar\nbaz", + "match": true + } + ] + }, + { + "pattern": "foo_", + "flags": "", + "throws": false, + "results": [ + { + "input": "foo", + "match": false + }, + { + "input": "foobar", + "match": false + }, + { + "input": "foob", + "match": true + }, + { + "input": "bar", + "match": false + }, + { + "input": "Foo", + "match": false + }, + { + "input": "FOO", + "match": false + }, + { + "input": "fo", + "match": false + }, + { + "input": "afoo", + "match": false + }, + { + "input": "afoob", + "match": false + }, + { + "input": "fooa\nbar", + "match": false + } + ] + }, + { + "pattern": "a%b", + "flags": "", + "throws": false, + "results": [ + { + "input": "a\nb", + "match": true + }, + { + "input": "axb", + "match": true + }, + { + "input": "ab", + "match": true + }, + { + "input": "z\nab", + "match": false + }, + { + "input": "a\nbz", + "match": false + } + ] + }, + { + "pattern": "a_b", + "flags": "", + "throws": false, + "results": [ + { + "input": "a\nb", + "match": true + }, + { + "input": "axb", + "match": true + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nbc", + "match": false + } + ] + }, + { + "pattern": "foo\\%", + "flags": "", + "throws": false, + "results": [ + { + "input": "foo%", + "match": true + }, + { + "input": "foobar", + "match": false + }, + { + "input": "bar", + "match": false + }, + { + "input": "Foo", + "match": false + }, + { + "input": "FOO", + "match": false + }, + { + "input": "fo", + "match": false + }, + { + "input": "fooa", + "match": false + }, + { + "input": "afoo", + "match": false + }, + { + "input": "afoob", + "match": false + } + ] + }, + { + "pattern": "foo\\%", + "flags": "i", + "throws": false, + "results": [ + { + "input": "foo%", + "match": true + }, + { + "input": "FOO%", + "match": true + }, + { + "input": "foobar", + "match": false + }, + { + "input": "bar", + "match": false + }, + { + "input": "Foo", + "match": false + }, + { + "input": "FOO", + "match": false + }, + { + "input": "fo", + "match": false + }, + { + "input": "fooa", + "match": false + }, + { + "input": "afoo", + "match": false + }, + { + "input": "afoob", + "match": false + } + ] + }, + { + "pattern": "foo\\_", + "flags": "", + "throws": false, + "results": [ + { + "input": "foo_", + "match": true + }, + { + "input": "FOO_", + "match": false + }, + { + "input": "foobar", + "match": false + }, + { + "input": "bar", + "match": false + }, + { + "input": "Foo", + "match": false + }, + { + "input": "FOO", + "match": false + }, + { + "input": "fo", + "match": false + }, + { + "input": "fooa", + "match": false + }, + { + "input": "afoo", + "match": false + }, + { + "input": "afoob", + "match": false + } + ] + }, + { + "pattern": "%foo", + "flags": "", + "throws": false, + "results": [ + { + "input": "foo", + "match": true + }, + { + "input": "foobar", + "match": false + }, + { + "input": "bar", + "match": false + }, + { + "input": "Foo", + "match": false + }, + { + "input": "FOO", + "match": false + }, + { + "input": "fo", + "match": false + }, + { + "input": "fooa", + "match": false + }, + { + "input": "afoo", + "match": true + }, + { + "input": "afoob", + "match": false + }, + { + "input": "monkey\nfoo", + "match": true + }, + { + "input": "mon\nkeyfoo", + "match": true + } + ] + }, + { + "pattern": "%foo%", + "flags": "", + "throws": false, + "results": [ + { + "input": "foo", + "match": true + }, + { + "input": "foobar", + "match": true + }, + { + "input": "bar", + "match": false + }, + { + "input": "Foo", + "match": false + }, + { + "input": "FOO", + "match": false + }, + { + "input": "fo", + "match": false + }, + { + "input": "fooa", + "match": true + }, + { + "input": "afoo", + "match": true + }, + { + "input": "afoob", + "match": true + }, + { + "input": "mon\nfoo\nkey", + "match": true + }, + { + "input": "m\nonfooke\ny", + "match": true + } + ] + }, + { + "pattern": "%foo\nbar%", + "flags": "", + "throws": false, + "results": [ + { + "input": "foo\nbar", + "match": true + }, + { + "input": "foo\nbar\n", + "match": true + }, + { + "input": "foo\nbar\nbaz", + "match": true + }, + { + "input": "monkey\nfoo\nbar", + "match": true + } + ] + }, + { + "pattern": "", + "flags": "", + "throws": false, + "results": [ + { + "input": "", + "match": true + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "", + "flags": "i", + "throws": false, + "results": [ + { + "input": "", + "match": true + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "%", + "flags": "", + "throws": false, + "results": [ + { + "input": "", + "match": true + }, + { + "input": "a", + "match": true + }, + { + "input": "A", + "match": true + }, + { + "input": "ab", + "match": true + }, + { + "input": "a\nb", + "match": true + }, + { + "input": "\n", + "match": true + }, + { + "input": "😀", + "match": true + }, + { + "input": "a😀b", + "match": true + }, + { + "input": "é", + "match": true + }, + { + "input": "É", + "match": true + }, + { + "input": "İ", + "match": true + }, + { + "input": "i", + "match": true + }, + { + "input": "I", + "match": true + }, + { + "input": "ı", + "match": true + }, + { + "input": "ß", + "match": true + }, + { + "input": "SS", + "match": true + }, + { + "input": "ss", + "match": true + }, + { + "input": "Σ", + "match": true + }, + { + "input": "σ", + "match": true + }, + { + "input": "ς", + "match": true + }, + { + "input": "%", + "match": true + }, + { + "input": "_", + "match": true + }, + { + "input": "\\", + "match": true + }, + { + "input": "a.b", + "match": true + }, + { + "input": "a+b", + "match": true + }, + { + "input": "(x)", + "match": true + }, + { + "input": "[x]", + "match": true + }, + { + "input": "^$", + "match": true + } + ] + }, + { + "pattern": "%", + "flags": "i", + "throws": false, + "results": [ + { + "input": "", + "match": true + }, + { + "input": "a", + "match": true + }, + { + "input": "A", + "match": true + }, + { + "input": "ab", + "match": true + }, + { + "input": "a\nb", + "match": true + }, + { + "input": "\n", + "match": true + }, + { + "input": "😀", + "match": true + }, + { + "input": "a😀b", + "match": true + }, + { + "input": "é", + "match": true + }, + { + "input": "É", + "match": true + }, + { + "input": "İ", + "match": true + }, + { + "input": "i", + "match": true + }, + { + "input": "I", + "match": true + }, + { + "input": "ı", + "match": true + }, + { + "input": "ß", + "match": true + }, + { + "input": "SS", + "match": true + }, + { + "input": "ss", + "match": true + }, + { + "input": "Σ", + "match": true + }, + { + "input": "σ", + "match": true + }, + { + "input": "ς", + "match": true + }, + { + "input": "%", + "match": true + }, + { + "input": "_", + "match": true + }, + { + "input": "\\", + "match": true + }, + { + "input": "a.b", + "match": true + }, + { + "input": "a+b", + "match": true + }, + { + "input": "(x)", + "match": true + }, + { + "input": "[x]", + "match": true + }, + { + "input": "^$", + "match": true + } + ] + }, + { + "pattern": "_", + "flags": "", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": true + }, + { + "input": "A", + "match": true + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": true + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": true + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": true + }, + { + "input": "i", + "match": true + }, + { + "input": "I", + "match": true + }, + { + "input": "ı", + "match": true + }, + { + "input": "ß", + "match": true + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": true + }, + { + "input": "σ", + "match": true + }, + { + "input": "ς", + "match": true + }, + { + "input": "%", + "match": true + }, + { + "input": "_", + "match": true + }, + { + "input": "\\", + "match": true + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "_", + "flags": "i", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": true + }, + { + "input": "A", + "match": true + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": true + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": true + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": true + }, + { + "input": "i", + "match": true + }, + { + "input": "I", + "match": true + }, + { + "input": "ı", + "match": true + }, + { + "input": "ß", + "match": true + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": true + }, + { + "input": "σ", + "match": true + }, + { + "input": "ς", + "match": true + }, + { + "input": "%", + "match": true + }, + { + "input": "_", + "match": true + }, + { + "input": "\\", + "match": true + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "__", + "flags": "", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": true + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": true + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": true + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": true + }, + { + "input": "ss", + "match": true + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": true + } + ] + }, + { + "pattern": "__", + "flags": "i", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": true + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": true + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": true + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": true + }, + { + "input": "ss", + "match": true + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": true + } + ] + }, + { + "pattern": "a_b", + "flags": "", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": true + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": true + }, + { + "input": "a+b", + "match": true + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "a_b", + "flags": "i", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": true + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": true + }, + { + "input": "a+b", + "match": true + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "a%b", + "flags": "", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": true + }, + { + "input": "a\nb", + "match": true + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": true + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": true + }, + { + "input": "a+b", + "match": true + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "a%b", + "flags": "i", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": true + }, + { + "input": "a\nb", + "match": true + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": true + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": true + }, + { + "input": "a+b", + "match": true + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "\\%", + "flags": "", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": true + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "\\%", + "flags": "i", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": true + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "\\_", + "flags": "", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": true + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "\\_", + "flags": "i", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": true + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "\\\\", + "flags": "", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": true + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "\\\\", + "flags": "i", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": true + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "a\\b", + "flags": "", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": true + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "a\\b", + "flags": "i", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": true + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "%😀%", + "flags": "", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": true + }, + { + "input": "a😀b", + "match": true + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "%😀%", + "flags": "i", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": true + }, + { + "input": "a😀b", + "match": true + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "_😀", + "flags": "", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "_😀", + "flags": "i", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "😀", + "flags": "", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": true + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "😀", + "flags": "i", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": true + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "__b", + "flags": "", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": true + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": true + }, + { + "input": "a+b", + "match": true + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "__b", + "flags": "i", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": true + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": true + }, + { + "input": "a+b", + "match": true + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "a.b", + "flags": "", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": true + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "a.b", + "flags": "i", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": true + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "a+b", + "flags": "", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": true + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "a+b", + "flags": "i", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": true + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "(x)", + "flags": "", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": true + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "(x)", + "flags": "i", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": true + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "[x]", + "flags": "", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": true + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "[x]", + "flags": "i", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": true + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "^$", + "flags": "", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": true + } + ] + }, + { + "pattern": "^$", + "flags": "i", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": true + } + ] + }, + { + "pattern": "é", + "flags": "", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": true + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "é", + "flags": "i", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": true + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "e%", + "flags": "", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "e%", + "flags": "i", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": true + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "İ", + "flags": "", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": true + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "İ", + "flags": "i", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": true + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "i", + "flags": "", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": true + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "i", + "flags": "i", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": true + }, + { + "input": "I", + "match": true + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "ß", + "flags": "", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": true + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "ß", + "flags": "i", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": true + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "ss", + "flags": "", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": true + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "ss", + "flags": "i", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": true + }, + { + "input": "ss", + "match": true + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "σ", + "flags": "", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": false + }, + { + "input": "σ", + "match": true + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "σ", + "flags": "i", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": true + }, + { + "input": "σ", + "match": true + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "%Σ%", + "flags": "", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": true + }, + { + "input": "σ", + "match": false + }, + { + "input": "ς", + "match": false + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "%Σ%", + "flags": "i", + "throws": false, + "results": [ + { + "input": "", + "match": false + }, + { + "input": "a", + "match": false + }, + { + "input": "A", + "match": false + }, + { + "input": "ab", + "match": false + }, + { + "input": "a\nb", + "match": false + }, + { + "input": "\n", + "match": false + }, + { + "input": "😀", + "match": false + }, + { + "input": "a😀b", + "match": false + }, + { + "input": "é", + "match": false + }, + { + "input": "É", + "match": false + }, + { + "input": "İ", + "match": false + }, + { + "input": "i", + "match": false + }, + { + "input": "I", + "match": false + }, + { + "input": "ı", + "match": false + }, + { + "input": "ß", + "match": false + }, + { + "input": "SS", + "match": false + }, + { + "input": "ss", + "match": false + }, + { + "input": "Σ", + "match": true + }, + { + "input": "σ", + "match": true + }, + { + "input": "ς", + "match": true + }, + { + "input": "%", + "match": false + }, + { + "input": "_", + "match": false + }, + { + "input": "\\", + "match": false + }, + { + "input": "a.b", + "match": false + }, + { + "input": "a+b", + "match": false + }, + { + "input": "(x)", + "match": false + }, + { + "input": "[x]", + "match": false + }, + { + "input": "^$", + "match": false + } + ] + }, + { + "pattern": "a\\", + "flags": "", + "throws": true, + "results": [] + }, + { + "pattern": "a\\", + "flags": "i", + "throws": true, + "results": [] + } + ] +} diff --git a/packages/zero-conformance/vectors/name-args-hash.json b/packages/zero-conformance/vectors/name-args-hash.json new file mode 100644 index 0000000000..d7df09102f --- /dev/null +++ b/packages/zero-conformance/vectors/name-args-hash.json @@ -0,0 +1,1180 @@ +{ + "formatVersion": 1, + "kind": "name-args-hash", + "description": "hashOfNameAndArgs(name, args): the ID of a named query.", + "cases": [ + { + "name": "issues.byId", + "args": [], + "hash": "1wrdiyo0n41o5n" + }, + { + "name": "issues.byId", + "args": [ + { + "id": "1" + } + ], + "hash": "0o8p5w607y247b" + }, + { + "name": "issues.byId", + "args": [ + { + "id": 1 + } + ], + "hash": "0jm5ed012dgf3p" + }, + { + "name": "issues.byId", + "args": [ + { + "b": 1, + "a": 2 + } + ], + "hash": "04mq5n815tl18d" + }, + { + "name": "issues.byId", + "args": [ + { + "a": 2, + "b": 1 + } + ], + "hash": "04mq5n815tl18d" + }, + { + "name": "x", + "args": [ + null, + true, + false, + 0, + 0, + 1.5, + "x", + [ + 1, + [ + 2 + ] + ], + {} + ], + "hash": "0eonrvz1b4i0rn" + }, + { + "name": "x", + "args": [ + { + "nested": { + "z": 1, + "a": [ + true, + null + ] + } + } + ], + "hash": "0j9yqq90h1cva9" + }, + { + "name": "", + "args": [ + { + "": "" + } + ], + "hash": "03xecnu0d4q4qf" + }, + { + "name": "😀", + "args": [ + { + "😀": "é", + "é": "é" + } + ], + "hash": "0vz84cg1a3ahbd" + }, + { + "name": "", + "args": [ + "" + ], + "hash": "0ksdkq51q4ukdk" + }, + { + "name": "a", + "args": [ + "a" + ], + "hash": "0cqx8hg1po6tkn" + }, + { + "name": "A", + "args": [ + "A" + ], + "hash": "0p4p4co0ewmytk" + }, + { + "name": "abc", + "args": [ + "abc" + ], + "hash": "154xmvf1phnwu7" + }, + { + "name": "ab", + "args": [ + "ab" + ], + "hash": "1ehcrqp0qe6bh4" + }, + { + "name": "b", + "args": [ + "b" + ], + "hash": "0eg6r990cbjsfh" + }, + { + "name": "Z", + "args": [ + "Z" + ], + "hash": "0nn9rus12lb5yo" + }, + { + "name": "z", + "args": [ + "z" + ], + "hash": "0gofsy900dh3eb" + }, + { + "name": " ", + "args": [ + " " + ], + "hash": "1s5oe3o11aa8i9" + }, + { + "name": "0", + "args": [ + "0" + ], + "hash": "1inuwt30sldx99" + }, + { + "name": "9", + "args": [ + "9" + ], + "hash": "1jwjyuc0tg33a5" + }, + { + "name": "\u0000", + "args": [ + "\u0000" + ], + "hash": "1gu0eu31ezovbg" + }, + { + "name": "\u001f", + "args": [ + "\u001f" + ], + "hash": "0s1inw21wpwvhn" + }, + { + "name": "\"", + "args": [ + "\"" + ], + "hash": "01gpms10yp3fga" + }, + { + "name": "\\", + "args": [ + "\\" + ], + "hash": "0a08jio1snxuoa" + }, + { + "name": "\n", + "args": [ + "\n" + ], + "hash": "1p46tfx0pr7ly2" + }, + { + "name": "é", + "args": [ + "é" + ], + "hash": "0ff4t5w00a3703" + }, + { + "name": "é", + "args": [ + "é" + ], + "hash": "0ag600s0en3swf" + }, + { + "name": "ß", + "args": [ + "ß" + ], + "hash": "1r7a9o4127u58o" + }, + { + "name": "İ", + "args": [ + "İ" + ], + "hash": "0dg2xwp0scxyf8" + }, + { + "name": "ı", + "args": [ + "ı" + ], + "hash": "0xp2o960o6h9nm" + }, + { + "name": "ff", + "args": [ + "ff" + ], + "hash": "0f94i911rdvtax" + }, + { + "name": " ", + "args": [ + " " + ], + "hash": "1pg4gap1pntsnz" + }, + { + "name": "", + "args": [ + "" + ], + "hash": "0t1k47q06kking" + }, + { + "name": "�", + "args": [ + "�" + ], + "hash": "0c8ex5o1tuxrvi" + }, + { + "name": "￿", + "args": [ + "￿" + ], + "hash": "1mrn3f00jewh30" + }, + { + "name": "😀", + "args": [ + "😀" + ], + "hash": "1155ixk19wcb95" + }, + { + "name": "😀a", + "args": [ + "😀a" + ], + "hash": "03vs5ta0576g0m" + }, + { + "name": "a😀", + "args": [ + "a😀" + ], + "hash": "1okkkm80gwfvt1" + }, + { + "name": "𝒜", + "args": [ + "𝒜" + ], + "hash": "0v7ygw803al70y" + }, + { + "name": "中文", + "args": [ + "中文" + ], + "hash": "13wq4ld1fdanhb" + }, + { + "name": "Ω", + "args": [ + "Ω" + ], + "hash": "0mcyz811s8jrgz" + }, + { + "name": "ω", + "args": [ + "ω" + ], + "hash": "08nl8o805sg6pi" + }, + { + "name": "􏿿", + "args": [ + "􏿿" + ], + "hash": "05g5zo11kze7oj" + }, + { + "name": "a", + "args": [ + { + "closed": "", + "createdAt": 2147483648 + } + ], + "hash": "1xa3u361ef4f4b" + }, + { + "name": "a", + "args": [ + { + "é": 0 + } + ], + "hash": "18j774v0tb04dy" + }, + { + "name": "users.byId", + "args": [], + "hash": "1ke1bry0uo4aq6" + }, + { + "name": "issues.list", + "args": [ + 1e+21, + { + "closed": 1, + "createdAt": -2147483649 + } + ], + "hash": "1uiw4s10vu0afj" + }, + { + "name": "a", + "args": [ + 0 + ], + "hash": "0k08b7r12abo0n" + }, + { + "name": "a", + "args": [], + "hash": "1o2vimr0oukr3l" + }, + { + "name": "users.byId", + "args": [], + "hash": "1ke1bry0uo4aq6" + }, + { + "name": "a", + "args": [ + false, + 1.5 + ], + "hash": "0vmt16505iwyki" + }, + { + "name": "users.byId", + "args": [ + { + "closed": 1e+21, + "id": "1" + } + ], + "hash": "0fxtadm066fe36" + }, + { + "name": "users.byId", + "args": [ + true + ], + "hash": "0dt51cf0rbo8o9" + }, + { + "name": "a", + "args": [ + 2147483648, + 1 + ], + "hash": "1u8o3yh1f8b057" + }, + { + "name": "a", + "args": [ + "a" + ], + "hash": "0cqx8hg1po6tkn" + }, + { + "name": "issues.list", + "args": [ + 2147483648, + "1" + ], + "hash": "0vfnq7m08s1ci5" + }, + { + "name": "a", + "args": [], + "hash": "1o2vimr0oukr3l" + }, + { + "name": "a", + "args": [ + { + "a": -1, + "createdAt": "é" + } + ], + "hash": "05ksuyv07tr602" + }, + { + "name": "a", + "args": [ + { + "closed": "é", + "ownerId": "😀" + } + ], + "hash": "018kpt409ncys4" + }, + { + "name": "users.byId", + "args": [ + { + "closed": "", + "id": -1 + } + ], + "hash": "1fjygdb1mkqtca" + }, + { + "name": "issues.list", + "args": [ + "é" + ], + "hash": "0k1fq9d1p6adqy" + }, + { + "name": "a", + "args": [ + { + "createdAt": 0, + "closed": 2147483648 + }, + { + "closed": true, + "createdAt": false + } + ], + "hash": "1pmrz3807awauc" + }, + { + "name": "users.byId", + "args": [ + { + "id": -1, + "ownerId": "1" + } + ], + "hash": "0ysd5vt10v8nvq" + }, + { + "name": "a", + "args": [ + -2147483649, + -1 + ], + "hash": "1x6sdys17z7onv" + }, + { + "name": "issues.list", + "args": [], + "hash": "13fodwg16cu4vt" + }, + { + "name": "issues.list", + "args": [ + -1, + -2147483649 + ], + "hash": "0c5n5dp14ofs09" + }, + { + "name": "issues.list", + "args": [ + "b", + 1 + ], + "hash": "0eure1p17op66j" + }, + { + "name": "a", + "args": [], + "hash": "1o2vimr0oukr3l" + }, + { + "name": "issues.list", + "args": [ + { + "closed": "", + "ownerId": 0 + } + ], + "hash": "1b5zqqi1vcw88w" + }, + { + "name": "a", + "args": [ + "", + "" + ], + "hash": "1ap19mv0nf9dlk" + }, + { + "name": "users.byId", + "args": [ + { + "é": "é", + "id": "é" + } + ], + "hash": "1l9a9ca00vpu3a" + }, + { + "name": "a", + "args": [], + "hash": "1o2vimr0oukr3l" + }, + { + "name": "a", + "args": [ + 0 + ], + "hash": "0k08b7r12abo0n" + }, + { + "name": "users.byId", + "args": [], + "hash": "1ke1bry0uo4aq6" + }, + { + "name": "users.byId", + "args": [ + { + "id": true + } + ], + "hash": "1wfko5r0xms8zm" + }, + { + "name": "users.byId", + "args": [ + 0, + { + "é": 1, + "createdAt": "1" + } + ], + "hash": "05iz0e41384lis" + }, + { + "name": "users.byId", + "args": [ + "😀" + ], + "hash": "1id5em8026fmen" + }, + { + "name": "issues.list", + "args": [], + "hash": "13fodwg16cu4vt" + }, + { + "name": "users.byId", + "args": [], + "hash": "1ke1bry0uo4aq6" + }, + { + "name": "issues.list", + "args": [ + "é" + ], + "hash": "0k1fq9d1p6adqy" + }, + { + "name": "users.byId", + "args": [ + { + "a": "é", + "é": "é" + } + ], + "hash": "19k8bav0lolkk9" + }, + { + "name": "issues.list", + "args": [ + { + "a": "b", + "é": "1" + }, + "b" + ], + "hash": "1n1vw5f13e6x8e" + }, + { + "name": "users.byId", + "args": [ + { + "title": 0 + }, + { + "a": false + } + ], + "hash": "1f76afb11motje" + }, + { + "name": "users.byId", + "args": [ + { + "createdAt": -2147483649, + "id": 1.5 + } + ], + "hash": "1xkwb3s16ymibm" + }, + { + "name": "users.byId", + "args": [ + false + ], + "hash": "03vc65q13nvtph" + }, + { + "name": "issues.list", + "args": [ + { + "é": 0, + "id": false + } + ], + "hash": "1sfi0fs1nn26ra" + }, + { + "name": "a", + "args": [ + { + "closed": "b", + "id": "a" + } + ], + "hash": "184kt34142bqqk" + }, + { + "name": "a", + "args": [ + 0, + 1 + ], + "hash": "161izox1b52bw7" + }, + { + "name": "a", + "args": [ + { + "createdAt": -1, + "é": "a" + }, + { + "id": "b", + "closed": false + } + ], + "hash": "1eql9yr14oedvf" + }, + { + "name": "a", + "args": [], + "hash": "1o2vimr0oukr3l" + }, + { + "name": "issues.list", + "args": [ + 2147483648 + ], + "hash": "1pkwe231lgmoyq" + }, + { + "name": "a", + "args": [], + "hash": "1o2vimr0oukr3l" + }, + { + "name": "a", + "args": [ + { + "title": "é", + "ownerId": "😀" + } + ], + "hash": "0sk6i4j0cnxi6h" + }, + { + "name": "users.byId", + "args": [ + { + "é": 0, + "a": -2147483649 + } + ], + "hash": "183dcl619ybwxl" + }, + { + "name": "issues.list", + "args": [], + "hash": "13fodwg16cu4vt" + }, + { + "name": "a", + "args": [], + "hash": "1o2vimr0oukr3l" + }, + { + "name": "issues.list", + "args": [ + 1e+21, + 1e+21 + ], + "hash": "01exy7q00jxh3u" + }, + { + "name": "issues.list", + "args": [ + "é" + ], + "hash": "0k1fq9d1p6adqy" + }, + { + "name": "a", + "args": [ + "b", + "é" + ], + "hash": "07i67200rghwk4" + }, + { + "name": "users.byId", + "args": [ + { + "a": "a", + "createdAt": 1e+21 + } + ], + "hash": "0loo9cr02imnvd" + }, + { + "name": "a", + "args": [ + { + "é": 0, + "id": "😀" + }, + "1" + ], + "hash": "10h9yie110fuqp" + }, + { + "name": "issues.list", + "args": [ + { + "title": "é", + "closed": "1" + } + ], + "hash": "1g6lkz81hv8m4a" + }, + { + "name": "issues.list", + "args": [ + { + "a": -2147483649, + "createdAt": true + } + ], + "hash": "1m2ih0y06nbl6v" + }, + { + "name": "users.byId", + "args": [ + "a", + { + "é": "1", + "createdAt": -2147483649 + } + ], + "hash": "0gkqzfo0mq26d9" + }, + { + "name": "users.byId", + "args": [ + { + "createdAt": 0, + "ownerId": -2147483649 + } + ], + "hash": "0szaybm0x4ja8f" + }, + { + "name": "users.byId", + "args": [], + "hash": "1ke1bry0uo4aq6" + }, + { + "name": "issues.list", + "args": [], + "hash": "13fodwg16cu4vt" + }, + { + "name": "users.byId", + "args": [ + { + "closed": "", + "title": 2147483648 + } + ], + "hash": "0pklqty10t4mij" + }, + { + "name": "issues.list", + "args": [ + false, + "😀" + ], + "hash": "0eh8ob60oyirgp" + }, + { + "name": "users.byId", + "args": [ + 1.5 + ], + "hash": "0wdruz70o8npzd" + }, + { + "name": "a", + "args": [ + { + "closed": 1.5, + "id": 0 + } + ], + "hash": "162y35a0qrthsn" + }, + { + "name": "a", + "args": [ + { + "a": "é", + "ownerId": -2147483649 + } + ], + "hash": "10sfzmb1gylwh3" + }, + { + "name": "users.byId", + "args": [], + "hash": "1ke1bry0uo4aq6" + }, + { + "name": "issues.list", + "args": [ + 0 + ], + "hash": "18d8piq0y28v3b" + }, + { + "name": "a", + "args": [ + true, + "" + ], + "hash": "19u9kmw0zxlktj" + }, + { + "name": "users.byId", + "args": [ + 0, + { + "id": "é", + "a": 1e+21 + } + ], + "hash": "1jq2mye0m2de1c" + }, + { + "name": "issues.list", + "args": [ + { + "id": "é", + "é": -2147483649 + } + ], + "hash": "1mk3qt71ne4mym" + }, + { + "name": "users.byId", + "args": [], + "hash": "1ke1bry0uo4aq6" + }, + { + "name": "users.byId", + "args": [ + { + "createdAt": "a", + "a": "a" + } + ], + "hash": "05874160h4y98i" + }, + { + "name": "a", + "args": [], + "hash": "1o2vimr0oukr3l" + }, + { + "name": "users.byId", + "args": [ + 1.5 + ], + "hash": "0wdruz70o8npzd" + }, + { + "name": "issues.list", + "args": [ + { + "title": "é" + }, + { + "é": "é", + "a": "é" + } + ], + "hash": "0kznllo1mwkp94" + }, + { + "name": "issues.list", + "args": [], + "hash": "13fodwg16cu4vt" + }, + { + "name": "issues.list", + "args": [ + { + "id": 1e+21, + "é": "é" + } + ], + "hash": "1uwxokb1ypuwzk" + }, + { + "name": "issues.list", + "args": [ + { + "createdAt": 1, + "a": "😀" + } + ], + "hash": "0ib51kd0tbfyv8" + }, + { + "name": "issues.list", + "args": [ + { + "ownerId": "a", + "closed": -1 + } + ], + "hash": "17kv3cs1n4wjge" + }, + { + "name": "a", + "args": [ + { + "title": "", + "createdAt": -2147483649 + } + ], + "hash": "1d1309x0ir09av" + }, + { + "name": "issues.list", + "args": [ + { + "title": "b", + "createdAt": true + }, + 1 + ], + "hash": "0uep2de0tdvw33" + }, + { + "name": "a", + "args": [ + "a" + ], + "hash": "0cqx8hg1po6tkn" + }, + { + "name": "users.byId", + "args": [], + "hash": "1ke1bry0uo4aq6" + }, + { + "name": "users.byId", + "args": [ + { + "id": 1, + "createdAt": -2147483649 + } + ], + "hash": "0m2e1wn0yw4i83" + }, + { + "name": "a", + "args": [ + { + "a": -2147483649, + "ownerId": "é" + } + ], + "hash": "0ka79dc1a6wpwi" + }, + { + "name": "users.byId", + "args": [ + { + "title": "" + }, + "1" + ], + "hash": "0uccm3p1e8pw0c" + }, + { + "name": "a", + "args": [ + { + "closed": "😀", + "ownerId": "" + } + ], + "hash": "1jo98qz1wzimos" + }, + { + "name": "issues.list", + "args": [ + { + "id": -2147483649, + "ownerId": 0 + }, + "a" + ], + "hash": "0md85pg0nxnkgo" + }, + { + "name": "a", + "args": [ + "1", + true + ], + "hash": "1tchkzy0juko8e" + }, + { + "name": "users.byId", + "args": [ + 1 + ], + "hash": "1joh0fu1cdsjzh" + }, + { + "name": "a", + "args": [ + { + "é": "é", + "ownerId": 0 + }, + 1e+21 + ], + "hash": "1rlgnnw12o0w6n" + }, + { + "name": "a", + "args": [], + "hash": "1o2vimr0oukr3l" + }, + { + "name": "issues.list", + "args": [ + { + "createdAt": "é", + "title": 0 + } + ], + "hash": "1nv9cc8192g4zq" + }, + { + "name": "users.byId", + "args": [ + { + "createdAt": 2147483648, + "é": 0 + }, + { + "a": "b", + "closed": true + } + ], + "hash": "0usp7ot1lfercf" + }, + { + "name": "issues.list", + "args": [ + { + "ownerId": -1, + "a": "😀" + } + ], + "hash": "16gcx8w1f0dh59" + }, + { + "name": "users.byId", + "args": [], + "hash": "1ke1bry0uo4aq6" + } + ] +} diff --git a/packages/zero-conformance/vectors/number-format.json b/packages/zero-conformance/vectors/number-format.json new file mode 100644 index 0000000000..155b2f224e --- /dev/null +++ b/packages/zero-conformance/vectors/number-format.json @@ -0,0 +1,1779 @@ +{ + "formatVersion": 1, + "kind": "number-format", + "description": "`String(n)` for float64 values given as big-endian hex bit patterns.", + "cases": [ + { + "bits": "0000000000000000", + "string": "0" + }, + { + "bits": "8000000000000000", + "string": "0" + }, + { + "bits": "3ff0000000000000", + "string": "1" + }, + { + "bits": "bff0000000000000", + "string": "-1" + }, + { + "bits": "3fb999999999999a", + "string": "0.1" + }, + { + "bits": "3fe0000000000000", + "string": "0.5" + }, + { + "bits": "3ff8000000000000", + "string": "1.5" + }, + { + "bits": "bff8000000000000", + "string": "-1.5" + }, + { + "bits": "4059000000000000", + "string": "100" + }, + { + "bits": "419d6f3454000000", + "string": "123456789" + }, + { + "bits": "41dfffffffc00000", + "string": "2147483647" + }, + { + "bits": "41e0000000000000", + "string": "2147483648" + }, + { + "bits": "c1e0000000000000", + "string": "-2147483648" + }, + { + "bits": "41f0000000000000", + "string": "4294967296" + }, + { + "bits": "433fffffffffffff", + "string": "9007199254740991" + }, + { + "bits": "4340000000000000", + "string": "9007199254740992" + }, + { + "bits": "4340000000000001", + "string": "9007199254740994" + }, + { + "bits": "4415af1d78b58c40", + "string": "100000000000000000000" + }, + { + "bits": "444b1ae4d6e2ef50", + "string": "1e+21" + }, + { + "bits": "4454542ba12a337c", + "string": "1.5e+21" + }, + { + "bits": "441ac53a7e04bcda", + "string": "123456789012345680000" + }, + { + "bits": "3eb0c6f7a0b5ed8d", + "string": "0.000001" + }, + { + "bits": "3e7ad7f29abcaf48", + "string": "1e-7" + }, + { + "bits": "3e8421f5f40d8376", + "string": "1.5e-7" + }, + { + "bits": "3eb4b3fd5942cd96", + "string": "0.000001234" + }, + { + "bits": "3e808ffde1023e12", + "string": "1.234e-7" + }, + { + "bits": "0000000000000001", + "string": "5e-324" + }, + { + "bits": "7fefffffffffffff", + "string": "1.7976931348623157e+308" + }, + { + "bits": "0000000000000001", + "string": "5e-324" + }, + { + "bits": "3cb0000000000000", + "string": "2.220446049250313e-16" + }, + { + "bits": "400921fb54442d18", + "string": "3.141592653589793" + }, + { + "bits": "4005bf0a8b145769", + "string": "2.718281828459045" + }, + { + "bits": "3fd5555555555555", + "string": "0.3333333333333333" + }, + { + "bits": "3fe5555555555555", + "string": "0.6666666666666666" + }, + { + "bits": "3fd3333333333334", + "string": "0.30000000000000004" + }, + { + "bits": "7e37e43c8800759c", + "string": "1e+300" + }, + { + "bits": "01a56e1fc2f8f359", + "string": "1e-300" + }, + { + "bits": "7ff8000000000000", + "string": "NaN" + }, + { + "bits": "7ff0000000000000", + "string": "Infinity" + }, + { + "bits": "fff0000000000000", + "string": "-Infinity" + }, + { + "bits": "4011666666666666", + "string": "4.35" + }, + { + "bits": "3f201f31f46ed246", + "string": "0.000123" + }, + { + "bits": "40934a456d5cfaad", + "string": "1234.5678" + }, + { + "bits": "8ba1f5d7e267d50f", + "string": "-1.224863933913353e-252" + }, + { + "bits": "5f5ba5b79c9bc015", + "string": "2.2625129997237377e+151" + }, + { + "bits": "207a76c1eaa5cfa7", + "string": "3.158028106686359e-152" + }, + { + "bits": "e9b69a4e6e184984", + "string": "-1.730129375834768e+201" + }, + { + "bits": "af68731900a165ee", + "string": "-2.5775252554536925e-80" + }, + { + "bits": "2a8c08215f125a71", + "string": "9.777815957269319e-104" + }, + { + "bits": "0f0370da0f45d5b6", + "string": "2.388406009922739e-236" + }, + { + "bits": "1207beade78c79cf", + "string": "8.21112136778263e-222" + }, + { + "bits": "f3d112d06d419c3f", + "string": "-7.640095640723955e+249" + }, + { + "bits": "9187d177334fcda4", + "string": "-3.2173820493095736e-224" + }, + { + "bits": "984acd08dd63d776", + "string": "-1.1748499876433684e-191" + }, + { + "bits": "4890d635415295ee", + "string": "3.6667194742183836e+41" + }, + { + "bits": "9758259661825223", + "string": "-3.230300214443048e-196" + }, + { + "bits": "8b4ea6aacb7537f9", + "string": "-3.2661711530048663e-254" + }, + { + "bits": "8b15abe3e16293a7", + "string": "-2.8866330137237063e-255" + }, + { + "bits": "db63e0794d1ba324", + "string": "-1.7635821481997597e+132" + }, + { + "bits": "572312876a7ba5fa", + "string": "5.733413597135183e+111" + }, + { + "bits": "9ca6d8eb92864a6b", + "string": "-1.1824102839160235e-170" + }, + { + "bits": "e63931ca31d7bf45", + "string": "-2.676350328671548e+184" + }, + { + "bits": "99245722a2b904e1", + "string": "-1.4608701212317362e-187" + }, + { + "bits": "c6ca13fedc8d4621", + "string": "-1.0578537212120734e+33" + }, + { + "bits": "074a801e8f584f02", + "string": "1.5308317675664028e-273" + }, + { + "bits": "a236dc68aa1a5092", + "string": "-7.323130509875606e-144" + }, + { + "bits": "514dd1133ae73af8", + "string": "4.5253105416477725e+83" + }, + { + "bits": "4916491a4a715348", + "string": "1.2424613438338063e+44" + }, + { + "bits": "2e562fbd26a2cf2f", + "string": "1.784471614411758e-85" + }, + { + "bits": "6a45d1d483092556", + "string": "8.551353116505623e+203" + }, + { + "bits": "ab041288e4367562", + "string": "-1.792382937203266e-101" + }, + { + "bits": "03b473a335b5fa8f", + "string": "8.197733670907325e-291" + }, + { + "bits": "897c324736096531", + "string": "-5.5965121035298485e-263" + }, + { + "bits": "35179810783de8f0", + "string": "6.158321686017003e-53" + }, + { + "bits": "f0601122b2312493", + "string": "-1.995536665227719e+233" + }, + { + "bits": "21518f827c27b4d0", + "string": "3.433374019061734e-148" + }, + { + "bits": "9ed23c2aa6761b3b", + "string": "-3.24256504992436e-160" + }, + { + "bits": "adbda58157575f87", + "string": "-2.3285994108426707e-88" + }, + { + "bits": "948925fe4636110e", + "string": "-9.56188647518886e-210" + }, + { + "bits": "c4d665321ab7015c", + "string": "-4.230351766953785e+23" + }, + { + "bits": "4ff46959984901e9", + "string": "1.4771830839187838e+77" + }, + { + "bits": "efb4f510be1ebbf8", + "string": "-1.2709596407214192e+230" + }, + { + "bits": "bd5f12ae11c3fb28", + "string": "-4.4157344217703435e-13" + }, + { + "bits": "603cacc3be15dc9f", + "string": "3.8446703934945317e+155" + }, + { + "bits": "81a591dd6f00326c", + "string": "-1.0065147827036832e-300" + }, + { + "bits": "d1e9a09bfedd1fb1", + "string": "-3.9828411603674435e+86" + }, + { + "bits": "69b19d5c034433f1", + "string": "1.3483169939033076e+201" + }, + { + "bits": "e1737146d40070a4", + "string": "-2.7334397803553726e+161" + }, + { + "bits": "7119db091609f8e8", + "string": "6.576750307677054e+236" + }, + { + "bits": "4f298d303c8e0114", + "string": "2.2572811654475873e+73" + }, + { + "bits": "c0564db0d30d3ea3", + "string": "-89.21391750615525" + }, + { + "bits": "4ae4ed1ff87398ad", + "string": "6.263557432440174e+52" + }, + { + "bits": "e59bc4928217aa26", + "string": "-2.880579240352805e+181" + }, + { + "bits": "a65624b2bd415c15", + "string": "-5.233896927411385e-124" + }, + { + "bits": "0d31613c85e6d66a", + "string": "3.977123308545267e-245" + }, + { + "bits": "98b067c008e4ed02", + "string": "-9.205051610960223e-190" + }, + { + "bits": "985c958af8b3ae5c", + "string": "-2.5060404447806254e-191" + }, + { + "bits": "51f7e4be5887e656", + "string": "7.426754621075819e+86" + }, + { + "bits": "20eab1cdff95ffae", + "string": "4.0775075042180087e-150" + }, + { + "bits": "55741b7f91baa18b", + "string": "4.503547770219673e+103" + }, + { + "bits": "2033634aeb1c5425", + "string": "1.4460128020465266e-153" + }, + { + "bits": "63a0b9d4d013db86", + "string": "8.079735217834511e+171" + }, + { + "bits": "292aef23cee561a5", + "string": "2.2399313874899917e-110" + }, + { + "bits": "7925e75e44ebaf90", + "string": "3.7917977277497066e+275" + }, + { + "bits": "90d416cd61e048ac", + "string": "-1.3250221817639945e-227" + }, + { + "bits": "5478780b8372e96e", + "string": "8.362449286224318e+98" + }, + { + "bits": "8d30aeeec36125d6", + "string": "-3.817739555653112e-245" + }, + { + "bits": "292aaa6f20a3a9a8", + "string": "2.217611863196458e-110" + }, + { + "bits": "9bd8a8a1ef07368f", + "string": "-1.557804911805271e-174" + }, + { + "bits": "73c28466056f5131", + "string": "4.1430608447828975e+249" + }, + { + "bits": "543d699ff41b7b09", + "string": "6.2824925772397506e+97" + }, + { + "bits": "5266945870194cde", + "string": "8.983437849933416e+88" + }, + { + "bits": "d7fcf7bda3e9d18e", + "string": "-7.133668560980713e+115" + }, + { + "bits": "4c2e731d2271f802", + "string": "9.556781719621864e+58" + }, + { + "bits": "a1e9f3296c52aa2c", + "string": "-2.5976878821519518e-145" + }, + { + "bits": "d64490f7925b6eb6", + "string": "-3.773498345091815e+107" + }, + { + "bits": "feab41b50ff30115", + "string": "-1.460287774058065e+302" + }, + { + "bits": "cbc98509a4b47a0d", + "string": "-1.2514813870923687e+57" + }, + { + "bits": "38f8d1ea9b51ddd8", + "string": "2.987597148796468e-34" + }, + { + "bits": "0647382778b13ab0", + "string": "2.0466525468254807e-278" + }, + { + "bits": "552cc6d8b2b56273", + "string": "2.0141425369889758e+102" + }, + { + "bits": "623b650b2f96c1c9", + "string": "1.5775539510357428e+165" + }, + { + "bits": "0cf536fb6204218b", + "string": "3.0341842577873404e-246" + }, + { + "bits": "e259fc369f9cdb5d", + "string": "-5.98554690160406e+165" + }, + { + "bits": "78411ed85960ee08", + "string": "1.8089321128440104e+271" + }, + { + "bits": "a3359862e2a12e33", + "string": "-4.5335710498805594e-139" + }, + { + "bits": "f768e7a33a4c4f63", + "string": "-1.6060898213253478e+267" + }, + { + "bits": "4725649a55a34a24", + "string": "5.55393513151273e+34" + }, + { + "bits": "8c5647275f663275", + "string": "-3.111563849290727e-249" + }, + { + "bits": "2213afbdf92a028d", + "string": "1.5765597545066407e-144" + }, + { + "bits": "8a6f51d6b129c55a", + "string": "-2.0370002383420795e-258" + }, + { + "bits": "f4c7572d47dc91e7", + "string": "-3.4224503299675466e+254" + }, + { + "bits": "1669b2d25558e67a", + "string": "1.0491583613306504e-200" + }, + { + "bits": "685c492951cce2b3", + "string": "5.162088892234756e+194" + }, + { + "bits": "3e2640f1e06073c9", + "string": "2.590670569574174e-9" + }, + { + "bits": "6431882bba6e4381", + "string": "4.336176129578861e+174" + }, + { + "bits": "55f766114c1306a0", + "string": "1.3416222338594508e+106" + }, + { + "bits": "a5204a5ef776c232", + "string": "-7.344235598416333e-130" + }, + { + "bits": "28ad867d6858cc63", + "string": "9.591503838386415e-113" + }, + { + "bits": "37b263fe4852bd0d", + "string": "2.111137206022432e-40" + }, + { + "bits": "12da7dd202f163ae", + "string": "7.50463309557668e-218" + }, + { + "bits": "2a0cd43620c13e36", + "string": "3.928080488848726e-106" + }, + { + "bits": "fe29a2959c4417f1", + "string": "-5.364887761525993e+299" + }, + { + "bits": "c426bb8a3e8a6137", + "string": "-209671026008773070000" + }, + { + "bits": "558d15d9999af40b", + "string": "1.3028752647441615e+104" + }, + { + "bits": "509202bbb3743a79", + "string": "1.3347160773862908e+80" + }, + { + "bits": "47f1ed858bdd7027", + "string": "3.812825428086395e+38" + }, + { + "bits": "2128828e69711ef2", + "string": "5.990115685857735e-149" + }, + { + "bits": "98c1d135d1f3d4ec", + "string": "-1.999460647116359e-189" + }, + { + "bits": "32c16c927df55c58", + "string": "3.3090363935387585e-64" + }, + { + "bits": "caa57489f3eee277", + "string": "-4.0136772819238955e+51" + }, + { + "bits": "7d0685ccc236a751", + "string": "1.7980649584863443e+294" + }, + { + "bits": "ffa7f3015681daea", + "string": "-8.408863834401784e+306" + }, + { + "bits": "2220242339659fc6", + "string": "2.585275882823633e-144" + }, + { + "bits": "0d463c9cddb925ff", + "string": "1.0177127380676609e-244" + }, + { + "bits": "5e030ac387529c7d", + "string": "7.430559948544484e+144" + }, + { + "bits": "2d4bd5d562254512", + "string": "1.7080753325777562e-90" + }, + { + "bits": "cea6e44123810e9f", + "string": "-7.899607368700643e+70" + }, + { + "bits": "5cc59971ed136ac9", + "string": "8.038029534236268e+138" + }, + { + "bits": "514db87286db4107", + "string": "4.51070984628727e+83" + }, + { + "bits": "a34a9008b36630e6", + "string": "-1.1152779749681322e-138" + }, + { + "bits": "b6ec9b1afcd9d215", + "string": "-4.0085376484815633e-44" + }, + { + "bits": "2ab427c6ea94f320", + "string": "5.62435157030532e-103" + }, + { + "bits": "b2836a7bd10d6f1f", + "string": "-2.3045685698137596e-65" + }, + { + "bits": "fc62d88b2df91aa0", + "string": "-1.4692716571322766e+291" + }, + { + "bits": "08d1fe3443c5e863", + "string": "3.4876031362529646e-266" + }, + { + "bits": "dddafee7b69b88af", + "string": "-1.316779057087643e+144" + }, + { + "bits": "5174c36d0718cbd5", + "string": "2.5210235655614375e+84" + }, + { + "bits": "0efab43c11bfef7e", + "string": "1.640364690785387e-236" + }, + { + "bits": "acfb74650da75f90", + "string": "-5.2647471142054075e-92" + }, + { + "bits": "5e5905fcfbdc3c70", + "string": "3.124669442678499e+146" + }, + { + "bits": "3c1ae355bad89e05", + "string": "3.644007145181031e-19" + }, + { + "bits": "fa1dfe376ffba454", + "string": "-1.7013606191402286e+280" + }, + { + "bits": "da2aada3ae110d3d", + "string": "-2.2573869366967858e+126" + }, + { + "bits": "671706da8a0cdc64", + "string": "4.007655354483591e+188" + }, + { + "bits": "ceed24818cc1d1cb", + "string": "-1.6090787626134757e+72" + }, + { + "bits": "6fb6176d3fb0927a", + "string": "1.3397449270378739e+230" + }, + { + "bits": "bab92e8cbc2380b8", + "string": "-8.136689708798726e-26" + }, + { + "bits": "17dae9253b012b10", + "string": "9.216121437764596e-194" + }, + { + "bits": "fb2bf69ac1b02237", + "string": "-2.0790949139236676e+285" + }, + { + "bits": "8d14976e297036ed", + "string": "-1.1780183397643533e-245" + }, + { + "bits": "8b537f4d1544dc80", + "string": "-4.155253011420579e-254" + }, + { + "bits": "c42cbd0b3c27c53d", + "string": "-265065440851645070000" + }, + { + "bits": "ff2cd285200116be", + "string": "-3.95307385464673e+304" + }, + { + "bits": "05a1cddb95a36efe", + "string": "1.532550748436586e-281" + }, + { + "bits": "eaa9e0fdaf1d32af", + "string": "-6.491011522854307e+205" + }, + { + "bits": "60e560d68f6c9a33", + "string": "5.870300993984589e+158" + }, + { + "bits": "b570d3b22e7d9e7c", + "string": "-2.8109028098429886e-51" + }, + { + "bits": "618132d0e6d454e4", + "string": "4.835910580324673e+161" + }, + { + "bits": "eb500183d5a3a5f9", + "string": "-8.22200227592631e+208" + }, + { + "bits": "e86276673df0e956", + "string": "-6.738729481412595e+194" + }, + { + "bits": "8823ede7b8d6275f", + "string": "-1.886193768138007e-269" + }, + { + "bits": "d6474165c32706c9", + "string": "-4.266909046069389e+107" + }, + { + "bits": "a0ae2239a21ec80d", + "string": "-2.8767659748395472e-151" + }, + { + "bits": "4e0c60495db804fc", + "string": "9.56273334673647e+67" + }, + { + "bits": "461d4e920e2e41de", + "string": "5.804832784539554e+29" + }, + { + "bits": "89e5070068d6aaf4", + "string": "-5.342177471800518e-261" + }, + { + "bits": "82aecf6eb54fca50", + "string": "-9.422148803596803e-296" + }, + { + "bits": "0b71de58906a0a0a", + "string": "1.5232553112752574e-253" + }, + { + "bits": "b0134e95efe08838", + "string": "-4.168457336880042e-77" + }, + { + "bits": "341976b25af119d2", + "string": "1.014148728921239e-57" + }, + { + "bits": "cbff7b0149ca55ea", + "string": "-1.235038927566493e+58" + }, + { + "bits": "32e3fe669fb8ac2d", + "string": "1.518816320476278e-63" + }, + { + "bits": "fd8e1d01c6a128c2", + "string": "-6.1543787166156505e+296" + }, + { + "bits": "4aacbcdcc626d111", + "string": "5.3760332205523304e+51" + }, + { + "bits": "7284e1ae7a623d82", + "string": "4.455635220229809e+243" + }, + { + "bits": "ad6c5c3e698fe727", + "string": "-6.9611748801675945e-90" + }, + { + "bits": "189279b833d6f63f", + "string": "2.591660877043894e-190" + }, + { + "bits": "97ce30afc45d94e9", + "string": "-5.1696189952480134e-194" + }, + { + "bits": "e42777cf50951ca0", + "string": "-2.9021758116815655e+174" + }, + { + "bits": "434f30a9f898e7af", + "string": "17558461712813918" + }, + { + "bits": "a6293f260ee86a8d", + "string": "-7.459264178411882e-125" + }, + { + "bits": "954bdb9758d4e24e", + "string": "-4.33849852258794e-206" + }, + { + "bits": "6b078d004170093b", + "string": "3.7805276635499495e+207" + }, + { + "bits": "5852c81825f62fe0", + "string": "2.960133789905025e+117" + }, + { + "bits": "218dce6bb52e636d", + "string": "4.6620899534259807e-147" + }, + { + "bits": "8aa7e7a7f0f1fd83", + "string": "-2.4876012262879067e-257" + }, + { + "bits": "bd2afd3bbc145b88", + "string": "-4.79424370939934e-14" + }, + { + "bits": "0356be26e3cc3e0f", + "string": "1.4243863100634628e-292" + }, + { + "bits": "9187ad529cb2a3ae", + "string": "-3.1983109139580095e-224" + }, + { + "bits": "e7e120f426f8490f", + "string": "-2.442149775005723e+192" + }, + { + "bits": "5b9d7a17c6084823", + "string": "2.0922822997148417e+133" + }, + { + "bits": "85d8a4ebfaa36262", + "string": "-1.6970679370805523e-280" + }, + { + "bits": "449e4b43f075490c", + "string": "3.576484895834228e+22" + }, + { + "bits": "6231173670c0fbf6", + "string": "9.841852290539154e+164" + }, + { + "bits": "2ad948a622c6f09f", + "string": "2.822172606963802e-102" + }, + { + "bits": "1420bc36a6722281", + "string": "9.942238903601988e-212" + }, + { + "bits": "cb727623e1f1ed2c", + "string": "-2.8292143478559793e+55" + }, + { + "bits": "7c34dc9acf4b3598", + "string": "2.0330417992684585e+290" + }, + { + "bits": "f125afeeecd183b5", + "string": "-1.1032930428346815e+237" + }, + { + "bits": "192c96696200ecb3", + "string": "2.0531920739858597e-187" + }, + { + "bits": "6ca3bdb68d310c52", + "string": "2.126657413671625e+215" + }, + { + "bits": "e52cf5632a08d4ac", + "string": "-2.3469517722373423e+179" + }, + { + "bits": "fa356b513d6a07bb", + "string": "-4.860034945082597e+280" + }, + { + "bits": "78ef10da03ff027a", + "string": "3.3611584156926683e+274" + }, + { + "bits": "0f476d9c30c94385", + "string": "4.605233554984736e-235" + }, + { + "bits": "6c8c42484ce7830a", + "string": "7.610661739523988e+214" + }, + { + "bits": "2aa9d2b9f8bef3b7", + "string": "3.6029705913717957e-103" + }, + { + "bits": "4e9fdc5bb151b417", + "string": "5.4973745345172886e+70" + }, + { + "bits": "0e9d26231e41f5c0", + "string": "2.7977358385914453e-238" + }, + { + "bits": "3fe6e9c34aa4814e", + "string": "0.7160355050910228" + }, + { + "bits": "213ee94d364cb8fa", + "string": "1.5109145526944213e-148" + }, + { + "bits": "c23c4c4300286a91", + "string": "-121538543656.41628" + }, + { + "bits": "a6705b4f086cb53c", + "string": "-1.5464532759624746e-123" + }, + { + "bits": "17c79ccd03f35b73", + "string": "4.043289488941283e-194" + }, + { + "bits": "6cdc1fb879788569", + "string": "2.4237765649646462e+216" + }, + { + "bits": "2b964b7d5a022d63", + "string": "1.0193107361191783e-98" + }, + { + "bits": "529a61e3ffec7b65", + "string": "8.397166858510373e+89" + }, + { + "bits": "e5a0457619e43504", + "string": "-3.375907549038865e+181" + }, + { + "bits": "113934b16d08edfc", + "string": "1.0640065543259054e-225" + }, + { + "bits": "0396f7581e1013d0", + "string": "2.3014053985786212e-291" + }, + { + "bits": "243845bad55d8e95", + "string": "3.3394453667642313e-134" + }, + { + "bits": "1846e4497655888c", + "string": "1.0034868555415815e-191" + }, + { + "bits": "e764d8ddf652eaf8", + "string": "-1.1610574223270815e+190" + }, + { + "bits": "d6451ee6b23e0fcd", + "string": "-3.875225100306009e+107" + }, + { + "bits": "568107f2be573df1", + "string": "4.999767600589803e+108" + }, + { + "bits": "740251a3d956c80a", + "string": "6.557917498871288e+250" + }, + { + "bits": "965783120f3062d3", + "string": "-4.799459963128548e-201" + }, + { + "bits": "2bf692ea6ee05720", + "string": "6.605227842491428e-97" + }, + { + "bits": "a9a828259588ac7b", + "string": "-5.142939213726734e-108" + }, + { + "bits": "a3ddbd2df559f745", + "string": "-6.393049604215246e-136" + }, + { + "bits": "32072d6f7ff1cb35", + "string": "1.0746257627946942e-67" + }, + { + "bits": "2e9093a0b2eeafc7", + "string": "2.133234192376747e-84" + }, + { + "bits": "91ee4a433842913d", + "string": "-2.618627659311544e-222" + }, + { + "bits": "11847d370dea172b", + "string": "2.7676844445416825e-224" + }, + { + "bits": "7be9777aba1ec634", + "string": "7.755660884755292e+288" + }, + { + "bits": "314e0af21bf18381", + "string": "3.4007195519526853e-71" + }, + { + "bits": "1270326889a2db16", + "string": "7.169275654548098e-220" + }, + { + "bits": "0146eb57dcc10a26", + "string": "1.6710748160426072e-302" + }, + { + "bits": "f7e2311e987129ba", + "string": "-3.003333459313989e+269" + }, + { + "bits": "dae1f726ff6b177d", + "string": "-6.226528465601157e+129" + }, + { + "bits": "49c847e1989e5c42", + "string": "2.772375672132386e+47" + }, + { + "bits": "de14ff1b3dd7add8", + "string": "-1.6386455713552436e+145" + }, + { + "bits": "4d97f5bb1c625432", + "string": "6.308176345242204e+65" + }, + { + "bits": "fc4adba4649ae60a", + "string": "-5.234788428149059e+290" + }, + { + "bits": "6aaa18b14429788e", + "string": "6.54558654578185e+205" + }, + { + "bits": "5598a02e3af6d1f5", + "string": "2.2062117693700776e+104" + }, + { + "bits": "960f006dd88b2fb3", + "string": "-1.9775983977546798e-202" + }, + { + "bits": "4f0fc0c84556fb8f", + "string": "7.0128491997972306e+72" + }, + { + "bits": "4b585ee99c41f9e1", + "string": "9.337017234619754e+54" + }, + { + "bits": "29ca005921e8c1b6", + "string": "2.2142549378570653e-107" + }, + { + "bits": "ec8ffc6a0d4f4ff7", + "string": "-8.614434371037892e+214" + }, + { + "bits": "7117915f026d3992", + "string": "5.9948274163823735e+236" + }, + { + "bits": "f76e74e43e23591b", + "string": "-1.9641185655373888e+267" + }, + { + "bits": "84cdd09f7f639441", + "string": "-1.5664191074034147e-285" + }, + { + "bits": "c52c82fa08d4a408", + "string": "-1.723422145715745e+25" + }, + { + "bits": "f89d19cacd2e0dc4", + "string": "-9.83921118453965e+272" + }, + { + "bits": "c174ab964dd5bb93", + "string": "-21674340.864680838" + }, + { + "bits": "e16cebf66167cddf", + "string": "-2.0330681569010735e+161" + }, + { + "bits": "8cf808368e08eef8", + "string": "-3.4371221034401276e-246" + }, + { + "bits": "ae3e525d04b96b9d", + "string": "-6.09698675410137e-86" + }, + { + "bits": "c43ff443a6501312", + "string": "-589450160916537700000" + }, + { + "bits": "f5faba091405f70d", + "string": "-2.0546678549040736e+260" + }, + { + "bits": "526a2131a8f07656", + "string": "1.0395911253516284e+89" + }, + { + "bits": "19175aa7215453b9", + "string": "8.386603018356909e-188" + }, + { + "bits": "5a3401616167cbb0", + "string": "3.3855190769334124e+126" + }, + { + "bits": "2caa1c0f005cbdb2", + "string": "1.5646299548807952e-93" + }, + { + "bits": "8ab6bc584da2d10e", + "string": "-4.731866349618061e-257" + }, + { + "bits": "3ea5687b437394c0", + "string": "6.380120173536747e-7" + }, + { + "bits": "d2fb9db96d33fcb5", + "string": "-5.625501088990201e+91" + }, + { + "bits": "439d21639b433671", + "string": "524767104957520960" + }, + { + "bits": "e2bf887dee919618", + "string": "-4.648632993543964e+167" + }, + { + "bits": "66c8329626099eeb", + "string": "1.316072099780463e+187" + }, + { + "bits": "cddb55842e415c15", + "string": "-1.1514445012261027e+67" + }, + { + "bits": "b4cffaa9b68365c8", + "string": "-2.6084213021528167e-54" + }, + { + "bits": "207601ac96b3b981", + "string": "2.626116347638352e-152" + }, + { + "bits": "0e186904a060f152", + "string": "9.151984856280553e-241" + }, + { + "bits": "b955071589d4299a", + "string": "-1.619912957421946e-32" + }, + { + "bits": "8cb427862e0bcb56", + "string": "-1.8015787736830532e-247" + }, + { + "bits": "2ebeb404181011f1", + "string": "1.580464256790177e-83" + }, + { + "bits": "4234d055c2c38ad3", + "string": "89394627267.54228" + }, + { + "bits": "cd8068d950312dab", + "string": "-2.160161242646075e+65" + }, + { + "bits": "83eaf2cad158e928", + "string": "-8.641463227204146e-290" + }, + { + "bits": "bc194382b3c80618", + "string": "-3.423871579644678e-19" + }, + { + "bits": "04787a8372ddd15e", + "string": "4.018927368670986e-287" + }, + { + "bits": "8f1bbf250723d8a3", + "string": "-6.8176405667487725e-236" + }, + { + "bits": "ea9f94b57fc756e1", + "string": "-3.960604589853147e+205" + }, + { + "bits": "e0c28827872449d7", + "string": "-1.272174221272416e+158" + }, + { + "bits": "a4f79d5f1d2dc4f1", + "string": "-1.3307758845329281e-130" + }, + { + "bits": "1ab8105cc41da13b", + "string": "5.799210799079424e-180" + }, + { + "bits": "0ffea09f92aa9e70", + "string": "1.2329741534636864e-231" + }, + { + "bits": "9ec1d7a82b947fc7", + "string": "-1.586374901939994e-160" + }, + { + "bits": "703184e040c44a33", + "string": "2.719863716211805e+232" + }, + { + "bits": "f64bf7fa71cd9c1c", + "string": "-6.880468169421749e+261" + }, + { + "bits": "977d90177b5d35bb", + "string": "-1.5819372367381834e-195" + }, + { + "bits": "8a34a11a9de0235b", + "string": "-1.6771368398925407e-259" + }, + { + "bits": "0e385405a062b4d4", + "string": "3.648494030503052e-240" + }, + { + "bits": "f5c7008b2ebdeb70", + "string": "-2.210417039478717e+259" + }, + { + "bits": "7cc095f99cedfe1d", + "string": "8.275672030935998e+292" + }, + { + "bits": "9638040701360aaa", + "string": "-1.2255717456805557e-201" + }, + { + "bits": "7cc363f351176994", + "string": "9.675051186359778e+292" + }, + { + "bits": "3433be501d28d91e", + "string": "3.1453066675450596e-57" + }, + { + "bits": "430d216ea25fcf19", + "string": "1024941672364515.1" + }, + { + "bits": "0bbea6b64c392bdc", + "string": "4.180723016414443e-252" + }, + { + "bits": "1bab18d1b3be89ee", + "string": "2.1397988182570323e-175" + }, + { + "bits": "779ba05d94cc5d05", + "string": "1.4252823040665339e+268" + }, + { + "bits": "287a51a9b150ae96", + "string": "1.068737278928392e-113" + }, + { + "bits": "95db34388162a66c", + "string": "-2.1691795512878474e-203" + }, + { + "bits": "cd0cd4408499f43e", + "string": "-1.4824509690648582e+63" + }, + { + "bits": "0ff639b7ec2fe49e", + "string": "8.947344477202354e-232" + }, + { + "bits": "c370d11171b889f0", + "string": "-75735559679024900" + }, + { + "bits": "3de9425a07086633", + "string": "1.8378476823420885e-10" + }, + { + "bits": "f67129a462706e7a", + "string": "-3.377699042167709e+262" + }, + { + "bits": "40f0d13b0902de01", + "string": "68883.6897" + }, + { + "bits": "41bf00b9e5000000", + "string": "520141285" + }, + { + "bits": "41787d76d0000000", + "string": "25679725" + }, + { + "bits": "40e8d8992ca57a78", + "string": "50884.7867" + }, + { + "bits": "3fd2636a27e6f9ca", + "string": "0.28731779" + }, + { + "bits": "40e92c5ccccccccd", + "string": "51554.9" + }, + { + "bits": "4085fd1eaa2a0a96", + "string": "703.639973" + }, + { + "bits": "4010b60ca0f832ac", + "string": "4.17778255" + }, + { + "bits": "3fb71b814f02c416", + "string": "0.0902634447" + }, + { + "bits": "4053df8cb07d0aee", + "string": "79.492962" + }, + { + "bits": "40472427e29d18aa", + "string": "46.2824672" + }, + { + "bits": "4159b301e8f5c28f", + "string": "6736903.64" + }, + { + "bits": "41a6c89dc6000000", + "string": "191123171" + }, + { + "bits": "40e8b0711de69ad4", + "string": "50563.5349" + }, + { + "bits": "4156eec636666666", + "string": "6011672.85" + }, + { + "bits": "407ea5c1a1554fbe", + "string": "490.359773" + }, + { + "bits": "40527fed6c142551", + "string": "73.9988661" + }, + { + "bits": "4055b36aa4b9884c", + "string": "86.803384" + }, + { + "bits": "4022f085cd5eedf7", + "string": "9.46977083" + }, + { + "bits": "4124304089374bc7", + "string": "661536.268" + }, + { + "bits": "4089feb5e071c53f", + "string": "831.838807" + }, + { + "bits": "40564c36c29f4c61", + "string": "89.1908423" + }, + { + "bits": "4148ceba88f5c28f", + "string": "3251573.07" + }, + { + "bits": "40a47874cad57bc8", + "string": "2620.22811" + }, + { + "bits": "41c6e8f8a1800000", + "string": "768733507" + }, + { + "bits": "409551bb9628cbd1", + "string": "1364.43319" + }, + { + "bits": "401548655da7d0ea", + "string": "5.32069918" + }, + { + "bits": "3fc29161559f32a5", + "string": "0.145061652" + }, + { + "bits": "400b6b1ae38621c3", + "string": "3.42729738" + }, + { + "bits": "4021ee2db214e595", + "string": "8.96519238" + }, + { + "bits": "408c7cde1437c569", + "string": "911.608437" + }, + { + "bits": "3f647fb91bb39b69", + "string": "0.00250230936" + }, + { + "bits": "3f81df8977696c4b", + "string": "0.0087271442" + }, + { + "bits": "40e2ceee6b50b0f2", + "string": "38519.4506" + }, + { + "bits": "3fd9f44da60c54de", + "string": "0.405536091" + }, + { + "bits": "40c269858b2e9ccb", + "string": "9427.04331" + }, + { + "bits": "4087f2e52a411c2a", + "string": "766.361897" + }, + { + "bits": "40556cc22c5fda5b", + "string": "85.6993514" + }, + { + "bits": "4128700c9eb851ec", + "string": "800774.31" + }, + { + "bits": "41933383c6000000", + "string": "80535793.5" + }, + { + "bits": "3f82443359cb57c0", + "string": "0.0089191448" + }, + { + "bits": "3f7d552fc270697e", + "string": "0.00716131836" + }, + { + "bits": "40461b21342d2e36", + "string": "44.2119508" + }, + { + "bits": "4136ff0c4ccccccd", + "string": "1507084.3" + }, + { + "bits": "41191b683f7ced91", + "string": "411354.062" + }, + { + "bits": "40104708090df0ae", + "string": "4.06936659" + }, + { + "bits": "3f377dc04a327e2d", + "string": "0.0003584475" + }, + { + "bits": "40eb1e2c1cac0831", + "string": "55537.3785" + }, + { + "bits": "4020c2309afd71db", + "string": "8.37927708" + }, + { + "bits": "410661df9999999a", + "string": "183355.95" + }, + { + "bits": "410ab4e7189374bc", + "string": "218780.887" + }, + { + "bits": "412d1fe7e7ef9db2", + "string": "954355.953" + }, + { + "bits": "41a4a55ad4000000", + "string": "173190506" + }, + { + "bits": "40af131d2f1a9fbe", + "string": "3977.557" + }, + { + "bits": "401d2eaa75dfd614", + "string": "7.29557213" + }, + { + "bits": "41c39ce946800000", + "string": "658100877" + }, + { + "bits": "410ed7129db22d0e", + "string": "252642.327" + }, + { + "bits": "408146e5de9c022a", + "string": "552.862241" + }, + { + "bits": "40ac5399e30014f9", + "string": "3625.80056" + }, + { + "bits": "41784ed426666666", + "string": "25488706.4" + }, + { + "bits": "412418c6fae147ae", + "string": "658531.49" + }, + { + "bits": "4138cb35bae147ae", + "string": "1624885.73" + }, + { + "bits": "41c02f4518800000", + "string": "543066673" + }, + { + "bits": "4121ad1dc9374bc7", + "string": "579214.893" + }, + { + "bits": "4046bae7e62dc6e3", + "string": "45.460202" + }, + { + "bits": "4083700ac9f2fdb9", + "string": "622.005268" + }, + { + "bits": "40511c0950763a19", + "string": "68.4380685" + }, + { + "bits": "419564857799999a", + "string": "89727325.9" + }, + { + "bits": "40e7a18fdcc63f14", + "string": "48396.4957" + }, + { + "bits": "40f7008872b020c5", + "string": "94216.528" + }, + { + "bits": "4123bf5bf851eb85", + "string": "647085.985" + }, + { + "bits": "414516bd67ae147b", + "string": "2764154.81" + }, + { + "bits": "3f0adac218ecc884", + "string": "0.00005122094" + }, + { + "bits": "40f06cebebedfa44", + "string": "67278.7451" + }, + { + "bits": "402278550fd1da28", + "string": "9.23502397" + }, + { + "bits": "3fb6c6e5a111da89", + "string": "0.0889724272" + }, + { + "bits": "4084de5f4d37c137", + "string": "667.796534" + }, + { + "bits": "411314d654fdf3b6", + "string": "312629.583" + }, + { + "bits": "400ef763ee111526", + "string": "3.87079607" + }, + { + "bits": "40425de775c57efb", + "string": "36.7336261" + }, + { + "bits": "41cb538224800000", + "string": "916915273" + }, + { + "bits": "4158aa44b3333333", + "string": "6465810.8" + }, + { + "bits": "408728090ff97247", + "string": "741.004425" + }, + { + "bits": "412989aace560419", + "string": "836821.403" + }, + { + "bits": "41038228c28f5c29", + "string": "159813.095" + }, + { + "bits": "408e04a05ee568a5", + "string": "960.578306" + }, + { + "bits": "40bc4bef3a14cec4", + "string": "7243.93448" + }, + { + "bits": "40833fc1a9b8cb8e", + "string": "615.969562" + }, + { + "bits": "40e8058c3fe5c91d", + "string": "49196.3828" + }, + { + "bits": "3f6ca7d4f26a7e14", + "string": "0.0034979972" + }, + { + "bits": "408860c238da3c21", + "string": "780.094835" + }, + { + "bits": "3f7106a91c857b0b", + "string": "0.00415674266" + }, + { + "bits": "4161c25f20000000", + "string": "9310969" + }, + { + "bits": "407d101482385404", + "string": "465.005007" + }, + { + "bits": "401ec17fc0aa7f9b", + "string": "7.6889639" + }, + { + "bits": "3f7bf7beb5ac4d92", + "string": "0.00682806488" + }, + { + "bits": "3f754e9fbed85d9c", + "string": "0.00520193482" + }, + { + "bits": "414a9e6dd3333333", + "string": "3488987.65" + }, + { + "bits": "401969a7af5bf8a4", + "string": "6.35317873" + }, + { + "bits": "41be41bdef000000", + "string": "507624943" + } + ] +} diff --git a/packages/zero-conformance/vectors/number-parse.json b/packages/zero-conformance/vectors/number-parse.json new file mode 100644 index 0000000000..daee938e6d --- /dev/null +++ b/packages/zero-conformance/vectors/number-parse.json @@ -0,0 +1,187 @@ +{ + "formatVersion": 1, + "kind": "number-parse", + "description": "`Number(s)` as a big-endian hex float64 bit pattern.", + "cases": [ + { + "input": "", + "bits": "0000000000000000" + }, + { + "input": " ", + "bits": "0000000000000000" + }, + { + "input": "0", + "bits": "0000000000000000" + }, + { + "input": "-0", + "bits": "8000000000000000" + }, + { + "input": "+0", + "bits": "0000000000000000" + }, + { + "input": "1", + "bits": "3ff0000000000000" + }, + { + "input": "-1", + "bits": "bff0000000000000" + }, + { + "input": "+1", + "bits": "3ff0000000000000" + }, + { + "input": "1.5", + "bits": "3ff8000000000000" + }, + { + "input": ".5", + "bits": "3fe0000000000000" + }, + { + "input": "5.", + "bits": "4014000000000000" + }, + { + "input": "1e3", + "bits": "408f400000000000" + }, + { + "input": "1E3", + "bits": "408f400000000000" + }, + { + "input": "1e+3", + "bits": "408f400000000000" + }, + { + "input": "1e-3", + "bits": "3f50624dd2f1a9fc" + }, + { + "input": "1e", + "bits": "7ff8000000000000" + }, + { + "input": "e3", + "bits": "7ff8000000000000" + }, + { + "input": " 42 ", + "bits": "4045000000000000" + }, + { + "input": "\t42\n", + "bits": "4045000000000000" + }, + { + "input": " 42
", + "bits": "4045000000000000" + }, + { + "input": "0x10", + "bits": "4030000000000000" + }, + { + "input": "0X1f", + "bits": "403f000000000000" + }, + { + "input": "0o17", + "bits": "402e000000000000" + }, + { + "input": "0b101", + "bits": "4014000000000000" + }, + { + "input": "-0x10", + "bits": "7ff8000000000000" + }, + { + "input": "0x", + "bits": "7ff8000000000000" + }, + { + "input": "0xg", + "bits": "7ff8000000000000" + }, + { + "input": "Infinity", + "bits": "7ff0000000000000" + }, + { + "input": "-Infinity", + "bits": "fff0000000000000" + }, + { + "input": "+Infinity", + "bits": "7ff0000000000000" + }, + { + "input": "infinity", + "bits": "7ff8000000000000" + }, + { + "input": "NaN", + "bits": "7ff8000000000000" + }, + { + "input": "abc", + "bits": "7ff8000000000000" + }, + { + "input": "1a", + "bits": "7ff8000000000000" + }, + { + "input": "1_000", + "bits": "7ff8000000000000" + }, + { + "input": "1,000", + "bits": "7ff8000000000000" + }, + { + "input": "00012", + "bits": "4028000000000000" + }, + { + "input": "1e400", + "bits": "7ff0000000000000" + }, + { + "input": "-1e400", + "bits": "fff0000000000000" + }, + { + "input": "1e-400", + "bits": "0000000000000000" + }, + { + "input": "123456789012345678901234567890", + "bits": "45f8ee90ff6c373e" + }, + { + "input": "9007199254740993", + "bits": "4340000000000000" + }, + { + "input": "0.1", + "bits": "3fb999999999999a" + }, + { + "input": "10", + "bits": "4024000000000000" + }, + { + "input": "30", + "bits": "403e000000000000" + } + ] +} diff --git a/packages/zero-conformance/vectors/primary-key-string.json b/packages/zero-conformance/vectors/primary-key-string.json new file mode 100644 index 0000000000..b800ed0d7b --- /dev/null +++ b/packages/zero-conformance/vectors/primary-key-string.json @@ -0,0 +1,1816 @@ +{ + "formatVersion": 1, + "kind": "primary-key-string", + "description": "toPrimaryKeyString(table, primaryKey, row): the `e/` storage key of a row.", + "cases": [ + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": "", + "other": "x" + }, + "key": "e/issue/" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": "a", + "other": "x" + }, + "key": "e/issue/a" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": "A", + "other": "x" + }, + "key": "e/issue/A" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": "abc", + "other": "x" + }, + "key": "e/issue/abc" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": "ab", + "other": "x" + }, + "key": "e/issue/ab" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": "b", + "other": "x" + }, + "key": "e/issue/b" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": "Z", + "other": "x" + }, + "key": "e/issue/Z" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": "z", + "other": "x" + }, + "key": "e/issue/z" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": " ", + "other": "x" + }, + "key": "e/issue/ " + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": "0", + "other": "x" + }, + "key": "e/issue/0" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": "9", + "other": "x" + }, + "key": "e/issue/9" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": "\u0000", + "other": "x" + }, + "key": "e/issue/\u0000" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": "\u001f", + "other": "x" + }, + "key": "e/issue/\u001f" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": "\"", + "other": "x" + }, + "key": "e/issue/\"" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": "\\", + "other": "x" + }, + "key": "e/issue/\\" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": "\n", + "other": "x" + }, + "key": "e/issue/\n" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": "é", + "other": "x" + }, + "key": "e/issue/é" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": "é", + "other": "x" + }, + "key": "e/issue/é" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": "ß", + "other": "x" + }, + "key": "e/issue/ß" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": "İ", + "other": "x" + }, + "key": "e/issue/İ" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": "ı", + "other": "x" + }, + "key": "e/issue/ı" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": "ff", + "other": "x" + }, + "key": "e/issue/ff" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": " ", + "other": "x" + }, + "key": "e/issue/ " + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": "", + "other": "x" + }, + "key": "e/issue/" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": "�", + "other": "x" + }, + "key": "e/issue/�" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": "￿", + "other": "x" + }, + "key": "e/issue/￿" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": "😀", + "other": "x" + }, + "key": "e/issue/😀" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": "😀a", + "other": "x" + }, + "key": "e/issue/😀a" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": "a😀", + "other": "x" + }, + "key": "e/issue/a😀" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": "𝒜", + "other": "x" + }, + "key": "e/issue/𝒜" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": "中文", + "other": "x" + }, + "key": "e/issue/中文" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": "Ω", + "other": "x" + }, + "key": "e/issue/Ω" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": "ω", + "other": "x" + }, + "key": "e/issue/ω" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": "􏿿", + "other": "x" + }, + "key": "e/issue/􏿿" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": 0, + "other": "x" + }, + "key": "e/issue/0" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": 0, + "other": "x" + }, + "key": "e/issue/0" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": 1, + "other": "x" + }, + "key": "e/issue/1" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": -1, + "other": "x" + }, + "key": "e/issue/-1" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": 1.5, + "other": "x" + }, + "key": "e/issue/1.5" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": 1e+21, + "other": "x" + }, + "key": "e/issue/1e+21" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": 1e-7, + "other": "x" + }, + "key": "e/issue/1e-7" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": 9007199254740992, + "other": "x" + }, + "key": "e/issue/9007199254740992" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": 123456789, + "other": "x" + }, + "key": "e/issue/123456789" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": true, + "other": "x" + }, + "key": "e/issue/true" + }, + { + "table": "issue", + "primaryKey": [ + "id" + ], + "row": { + "id": false, + "other": "x" + }, + "key": "e/issue/false" + }, + { + "table": "a/b", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "😀", + "b": "😀a", + "c": 1 + }, + "key": "e/a/b/138237440500213278766544576420175538153" + }, + { + "table": "a/b", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "a", + "b": "\u001f", + "c": 1 + }, + "key": "e/a/b/108953544491027897133118810734227504117" + }, + { + "table": "issueLabel", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "中文", + "b": "Ω", + "c": 1 + }, + "key": "e/issueLabel/298697958174904119828597442393412379150" + }, + { + "table": "a/b", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "ff", + "b": "\u0000", + "c": 1 + }, + "key": "e/a/b/174057468085118144377299764343613956108" + }, + { + "table": "a/b", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "𝒜", + "b": "\n", + "c": 1 + }, + "key": "e/a/b/5083973163605689538709407885124552225" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "ω", + "b": "b", + "c": 1 + }, + "key": "e/T/324429194745012102815157364387854475334" + }, + { + "table": "a/b", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": " ", + "b": "\n", + "c": 1 + }, + "key": "e/a/b/20111422161262127299137814277831705957" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "中文", + "b": "Z", + "c": 1 + }, + "key": "e/T/288163432826267393645295772203882135741" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "😀", + "b": "ff", + "c": 1 + }, + "key": "e/T/69353963259799663590030124423535688954" + }, + { + "table": "a/b", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "ß", + "b": "\"", + "c": 1 + }, + "key": "e/a/b/15119373219820456239748869485582419230" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": 123456789, + "b": 1.5, + "c": 1 + }, + "key": "e/T/70030911325523829558365918209963861322" + }, + { + "table": "a/b", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "\u001f", + "b": "A", + "c": 1 + }, + "key": "e/a/b/107849840668642228546486329285446987602" + }, + { + "table": "issueLabel", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "a😀", + "b": "b", + "c": 1 + }, + "key": "e/issueLabel/50621609346134512976613415856759239532" + }, + { + "table": "issueLabel", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "a", + "b": "abc", + "c": 1 + }, + "key": "e/issueLabel/125534053758872032010433667743440732573" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "\"", + "b": 123456789, + "c": 1 + }, + "key": "e/T/157876255552548717928228875885739892834" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "0", + "b": "Z", + "c": 1 + }, + "key": "e/T/33688599146367535308476222411125388361" + }, + { + "table": "issueLabel", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": false, + "b": 9007199254740992, + "c": 1 + }, + "key": "e/issueLabel/125342882607468997836025138316361085066" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": -1, + "b": "𝒜", + "c": 1 + }, + "key": "e/T/322677190894481446281944065921136200835" + }, + { + "table": "issueLabel", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": " ", + "b": "0", + "c": 1 + }, + "key": "e/issueLabel/150959838649620094276462845126391292143" + }, + { + "table": "issueLabel", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "\u001f", + "b": "😀a", + "c": 1 + }, + "key": "e/issueLabel/168203813663984148152088867871369561216" + }, + { + "table": "a/b", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "\"", + "b": "ab", + "c": 1 + }, + "key": "e/a/b/228239857778646129970252129512884072839" + }, + { + "table": "a/b", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "ab", + "b": " ", + "c": 1 + }, + "key": "e/a/b/200945460326997410844512284536355940238" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "0", + "b": "A", + "c": 1 + }, + "key": "e/T/99581474221892365417517847622793377579" + }, + { + "table": "a/b", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "9", + "b": "𝒜", + "c": 1 + }, + "key": "e/a/b/36428554136804102800734382375682860844" + }, + { + "table": "a/b", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "", + "b": "abc", + "c": 1 + }, + "key": "e/a/b/112042633584996058780323154254004858166" + }, + { + "table": "a/b", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": " ", + "b": "ω", + "c": 1 + }, + "key": "e/a/b/128733077402849419843058939129632512826" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": 0, + "b": "é", + "c": 1 + }, + "key": "e/T/216972578155720916255596934813343275522" + }, + { + "table": "issueLabel", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "", + "b": "", + "c": 1 + }, + "key": "e/issueLabel/327970993165733907483913338773888203364" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "A", + "b": "\\", + "c": 1 + }, + "key": "e/T/57056319883518651848811490593888278449" + }, + { + "table": "a/b", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": true, + "b": "9", + "c": 1 + }, + "key": "e/a/b/34432540034224198489912508140820750377" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": -1, + "b": "Ω", + "c": 1 + }, + "key": "e/T/291576052045140909396011381037730117768" + }, + { + "table": "issueLabel", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "�", + "b": "ω", + "c": 1 + }, + "key": "e/issueLabel/190298845942192748196995388155094637017" + }, + { + "table": "a/b", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "9", + "b": "😀", + "c": 1 + }, + "key": "e/a/b/330830603906885855059689932840073939137" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": 9007199254740992, + "b": "Ω", + "c": 1 + }, + "key": "e/T/315043682682061335616776018712350231967" + }, + { + "table": "a/b", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "\"", + "b": 1e-7, + "c": 1 + }, + "key": "e/a/b/237440861506891350671596720124902172833" + }, + { + "table": "a/b", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": true, + "b": 1, + "c": 1 + }, + "key": "e/a/b/78411978607788658615493657205782043922" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "􏿿", + "b": "é", + "c": 1 + }, + "key": "e/T/75455583978625570409743621297965845815" + }, + { + "table": "issueLabel", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "z", + "b": "𝒜", + "c": 1 + }, + "key": "e/issueLabel/101616210220851271147161439282941189856" + }, + { + "table": "a/b", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": " ", + "b": true, + "c": 1 + }, + "key": "e/a/b/46829720116334243019408552042163864756" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "abc", + "b": 1.5, + "c": 1 + }, + "key": "e/T/64048578147264822146398123571065236851" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "￿", + "b": 1.5, + "c": 1 + }, + "key": "e/T/317874927184929111149102636038329333040" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "Ω", + "b": 0, + "c": 1 + }, + "key": "e/T/325135499577073968285543816007604479784" + }, + { + "table": "issueLabel", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "9", + "b": -1, + "c": 1 + }, + "key": "e/issueLabel/61786085883502793018744180497656294249" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": 123456789, + "b": "Ω", + "c": 1 + }, + "key": "e/T/215684009329767761640860471127337290404" + }, + { + "table": "issueLabel", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "😀", + "b": "ß", + "c": 1 + }, + "key": "e/issueLabel/320193981998157265838888891975155501501" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "", + "b": false, + "c": 1 + }, + "key": "e/T/158236726775577381120598796334867873537" + }, + { + "table": "a/b", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "abc", + "b": "􏿿", + "c": 1 + }, + "key": "e/a/b/289065079426825981885888246342318617356" + }, + { + "table": "issueLabel", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "A", + "b": "z", + "c": 1 + }, + "key": "e/issueLabel/179060136545276317652261798870425989130" + }, + { + "table": "issueLabel", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "\n", + "b": "�", + "c": 1 + }, + "key": "e/issueLabel/190612110649815538729456182756485101778" + }, + { + "table": "a/b", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "\"", + "b": -1, + "c": 1 + }, + "key": "e/a/b/127001739953936009975223094780501680997" + }, + { + "table": "a/b", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "İ", + "b": "abc", + "c": 1 + }, + "key": "e/a/b/18831307009605723781816576344424793596" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": 1e+21, + "b": false, + "c": 1 + }, + "key": "e/T/65937749629351462764609993225687627125" + }, + { + "table": "a/b", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": 0, + "b": " ", + "c": 1 + }, + "key": "e/a/b/143246219368763434548493369281354183848" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": 1.5, + "b": "é", + "c": 1 + }, + "key": "e/T/159299925970059360783011581715802095448" + }, + { + "table": "issueLabel", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "a😀", + "b": "􏿿", + "c": 1 + }, + "key": "e/issueLabel/324939011670342040272630589850627470929" + }, + { + "table": "a/b", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": 1.5, + "b": "中文", + "c": 1 + }, + "key": "e/a/b/207871901021492906503983047150480434076" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": 0, + "b": 1e+21, + "c": 1 + }, + "key": "e/T/70163525744960846665241958113909593591" + }, + { + "table": "issueLabel", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": 1.5, + "b": 1e-7, + "c": 1 + }, + "key": "e/issueLabel/155079488285218437467907829788807103606" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "İ", + "b": "\u001f", + "c": 1 + }, + "key": "e/T/186422773973479497819357525176513524517" + }, + { + "table": "issueLabel", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": false, + "b": "￿", + "c": 1 + }, + "key": "e/issueLabel/79317005981325900607816290139328932864" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "z", + "b": 123456789, + "c": 1 + }, + "key": "e/T/6399303255988564931957762439559899632" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "z", + "b": 1, + "c": 1 + }, + "key": "e/T/198193634805884345557533887603028416635" + }, + { + "table": "issueLabel", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "😀", + "b": "\n", + "c": 1 + }, + "key": "e/issueLabel/17885664512885852230051151740110520126" + }, + { + "table": "issueLabel", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "ß", + "b": "�", + "c": 1 + }, + "key": "e/issueLabel/211464393569957171780078204392551827881" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "é", + "b": "abc", + "c": 1 + }, + "key": "e/T/118487262269115251264257623907098965396" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "İ", + "b": "�", + "c": 1 + }, + "key": "e/T/35398161988335902265402093060145852913" + }, + { + "table": "issueLabel", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "\u001f", + "b": true, + "c": 1 + }, + "key": "e/issueLabel/18466211484688823538057217979333077486" + }, + { + "table": "issueLabel", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "b", + "b": 1e-7, + "c": 1 + }, + "key": "e/issueLabel/54041244556617852768186306963445944461" + }, + { + "table": "issueLabel", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "", + "b": "\n", + "c": 1 + }, + "key": "e/issueLabel/300045346511509397034103317327319191170" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "ß", + "b": 1, + "c": 1 + }, + "key": "e/T/6896813769461230752784065333868751076" + }, + { + "table": "a/b", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "a😀", + "b": true, + "c": 1 + }, + "key": "e/a/b/9721472666910517981283068075515492105" + }, + { + "table": "a/b", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "A", + "b": true, + "c": 1 + }, + "key": "e/a/b/111713581439921651297581026986496836305" + }, + { + "table": "a/b", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": 0, + "b": "ß", + "c": 1 + }, + "key": "e/a/b/38133843252944601293852926323167589702" + }, + { + "table": "a/b", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": 1, + "b": 1e-7, + "c": 1 + }, + "key": "e/a/b/42465484080886270641466938407081859154" + }, + { + "table": "issueLabel", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": 123456789, + "b": "􏿿", + "c": 1 + }, + "key": "e/issueLabel/6183215748030952876286034738204560240" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "😀", + "b": "￿", + "c": 1 + }, + "key": "e/T/143547021887944509658265388626285181634" + }, + { + "table": "issueLabel", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "￿", + "b": "İ", + "c": 1 + }, + "key": "e/issueLabel/248870845571210344217745119129301599383" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "ff", + "b": " ", + "c": 1 + }, + "key": "e/T/308859664407470953838277179172284281835" + }, + { + "table": "a/b", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "\\", + "b": "\"", + "c": 1 + }, + "key": "e/a/b/53081955814286587807398233289845056557" + }, + { + "table": "a/b", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "􏿿", + "b": "Ω", + "c": 1 + }, + "key": "e/a/b/118614057762437404711566357778112782733" + }, + { + "table": "issueLabel", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "ω", + "b": "0", + "c": 1 + }, + "key": "e/issueLabel/4986777469217447230184777457183934942" + }, + { + "table": "issueLabel", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "a", + "b": "a", + "c": 1 + }, + "key": "e/issueLabel/92234837145786221424074851044696905750" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "😀a", + "b": "é", + "c": 1 + }, + "key": "e/T/219717427851274269178975352696961414002" + }, + { + "table": "issueLabel", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": 0, + "b": 1, + "c": 1 + }, + "key": "e/issueLabel/295170494340311890617300055200635032228" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "\"", + "b": "abc", + "c": 1 + }, + "key": "e/T/238749775941851600778060135804831347619" + }, + { + "table": "a/b", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "é", + "b": 9007199254740992, + "c": 1 + }, + "key": "e/a/b/119745039924289343650236563576274633240" + }, + { + "table": "a/b", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "", + "b": -1, + "c": 1 + }, + "key": "e/a/b/2893012737170642695247897011367444056" + }, + { + "table": "issueLabel", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "z", + "b": "\\", + "c": 1 + }, + "key": "e/issueLabel/275359418673279422273815927308651637405" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "\\", + "b": "\n", + "c": 1 + }, + "key": "e/T/60128992136367324382740234172678835869" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": " ", + "b": "�", + "c": 1 + }, + "key": "e/T/17327414818598726598999821881078743270" + }, + { + "table": "a/b", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "0", + "b": "b", + "c": 1 + }, + "key": "e/a/b/57736290139742924998707954815414346261" + }, + { + "table": "issueLabel", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "ab", + "b": "", + "c": 1 + }, + "key": "e/issueLabel/245756579003707184509831813755821813560" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": 1, + "b": "�", + "c": 1 + }, + "key": "e/T/244004825321001507357998545663546775219" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "￿", + "b": "�", + "c": 1 + }, + "key": "e/T/368421437378419272236062167667437425" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "ı", + "b": 0, + "c": 1 + }, + "key": "e/T/128857581234351733708146465281690107524" + }, + { + "table": "a/b", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "z", + "b": "\"", + "c": 1 + }, + "key": "e/a/b/216199514477132186310266210059508007336" + }, + { + "table": "issueLabel", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "é", + "b": "é", + "c": 1 + }, + "key": "e/issueLabel/173928429345626310737325804715046303864" + }, + { + "table": "a/b", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "\"", + "b": "􏿿", + "c": 1 + }, + "key": "e/a/b/264565957223921066041612742487091725285" + }, + { + "table": "T", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "􏿿", + "b": " ", + "c": 1 + }, + "key": "e/T/145208218977391520964786729770952069982" + }, + { + "table": "issueLabel", + "primaryKey": [ + "a", + "b" + ], + "row": { + "a": "0", + "b": "Z", + "c": 1 + }, + "key": "e/issueLabel/33688599146367535308476222411125388361" + }, + { + "table": "three", + "primaryKey": [ + "z", + "a", + "m" + ], + "row": { + "a": 1, + "m": "x", + "z": true + }, + "key": "e/three/4375090957187456350369291908112057677" + } + ] +} diff --git a/packages/zero-conformance/vectors/string-compare.json b/packages/zero-conformance/vectors/string-compare.json new file mode 100644 index 0000000000..2ffcf5538d --- /dev/null +++ b/packages/zero-conformance/vectors/string-compare.json @@ -0,0 +1,8143 @@ +{ + "formatVersion": 1, + "kind": "string-compare", + "description": "compareUTF8 (code point order) and JS `<` (UTF-16 code unit order) signs.", + "cases": [ + { + "a": "", + "b": "", + "utf8": 0, + "utf16": 0 + }, + { + "a": "", + "b": "a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "A", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "abc", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "ab", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "b", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "Z", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "z", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": " ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "0", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "9", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "\u0000", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "\u001f", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "\"", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "\\", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "\n", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "ß", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "İ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "ı", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "ff", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": " ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "�", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "￿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "😀a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "a😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "𝒜", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "中文", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "Ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "􏿿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "a", + "b": "a", + "utf8": 0, + "utf16": 0 + }, + { + "a": "a", + "b": "A", + "utf8": 1, + "utf16": 1 + }, + { + "a": "a", + "b": "abc", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a", + "b": "ab", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a", + "b": "b", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a", + "b": "Z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "a", + "b": "z", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "a", + "b": "0", + "utf8": 1, + "utf16": 1 + }, + { + "a": "a", + "b": "9", + "utf8": 1, + "utf16": 1 + }, + { + "a": "a", + "b": "\u0000", + "utf8": 1, + "utf16": 1 + }, + { + "a": "a", + "b": "\u001f", + "utf8": 1, + "utf16": 1 + }, + { + "a": "a", + "b": "\"", + "utf8": 1, + "utf16": 1 + }, + { + "a": "a", + "b": "\\", + "utf8": 1, + "utf16": 1 + }, + { + "a": "a", + "b": "\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "a", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a", + "b": "ß", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a", + "b": "İ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a", + "b": "ı", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a", + "b": "ff", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a", + "b": " ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a", + "b": "", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a", + "b": "�", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a", + "b": "￿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a", + "b": "😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a", + "b": "😀a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a", + "b": "a😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a", + "b": "𝒜", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a", + "b": "中文", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a", + "b": "Ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a", + "b": "ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a", + "b": "􏿿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "A", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "A", + "b": "a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "A", + "b": "A", + "utf8": 0, + "utf16": 0 + }, + { + "a": "A", + "b": "abc", + "utf8": -1, + "utf16": -1 + }, + { + "a": "A", + "b": "ab", + "utf8": -1, + "utf16": -1 + }, + { + "a": "A", + "b": "b", + "utf8": -1, + "utf16": -1 + }, + { + "a": "A", + "b": "Z", + "utf8": -1, + "utf16": -1 + }, + { + "a": "A", + "b": "z", + "utf8": -1, + "utf16": -1 + }, + { + "a": "A", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "A", + "b": "0", + "utf8": 1, + "utf16": 1 + }, + { + "a": "A", + "b": "9", + "utf8": 1, + "utf16": 1 + }, + { + "a": "A", + "b": "\u0000", + "utf8": 1, + "utf16": 1 + }, + { + "a": "A", + "b": "\u001f", + "utf8": 1, + "utf16": 1 + }, + { + "a": "A", + "b": "\"", + "utf8": 1, + "utf16": 1 + }, + { + "a": "A", + "b": "\\", + "utf8": -1, + "utf16": -1 + }, + { + "a": "A", + "b": "\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "A", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": "A", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": "A", + "b": "ß", + "utf8": -1, + "utf16": -1 + }, + { + "a": "A", + "b": "İ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "A", + "b": "ı", + "utf8": -1, + "utf16": -1 + }, + { + "a": "A", + "b": "ff", + "utf8": -1, + "utf16": -1 + }, + { + "a": "A", + "b": " ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "A", + "b": "", + "utf8": -1, + "utf16": -1 + }, + { + "a": "A", + "b": "�", + "utf8": -1, + "utf16": -1 + }, + { + "a": "A", + "b": "￿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "A", + "b": "😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "A", + "b": "😀a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "A", + "b": "a😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "A", + "b": "𝒜", + "utf8": -1, + "utf16": -1 + }, + { + "a": "A", + "b": "中文", + "utf8": -1, + "utf16": -1 + }, + { + "a": "A", + "b": "Ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "A", + "b": "ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "A", + "b": "􏿿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "abc", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "abc", + "b": "a", + "utf8": 1, + "utf16": 1 + }, + { + "a": "abc", + "b": "A", + "utf8": 1, + "utf16": 1 + }, + { + "a": "abc", + "b": "abc", + "utf8": 0, + "utf16": 0 + }, + { + "a": "abc", + "b": "ab", + "utf8": 1, + "utf16": 1 + }, + { + "a": "abc", + "b": "b", + "utf8": -1, + "utf16": -1 + }, + { + "a": "abc", + "b": "Z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "abc", + "b": "z", + "utf8": -1, + "utf16": -1 + }, + { + "a": "abc", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "abc", + "b": "0", + "utf8": 1, + "utf16": 1 + }, + { + "a": "abc", + "b": "9", + "utf8": 1, + "utf16": 1 + }, + { + "a": "abc", + "b": "\u0000", + "utf8": 1, + "utf16": 1 + }, + { + "a": "abc", + "b": "\u001f", + "utf8": 1, + "utf16": 1 + }, + { + "a": "abc", + "b": "\"", + "utf8": 1, + "utf16": 1 + }, + { + "a": "abc", + "b": "\\", + "utf8": 1, + "utf16": 1 + }, + { + "a": "abc", + "b": "\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "abc", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": "abc", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": "abc", + "b": "ß", + "utf8": -1, + "utf16": -1 + }, + { + "a": "abc", + "b": "İ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "abc", + "b": "ı", + "utf8": -1, + "utf16": -1 + }, + { + "a": "abc", + "b": "ff", + "utf8": -1, + "utf16": -1 + }, + { + "a": "abc", + "b": " ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "abc", + "b": "", + "utf8": -1, + "utf16": -1 + }, + { + "a": "abc", + "b": "�", + "utf8": -1, + "utf16": -1 + }, + { + "a": "abc", + "b": "￿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "abc", + "b": "😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "abc", + "b": "😀a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "abc", + "b": "a😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "abc", + "b": "𝒜", + "utf8": -1, + "utf16": -1 + }, + { + "a": "abc", + "b": "中文", + "utf8": -1, + "utf16": -1 + }, + { + "a": "abc", + "b": "Ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "abc", + "b": "ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "abc", + "b": "􏿿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ab", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ab", + "b": "a", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ab", + "b": "A", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ab", + "b": "abc", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ab", + "b": "ab", + "utf8": 0, + "utf16": 0 + }, + { + "a": "ab", + "b": "b", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ab", + "b": "Z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ab", + "b": "z", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ab", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ab", + "b": "0", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ab", + "b": "9", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ab", + "b": "\u0000", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ab", + "b": "\u001f", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ab", + "b": "\"", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ab", + "b": "\\", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ab", + "b": "\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ab", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ab", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ab", + "b": "ß", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ab", + "b": "İ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ab", + "b": "ı", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ab", + "b": "ff", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ab", + "b": " ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ab", + "b": "", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ab", + "b": "�", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ab", + "b": "￿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ab", + "b": "😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ab", + "b": "😀a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ab", + "b": "a😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ab", + "b": "𝒜", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ab", + "b": "中文", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ab", + "b": "Ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ab", + "b": "ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ab", + "b": "􏿿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "b", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "b", + "b": "a", + "utf8": 1, + "utf16": 1 + }, + { + "a": "b", + "b": "A", + "utf8": 1, + "utf16": 1 + }, + { + "a": "b", + "b": "abc", + "utf8": 1, + "utf16": 1 + }, + { + "a": "b", + "b": "ab", + "utf8": 1, + "utf16": 1 + }, + { + "a": "b", + "b": "b", + "utf8": 0, + "utf16": 0 + }, + { + "a": "b", + "b": "Z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "b", + "b": "z", + "utf8": -1, + "utf16": -1 + }, + { + "a": "b", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "b", + "b": "0", + "utf8": 1, + "utf16": 1 + }, + { + "a": "b", + "b": "9", + "utf8": 1, + "utf16": 1 + }, + { + "a": "b", + "b": "\u0000", + "utf8": 1, + "utf16": 1 + }, + { + "a": "b", + "b": "\u001f", + "utf8": 1, + "utf16": 1 + }, + { + "a": "b", + "b": "\"", + "utf8": 1, + "utf16": 1 + }, + { + "a": "b", + "b": "\\", + "utf8": 1, + "utf16": 1 + }, + { + "a": "b", + "b": "\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "b", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": "b", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": "b", + "b": "ß", + "utf8": -1, + "utf16": -1 + }, + { + "a": "b", + "b": "İ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "b", + "b": "ı", + "utf8": -1, + "utf16": -1 + }, + { + "a": "b", + "b": "ff", + "utf8": -1, + "utf16": -1 + }, + { + "a": "b", + "b": " ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "b", + "b": "", + "utf8": -1, + "utf16": -1 + }, + { + "a": "b", + "b": "�", + "utf8": -1, + "utf16": -1 + }, + { + "a": "b", + "b": "￿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "b", + "b": "😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "b", + "b": "😀a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "b", + "b": "a😀", + "utf8": 1, + "utf16": 1 + }, + { + "a": "b", + "b": "𝒜", + "utf8": -1, + "utf16": -1 + }, + { + "a": "b", + "b": "中文", + "utf8": -1, + "utf16": -1 + }, + { + "a": "b", + "b": "Ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "b", + "b": "ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "b", + "b": "􏿿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Z", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "Z", + "b": "a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Z", + "b": "A", + "utf8": 1, + "utf16": 1 + }, + { + "a": "Z", + "b": "abc", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Z", + "b": "ab", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Z", + "b": "b", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Z", + "b": "Z", + "utf8": 0, + "utf16": 0 + }, + { + "a": "Z", + "b": "z", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Z", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "Z", + "b": "0", + "utf8": 1, + "utf16": 1 + }, + { + "a": "Z", + "b": "9", + "utf8": 1, + "utf16": 1 + }, + { + "a": "Z", + "b": "\u0000", + "utf8": 1, + "utf16": 1 + }, + { + "a": "Z", + "b": "\u001f", + "utf8": 1, + "utf16": 1 + }, + { + "a": "Z", + "b": "\"", + "utf8": 1, + "utf16": 1 + }, + { + "a": "Z", + "b": "\\", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Z", + "b": "\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "Z", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Z", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Z", + "b": "ß", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Z", + "b": "İ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Z", + "b": "ı", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Z", + "b": "ff", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Z", + "b": " ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Z", + "b": "", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Z", + "b": "�", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Z", + "b": "￿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Z", + "b": "😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Z", + "b": "😀a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Z", + "b": "a😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Z", + "b": "𝒜", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Z", + "b": "中文", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Z", + "b": "Ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Z", + "b": "ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Z", + "b": "􏿿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "z", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "z", + "b": "a", + "utf8": 1, + "utf16": 1 + }, + { + "a": "z", + "b": "A", + "utf8": 1, + "utf16": 1 + }, + { + "a": "z", + "b": "abc", + "utf8": 1, + "utf16": 1 + }, + { + "a": "z", + "b": "ab", + "utf8": 1, + "utf16": 1 + }, + { + "a": "z", + "b": "b", + "utf8": 1, + "utf16": 1 + }, + { + "a": "z", + "b": "Z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "z", + "b": "z", + "utf8": 0, + "utf16": 0 + }, + { + "a": "z", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "z", + "b": "0", + "utf8": 1, + "utf16": 1 + }, + { + "a": "z", + "b": "9", + "utf8": 1, + "utf16": 1 + }, + { + "a": "z", + "b": "\u0000", + "utf8": 1, + "utf16": 1 + }, + { + "a": "z", + "b": "\u001f", + "utf8": 1, + "utf16": 1 + }, + { + "a": "z", + "b": "\"", + "utf8": 1, + "utf16": 1 + }, + { + "a": "z", + "b": "\\", + "utf8": 1, + "utf16": 1 + }, + { + "a": "z", + "b": "\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "z", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": "z", + "b": "é", + "utf8": 1, + "utf16": 1 + }, + { + "a": "z", + "b": "ß", + "utf8": -1, + "utf16": -1 + }, + { + "a": "z", + "b": "İ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "z", + "b": "ı", + "utf8": -1, + "utf16": -1 + }, + { + "a": "z", + "b": "ff", + "utf8": -1, + "utf16": -1 + }, + { + "a": "z", + "b": " ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "z", + "b": "", + "utf8": -1, + "utf16": -1 + }, + { + "a": "z", + "b": "�", + "utf8": -1, + "utf16": -1 + }, + { + "a": "z", + "b": "￿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "z", + "b": "😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "z", + "b": "😀a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "z", + "b": "a😀", + "utf8": 1, + "utf16": 1 + }, + { + "a": "z", + "b": "𝒜", + "utf8": -1, + "utf16": -1 + }, + { + "a": "z", + "b": "中文", + "utf8": -1, + "utf16": -1 + }, + { + "a": "z", + "b": "Ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "z", + "b": "ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "z", + "b": "􏿿", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": " ", + "b": "a", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "A", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "abc", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "ab", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "b", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "Z", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "z", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": " ", + "utf8": 0, + "utf16": 0 + }, + { + "a": " ", + "b": "0", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "9", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "\u0000", + "utf8": 1, + "utf16": 1 + }, + { + "a": " ", + "b": "\u001f", + "utf8": 1, + "utf16": 1 + }, + { + "a": " ", + "b": "\"", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "\\", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": " ", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "ß", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "İ", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "ı", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "ff", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": " ", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "�", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "￿", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "😀a", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "a😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "𝒜", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "中文", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "Ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "􏿿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "0", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "0", + "b": "a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "0", + "b": "A", + "utf8": -1, + "utf16": -1 + }, + { + "a": "0", + "b": "abc", + "utf8": -1, + "utf16": -1 + }, + { + "a": "0", + "b": "ab", + "utf8": -1, + "utf16": -1 + }, + { + "a": "0", + "b": "b", + "utf8": -1, + "utf16": -1 + }, + { + "a": "0", + "b": "Z", + "utf8": -1, + "utf16": -1 + }, + { + "a": "0", + "b": "z", + "utf8": -1, + "utf16": -1 + }, + { + "a": "0", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "0", + "b": "0", + "utf8": 0, + "utf16": 0 + }, + { + "a": "0", + "b": "9", + "utf8": -1, + "utf16": -1 + }, + { + "a": "0", + "b": "\u0000", + "utf8": 1, + "utf16": 1 + }, + { + "a": "0", + "b": "\u001f", + "utf8": 1, + "utf16": 1 + }, + { + "a": "0", + "b": "\"", + "utf8": 1, + "utf16": 1 + }, + { + "a": "0", + "b": "\\", + "utf8": -1, + "utf16": -1 + }, + { + "a": "0", + "b": "\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "0", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": "0", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": "0", + "b": "ß", + "utf8": -1, + "utf16": -1 + }, + { + "a": "0", + "b": "İ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "0", + "b": "ı", + "utf8": -1, + "utf16": -1 + }, + { + "a": "0", + "b": "ff", + "utf8": -1, + "utf16": -1 + }, + { + "a": "0", + "b": " ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "0", + "b": "", + "utf8": -1, + "utf16": -1 + }, + { + "a": "0", + "b": "�", + "utf8": -1, + "utf16": -1 + }, + { + "a": "0", + "b": "￿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "0", + "b": "😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "0", + "b": "😀a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "0", + "b": "a😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "0", + "b": "𝒜", + "utf8": -1, + "utf16": -1 + }, + { + "a": "0", + "b": "中文", + "utf8": -1, + "utf16": -1 + }, + { + "a": "0", + "b": "Ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "0", + "b": "ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "0", + "b": "􏿿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "9", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "9", + "b": "a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "9", + "b": "A", + "utf8": -1, + "utf16": -1 + }, + { + "a": "9", + "b": "abc", + "utf8": -1, + "utf16": -1 + }, + { + "a": "9", + "b": "ab", + "utf8": -1, + "utf16": -1 + }, + { + "a": "9", + "b": "b", + "utf8": -1, + "utf16": -1 + }, + { + "a": "9", + "b": "Z", + "utf8": -1, + "utf16": -1 + }, + { + "a": "9", + "b": "z", + "utf8": -1, + "utf16": -1 + }, + { + "a": "9", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "9", + "b": "0", + "utf8": 1, + "utf16": 1 + }, + { + "a": "9", + "b": "9", + "utf8": 0, + "utf16": 0 + }, + { + "a": "9", + "b": "\u0000", + "utf8": 1, + "utf16": 1 + }, + { + "a": "9", + "b": "\u001f", + "utf8": 1, + "utf16": 1 + }, + { + "a": "9", + "b": "\"", + "utf8": 1, + "utf16": 1 + }, + { + "a": "9", + "b": "\\", + "utf8": -1, + "utf16": -1 + }, + { + "a": "9", + "b": "\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "9", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": "9", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": "9", + "b": "ß", + "utf8": -1, + "utf16": -1 + }, + { + "a": "9", + "b": "İ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "9", + "b": "ı", + "utf8": -1, + "utf16": -1 + }, + { + "a": "9", + "b": "ff", + "utf8": -1, + "utf16": -1 + }, + { + "a": "9", + "b": " ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "9", + "b": "", + "utf8": -1, + "utf16": -1 + }, + { + "a": "9", + "b": "�", + "utf8": -1, + "utf16": -1 + }, + { + "a": "9", + "b": "￿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "9", + "b": "😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "9", + "b": "😀a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "9", + "b": "a😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "9", + "b": "𝒜", + "utf8": -1, + "utf16": -1 + }, + { + "a": "9", + "b": "中文", + "utf8": -1, + "utf16": -1 + }, + { + "a": "9", + "b": "Ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "9", + "b": "ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "9", + "b": "􏿿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u0000", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "\u0000", + "b": "a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u0000", + "b": "A", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u0000", + "b": "abc", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u0000", + "b": "ab", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u0000", + "b": "b", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u0000", + "b": "Z", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u0000", + "b": "z", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u0000", + "b": " ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u0000", + "b": "0", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u0000", + "b": "9", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u0000", + "b": "\u0000", + "utf8": 0, + "utf16": 0 + }, + { + "a": "\u0000", + "b": "\u001f", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u0000", + "b": "\"", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u0000", + "b": "\\", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u0000", + "b": "\n", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u0000", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u0000", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u0000", + "b": "ß", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u0000", + "b": "İ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u0000", + "b": "ı", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u0000", + "b": "ff", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u0000", + "b": " ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u0000", + "b": "", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u0000", + "b": "�", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u0000", + "b": "￿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u0000", + "b": "😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u0000", + "b": "😀a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u0000", + "b": "a😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u0000", + "b": "𝒜", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u0000", + "b": "中文", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u0000", + "b": "Ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u0000", + "b": "ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u0000", + "b": "􏿿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u001f", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "\u001f", + "b": "a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u001f", + "b": "A", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u001f", + "b": "abc", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u001f", + "b": "ab", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u001f", + "b": "b", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u001f", + "b": "Z", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u001f", + "b": "z", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u001f", + "b": " ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u001f", + "b": "0", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u001f", + "b": "9", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u001f", + "b": "\u0000", + "utf8": 1, + "utf16": 1 + }, + { + "a": "\u001f", + "b": "\u001f", + "utf8": 0, + "utf16": 0 + }, + { + "a": "\u001f", + "b": "\"", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u001f", + "b": "\\", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u001f", + "b": "\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "\u001f", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u001f", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u001f", + "b": "ß", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u001f", + "b": "İ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u001f", + "b": "ı", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u001f", + "b": "ff", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u001f", + "b": " ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u001f", + "b": "", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u001f", + "b": "�", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u001f", + "b": "￿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u001f", + "b": "😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u001f", + "b": "😀a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u001f", + "b": "a😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u001f", + "b": "𝒜", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u001f", + "b": "中文", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u001f", + "b": "Ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u001f", + "b": "ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\u001f", + "b": "􏿿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\"", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "\"", + "b": "a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\"", + "b": "A", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\"", + "b": "abc", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\"", + "b": "ab", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\"", + "b": "b", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\"", + "b": "Z", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\"", + "b": "z", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\"", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "\"", + "b": "0", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\"", + "b": "9", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\"", + "b": "\u0000", + "utf8": 1, + "utf16": 1 + }, + { + "a": "\"", + "b": "\u001f", + "utf8": 1, + "utf16": 1 + }, + { + "a": "\"", + "b": "\"", + "utf8": 0, + "utf16": 0 + }, + { + "a": "\"", + "b": "\\", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\"", + "b": "\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "\"", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\"", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\"", + "b": "ß", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\"", + "b": "İ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\"", + "b": "ı", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\"", + "b": "ff", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\"", + "b": " ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\"", + "b": "", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\"", + "b": "�", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\"", + "b": "￿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\"", + "b": "😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\"", + "b": "😀a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\"", + "b": "a😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\"", + "b": "𝒜", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\"", + "b": "中文", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\"", + "b": "Ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\"", + "b": "ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\"", + "b": "􏿿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\\", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "\\", + "b": "a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\\", + "b": "A", + "utf8": 1, + "utf16": 1 + }, + { + "a": "\\", + "b": "abc", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\\", + "b": "ab", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\\", + "b": "b", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\\", + "b": "Z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "\\", + "b": "z", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\\", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "\\", + "b": "0", + "utf8": 1, + "utf16": 1 + }, + { + "a": "\\", + "b": "9", + "utf8": 1, + "utf16": 1 + }, + { + "a": "\\", + "b": "\u0000", + "utf8": 1, + "utf16": 1 + }, + { + "a": "\\", + "b": "\u001f", + "utf8": 1, + "utf16": 1 + }, + { + "a": "\\", + "b": "\"", + "utf8": 1, + "utf16": 1 + }, + { + "a": "\\", + "b": "\\", + "utf8": 0, + "utf16": 0 + }, + { + "a": "\\", + "b": "\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "\\", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\\", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\\", + "b": "ß", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\\", + "b": "İ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\\", + "b": "ı", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\\", + "b": "ff", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\\", + "b": " ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\\", + "b": "", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\\", + "b": "�", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\\", + "b": "￿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\\", + "b": "😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\\", + "b": "😀a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\\", + "b": "a😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\\", + "b": "𝒜", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\\", + "b": "中文", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\\", + "b": "Ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\\", + "b": "ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\\", + "b": "􏿿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\n", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "\n", + "b": "a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\n", + "b": "A", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\n", + "b": "abc", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\n", + "b": "ab", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\n", + "b": "b", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\n", + "b": "Z", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\n", + "b": "z", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\n", + "b": " ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\n", + "b": "0", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\n", + "b": "9", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\n", + "b": "\u0000", + "utf8": 1, + "utf16": 1 + }, + { + "a": "\n", + "b": "\u001f", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\n", + "b": "\"", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\n", + "b": "\\", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\n", + "b": "\n", + "utf8": 0, + "utf16": 0 + }, + { + "a": "\n", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\n", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\n", + "b": "ß", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\n", + "b": "İ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\n", + "b": "ı", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\n", + "b": "ff", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\n", + "b": " ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\n", + "b": "", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\n", + "b": "�", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\n", + "b": "￿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\n", + "b": "😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\n", + "b": "😀a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\n", + "b": "a😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\n", + "b": "𝒜", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\n", + "b": "中文", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\n", + "b": "Ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\n", + "b": "ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\n", + "b": "􏿿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "é", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "a", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "A", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "abc", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "ab", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "b", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "Z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "0", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "9", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "\u0000", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "\u001f", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "\"", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "\\", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "é", + "utf8": 0, + "utf16": 0 + }, + { + "a": "é", + "b": "é", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "ß", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "İ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "é", + "b": "ı", + "utf8": -1, + "utf16": -1 + }, + { + "a": "é", + "b": "ff", + "utf8": -1, + "utf16": -1 + }, + { + "a": "é", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "", + "utf8": -1, + "utf16": -1 + }, + { + "a": "é", + "b": "�", + "utf8": -1, + "utf16": -1 + }, + { + "a": "é", + "b": "￿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "é", + "b": "😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "é", + "b": "😀a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "é", + "b": "a😀", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "𝒜", + "utf8": -1, + "utf16": -1 + }, + { + "a": "é", + "b": "中文", + "utf8": -1, + "utf16": -1 + }, + { + "a": "é", + "b": "Ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "é", + "b": "ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "é", + "b": "􏿿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "é", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "a", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "A", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "abc", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "ab", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "b", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "Z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "z", + "utf8": -1, + "utf16": -1 + }, + { + "a": "é", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "0", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "9", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "\u0000", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "\u001f", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "\"", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "\\", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": "é", + "b": "é", + "utf8": 0, + "utf16": 0 + }, + { + "a": "é", + "b": "ß", + "utf8": -1, + "utf16": -1 + }, + { + "a": "é", + "b": "İ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "é", + "b": "ı", + "utf8": -1, + "utf16": -1 + }, + { + "a": "é", + "b": "ff", + "utf8": -1, + "utf16": -1 + }, + { + "a": "é", + "b": " ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "é", + "b": "", + "utf8": -1, + "utf16": -1 + }, + { + "a": "é", + "b": "�", + "utf8": -1, + "utf16": -1 + }, + { + "a": "é", + "b": "￿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "é", + "b": "😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "é", + "b": "😀a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "é", + "b": "a😀", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "𝒜", + "utf8": -1, + "utf16": -1 + }, + { + "a": "é", + "b": "中文", + "utf8": -1, + "utf16": -1 + }, + { + "a": "é", + "b": "Ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "é", + "b": "ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "é", + "b": "􏿿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ß", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ß", + "b": "a", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ß", + "b": "A", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ß", + "b": "abc", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ß", + "b": "ab", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ß", + "b": "b", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ß", + "b": "Z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ß", + "b": "z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ß", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ß", + "b": "0", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ß", + "b": "9", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ß", + "b": "\u0000", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ß", + "b": "\u001f", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ß", + "b": "\"", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ß", + "b": "\\", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ß", + "b": "\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ß", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ß", + "b": "é", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ß", + "b": "ß", + "utf8": 0, + "utf16": 0 + }, + { + "a": "ß", + "b": "İ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ß", + "b": "ı", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ß", + "b": "ff", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ß", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ß", + "b": "", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ß", + "b": "�", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ß", + "b": "￿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ß", + "b": "😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ß", + "b": "😀a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ß", + "b": "a😀", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ß", + "b": "𝒜", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ß", + "b": "中文", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ß", + "b": "Ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ß", + "b": "ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ß", + "b": "􏿿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "İ", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "İ", + "b": "a", + "utf8": 1, + "utf16": 1 + }, + { + "a": "İ", + "b": "A", + "utf8": 1, + "utf16": 1 + }, + { + "a": "İ", + "b": "abc", + "utf8": 1, + "utf16": 1 + }, + { + "a": "İ", + "b": "ab", + "utf8": 1, + "utf16": 1 + }, + { + "a": "İ", + "b": "b", + "utf8": 1, + "utf16": 1 + }, + { + "a": "İ", + "b": "Z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "İ", + "b": "z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "İ", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "İ", + "b": "0", + "utf8": 1, + "utf16": 1 + }, + { + "a": "İ", + "b": "9", + "utf8": 1, + "utf16": 1 + }, + { + "a": "İ", + "b": "\u0000", + "utf8": 1, + "utf16": 1 + }, + { + "a": "İ", + "b": "\u001f", + "utf8": 1, + "utf16": 1 + }, + { + "a": "İ", + "b": "\"", + "utf8": 1, + "utf16": 1 + }, + { + "a": "İ", + "b": "\\", + "utf8": 1, + "utf16": 1 + }, + { + "a": "İ", + "b": "\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "İ", + "b": "é", + "utf8": 1, + "utf16": 1 + }, + { + "a": "İ", + "b": "é", + "utf8": 1, + "utf16": 1 + }, + { + "a": "İ", + "b": "ß", + "utf8": 1, + "utf16": 1 + }, + { + "a": "İ", + "b": "İ", + "utf8": 0, + "utf16": 0 + }, + { + "a": "İ", + "b": "ı", + "utf8": -1, + "utf16": -1 + }, + { + "a": "İ", + "b": "ff", + "utf8": -1, + "utf16": -1 + }, + { + "a": "İ", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "İ", + "b": "", + "utf8": -1, + "utf16": -1 + }, + { + "a": "İ", + "b": "�", + "utf8": -1, + "utf16": -1 + }, + { + "a": "İ", + "b": "￿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "İ", + "b": "😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "İ", + "b": "😀a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "İ", + "b": "a😀", + "utf8": 1, + "utf16": 1 + }, + { + "a": "İ", + "b": "𝒜", + "utf8": -1, + "utf16": -1 + }, + { + "a": "İ", + "b": "中文", + "utf8": -1, + "utf16": -1 + }, + { + "a": "İ", + "b": "Ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "İ", + "b": "ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "İ", + "b": "􏿿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ı", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ı", + "b": "a", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ı", + "b": "A", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ı", + "b": "abc", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ı", + "b": "ab", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ı", + "b": "b", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ı", + "b": "Z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ı", + "b": "z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ı", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ı", + "b": "0", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ı", + "b": "9", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ı", + "b": "\u0000", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ı", + "b": "\u001f", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ı", + "b": "\"", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ı", + "b": "\\", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ı", + "b": "\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ı", + "b": "é", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ı", + "b": "é", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ı", + "b": "ß", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ı", + "b": "İ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ı", + "b": "ı", + "utf8": 0, + "utf16": 0 + }, + { + "a": "ı", + "b": "ff", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ı", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ı", + "b": "", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ı", + "b": "�", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ı", + "b": "￿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ı", + "b": "😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ı", + "b": "😀a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ı", + "b": "a😀", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ı", + "b": "𝒜", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ı", + "b": "中文", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ı", + "b": "Ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ı", + "b": "ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ı", + "b": "􏿿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ff", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ff", + "b": "a", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ff", + "b": "A", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ff", + "b": "abc", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ff", + "b": "ab", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ff", + "b": "b", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ff", + "b": "Z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ff", + "b": "z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ff", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ff", + "b": "0", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ff", + "b": "9", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ff", + "b": "\u0000", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ff", + "b": "\u001f", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ff", + "b": "\"", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ff", + "b": "\\", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ff", + "b": "\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ff", + "b": "é", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ff", + "b": "é", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ff", + "b": "ß", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ff", + "b": "İ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ff", + "b": "ı", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ff", + "b": "ff", + "utf8": 0, + "utf16": 0 + }, + { + "a": "ff", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ff", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ff", + "b": "�", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ff", + "b": "￿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ff", + "b": "😀", + "utf8": -1, + "utf16": 1 + }, + { + "a": "ff", + "b": "😀a", + "utf8": -1, + "utf16": 1 + }, + { + "a": "ff", + "b": "a😀", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ff", + "b": "𝒜", + "utf8": -1, + "utf16": 1 + }, + { + "a": "ff", + "b": "中文", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ff", + "b": "Ω", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ff", + "b": "ω", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ff", + "b": "􏿿", + "utf8": -1, + "utf16": 1 + }, + { + "a": " ", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": " ", + "b": "a", + "utf8": 1, + "utf16": 1 + }, + { + "a": " ", + "b": "A", + "utf8": 1, + "utf16": 1 + }, + { + "a": " ", + "b": "abc", + "utf8": 1, + "utf16": 1 + }, + { + "a": " ", + "b": "ab", + "utf8": 1, + "utf16": 1 + }, + { + "a": " ", + "b": "b", + "utf8": 1, + "utf16": 1 + }, + { + "a": " ", + "b": "Z", + "utf8": 1, + "utf16": 1 + }, + { + "a": " ", + "b": "z", + "utf8": 1, + "utf16": 1 + }, + { + "a": " ", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": " ", + "b": "0", + "utf8": 1, + "utf16": 1 + }, + { + "a": " ", + "b": "9", + "utf8": 1, + "utf16": 1 + }, + { + "a": " ", + "b": "\u0000", + "utf8": 1, + "utf16": 1 + }, + { + "a": " ", + "b": "\u001f", + "utf8": 1, + "utf16": 1 + }, + { + "a": " ", + "b": "\"", + "utf8": 1, + "utf16": 1 + }, + { + "a": " ", + "b": "\\", + "utf8": 1, + "utf16": 1 + }, + { + "a": " ", + "b": "\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": " ", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "é", + "utf8": 1, + "utf16": 1 + }, + { + "a": " ", + "b": "ß", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "İ", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "ı", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "ff", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": " ", + "utf8": 0, + "utf16": 0 + }, + { + "a": " ", + "b": "", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "�", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "￿", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "😀a", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "a😀", + "utf8": 1, + "utf16": 1 + }, + { + "a": " ", + "b": "𝒜", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "中文", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "Ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": " ", + "b": "􏿿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "", + "b": "a", + "utf8": 1, + "utf16": 1 + }, + { + "a": "", + "b": "A", + "utf8": 1, + "utf16": 1 + }, + { + "a": "", + "b": "abc", + "utf8": 1, + "utf16": 1 + }, + { + "a": "", + "b": "ab", + "utf8": 1, + "utf16": 1 + }, + { + "a": "", + "b": "b", + "utf8": 1, + "utf16": 1 + }, + { + "a": "", + "b": "Z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "", + "b": "z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "", + "b": "0", + "utf8": 1, + "utf16": 1 + }, + { + "a": "", + "b": "9", + "utf8": 1, + "utf16": 1 + }, + { + "a": "", + "b": "\u0000", + "utf8": 1, + "utf16": 1 + }, + { + "a": "", + "b": "\u001f", + "utf8": 1, + "utf16": 1 + }, + { + "a": "", + "b": "\"", + "utf8": 1, + "utf16": 1 + }, + { + "a": "", + "b": "\\", + "utf8": 1, + "utf16": 1 + }, + { + "a": "", + "b": "\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "", + "b": "é", + "utf8": 1, + "utf16": 1 + }, + { + "a": "", + "b": "é", + "utf8": 1, + "utf16": 1 + }, + { + "a": "", + "b": "ß", + "utf8": 1, + "utf16": 1 + }, + { + "a": "", + "b": "İ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "", + "b": "ı", + "utf8": 1, + "utf16": 1 + }, + { + "a": "", + "b": "ff", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "", + "b": "", + "utf8": 0, + "utf16": 0 + }, + { + "a": "", + "b": "�", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "￿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "😀", + "utf8": -1, + "utf16": 1 + }, + { + "a": "", + "b": "😀a", + "utf8": -1, + "utf16": 1 + }, + { + "a": "", + "b": "a😀", + "utf8": 1, + "utf16": 1 + }, + { + "a": "", + "b": "𝒜", + "utf8": -1, + "utf16": 1 + }, + { + "a": "", + "b": "中文", + "utf8": 1, + "utf16": 1 + }, + { + "a": "", + "b": "Ω", + "utf8": 1, + "utf16": 1 + }, + { + "a": "", + "b": "ω", + "utf8": 1, + "utf16": 1 + }, + { + "a": "", + "b": "􏿿", + "utf8": -1, + "utf16": 1 + }, + { + "a": "�", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "�", + "b": "a", + "utf8": 1, + "utf16": 1 + }, + { + "a": "�", + "b": "A", + "utf8": 1, + "utf16": 1 + }, + { + "a": "�", + "b": "abc", + "utf8": 1, + "utf16": 1 + }, + { + "a": "�", + "b": "ab", + "utf8": 1, + "utf16": 1 + }, + { + "a": "�", + "b": "b", + "utf8": 1, + "utf16": 1 + }, + { + "a": "�", + "b": "Z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "�", + "b": "z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "�", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "�", + "b": "0", + "utf8": 1, + "utf16": 1 + }, + { + "a": "�", + "b": "9", + "utf8": 1, + "utf16": 1 + }, + { + "a": "�", + "b": "\u0000", + "utf8": 1, + "utf16": 1 + }, + { + "a": "�", + "b": "\u001f", + "utf8": 1, + "utf16": 1 + }, + { + "a": "�", + "b": "\"", + "utf8": 1, + "utf16": 1 + }, + { + "a": "�", + "b": "\\", + "utf8": 1, + "utf16": 1 + }, + { + "a": "�", + "b": "\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "�", + "b": "é", + "utf8": 1, + "utf16": 1 + }, + { + "a": "�", + "b": "é", + "utf8": 1, + "utf16": 1 + }, + { + "a": "�", + "b": "ß", + "utf8": 1, + "utf16": 1 + }, + { + "a": "�", + "b": "İ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "�", + "b": "ı", + "utf8": 1, + "utf16": 1 + }, + { + "a": "�", + "b": "ff", + "utf8": 1, + "utf16": 1 + }, + { + "a": "�", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "�", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "�", + "b": "�", + "utf8": 0, + "utf16": 0 + }, + { + "a": "�", + "b": "￿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "�", + "b": "😀", + "utf8": -1, + "utf16": 1 + }, + { + "a": "�", + "b": "😀a", + "utf8": -1, + "utf16": 1 + }, + { + "a": "�", + "b": "a😀", + "utf8": 1, + "utf16": 1 + }, + { + "a": "�", + "b": "𝒜", + "utf8": -1, + "utf16": 1 + }, + { + "a": "�", + "b": "中文", + "utf8": 1, + "utf16": 1 + }, + { + "a": "�", + "b": "Ω", + "utf8": 1, + "utf16": 1 + }, + { + "a": "�", + "b": "ω", + "utf8": 1, + "utf16": 1 + }, + { + "a": "�", + "b": "􏿿", + "utf8": -1, + "utf16": 1 + }, + { + "a": "￿", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "￿", + "b": "a", + "utf8": 1, + "utf16": 1 + }, + { + "a": "￿", + "b": "A", + "utf8": 1, + "utf16": 1 + }, + { + "a": "￿", + "b": "abc", + "utf8": 1, + "utf16": 1 + }, + { + "a": "￿", + "b": "ab", + "utf8": 1, + "utf16": 1 + }, + { + "a": "￿", + "b": "b", + "utf8": 1, + "utf16": 1 + }, + { + "a": "￿", + "b": "Z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "￿", + "b": "z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "￿", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "￿", + "b": "0", + "utf8": 1, + "utf16": 1 + }, + { + "a": "￿", + "b": "9", + "utf8": 1, + "utf16": 1 + }, + { + "a": "￿", + "b": "\u0000", + "utf8": 1, + "utf16": 1 + }, + { + "a": "￿", + "b": "\u001f", + "utf8": 1, + "utf16": 1 + }, + { + "a": "￿", + "b": "\"", + "utf8": 1, + "utf16": 1 + }, + { + "a": "￿", + "b": "\\", + "utf8": 1, + "utf16": 1 + }, + { + "a": "￿", + "b": "\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "￿", + "b": "é", + "utf8": 1, + "utf16": 1 + }, + { + "a": "￿", + "b": "é", + "utf8": 1, + "utf16": 1 + }, + { + "a": "￿", + "b": "ß", + "utf8": 1, + "utf16": 1 + }, + { + "a": "￿", + "b": "İ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "￿", + "b": "ı", + "utf8": 1, + "utf16": 1 + }, + { + "a": "￿", + "b": "ff", + "utf8": 1, + "utf16": 1 + }, + { + "a": "￿", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "￿", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "￿", + "b": "�", + "utf8": 1, + "utf16": 1 + }, + { + "a": "￿", + "b": "￿", + "utf8": 0, + "utf16": 0 + }, + { + "a": "￿", + "b": "😀", + "utf8": -1, + "utf16": 1 + }, + { + "a": "￿", + "b": "😀a", + "utf8": -1, + "utf16": 1 + }, + { + "a": "￿", + "b": "a😀", + "utf8": 1, + "utf16": 1 + }, + { + "a": "￿", + "b": "𝒜", + "utf8": -1, + "utf16": 1 + }, + { + "a": "￿", + "b": "中文", + "utf8": 1, + "utf16": 1 + }, + { + "a": "￿", + "b": "Ω", + "utf8": 1, + "utf16": 1 + }, + { + "a": "￿", + "b": "ω", + "utf8": 1, + "utf16": 1 + }, + { + "a": "￿", + "b": "􏿿", + "utf8": -1, + "utf16": 1 + }, + { + "a": "😀", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀", + "b": "a", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀", + "b": "A", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀", + "b": "abc", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀", + "b": "ab", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀", + "b": "b", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀", + "b": "Z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀", + "b": "z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀", + "b": "0", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀", + "b": "9", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀", + "b": "\u0000", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀", + "b": "\u001f", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀", + "b": "\"", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀", + "b": "\\", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀", + "b": "\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀", + "b": "é", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀", + "b": "é", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀", + "b": "ß", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀", + "b": "İ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀", + "b": "ı", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀", + "b": "ff", + "utf8": 1, + "utf16": -1 + }, + { + "a": "😀", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀", + "b": "", + "utf8": 1, + "utf16": -1 + }, + { + "a": "😀", + "b": "�", + "utf8": 1, + "utf16": -1 + }, + { + "a": "😀", + "b": "￿", + "utf8": 1, + "utf16": -1 + }, + { + "a": "😀", + "b": "😀", + "utf8": 0, + "utf16": 0 + }, + { + "a": "😀", + "b": "😀a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "😀", + "b": "a😀", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀", + "b": "𝒜", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀", + "b": "中文", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀", + "b": "Ω", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀", + "b": "ω", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀", + "b": "􏿿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "😀a", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀a", + "b": "a", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀a", + "b": "A", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀a", + "b": "abc", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀a", + "b": "ab", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀a", + "b": "b", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀a", + "b": "Z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀a", + "b": "z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀a", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀a", + "b": "0", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀a", + "b": "9", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀a", + "b": "\u0000", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀a", + "b": "\u001f", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀a", + "b": "\"", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀a", + "b": "\\", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀a", + "b": "\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀a", + "b": "é", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀a", + "b": "é", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀a", + "b": "ß", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀a", + "b": "İ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀a", + "b": "ı", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀a", + "b": "ff", + "utf8": 1, + "utf16": -1 + }, + { + "a": "😀a", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀a", + "b": "", + "utf8": 1, + "utf16": -1 + }, + { + "a": "😀a", + "b": "�", + "utf8": 1, + "utf16": -1 + }, + { + "a": "😀a", + "b": "￿", + "utf8": 1, + "utf16": -1 + }, + { + "a": "😀a", + "b": "😀", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀a", + "b": "😀a", + "utf8": 0, + "utf16": 0 + }, + { + "a": "😀a", + "b": "a😀", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀a", + "b": "𝒜", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀a", + "b": "中文", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀a", + "b": "Ω", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀a", + "b": "ω", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀a", + "b": "􏿿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a😀", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "a😀", + "b": "a", + "utf8": 1, + "utf16": 1 + }, + { + "a": "a😀", + "b": "A", + "utf8": 1, + "utf16": 1 + }, + { + "a": "a😀", + "b": "abc", + "utf8": 1, + "utf16": 1 + }, + { + "a": "a😀", + "b": "ab", + "utf8": 1, + "utf16": 1 + }, + { + "a": "a😀", + "b": "b", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a😀", + "b": "Z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "a😀", + "b": "z", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a😀", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "a😀", + "b": "0", + "utf8": 1, + "utf16": 1 + }, + { + "a": "a😀", + "b": "9", + "utf8": 1, + "utf16": 1 + }, + { + "a": "a😀", + "b": "\u0000", + "utf8": 1, + "utf16": 1 + }, + { + "a": "a😀", + "b": "\u001f", + "utf8": 1, + "utf16": 1 + }, + { + "a": "a😀", + "b": "\"", + "utf8": 1, + "utf16": 1 + }, + { + "a": "a😀", + "b": "\\", + "utf8": 1, + "utf16": 1 + }, + { + "a": "a😀", + "b": "\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "a😀", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a😀", + "b": "é", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a😀", + "b": "ß", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a😀", + "b": "İ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a😀", + "b": "ı", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a😀", + "b": "ff", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a😀", + "b": " ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a😀", + "b": "", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a😀", + "b": "�", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a😀", + "b": "￿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a😀", + "b": "😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a😀", + "b": "😀a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a😀", + "b": "a😀", + "utf8": 0, + "utf16": 0 + }, + { + "a": "a😀", + "b": "𝒜", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a😀", + "b": "中文", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a😀", + "b": "Ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a😀", + "b": "ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a😀", + "b": "􏿿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "𝒜", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜", + "b": "a", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜", + "b": "A", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜", + "b": "abc", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜", + "b": "ab", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜", + "b": "b", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜", + "b": "Z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜", + "b": "z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜", + "b": "0", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜", + "b": "9", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜", + "b": "\u0000", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜", + "b": "\u001f", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜", + "b": "\"", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜", + "b": "\\", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜", + "b": "\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜", + "b": "é", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜", + "b": "é", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜", + "b": "ß", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜", + "b": "İ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜", + "b": "ı", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜", + "b": "ff", + "utf8": 1, + "utf16": -1 + }, + { + "a": "𝒜", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜", + "b": "", + "utf8": 1, + "utf16": -1 + }, + { + "a": "𝒜", + "b": "�", + "utf8": 1, + "utf16": -1 + }, + { + "a": "𝒜", + "b": "￿", + "utf8": 1, + "utf16": -1 + }, + { + "a": "𝒜", + "b": "😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "𝒜", + "b": "😀a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "𝒜", + "b": "a😀", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜", + "b": "𝒜", + "utf8": 0, + "utf16": 0 + }, + { + "a": "𝒜", + "b": "中文", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜", + "b": "Ω", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜", + "b": "ω", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜", + "b": "􏿿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "中文", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "中文", + "b": "a", + "utf8": 1, + "utf16": 1 + }, + { + "a": "中文", + "b": "A", + "utf8": 1, + "utf16": 1 + }, + { + "a": "中文", + "b": "abc", + "utf8": 1, + "utf16": 1 + }, + { + "a": "中文", + "b": "ab", + "utf8": 1, + "utf16": 1 + }, + { + "a": "中文", + "b": "b", + "utf8": 1, + "utf16": 1 + }, + { + "a": "中文", + "b": "Z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "中文", + "b": "z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "中文", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "中文", + "b": "0", + "utf8": 1, + "utf16": 1 + }, + { + "a": "中文", + "b": "9", + "utf8": 1, + "utf16": 1 + }, + { + "a": "中文", + "b": "\u0000", + "utf8": 1, + "utf16": 1 + }, + { + "a": "中文", + "b": "\u001f", + "utf8": 1, + "utf16": 1 + }, + { + "a": "中文", + "b": "\"", + "utf8": 1, + "utf16": 1 + }, + { + "a": "中文", + "b": "\\", + "utf8": 1, + "utf16": 1 + }, + { + "a": "中文", + "b": "\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "中文", + "b": "é", + "utf8": 1, + "utf16": 1 + }, + { + "a": "中文", + "b": "é", + "utf8": 1, + "utf16": 1 + }, + { + "a": "中文", + "b": "ß", + "utf8": 1, + "utf16": 1 + }, + { + "a": "中文", + "b": "İ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "中文", + "b": "ı", + "utf8": 1, + "utf16": 1 + }, + { + "a": "中文", + "b": "ff", + "utf8": -1, + "utf16": -1 + }, + { + "a": "中文", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "中文", + "b": "", + "utf8": -1, + "utf16": -1 + }, + { + "a": "中文", + "b": "�", + "utf8": -1, + "utf16": -1 + }, + { + "a": "中文", + "b": "￿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "中文", + "b": "😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "中文", + "b": "😀a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "中文", + "b": "a😀", + "utf8": 1, + "utf16": 1 + }, + { + "a": "中文", + "b": "𝒜", + "utf8": -1, + "utf16": -1 + }, + { + "a": "中文", + "b": "中文", + "utf8": 0, + "utf16": 0 + }, + { + "a": "中文", + "b": "Ω", + "utf8": 1, + "utf16": 1 + }, + { + "a": "中文", + "b": "ω", + "utf8": 1, + "utf16": 1 + }, + { + "a": "中文", + "b": "􏿿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Ω", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "Ω", + "b": "a", + "utf8": 1, + "utf16": 1 + }, + { + "a": "Ω", + "b": "A", + "utf8": 1, + "utf16": 1 + }, + { + "a": "Ω", + "b": "abc", + "utf8": 1, + "utf16": 1 + }, + { + "a": "Ω", + "b": "ab", + "utf8": 1, + "utf16": 1 + }, + { + "a": "Ω", + "b": "b", + "utf8": 1, + "utf16": 1 + }, + { + "a": "Ω", + "b": "Z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "Ω", + "b": "z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "Ω", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "Ω", + "b": "0", + "utf8": 1, + "utf16": 1 + }, + { + "a": "Ω", + "b": "9", + "utf8": 1, + "utf16": 1 + }, + { + "a": "Ω", + "b": "\u0000", + "utf8": 1, + "utf16": 1 + }, + { + "a": "Ω", + "b": "\u001f", + "utf8": 1, + "utf16": 1 + }, + { + "a": "Ω", + "b": "\"", + "utf8": 1, + "utf16": 1 + }, + { + "a": "Ω", + "b": "\\", + "utf8": 1, + "utf16": 1 + }, + { + "a": "Ω", + "b": "\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "Ω", + "b": "é", + "utf8": 1, + "utf16": 1 + }, + { + "a": "Ω", + "b": "é", + "utf8": 1, + "utf16": 1 + }, + { + "a": "Ω", + "b": "ß", + "utf8": 1, + "utf16": 1 + }, + { + "a": "Ω", + "b": "İ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "Ω", + "b": "ı", + "utf8": 1, + "utf16": 1 + }, + { + "a": "Ω", + "b": "ff", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Ω", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "Ω", + "b": "", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Ω", + "b": "�", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Ω", + "b": "￿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Ω", + "b": "😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Ω", + "b": "😀a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Ω", + "b": "a😀", + "utf8": 1, + "utf16": 1 + }, + { + "a": "Ω", + "b": "𝒜", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Ω", + "b": "中文", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Ω", + "b": "Ω", + "utf8": 0, + "utf16": 0 + }, + { + "a": "Ω", + "b": "ω", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Ω", + "b": "􏿿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ω", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ω", + "b": "a", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ω", + "b": "A", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ω", + "b": "abc", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ω", + "b": "ab", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ω", + "b": "b", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ω", + "b": "Z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ω", + "b": "z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ω", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ω", + "b": "0", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ω", + "b": "9", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ω", + "b": "\u0000", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ω", + "b": "\u001f", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ω", + "b": "\"", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ω", + "b": "\\", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ω", + "b": "\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ω", + "b": "é", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ω", + "b": "é", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ω", + "b": "ß", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ω", + "b": "İ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ω", + "b": "ı", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ω", + "b": "ff", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ω", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ω", + "b": "", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ω", + "b": "�", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ω", + "b": "￿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ω", + "b": "😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ω", + "b": "😀a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ω", + "b": "a😀", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ω", + "b": "𝒜", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ω", + "b": "中文", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ω", + "b": "Ω", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ω", + "b": "ω", + "utf8": 0, + "utf16": 0 + }, + { + "a": "ω", + "b": "􏿿", + "utf8": -1, + "utf16": -1 + }, + { + "a": "􏿿", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "􏿿", + "b": "a", + "utf8": 1, + "utf16": 1 + }, + { + "a": "􏿿", + "b": "A", + "utf8": 1, + "utf16": 1 + }, + { + "a": "􏿿", + "b": "abc", + "utf8": 1, + "utf16": 1 + }, + { + "a": "􏿿", + "b": "ab", + "utf8": 1, + "utf16": 1 + }, + { + "a": "􏿿", + "b": "b", + "utf8": 1, + "utf16": 1 + }, + { + "a": "􏿿", + "b": "Z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "􏿿", + "b": "z", + "utf8": 1, + "utf16": 1 + }, + { + "a": "􏿿", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "􏿿", + "b": "0", + "utf8": 1, + "utf16": 1 + }, + { + "a": "􏿿", + "b": "9", + "utf8": 1, + "utf16": 1 + }, + { + "a": "􏿿", + "b": "\u0000", + "utf8": 1, + "utf16": 1 + }, + { + "a": "􏿿", + "b": "\u001f", + "utf8": 1, + "utf16": 1 + }, + { + "a": "􏿿", + "b": "\"", + "utf8": 1, + "utf16": 1 + }, + { + "a": "􏿿", + "b": "\\", + "utf8": 1, + "utf16": 1 + }, + { + "a": "􏿿", + "b": "\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "􏿿", + "b": "é", + "utf8": 1, + "utf16": 1 + }, + { + "a": "􏿿", + "b": "é", + "utf8": 1, + "utf16": 1 + }, + { + "a": "􏿿", + "b": "ß", + "utf8": 1, + "utf16": 1 + }, + { + "a": "􏿿", + "b": "İ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "􏿿", + "b": "ı", + "utf8": 1, + "utf16": 1 + }, + { + "a": "􏿿", + "b": "ff", + "utf8": 1, + "utf16": -1 + }, + { + "a": "􏿿", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "􏿿", + "b": "", + "utf8": 1, + "utf16": -1 + }, + { + "a": "􏿿", + "b": "�", + "utf8": 1, + "utf16": -1 + }, + { + "a": "􏿿", + "b": "￿", + "utf8": 1, + "utf16": -1 + }, + { + "a": "􏿿", + "b": "😀", + "utf8": 1, + "utf16": 1 + }, + { + "a": "􏿿", + "b": "😀a", + "utf8": 1, + "utf16": 1 + }, + { + "a": "􏿿", + "b": "a😀", + "utf8": 1, + "utf16": 1 + }, + { + "a": "􏿿", + "b": "𝒜", + "utf8": 1, + "utf16": 1 + }, + { + "a": "􏿿", + "b": "中文", + "utf8": 1, + "utf16": 1 + }, + { + "a": "􏿿", + "b": "Ω", + "utf8": 1, + "utf16": 1 + }, + { + "a": "􏿿", + "b": "ω", + "utf8": 1, + "utf16": 1 + }, + { + "a": "􏿿", + "b": "􏿿", + "utf8": 0, + "utf16": 0 + }, + { + "a": "B", + "b": "aéAB\n", + "utf8": -1, + "utf16": -1 + }, + { + "a": "İé\\_𝒜İ", + "b": "ßİ中中", + "utf8": 1, + "utf16": 1 + }, + { + "a": " \\ßé%", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "\\😀_\"%ba", + "b": "😀_中ß𝒜ß", + "utf8": -1, + "utf16": -1 + }, + { + "a": "𝒜", + "b": "a", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜😀 a中中\"ß", + "b": "Ba_ \n\\\\𝒜", + "utf8": 1, + "utf16": 1 + }, + { + "a": " \"\n\n", + "b": "中\n_\n", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\n\"\né", + "b": "\"\\😀", + "utf8": 1, + "utf16": 1 + }, + { + "a": "", + "b": "%%", + "utf8": -1, + "utf16": -1 + }, + { + "a": "_a", + "b": "\n", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Ab𝒜%b\n", + "b": "中%\\\\", + "utf8": -1, + "utf16": -1 + }, + { + "a": "𝒜", + "b": "😀𝒜Ab", + "utf8": -1, + "utf16": -1 + }, + { + "a": "𝒜ß", + "b": "b😀𝒜aßİ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "AB😀%", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ßB\nA", + "b": "\nßaB_a\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": " 😀𝒜", + "b": "éßaBba%é", + "utf8": -1, + "utf16": -1 + }, + { + "a": "BbB", + "b": "中é", + "utf8": -1, + "utf16": -1 + }, + { + "a": "𝒜\"b_中b", + "b": "İ\n中", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ßé", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "%\n😀𝒜%", + "b": "\\😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "éé ", + "b": "\\", + "utf8": 1, + "utf16": 1 + }, + { + "a": " ", + "b": "Bß%😀a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "A\nb\né", + "b": "B😀ßa", + "utf8": -1, + "utf16": -1 + }, + { + "a": "𝒜\n", + "b": "b%中𝒜\\", + "utf8": 1, + "utf16": 1 + }, + { + "a": "%𝒜", + "b": "ßA", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\"%_%a", + "b": "\\😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "B\n", + "b": "B𝒜 中\"𝒜中", + "utf8": -1, + "utf16": -1 + }, + { + "a": "_𝒜", + "b": "ßß\n\\", + "utf8": -1, + "utf16": -1 + }, + { + "a": "%ß\\", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "İ\nß\\A\nA", + "b": "", + "utf8": -1, + "utf16": -1 + }, + { + "a": "😀_%ßAß", + "b": "aİ b😀a", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜é", + "b": "é\n_ é中ß", + "utf8": 1, + "utf16": 1 + }, + { + "a": "a𝒜", + "b": "B𝒜İBb𝒜", + "utf8": 1, + "utf16": 1 + }, + { + "a": "%A𝒜", + "b": "\n%A\n中𝒜b", + "utf8": 1, + "utf16": 1 + }, + { + "a": " _𝒜😀İ\\\n", + "b": "éba😀é中\"", + "utf8": -1, + "utf16": -1 + }, + { + "a": "😀İ\"", + "b": "ßaB\"\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "", + "b": "\\%ß_ ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\\éa😀😀", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "中a中éß中", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "\na İ𝒜𝒜", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "Bb\"%İ\\ ", + "b": "𝒜中%中\"\"", + "utf8": -1, + "utf16": -1 + }, + { + "a": "😀A中\"中𝒜", + "b": "\na_", + "utf8": 1, + "utf16": 1 + }, + { + "a": "İ\"𝒜", + "b": " 😀", + "utf8": 1, + "utf16": 1 + }, + { + "a": "\\😀b", + "b": "_aé", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ß", + "b": "\\ß_", + "utf8": 1, + "utf16": 1 + }, + { + "a": "中béİ😀😀", + "b": "İABabA", + "utf8": 1, + "utf16": 1 + }, + { + "a": "İa_", + "b": "a", + "utf8": 1, + "utf16": 1 + }, + { + "a": "é", + "b": "a\né%", + "utf8": 1, + "utf16": 1 + }, + { + "a": " A中B𝒜😀a", + "b": "_ß😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "\\Aé😀B", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\"AA_ß%", + "b": "ß中𝒜𝒜中B😀A", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ßA", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "\\\"%ß", + "b": "ß😀😀éA\\", + "utf8": -1, + "utf16": -1 + }, + { + "a": "_B𝒜İ\\中B_", + "b": "中\"BA\\b", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "éİ𝒜 ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "%", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Aa__", + "b": "İ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\"bA\"\"", + "b": "中\"__ B", + "utf8": -1, + "utf16": -1 + }, + { + "a": "b", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "Aİ", + "b": "é😀b", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "Bß\"\"", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\nb \"", + "b": "_A", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ß\"\\\\", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜中 ", + "b": "Ab 中AB", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ß😀B\nİ\nİ", + "b": "İ\n\\\na\\", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a ßé", + "b": "ß\\ß\\", + "utf8": -1, + "utf16": -1 + }, + { + "a": "b", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": " ", + "b": " 中a İ%bß", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\\_éB𝒜Aa", + "b": "\né_\"İ\n中\\", + "utf8": 1, + "utf16": 1 + }, + { + "a": "_\n", + "b": "é😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "%_BB", + "b": "Bé😀\n%aB😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "Aß_ a", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "%😀\\_İ", + "b": "中\na中Aß", + "utf8": -1, + "utf16": -1 + }, + { + "a": "BB_", + "b": "é𝒜İ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "中é", + "b": "Aİé", + "utf8": 1, + "utf16": 1 + }, + { + "a": "%ß%%", + "b": "", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ßé%%", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜aé中AB", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "%é%中𝒜", + "b": "__\"b", + "utf8": -1, + "utf16": -1 + }, + { + "a": "BB中", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": " b中a", + "b": "中a中\"İ ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "😀%𝒜ß\"中İ", + "b": "İ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "", + "b": "", + "utf8": 0, + "utf16": 0 + }, + { + "a": "𝒜é𝒜ß_ ", + "b": "aİ中 𝒜\"", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "", + "b": "A\\ ß😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "𝒜", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "中", + "b": "\n \"a\"\"😀_", + "utf8": 1, + "utf16": 1 + }, + { + "a": "中中", + "b": "ßb中\"𝒜\"", + "utf8": -1, + "utf16": -1 + }, + { + "a": "İA", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "%İ% ", + "b": "a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\nA", + "b": "_ß B%", + "utf8": -1, + "utf16": -1 + }, + { + "a": "𝒜😀B", + "b": "中\n%", + "utf8": 1, + "utf16": 1 + }, + { + "a": "\\😀A中\\ é", + "b": "\\ ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "b😀a", + "b": "é\"\n😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "%", + "b": "A_", + "utf8": -1, + "utf16": -1 + }, + { + "a": " 𝒜B _b😀", + "b": "中\né%", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\né\"b\\中", + "b": "_𝒜%A𝒜中", + "utf8": -1, + "utf16": -1 + }, + { + "a": "İ", + "b": "\n ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "B\\a", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "a\n_\\\n", + "b": "\"𝒜中\n𝒜", + "utf8": 1, + "utf16": 1 + }, + { + "a": "%B\"_", + "b": "A\"B\\\\", + "utf8": -1, + "utf16": -1 + }, + { + "a": "_\n", + "b": "b😀İ%BéBé", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a😀😀ßß%", + "b": "\nİb𝒜\\bAb", + "utf8": 1, + "utf16": 1 + }, + { + "a": "İİß\"b\"", + "b": "_éé\"\n中\\😀", + "utf8": 1, + "utf16": 1 + }, + { + "a": "", + "b": "😀\\abİ😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "İ%😀😀\"中", + "b": " \nB\n_", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜", + "b": " A😀ß%%é", + "utf8": 1, + "utf16": 1 + }, + { + "a": "中__A𝒜 b", + "b": "中\"😀𝒜%", + "utf8": 1, + "utf16": 1 + }, + { + "a": "A😀a\\ßB", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "_\"\" ", + "b": "a%\"_", + "utf8": -1, + "utf16": -1 + }, + { + "a": "😀a%\n𝒜\"\"b", + "b": "_aB😀", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀", + "b": "\n_a", + "utf8": 1, + "utf16": 1 + }, + { + "a": " ", + "b": "\n中\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜_é", + "b": "\"𝒜𝒜_ßb", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀𝒜\n\\a", + "b": "😀Aé", + "utf8": 1, + "utf16": 1 + }, + { + "a": "aB%", + "b": "%béß", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜\"ß\nß", + "b": "é\\😀", + "utf8": 1, + "utf16": 1 + }, + { + "a": "", + "b": "中ßé\\", + "utf8": -1, + "utf16": -1 + }, + { + "a": "中😀a", + "b": "Aé ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀é", + "b": "\nß中 𝒜\"", + "utf8": 1, + "utf16": 1 + }, + { + "a": " İ 😀A", + "b": "b", + "utf8": -1, + "utf16": -1 + }, + { + "a": "𝒜B", + "b": "Abİ%B\\\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "_𝒜\\😀 \"é", + "b": "_", + "utf8": 1, + "utf16": 1 + }, + { + "a": "%中中\\B", + "b": " ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "\"😀𝒜_a", + "b": "𝒜é\"Aİ%A", + "utf8": -1, + "utf16": 1 + }, + { + "a": "bİB", + "b": "\\", + "utf8": 1, + "utf16": 1 + }, + { + "a": "\\中", + "b": "é\\A é\n\"", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ß", + "b": "aé %b", + "utf8": 1, + "utf16": 1 + }, + { + "a": "\\中中%中é", + "b": "中A", + "utf8": -1, + "utf16": -1 + }, + { + "a": "AA b\"", + "b": "中\na𝒜A b", + "utf8": -1, + "utf16": -1 + }, + { + "a": "%ß\"\nbß", + "b": "aé", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\n\na\\%\nß", + "b": "\\\n", + "utf8": -1, + "utf16": -1 + }, + { + "a": "é", + "b": "bİéB\n\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "B", + "b": "_", + "utf8": -1, + "utf16": -1 + }, + { + "a": "%İİ_İ A\n", + "b": "a\n", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "𝒜😀\\", + "utf8": -1, + "utf16": -1 + }, + { + "a": "%", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "", + "b": "ßßa𝒜", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "%A", + "utf8": -1, + "utf16": -1 + }, + { + "a": "b\"", + "b": "é%éa", + "utf8": -1, + "utf16": -1 + }, + { + "a": "İ😀𝒜 ", + "b": "ß_中_", + "utf8": 1, + "utf16": 1 + }, + { + "a": "_İb😀 A", + "b": "b", + "utf8": -1, + "utf16": -1 + }, + { + "a": "é𝒜\"𝒜ßßAİ", + "b": "中\"\n", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "b 😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "%\n 😀_", + "utf8": -1, + "utf16": -1 + }, + { + "a": "😀A_bBB😀", + "b": " é\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "\\\"_a😀𝒜𝒜 ", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "", + "b": " Aß_中A", + "utf8": -1, + "utf16": -1 + }, + { + "a": "BAİb\né", + "b": "__\n\\", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ß_%%aAA", + "b": "\\A中𝒜\"é", + "utf8": 1, + "utf16": 1 + }, + { + "a": "B\\\nB中", + "b": "𝒜", + "utf8": -1, + "utf16": -1 + }, + { + "a": "_bb", + "b": "%\"İ😀%\\中", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀İ\n😀A", + "b": "A中é_", + "utf8": 1, + "utf16": 1 + }, + { + "a": "\n😀", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "中\\é中\\", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "éİ\\中\\𝒜ßa", + "b": "\"é", + "utf8": 1, + "utf16": 1 + }, + { + "a": "中ß\\A", + "b": "𝒜ß\"", + "utf8": -1, + "utf16": -1 + }, + { + "a": "_", + "b": " 中\\", + "utf8": 1, + "utf16": 1 + }, + { + "a": "A中\"😀𝒜é", + "b": " \"\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "% ", + "b": "İA ", + "utf8": -1, + "utf16": -1 + }, + { + "a": "😀İ", + "b": "B_中 𝒜", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜ß", + "b": "a ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "%\"\\İß", + "b": "中\\", + "utf8": -1, + "utf16": -1 + }, + { + "a": "b😀 Ab", + "b": "b", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "\\", + "b": "b😀_中Bb\"", + "utf8": -1, + "utf16": -1 + }, + { + "a": "é😀a", + "b": "𝒜\"A", + "utf8": -1, + "utf16": -1 + }, + { + "a": " \n", + "b": "A%\"\nb_", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\nßßİ_\\", + "b": "%aab", + "utf8": -1, + "utf16": -1 + }, + { + "a": "中", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "İ\\%", + "b": "%a", + "utf8": 1, + "utf16": 1 + }, + { + "a": "AéİAb", + "b": " a", + "utf8": 1, + "utf16": 1 + }, + { + "a": "", + "b": "_%éé\\", + "utf8": -1, + "utf16": -1 + }, + { + "a": "%a\"", + "b": "a a中ßa", + "utf8": -1, + "utf16": -1 + }, + { + "a": "a\"中\n", + "b": "\n", + "utf8": 1, + "utf16": 1 + }, + { + "a": "B_a😀", + "b": "a中AB𝒜İ😀😀", + "utf8": -1, + "utf16": -1 + }, + { + "a": "%", + "b": "b𝒜", + "utf8": -1, + "utf16": -1 + }, + { + "a": "__bİ", + "b": "ß", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "a\\B\"中a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "A%%ab", + "b": "😀𝒜_\\", + "utf8": -1, + "utf16": -1 + }, + { + "a": "", + "b": "\\a", + "utf8": -1, + "utf16": -1 + }, + { + "a": "A𝒜\\ İ😀B", + "b": "ßb\\", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\\b𝒜", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "中", + "b": "\n😀A中a", + "utf8": 1, + "utf16": 1 + }, + { + "a": "aİ", + "b": "_中_", + "utf8": 1, + "utf16": 1 + }, + { + "a": "😀A İ", + "b": "a😀𝒜", + "utf8": 1, + "utf16": 1 + }, + { + "a": "%b b", + "b": "中\n\n", + "utf8": -1, + "utf16": -1 + }, + { + "a": "𝒜éİ", + "b": "éBb", + "utf8": 1, + "utf16": 1 + }, + { + "a": "ß😀éA𝒜", + "b": "bé_中_ ", + "utf8": 1, + "utf16": 1 + }, + { + "a": "İ", + "b": "😀\\aéİ\\𝒜%", + "utf8": -1, + "utf16": -1 + }, + { + "a": "😀中a\nB", + "b": "😀 é \\😀ß\"", + "utf8": 1, + "utf16": 1 + }, + { + "a": "İ𝒜\\ ", + "b": "𝒜bAß\n\nb", + "utf8": -1, + "utf16": -1 + }, + { + "a": "\né\"", + "b": "", + "utf8": 1, + "utf16": 1 + }, + { + "a": "中aéİa_İ", + "b": "İb", + "utf8": 1, + "utf16": 1 + }, + { + "a": "𝒜\" ", + "b": "ß", + "utf8": 1, + "utf16": 1 + }, + { + "a": "%B_ \"b", + "b": "B\"\\%\"\\B", + "utf8": -1, + "utf16": -1 + }, + { + "a": "ßßB__", + "b": "A \\😀éé", + "utf8": 1, + "utf16": 1 + }, + { + "a": " __ß", + "b": "ß\\", + "utf8": -1, + "utf16": -1 + }, + { + "a": "😀ß😀", + "b": "bbİab", + "utf8": 1, + "utf16": 1 + } + ] +} diff --git a/packages/zero-conformance/vectors/ttl.json b/packages/zero-conformance/vectors/ttl.json new file mode 100644 index 0000000000..0a417b4fa4 --- /dev/null +++ b/packages/zero-conformance/vectors/ttl.json @@ -0,0 +1,1080 @@ +{ + "formatVersion": 1, + "kind": "ttl", + "description": "parseTTL, normalizeTTL, and clampTTL; plus compareTTL against every other case (signs).", + "cases": [ + { + "ttl": "none", + "parsed": 0, + "normalized": "none", + "clamped": 0, + "compare": [ + 0, + -1, + 0, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + 0, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + 1, + -1 + ] + }, + { + "ttl": "forever", + "parsed": -1, + "normalized": "forever", + "clamped": 600000, + "compare": [ + 1, + 0, + 1, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "ttl": 0, + "parsed": 0, + "normalized": "none", + "clamped": 0, + "compare": [ + 0, + -1, + 0, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + 0, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + 1, + -1 + ] + }, + { + "ttl": -1, + "parsed": -1, + "normalized": "forever", + "clamped": 600000, + "compare": [ + 1, + 0, + 1, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "ttl": -100, + "parsed": -1, + "normalized": "forever", + "clamped": 600000, + "compare": [ + 1, + 0, + 1, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "ttl": 1, + "parsed": 1, + "normalized": 1, + "clamped": 1, + "compare": [ + 1, + -1, + 1, + -1, + -1, + 0, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + 1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + 1, + -1 + ] + }, + { + "ttl": 999, + "parsed": 999, + "normalized": 999, + "clamped": 999, + "compare": [ + 1, + -1, + 1, + -1, + -1, + 1, + 0, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + 1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + 1, + -1 + ] + }, + { + "ttl": 1000, + "parsed": 1000, + "normalized": "1s", + "clamped": 1000, + "compare": [ + 1, + -1, + 1, + -1, + -1, + 1, + 1, + 0, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + 1, + 0, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + 1, + -1 + ] + }, + { + "ttl": 1500, + "parsed": 1500, + "normalized": 1500, + "clamped": 1500, + "compare": [ + 1, + -1, + 1, + -1, + -1, + 1, + 1, + 1, + 0, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + 1, + 1, + 0, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + 1, + -1 + ] + }, + { + "ttl": 60000, + "parsed": 60000, + "normalized": "1m", + "clamped": 60000, + "compare": [ + 1, + -1, + 1, + -1, + -1, + 1, + 1, + 1, + 1, + 0, + -1, + -1, + -1, + -1, + -1, + -1, + 1, + 1, + 1, + 1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + 1, + -1 + ] + }, + { + "ttl": 90000, + "parsed": 90000, + "normalized": "90s", + "clamped": 90000, + "compare": [ + 1, + -1, + 1, + -1, + -1, + 1, + 1, + 1, + 1, + 1, + 0, + -1, + -1, + -1, + -1, + -1, + 1, + 1, + 1, + 1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + 1, + -1 + ] + }, + { + "ttl": 3600000, + "parsed": 3600000, + "normalized": "1h", + "clamped": 600000, + "compare": [ + 1, + -1, + 1, + -1, + -1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + -1, + -1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + -1, + -1, + 1, + 1, + 1 + ] + }, + { + "ttl": 86400000, + "parsed": 86400000, + "normalized": "1d", + "clamped": 600000, + "compare": [ + 1, + -1, + 1, + -1, + -1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + -1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + -1, + 1, + 1, + 1 + ] + }, + { + "ttl": 31536000000, + "parsed": 31536000000, + "normalized": "1y", + "clamped": 600000, + "compare": [ + 1, + -1, + 1, + -1, + -1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 1, + 1 + ] + }, + { + "ttl": 600000, + "parsed": 600000, + "normalized": "10m", + "clamped": 600000, + "compare": [ + 1, + -1, + 1, + -1, + -1, + 1, + 1, + 1, + 1, + 1, + 1, + -1, + -1, + -1, + 0, + -1, + 1, + 1, + 1, + 1, + 1, + 0, + -1, + -1, + -1, + -1, + -1, + 1, + 1 + ] + }, + { + "ttl": 600001, + "parsed": 600001, + "normalized": 600001, + "clamped": 600000, + "compare": [ + 1, + -1, + 1, + -1, + -1, + 1, + 1, + 1, + 1, + 1, + 1, + -1, + -1, + -1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + -1, + -1, + -1, + -1, + -1, + 1, + 1 + ] + }, + { + "ttl": "0s", + "parsed": 0, + "normalized": "0s", + "clamped": 0, + "compare": [ + 0, + -1, + 0, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + 0, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + 1, + -1 + ] + }, + { + "ttl": "1s", + "parsed": 1000, + "normalized": "1s", + "clamped": 1000, + "compare": [ + 1, + -1, + 1, + -1, + -1, + 1, + 1, + 0, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + 1, + 0, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + 1, + -1 + ] + }, + { + "ttl": "1.5s", + "parsed": 1500, + "normalized": "1.5s", + "clamped": 1500, + "compare": [ + 1, + -1, + 1, + -1, + -1, + 1, + 1, + 1, + 0, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + 1, + 1, + 0, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + 1, + -1 + ] + }, + { + "ttl": "30s", + "parsed": 30000, + "normalized": "30s", + "clamped": 30000, + "compare": [ + 1, + -1, + 1, + -1, + -1, + 1, + 1, + 1, + 1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + 1, + 1, + 1, + 0, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + 1, + -1 + ] + }, + { + "ttl": "5m", + "parsed": 300000, + "normalized": "5m", + "clamped": 300000, + "compare": [ + 1, + -1, + 1, + -1, + -1, + 1, + 1, + 1, + 1, + 1, + 1, + -1, + -1, + -1, + -1, + -1, + 1, + 1, + 1, + 1, + 0, + -1, + -1, + -1, + -1, + -1, + -1, + 1, + 1 + ] + }, + { + "ttl": "10m", + "parsed": 600000, + "normalized": "10m", + "clamped": 600000, + "compare": [ + 1, + -1, + 1, + -1, + -1, + 1, + 1, + 1, + 1, + 1, + 1, + -1, + -1, + -1, + 0, + -1, + 1, + 1, + 1, + 1, + 1, + 0, + -1, + -1, + -1, + -1, + -1, + 1, + 1 + ] + }, + { + "ttl": "11m", + "parsed": 660000, + "normalized": "11m", + "clamped": 600000, + "compare": [ + 1, + -1, + 1, + -1, + -1, + 1, + 1, + 1, + 1, + 1, + 1, + -1, + -1, + -1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + -1, + -1, + -1, + -1, + 1, + 1 + ] + }, + { + "ttl": "1h", + "parsed": 3600000, + "normalized": "1h", + "clamped": 600000, + "compare": [ + 1, + -1, + 1, + -1, + -1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + -1, + -1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + -1, + -1, + 1, + 1, + 1 + ] + }, + { + "ttl": "1d", + "parsed": 86400000, + "normalized": "1d", + "clamped": 600000, + "compare": [ + 1, + -1, + 1, + -1, + -1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + -1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + -1, + 1, + 1, + 1 + ] + }, + { + "ttl": "1y", + "parsed": 31536000000, + "normalized": "1y", + "clamped": 600000, + "compare": [ + 1, + -1, + 1, + -1, + -1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 1, + 1 + ] + }, + { + "ttl": "0.5h", + "parsed": 1800000, + "normalized": "0.5h", + "clamped": 600000, + "compare": [ + 1, + -1, + 1, + -1, + -1, + 1, + 1, + 1, + 1, + 1, + 1, + -1, + -1, + -1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + -1, + -1, + -1, + 0, + 1, + 1 + ] + }, + { + "ttl": "-1m", + "parsed": -60000, + "normalized": "-1m", + "clamped": -60000, + "compare": [ + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + 0, + -1 + ] + }, + { + "ttl": " 2m", + "parsed": 120000, + "normalized": " 2m", + "clamped": 120000, + "compare": [ + 1, + -1, + 1, + -1, + -1, + 1, + 1, + 1, + 1, + 1, + 1, + -1, + -1, + -1, + -1, + -1, + 1, + 1, + 1, + 1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + 1, + 0 + ] + } + ] +} diff --git a/swift/Sources/Zero/AST/AST.swift b/swift/Sources/Zero/AST/AST.swift new file mode 100644 index 0000000000..101e237018 --- /dev/null +++ b/swift/Sources/Zero/AST/AST.swift @@ -0,0 +1,147 @@ +/// The ZQL abstract syntax tree (`packages/zero-protocol/src/ast.ts`). +public struct AST: Sendable, Hashable { + public var schema: String? + public var table: String + /// Set on subqueries, to tell apart two subqueries of the same table. + public var alias: String? + public var `where`: Condition? + public var related: [CorrelatedSubquery]? + public var start: Bound? + public var limit: Double? + public var orderBy: Ordering? + + public init( + schema: String? = nil, + table: String, + alias: String? = nil, + where: Condition? = nil, + related: [CorrelatedSubquery]? = nil, + start: Bound? = nil, + limit: Double? = nil, + orderBy: Ordering? = nil + ) { + self.schema = schema + self.table = table + self.alias = alias + self.where = `where` + self.related = related + self.start = start + self.limit = limit + self.orderBy = orderBy + } + + public enum System: String, Sendable, Hashable { + case permissions, client, test + } + + public struct Bound: Sendable, Hashable { + public var row: Row + public var exclusive: Bool + + public init(row: Row, exclusive: Bool) { + self.row = row + self.exclusive = exclusive + } + } + + public enum Direction: String, Sendable, Hashable { + case asc, desc + } + + public struct OrderPart: Sendable, Hashable { + public var field: String + public var direction: Direction + + public init(_ field: String, _ direction: Direction) { + self.field = field + self.direction = direction + } + } + + public typealias Ordering = [OrderPart] + + public enum SimpleOperator: String, Sendable, Hashable, CaseIterable { + case eq = "=" + case ne = "!=" + case `is` = "IS" + case isNot = "IS NOT" + case lt = "<" + case gt = ">" + case le = "<=" + case ge = ">=" + case like = "LIKE" + case notLike = "NOT LIKE" + case ilike = "ILIKE" + case notIlike = "NOT ILIKE" + case `in` = "IN" + case notIn = "NOT IN" + } + + public enum CorrelatedSubqueryOperator: String, Sendable, Hashable { + case exists = "EXISTS" + case notExists = "NOT EXISTS" + } + + public enum Anchor: String, Sendable, Hashable { + case authData + case preMutationRow + } + + public enum StaticField: Sendable, Hashable { + case single(String) + case path([String]) + } + + /// One side of a simple condition. A literal is a string, number, boolean, + /// null, or an array of strings, numbers, and booleans. + public enum ValuePosition: Sendable, Hashable { + case literal(JSONValue) + case column(String) + case `static`(anchor: Anchor, field: StaticField) + } + + public indirect enum Condition: Sendable, Hashable { + case simple(op: SimpleOperator, left: ValuePosition, right: ValuePosition) + case and([Condition]) + case or([Condition]) + case correlatedSubquery( + related: CorrelatedSubquery, op: CorrelatedSubqueryOperator, flip: Bool?, scalar: Bool?) + } + + public struct Correlation: Sendable, Hashable { + public var parentField: [String] + public var childField: [String] + + public init(parentField: [String], childField: [String]) { + self.parentField = parentField + self.childField = childField + } + } + + public struct CorrelatedSubquery: Sendable, Hashable { + public var correlation: Correlation + public var subquery: AST + public var system: System? + /// Hidden hops (junction tables) are left out of the output view; their + /// children are not. + public var hidden: Bool? + + public init( + correlation: Correlation, subquery: AST, system: System? = nil, hidden: Bool? = nil + ) { + self.correlation = correlation + self.subquery = subquery + self.system = system + self.hidden = hidden + } + } +} + +extension AST.Condition { + public var conditions: [AST.Condition]? { + switch self { + case .and(let c), .or(let c): c + default: nil + } + } +} diff --git a/swift/Sources/Zero/AST/ASTCoding.swift b/swift/Sources/Zero/AST/ASTCoding.swift new file mode 100644 index 0000000000..3150ba9bee --- /dev/null +++ b/swift/Sources/Zero/AST/ASTCoding.swift @@ -0,0 +1,187 @@ +/// JSON encoding of the AST, following `astSchema`. +extension AST: JSONCodable { + public init(json: JSONValue) throws(DecodingError) { + let r = try JSONObjectReader(json, "AST") + schema = try r.optionalString("schema") + table = try r.string("table") + alias = try r.optionalString("alias") + self.where = try r.optional("where", Condition.init(json:)) + related = try r.optional("related") { (v) throws(DecodingError) in + try JSONDecode.array(v, CorrelatedSubquery.init(json:)) + } + limit = try r.optionalNumber("limit") + orderBy = try r.optional("orderBy") { (v) throws(DecodingError) in + try JSONDecode.array(v) { (e) throws(DecodingError) -> OrderPart in + guard case .array(let t) = e, t.count == 2, case .string(let field) = t[0], + case .string(let dir) = t[1], let direction = Direction(rawValue: dir) + else { throw DecodingError("Expected [field, 'asc' | 'desc']") } + return OrderPart(field, direction) + } + } + start = try r.optional("start") { (v) throws(DecodingError) -> Bound in + let b = try JSONObjectReader(v, "bound") + return Bound( + row: try b.required("row", JSONDecode.object), exclusive: try b.bool("exclusive")) + } + } + + public var json: JSONValue { + var o = JSONObjectBuilder() + o.set("schema", schema) + o.set("table", .string(table)) + o.set("alias", alias) + o.set("where", self.where?.json) + o.set("related", related.map { JSONValue.array($0.map(\.json)) }) + o.set( + "start", + start.map { JSONValue.object(["row": .object($0.row), "exclusive": .bool($0.exclusive)]) }) + o.set("limit", limit) + o.set( + "orderBy", + orderBy.map { + JSONValue.array($0.map { .array([.string($0.field), .string($0.direction.rawValue)]) }) + }) + return o.value + } +} + +extension AST.CorrelatedSubquery: JSONCodable { + public init(json: JSONValue) throws(DecodingError) { + let r = try JSONObjectReader(json, "correlated subquery") + correlation = try r.required("correlation") { (v) throws(DecodingError) in + let c = try JSONObjectReader(v, "correlation") + return AST.Correlation( + parentField: try c.required("parentField", JSONDecode.compoundKey), + childField: try c.required("childField", JSONDecode.compoundKey)) + } + subquery = try r.required("subquery", AST.init(json:)) + system = try r.optional("system") { (v) throws(DecodingError) in + let s = try JSONDecode.string(v) + guard let system = AST.System(rawValue: s) else { + throw DecodingError("Invalid system \"\(s)\"") + } + return system + } + hidden = try r.optionalBool("hidden") + } + + public var json: JSONValue { + var o = JSONObjectBuilder() + o.set( + "correlation", + .object([ + "parentField": correlation.parentField.json, "childField": correlation.childField.json, + ])) + o.set("subquery", subquery.json) + o.set("system", system?.rawValue) + o.set("hidden", hidden) + return o.value + } +} + +extension AST.ValuePosition: JSONCodable { + public init(json: JSONValue) throws(DecodingError) { + let r = try JSONObjectReader(json, "value position") + let type = try r.string("type") + switch type { + case "literal": + let value = try r.required("value") { (v) throws(DecodingError) -> JSONValue in + switch v { + case .string, .number, .bool, .null: return v + case .array(let a): + for e in a { + switch e { + case .string, .number, .bool: continue + default: throw DecodingError("Invalid literal array element") + } + } + return v + case .object: throw DecodingError("Invalid literal value") + } + } + self = .literal(value) + case "column": + self = .column(try r.string("name")) + case "static": + let anchorName = try r.string("anchor") + guard let anchor = AST.Anchor(rawValue: anchorName) else { + throw DecodingError("Invalid anchor \"\(anchorName)\"", path: ["anchor"]) + } + let field = try r.required("field") { (v) throws(DecodingError) -> AST.StaticField in + switch v { + case .string(let s): return .single(s) + case .array: return .path(try JSONDecode.array(v, JSONDecode.string)) + default: throw DecodingError("Expected string or string array") + } + } + self = .static(anchor: anchor, field: field) + default: + throw DecodingError("Invalid value position type \"\(type)\"", path: ["type"]) + } + } + + public var json: JSONValue { + switch self { + case .literal(let v): ["type": "literal", "value": v] + case .column(let name): ["type": "column", "name": .string(name)] + case .static(let anchor, let field): + ["type": "static", "anchor": .string(anchor.rawValue), "field": field.json] + } + } +} + +extension AST.Condition: JSONCodable { + public init(json: JSONValue) throws(DecodingError) { + let r = try JSONObjectReader(json, "condition") + let type = try r.string("type") + switch type { + case "simple": + let opName = try r.string("op") + guard let op = AST.SimpleOperator(rawValue: opName) else { + throw DecodingError("Invalid operator \"\(opName)\"", path: ["op"]) + } + let left = try r.required("left", AST.ValuePosition.init(json:)) + let right = try r.required("right", AST.ValuePosition.init(json:)) + if case .column = right { + throw DecodingError("The right side of a condition can't be a column", path: ["right"]) + } + self = .simple(op: op, left: left, right: right) + case "and", "or": + let conditions = try r.required("conditions") { (v) throws(DecodingError) in + try JSONDecode.array(v, AST.Condition.init(json:)) + } + self = type == "and" ? .and(conditions) : .or(conditions) + case "correlatedSubquery": + let opName = try r.string("op") + guard let op = AST.CorrelatedSubqueryOperator(rawValue: opName) else { + throw DecodingError("Invalid operator \"\(opName)\"", path: ["op"]) + } + self = .correlatedSubquery( + related: try r.required("related", AST.CorrelatedSubquery.init(json:)), + op: op, + flip: try r.optionalBool("flip"), + scalar: try r.optionalBool("scalar")) + default: + throw DecodingError("Invalid condition type \"\(type)\"", path: ["type"]) + } + } + + public var json: JSONValue { + switch self { + case .simple(let op, let left, let right): + return ["type": "simple", "op": .string(op.rawValue), "left": left.json, "right": right.json] + case .and(let c): + return ["type": "and", "conditions": .array(c.map(\.json))] + case .or(let c): + return ["type": "or", "conditions": .array(c.map(\.json))] + case .correlatedSubquery(let related, let op, let flip, let scalar): + var o = JSONObjectBuilder() + o.set("type", "correlatedSubquery") + o.set("related", related.json) + o.set("op", .string(op.rawValue)) + o.set("flip", flip) + o.set("scalar", scalar) + return o.value + } + } +} diff --git a/swift/Sources/Zero/AST/ASTNormalize.swift b/swift/Sources/Zero/AST/ASTNormalize.swift new file mode 100644 index 0000000000..97bf8607ad --- /dev/null +++ b/swift/Sources/Zero/AST/ASTNormalize.swift @@ -0,0 +1,185 @@ +/// AST normalization (`normalizeAST` in `packages/zero-protocol/src/ast.ts`): +/// `related` sorted by alias, `where` flattened and sorted. Two ASTs that +/// describe the same query hash the same once normalized. +extension AST { + public func normalized() -> AST { + var ast = self + ast.where = `where`.map { Self.normalizeSubqueries($0) }.map(Self.normalizeCondition) + if let related { + let normalized = related.map { r -> CorrelatedSubquery in + var r = r + r.subquery = r.subquery.normalized() + return r + } + ast.related = Self.normalizeRelated(normalized) + } + return ast + } + + static func normalizeSubqueries(_ cond: Condition) -> Condition { + switch cond { + case .simple: + return cond + case .correlatedSubquery(var related, let op, let flip, let scalar): + related.subquery = related.subquery.normalized() + return .correlatedSubquery(related: related, op: op, flip: flip, scalar: scalar) + case .and(let c): + return .and(c.map(normalizeSubqueries)) + case .or(let c): + return .or(c.map(normalizeSubqueries)) + } + } + + static func normalizeRelated(_ related: [CorrelatedSubquery]) -> [CorrelatedSubquery] { + for i in related.indices.dropFirst() where cmpRelated(related[i - 1], related[i]) > 0 { + return JSSort.sorted(related, by: cmpRelated) + } + return related + } + + public static func normalizeCondition(_ cond: Condition) -> Condition { + switch cond { + case .simple, .correlatedSubquery: + return cond + case .and(let children), .or(let children): + let isAnd: Bool + if case .and = cond { isAnd = true } else { isAnd = false } + var conditions: [Condition] = [] + for c in children { + let n = normalizeCondition(c) + switch (isAnd, n) { + case (true, .and(let nested)), (false, .or(let nested)): + conditions.append(contentsOf: nested) + default: + conditions.append(n) + } + } + // A singleton conjunction is the condition itself. Empty ones are + // meaningful: an empty `and` is true and an empty `or` is false. + if conditions.count == 1 { + return conditions[0] + } + conditions = JSSort.sorted(conditions, by: cmpCondition) + return isAnd ? .and(conditions) : .or(conditions) + } + } + + // MARK: - Comparators, ported verbatim (including their asymmetries, since + // the sort order decides the hash). + + static func typeName(_ c: Condition) -> String { + switch c { + case .simple: "simple" + case .and: "and" + case .or: "or" + case .correlatedSubquery: "correlatedSubquery" + } + } + + static func cmpCondition(_ a: Condition, _ b: Condition) -> Int { + if case .simple(let aop, let al, let ar) = a { + guard case .simple(let bop, let bl, let br) = b else { return -1 } + let l = compareValuePosition(al, bl) + if l != 0 { return l } + let o = StringCompare.utf8(aop.rawValue, bop.rawValue) + if o != 0 { return o } + return compareValuePosition(ar, br) + } + if case .simple = b { return 1 } + + if case .correlatedSubquery(let ar, let aop, let af, let asc) = a { + guard case .correlatedSubquery(let br, let bop, let bf, let bsc) = b else { return -1 } + let r = cmpRelated(ar, br) + if r != 0 { return r } + let o = StringCompare.utf8(aop.rawValue, bop.rawValue) + if o != 0 { return o } + let f = cmpOptionalBool(af, bf) + if f != 0 { return f } + return cmpOptionalBool(asc, bsc) + } + if case .correlatedSubquery = b { return -1 } + + let t = StringCompare.utf8(typeName(a), typeName(b)) + if t != 0 { return t } + let ac = a.conditions! + let bc = b.conditions! + for (l, r) in zip(ac, bc) { + let v = cmpCondition(l, r) + if v != 0 { return v } + } + return ac.count - bc.count + } + + static func valuePositionType(_ v: ValuePosition) -> String { + switch v { + case .literal: "literal" + case .column: "column" + case .static: "static" + } + } + + static func compareValuePosition(_ a: ValuePosition, _ b: ValuePosition) -> Int { + switch (a, b) { + case (.literal(let x), .literal(let y)): + return cmpLiteralValue(x, y) + case (.column(let x), .column(let y)): + return StringCompare.utf8(x, y) + case (.static(let aa, let af), .static(let ba, let bf)): + let c = StringCompare.utf8(aa.rawValue, ba.rawValue) + if c != 0 { return c } + return cmpLiteralValue(af.json, bf.json) + default: + return StringCompare.utf8(valuePositionType(a), valuePositionType(b)) + } + } + + // null < boolean < number < string < array. + static func literalValueType(_ v: JSONValue) -> Int { + switch v { + case .null, .object: 0 + case .bool: 1 + case .number: 2 + case .string: 3 + case .array: 4 + } + } + + static func cmpLiteralValue(_ a: JSONValue, _ b: JSONValue) -> Int { + let t = literalValueType(a) - literalValueType(b) + if t != 0 { return t } + switch (a, b) { + case (.array(let x), .array(let y)): + for (l, r) in zip(x, y) { + let v = cmpLiteralValue(l, r) + if v != 0 { return v } + } + return x.count - y.count + case (.string(let x), .string(let y)): + return StringCompare.utf8(x, y) + case (.number(let x), .number(let y)): + return x < y ? -1 : x > y ? 1 : 0 + case (.bool(let x), .bool(let y)): + return (x ? 1 : 0) - (y ? 1 : 0) + default: + return 0 + } + } + + static func cmpRelated(_ a: CorrelatedSubquery, _ b: CorrelatedSubquery) -> Int { + StringCompare.utf8(a.subquery.alias ?? "", b.subquery.alias ?? "") + } + + static func cmpOptionalBool(_ a: Bool?, _ b: Bool?) -> Int { + func n(_ v: Bool?) -> Int { v == nil ? 0 : v! ? 2 : 1 } + return n(a) - n(b) + } +} + +extension AST.StaticField { + var json: JSONValue { + switch self { + case .single(let s): .string(s) + case .path(let p): .array(p.map { .string($0) }) + } + } +} diff --git a/swift/Sources/Zero/Client/Keys.swift b/swift/Sources/Zero/Client/Keys.swift new file mode 100644 index 0000000000..c2e40b256b --- /dev/null +++ b/swift/Sources/Zero/Client/Keys.swift @@ -0,0 +1,61 @@ +/// Storage key layout (`packages/zero-client/src/client/keys.ts`). +public enum Keys { + public static let desiredQueriesPrefix = "d/" + public static let gotQueriesPrefix = "g/" + public static let entitiesPrefix = "e/" + public static let mutationsPrefix = "m/" + + public struct InvalidPrimaryKeyValue: Error, CustomStringConvertible { + public let column: String + public let value: JSONValue? + public var description: String { + "Invalid primary key value for \(column): \(value.map(JSONSerializer.stringify) ?? "undefined")" + } + } + + /// `toPrimaryKeyString`: `e//`. A single-column key is the value + /// as JS `String()` prints it; a compound key is the decimal `h128` of the + /// JSON array of its values. + public static func primaryKeyString( + table: String, primaryKey: [String], row: Row + ) throws(InvalidPrimaryKeyValue) -> String { + func scalar(_ column: String) throws(InvalidPrimaryKeyValue) -> JSONValue { + let v = row[column] + switch v { + case .string?, .number?, .bool?: return v! + default: throw InvalidPrimaryKeyValue(column: column, value: v) + } + } + if primaryKey.count == 1 { + let v = try scalar(primaryKey[0]) + return entitiesPrefix + table + "/" + jsString(v) + } + var values: [JSONValue] = [] + for column in primaryKey { values.append(try scalar(column)) } + let hash = XXHash32.h128(JSONSerializer.stringify(.array(values))) + return entitiesPrefix + table + "/" + String(hash) + } + + /// JS `String(v)` for a scalar. + static func jsString(_ v: JSONValue) -> String { + switch v { + case .string(let s): s + case .number(let n): JSNumber.format(n) + case .bool(let b): b ? "true" : "false" + case .null: "null" + case .array, .object: JSONSerializer.stringify(v) + } + } + + public static func desiredQueriesKey(clientID: String, hash: String) -> String { + desiredQueriesPrefix + clientID + "/" + hash + } + + public static func gotQueriesKey(hash: String) -> String { + gotQueriesPrefix + hash + } + + public static func mutationResponseKey(clientID: String, id: Double) -> String { + mutationsPrefix + clientID + "/" + JSNumber.format(id) + } +} diff --git a/swift/Sources/Zero/Hash/QueryHash.swift b/swift/Sources/Zero/Hash/QueryHash.swift new file mode 100644 index 0000000000..966403e2b8 --- /dev/null +++ b/swift/Sources/Zero/Hash/QueryHash.swift @@ -0,0 +1,276 @@ +/// Query identities, ported from `packages/zero-protocol/src/query-hash-visitor.ts`. +/// +/// The JS implementation folds an AST (or a name and args) into two xxHash32 +/// lanes word by word. These digests are the query IDs the client registers, +/// so the Swift client reproduces them exactly. +public enum QueryHash { + struct State { + var h1: UInt32 = XXHash32.prime5 + var h2: UInt32 = XXHash32.prime1 + + @inline(__always) mutating func mix(_ w: UInt32) { + h1 = XXHash32.round(h1, w) + h2 = XXHash32.round(h2, w) + } + + @inline(__always) mutating func mix(_ w: Int) { + mix(UInt32(bitPattern: Int32(truncatingIfNeeded: w))) + } + + mutating func mixString(_ s: String) { + let units = Array(s.utf16) + let n = units.count + mix(UInt32(truncatingIfNeeded: n) | strMark) + var i = 0 + while i + 1 < n { + mix(UInt32(units[i]) | UInt32(units[i + 1]) << 16) + i += 2 + } + if i < n { + mix(UInt32(units[i])) + } + } + + mutating func mixNumber(_ n: Double) { + if JSNumber.isInt32(n) { + mix(JSNumber.int32Bits(n)) + } else { + let bits = n.bitPattern // little-endian words: low first + mix(UInt32(truncatingIfNeeded: bits)) + mix(UInt32(truncatingIfNeeded: bits >> 32)) + } + } + + func finalize() -> String { + func base36(_ v: UInt32) -> String { + let s = String(v, radix: 36) + return String(repeating: "0", count: max(0, 7 - s.count)) + s + } + return base36(XXHash32.avalanche(h1)) + base36(XXHash32.avalanche(h2)) + } + } + + static let tagNull: UInt32 = 0x1001 + static let tagFalse: UInt32 = 0x1002 + static let tagTrue: UInt32 = 0x1003 + static let tagInt: UInt32 = 0x1004 + static let tagFloat: UInt32 = 0x1005 + static let tagArr: UInt32 = 0x1007 + static let tagObj: UInt32 = 0x1008 + static let tagEnd: UInt32 = 0x1009 + static let tagUndef: UInt32 = 0x100a + + static let tagLiteral: UInt32 = 0x2001 + static let tagColumn: UInt32 = 0x2002 + static let tagStatic: UInt32 = 0x2003 + static let tagSimple: UInt32 = 0x2004 + static let tagSubquery: UInt32 = 0x2005 + static let tagAnd: UInt32 = 0x2006 + static let tagOr: UInt32 = 0x2007 + static let tagFormat: UInt32 = 0x2008 + static let tagNameArgs: UInt32 = 0x2009 + static let tagSystemPermissions: UInt32 = 0x200a + static let tagSystemClient: UInt32 = 0x200b + static let tagSystemTest: UInt32 = 0x200c + static let tagJunction: UInt32 = 0x200d + + static let strMark: UInt32 = 0x4000_0000 + + /// `hashOfAST`: the hash of `ast` after normalizing it. + public static func hashOfAST(_ ast: AST) -> String { + hashOfNormalizedAST(ast.normalized()) + } + + /// The hash of an AST that is already normalized. + public static func hashOfNormalizedAST(_ ast: AST) -> String { + var s = State() + s.visitAST(ast) + return s.finalize() + } + + /// `hashOfNameAndArgs`: the identity of a named (custom) query. + public static func hashOfNameAndArgs(_ name: String, _ args: [JSONValue]) -> String { + var s = State() + s.mix(tagNameArgs) + s.mixString(name) + s.visitValue(.array(args)) + return s.finalize() + } + + /// `hashOfQueryInternals`: the identity of a query as the client sees it. + public static func hashOfQueryInternals( + ast: AST, + format: JSONValue, + system: AST.System, + currentJunction: String?, + customQueryName: String?, + customQueryArgs: [JSONValue]? + ) -> String { + var s = State() + s.visitAST(ast) + s.mix(tagFormat) + s.visitValue(format) + s.mixSystem(system) + if let currentJunction { + s.mix(tagJunction) + s.mixString(currentJunction) + } else { + s.mix(tagUndef) + } + if let customQueryName { + s.mix(tagNameArgs) + s.mixString(customQueryName) + if let customQueryArgs { + s.visitValue(.array(customQueryArgs)) + } + } else { + s.mix(tagUndef) + } + return s.finalize() + } +} + +extension QueryHash.State { + typealias H = QueryHash + + mutating func visitValue(_ v: JSONValue) { + switch v { + case .string(let s): + mixString(s) + case .number(let n): + // NaN and Infinity are null on the wire (JSON.stringify writes null). + if !n.isFinite { + mix(H.tagNull) + return + } + mix(JSNumber.isInt32(n) ? H.tagInt : H.tagFloat) + mixNumber(n) + case .bool(let b): + mix(b ? H.tagTrue : H.tagFalse) + case .null: + mix(H.tagNull) + case .array(let a): + mix(H.tagArr) + for e in a { visitValue(e) } + mix(H.tagEnd) + case .object(let o): + mix(H.tagObj) + for k in o.keys.sorted(by: StringCompare.utf16Less) { + mixString(k) + visitValue(o[k]!) + } + mix(H.tagEnd) + } + } + + mutating func visitOptionalString(_ s: String?) { + if let s { mixString(s) } else { mix(H.tagUndef) } + } + + mutating func mixSystem(_ system: AST.System) { + switch system { + case .permissions: mix(H.tagSystemPermissions) + case .client: mix(H.tagSystemClient) + case .test: mix(H.tagSystemTest) + } + } + + mutating func mixOptionalBool(_ b: Bool?) { + switch b { + case nil: mix(H.tagUndef) + case true?: mix(H.tagTrue) + case false?: mix(H.tagFalse) + } + } + + mutating func visitCompoundKey(_ k: [String]) { + mix(k.count) + for s in k { mixString(s) } + } + + mutating func visitOrdering(_ o: AST.Ordering?) { + guard let o else { + mix(H.tagUndef) + return + } + mix(o.count) + for part in o { + mixString(part.field) + mix(part.direction == .asc ? H.tagTrue : H.tagFalse) + } + } + + mutating func visitValuePosition(_ v: AST.ValuePosition) { + switch v { + case .literal(let value): + mix(H.tagLiteral) + visitValue(value) + case .column(let name): + mix(H.tagColumn) + mixString(name) + case .static(let anchor, let field): + mix(H.tagStatic) + mixString(anchor.rawValue) + switch field { + case .single(let f): mixString(f) + case .path(let p): visitCompoundKey(p) + } + } + } + + mutating func visitCondition(_ c: AST.Condition) { + switch c { + case .simple(let op, let left, let right): + mix(H.tagSimple) + mixString(op.rawValue) + visitValuePosition(left) + visitValuePosition(right) + case .correlatedSubquery(let related, let op, let flip, let scalar): + mix(H.tagSubquery) + mixString(op.rawValue) + mixOptionalBool(flip) + mixOptionalBool(scalar) + visitCorrelatedSubquery(related) + case .and(let conditions), .or(let conditions): + if case .and = c { mix(H.tagAnd) } else { mix(H.tagOr) } + mix(conditions.count) + for cond in conditions { visitCondition(cond) } + } + } + + mutating func visitCorrelatedSubquery(_ csq: AST.CorrelatedSubquery) { + visitCompoundKey(csq.correlation.parentField) + visitCompoundKey(csq.correlation.childField) + mixOptionalBool(csq.hidden) + if let system = csq.system { mixSystem(system) } else { mix(H.tagUndef) } + visitAST(csq.subquery) + } + + mutating func visitAST(_ ast: AST) { + mix(H.tagObj) + visitOptionalString(ast.schema) + mixString(ast.table) + visitOptionalString(ast.alias) + if let w = ast.where { visitCondition(w) } else { mix(H.tagUndef) } + if let related = ast.related { + mix(related.count) + for r in related { visitCorrelatedSubquery(r) } + } else { + mix(H.tagUndef) + } + if let start = ast.start { + mix(start.exclusive ? H.tagTrue : H.tagFalse) + visitValue(.object(start.row)) + } else { + mix(H.tagUndef) + } + if let limit = ast.limit { + mix(H.tagInt) + mixNumber(limit) + } else { + mix(H.tagUndef) + } + visitOrdering(ast.orderBy) + mix(H.tagEnd) + } +} diff --git a/swift/Sources/Zero/Hash/XXHash32.swift b/swift/Sources/Zero/Hash/XXHash32.swift new file mode 100644 index 0000000000..f89e759589 --- /dev/null +++ b/swift/Sources/Zero/Hash/XXHash32.swift @@ -0,0 +1,101 @@ +/// xxHash32 over UTF-8 bytes, and the wider `h64`/`h128` built from it +/// (`packages/shared/src/hash.ts`). +public enum XXHash32 { + public static let prime1: UInt32 = 2_654_435_761 + public static let prime2: UInt32 = 2_246_822_519 + public static let prime3: UInt32 = 3_266_489_917 + public static let prime4: UInt32 = 668_265_263 + public static let prime5: UInt32 = 374_761_393 + + @inlinable + public static func round(_ acc: UInt32, _ word: UInt32) -> UInt32 { + var acc = acc &+ word &* prime2 + acc = (acc << 13) | (acc >> 19) + return acc &* prime1 + } + + @inlinable + public static func avalanche(_ acc: UInt32) -> UInt32 { + var acc = acc + acc ^= acc >> 15 + acc = acc &* prime2 + acc ^= acc >> 13 + acc = acc &* prime3 + acc ^= acc >> 16 + return acc + } + + /// Reference xxHash32 of `bytes` with `seed`. + public static func hash(_ bytes: C, seed: UInt32 = 0) -> UInt32 + where C.Element == UInt8, C.Index == Int { + let len = bytes.count + var offset = bytes.startIndex + let endIndex = bytes.endIndex + var acc: UInt32 + + @inline(__always) func word(_ o: Int) -> UInt32 { + UInt32(bytes[o]) | UInt32(bytes[o + 1]) << 8 | UInt32(bytes[o + 2]) << 16 + | UInt32(bytes[o + 3]) << 24 + } + + if len >= 16 { + var v1 = seed &+ prime1 &+ prime2 + var v2 = seed &+ prime2 + var v3 = seed + var v4 = seed &- prime1 + let limit = endIndex - 16 + while offset <= limit { + v1 = round(v1, word(offset)) + v2 = round(v2, word(offset + 4)) + v3 = round(v3, word(offset + 8)) + v4 = round(v4, word(offset + 12)) + offset += 16 + } + acc = + ((v1 << 1) | (v1 >> 31)) &+ ((v2 << 7) | (v2 >> 25)) &+ ((v3 << 12) | (v3 >> 20)) + &+ ((v4 << 18) | (v4 >> 14)) + } else { + acc = seed &+ prime5 + } + + acc = acc &+ UInt32(truncatingIfNeeded: len) + + while offset <= endIndex - 4 { + acc = acc &+ word(offset) &* prime3 + acc = ((acc << 17) | (acc >> 15)) &* prime4 + offset += 4 + } + while offset < endIndex { + acc = acc &+ UInt32(bytes[offset]) &* prime5 + acc = ((acc << 11) | (acc >> 21)) &* prime1 + offset += 1 + } + return avalanche(acc) + } + + /// `h32(s)`: xxHash32 of the UTF-8 bytes of `s`, seed 0. + public static func h32(_ s: String) -> UInt32 { + var s = s + return s.withUTF8 { hash($0, seed: 0) } + } + + /// `h64(s)`: seed-0 digest in the high word, seed-1 in the low word. + public static func h64(_ s: String) -> UInt64 { + var s = s + return s.withUTF8 { buf in + UInt64(hash(buf, seed: 0)) << 32 | UInt64(hash(buf, seed: 1)) + } + } + + /// `h128(s)`: digests for seeds 0...3, highest-order word first. + public static func h128(_ s: String) -> UInt128 { + var s = s + return s.withUTF8 { buf in + var r: UInt128 = 0 + for seed: UInt32 in 0..<4 { + r = r << 32 | UInt128(hash(buf, seed: seed)) + } + return r + } + } +} diff --git a/swift/Sources/Zero/IVM/Data.swift b/swift/Sources/Zero/IVM/Data.swift new file mode 100644 index 0000000000..0112ac5f56 --- /dev/null +++ b/swift/Sources/Zero/IVM/Data.swift @@ -0,0 +1,92 @@ +/// Value comparison for IVM (`packages/zql/src/ivm/data.ts`). +public enum ValueCompare { + public struct IncomparableError: Error, CustomStringConvertible { + public let description: String + } + + /// `compareValues(a, b)`: strings by UTF-8 bytes, numbers numerically, + /// `false < true`, and `null` before everything. Values of different + /// non-null types, and arrays or objects, can't be compared: JS throws. + public static func compareChecked(_ a: Value, _ b: Value) throws(IncomparableError) -> Int { + switch (a, b) { + case (.string(let x), .string(let y)): + return StringCompare.utf8(x, y) + case (.number(let x), .number(let y)): + return x < y ? -1 : x > y ? 1 : 0 + case (.bool(let x), .bool(let y)): + return x == y ? 0 : x ? 1 : -1 + case (.null, .null): + return 0 + case (.null, _): + return -1 + case (_, .null): + return 1 + default: + if a.typeName != b.typeName { + throw IncomparableError( + description: "Cannot compare values of different types: \(a.typeName) and \(b.typeName)") + } + throw IncomparableError(description: "Unsupported type: \(a.typeName)") + } + } + + /// `compareChecked`, with a total order for the inputs JS throws on: by + /// type, then by JSON encoding. Pipelines use this so a bad `orderBy` (on + /// a json column) degrades instead of trapping. + @inlinable + public static func compare(_ a: Value, _ b: Value) -> Int { + switch (a, b) { + case (.string(let x), .string(let y)): + return StringCompare.utf8(x, y) + case (.number(let x), .number(let y)): + return x < y ? -1 : x > y ? 1 : 0 + case (.bool(let x), .bool(let y)): + return x == y ? 0 : x ? 1 : -1 + case (.null, .null): + return 0 + case (.null, _): + return -1 + case (_, .null): + return 1 + default: + return fallbackCompare(a, b) + } + } + + @usableFromInline + static func fallbackCompare(_ a: Value, _ b: Value) -> Int { + func rank(_ v: Value) -> Int { + switch v { + case .null: 0 + case .bool: 1 + case .number: 2 + case .string: 3 + case .array: 4 + case .object: 5 + } + } + let r = rank(a) - rank(b) + if r != 0 { return r } + return StringCompare.utf8(JSONSerializer.stringify(a), JSONSerializer.stringify(b)) + } + + /// `valuesEqual(a, b)`: like `===`, except `null` equals nothing (as in + /// SQL joins). Arrays and objects are never equal: JS compares them by + /// reference. + @inlinable + public static func equal(_ a: Value, _ b: Value) -> Bool { + switch (a, b) { + case (.null, _), (_, .null): return false + case (.string(let x), .string(let y)): return x == y + case (.number(let x), .number(let y)): return x == y + case (.bool(let x), .bool(let y)): return x == y + default: return false + } + } + + /// A row's value for `column`; a missing column reads as `null`. + @inlinable + public static func value(_ row: Row, _ column: String) -> Value { + row[column] ?? .null + } +} diff --git a/swift/Sources/Zero/JSON/JSNumber.swift b/swift/Sources/Zero/JSON/JSNumber.swift new file mode 100644 index 0000000000..175c0a2766 --- /dev/null +++ b/swift/Sources/Zero/JSON/JSNumber.swift @@ -0,0 +1,164 @@ +/// JS number semantics the client has to reproduce exactly: `Number#toString` +/// (also what `JSON.stringify` writes) and `Number(string)`. +public enum JSNumber { + /// `String(n)` in JS, i.e. ECMAScript `Number::toString(n, 10)`. + /// + /// Uses the shortest round-tripping digits (which Swift's `description` + /// also produces) laid out with the JS rules: plain notation for decimal + /// exponents in `-6...21`, exponential otherwise. + public static func format(_ value: Double) -> String { + if value.isNaN { return "NaN" } + if value == 0 { return "0" } + if value.isInfinite { return value < 0 ? "-Infinity" : "Infinity" } + if value < 0 { return "-" + format(-value) } + + // Integers below 2^53 print exactly; skip the digit extraction. + if value < 9_007_199_254_740_992, value == value.rounded(.towardZero) { + return String(Int64(value)) + } + + let (digits, n) = shortestDigits(value) + let k = digits.count + + if k <= n && n <= 21 { + return digits + String(repeating: "0", count: n - k) + } + if 0 < n && n <= 21 { + let i = digits.index(digits.startIndex, offsetBy: n) + return String(digits[..= 0 ? "+" : "-") + String(abs(e)) + if k == 1 { + return digits + "e" + exponent + } + let first = digits.prefix(1) + return first + "." + digits.dropFirst() + "e" + exponent + } + + /// The shortest digit string `s` and exponent `n` with + /// `value == 0.s × 10^n`, for a positive finite `value`. + static func shortestDigits(_ value: Double) -> (digits: String, n: Int) { + let text = value.description // e.g. "123.45", "1e-07", "1.2345678e+20" + var mantissa = Substring(text) + var exponent = 0 + if let e = text.firstIndex(where: { $0 == "e" || $0 == "E" }) { + mantissa = text[.. "1", moving the point). + while digits.first == "0" && digits.count > 1 { + digits.removeFirst() + n -= 1 + } + while digits.last == "0" && digits.count > 1 { + digits.removeLast() + } + return (digits, n) + } + + /// `Number(string)` in JS (ECMAScript `StringToNumber`): trims JS + /// whitespace, maps the empty string to 0, and accepts decimal, `Infinity`, + /// and `0x`/`0o`/`0b` literals. Anything else is `NaN`. + public static func parse(_ string: String) -> Double { + let scalars = Array(string.unicodeScalars) + var start = 0 + var end = scalars.count + while start < end && isJSWhitespace(scalars[start]) { start += 1 } + while end > start && isJSWhitespace(scalars[end - 1]) { end -= 1 } + if start == end { return 0 } + let s = String(String.UnicodeScalarView(scalars[start.. Bool { + var bytes = Substring(s).utf8[...] + if let f = bytes.first, f == UInt8(ascii: "+") || f == UInt8(ascii: "-") { + bytes = bytes.dropFirst() + } + var intDigits = 0 + while let f = bytes.first, f >= 0x30 && f <= 0x39 { + intDigits += 1 + bytes = bytes.dropFirst() + } + var fracDigits = 0 + if bytes.first == UInt8(ascii: ".") { + bytes = bytes.dropFirst() + while let f = bytes.first, f >= 0x30 && f <= 0x39 { + fracDigits += 1 + bytes = bytes.dropFirst() + } + } + if intDigits == 0 && fracDigits == 0 { return false } + if let f = bytes.first, f == UInt8(ascii: "e") || f == UInt8(ascii: "E") { + bytes = bytes.dropFirst() + if let f = bytes.first, f == UInt8(ascii: "+") || f == UInt8(ascii: "-") { + bytes = bytes.dropFirst() + } + var expDigits = 0 + while let f = bytes.first, f >= 0x30 && f <= 0x39 { + expDigits += 1 + bytes = bytes.dropFirst() + } + if expDigits == 0 { return false } + } + return bytes.isEmpty + } + + /// ECMAScript `WhiteSpace` plus `LineTerminator`. + static func isJSWhitespace(_ s: Unicode.Scalar) -> Bool { + switch s.value { + case 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x20, 0xA0, 0x1680, 0x2000...0x200A, + 0x2028, 0x2029, 0x202F, 0x205F, 0x3000, 0xFEFF: + true + default: + false + } + } + + /// `(n | 0) === n` in JS: whether `n` is exactly an int32. `-0` counts, + /// because `-0 === 0`. + @inlinable + public static func isInt32(_ n: Double) -> Bool { + n >= -2_147_483_648 && n <= 2_147_483_647 && n == n.rounded(.towardZero) + } + + /// `ToInt32(n)` in JS, for an `n` that `isInt32` accepted. + @inlinable + public static func int32Bits(_ n: Double) -> UInt32 { + UInt32(bitPattern: Int32(n)) + } +} diff --git a/swift/Sources/Zero/JSON/JSONDecoding.swift b/swift/Sources/Zero/JSON/JSONDecoding.swift new file mode 100644 index 0000000000..cea3b11b09 --- /dev/null +++ b/swift/Sources/Zero/JSON/JSONDecoding.swift @@ -0,0 +1,212 @@ +/// Errors from decoding a `JSONValue` into a typed protocol value. The path +/// points at the offending value, like valita's error paths. +public struct DecodingError: Error, CustomStringConvertible, Equatable { + public var path: [String] + public var message: String + + public init(_ message: String, path: [String] = []) { + self.message = message + self.path = path + } + + public var description: String { + path.isEmpty ? message : "\(message) at \(path.joined(separator: "."))" + } + + func at(_ key: String) -> DecodingError { + DecodingError(message, path: [key] + path) + } +} + +/// Helpers for hand-written decoders that follow valita's rules: optional +/// fields may be absent but not `null` (unless declared nullable), unknown +/// keys are ignored (`passthrough` mode). +public struct JSONObjectReader { + public let object: [String: JSONValue] + + public init(_ value: JSONValue, _ what: String = "object") throws(DecodingError) { + guard case .object(let o) = value else { + throw DecodingError("Expected \(what), got \(value.typeName)") + } + object = o + } + + public func required( + _ key: String, _ decode: (JSONValue) throws(DecodingError) -> T + ) throws(DecodingError) -> T { + guard let v = object[key] else { throw DecodingError("Missing property", path: [key]) } + do { + return try decode(v) + } catch { + throw error.at(key) + } + } + + public func optional( + _ key: String, _ decode: (JSONValue) throws(DecodingError) -> T + ) throws(DecodingError) -> T? { + guard let v = object[key] else { return nil } + do { + return try decode(v) + } catch { + throw error.at(key) + } + } + + public func string(_ key: String) throws(DecodingError) -> String { + try required(key, JSONDecode.string) + } + + public func optionalString(_ key: String) throws(DecodingError) -> String? { + try optional(key, JSONDecode.string) + } + + public func number(_ key: String) throws(DecodingError) -> Double { + try required(key, JSONDecode.number) + } + + public func optionalNumber(_ key: String) throws(DecodingError) -> Double? { + try optional(key, JSONDecode.number) + } + + public func bool(_ key: String) throws(DecodingError) -> Bool { + try required(key, JSONDecode.bool) + } + + public func optionalBool(_ key: String) throws(DecodingError) -> Bool? { + try optional(key, JSONDecode.bool) + } + + /// `v.literal(expected)`. + public func literal(_ key: String, _ expected: String) throws(DecodingError) { + let s = try string(key) + if s != expected { + throw DecodingError("Expected literal \"\(expected)\", got \"\(s)\"", path: [key]) + } + } +} + +public enum JSONDecode { + public static func string(_ v: JSONValue) throws(DecodingError) -> String { + guard case .string(let s) = v else { throw DecodingError("Expected string, got \(v.typeName)") } + return s + } + + public static func number(_ v: JSONValue) throws(DecodingError) -> Double { + guard case .number(let n) = v else { throw DecodingError("Expected number, got \(v.typeName)") } + return n + } + + public static func bool(_ v: JSONValue) throws(DecodingError) -> Bool { + guard case .bool(let b) = v else { throw DecodingError("Expected boolean, got \(v.typeName)") } + return b + } + + public static func object(_ v: JSONValue) throws(DecodingError) -> [String: JSONValue] { + guard case .object(let o) = v else { throw DecodingError("Expected object, got \(v.typeName)") } + return o + } + + public static func array(_ v: JSONValue) throws(DecodingError) -> [JSONValue] { + guard case .array(let a) = v else { throw DecodingError("Expected array, got \(v.typeName)") } + return a + } + + /// Any JSON value (`jsonSchema`). + public static func json(_ v: JSONValue) throws(DecodingError) -> JSONValue { v } + + public static func array( + _ v: JSONValue, _ element: (JSONValue) throws(DecodingError) -> T + ) throws(DecodingError) -> [T] { + let a = try array(v) + var out: [T] = [] + out.reserveCapacity(a.count) + for (i, e) in a.enumerated() { + do { + out.append(try element(e)) + } catch { + throw error.at(String(i)) + } + } + return out + } + + /// `v.record(element)`. + public static func record( + _ v: JSONValue, _ element: (JSONValue) throws(DecodingError) -> T + ) throws(DecodingError) -> [String: T] { + let o = try object(v) + var out: [String: T] = [:] + out.reserveCapacity(o.count) + for (k, e) in o { + do { + out[k] = try element(e) + } catch { + throw error.at(k) + } + } + return out + } + + /// A non-empty array of strings (`CompoundKey`, `PrimaryKey`). + public static func compoundKey(_ v: JSONValue) throws(DecodingError) -> [String] { + let k = try array(v, string) + if k.isEmpty { throw DecodingError("Expected non-empty array") } + return k + } + + /// A string, number, or boolean (`primaryKeyValueSchema`). + public static func primaryKeyValue(_ v: JSONValue) throws(DecodingError) -> JSONValue { + switch v { + case .string, .number, .bool: return v + default: throw DecodingError("Expected string, number, or boolean, got \(v.typeName)") + } + } + + /// A tuple message `[tag, body]`. + public static func tagged(_ v: JSONValue) throws(DecodingError) -> (String, JSONValue) { + guard case .array(let a) = v, a.count == 2, case .string(let tag) = a[0] else { + throw DecodingError("Expected [tag, body] tuple") + } + return (tag, a[1]) + } +} + +/// Builds a JSON object, leaving out `nil` fields. +public struct JSONObjectBuilder { + public var object: [String: JSONValue] = [:] + + public init() {} + + public mutating func set(_ key: String, _ value: JSONValue) { + object[key] = value + } + + public mutating func set(_ key: String, _ value: JSONValue?) { + if let value { object[key] = value } + } + + public mutating func set(_ key: String, _ value: String?) { + if let value { object[key] = .string(value) } + } + + public mutating func set(_ key: String, _ value: Double?) { + if let value { object[key] = .number(value) } + } + + public mutating func set(_ key: String, _ value: Bool?) { + if let value { object[key] = .bool(value) } + } + + public var value: JSONValue { .object(object) } +} + +/// A value with a JSON representation following the `zero-protocol` schema. +public protocol JSONCodable: Sendable { + init(json: JSONValue) throws(DecodingError) + var json: JSONValue { get } +} + +extension Array where Element == String { + var json: JSONValue { .array(map { .string($0) }) } +} diff --git a/swift/Sources/Zero/JSON/JSONParser.swift b/swift/Sources/Zero/JSON/JSONParser.swift new file mode 100644 index 0000000000..a75f8a6845 --- /dev/null +++ b/swift/Sources/Zero/JSON/JSONParser.swift @@ -0,0 +1,282 @@ +/// Parses JSON text as `JSON.parse` does. +/// +/// Differences from JS are limited to what a Swift `String` can represent: an +/// escaped lone surrogate (`"\ud800"`) becomes U+FFFD. +public struct JSONParser { + public struct Error: Swift.Error, CustomStringConvertible, Equatable { + public let message: String + public let offset: Int + public var description: String { "\(message) at offset \(offset)" } + } + + /// Nesting deeper than this is rejected instead of overflowing the stack. + public static let maxDepth = 5_000 + + public static func parse(_ text: String) throws(Error) -> JSONValue { + var text = text + let result = text.withUTF8 { buffer in + Result { () throws(Error) in try parse(buffer) } + } + return try result.get() + } + + public static func parse(_ bytes: [UInt8]) throws(Error) -> JSONValue { + let result = bytes.withUnsafeBufferPointer { buffer in + Result { () throws(Error) in try parse(buffer) } + } + return try result.get() + } + + public static func parse(_ buffer: UnsafeBufferPointer) throws(Error) -> JSONValue { + var p = JSONParser(buffer) + p.skipWhitespace() + let value = try p.parseValue(depth: 0) + p.skipWhitespace() + if p.i != p.end { throw p.error("Unexpected non-whitespace character after JSON") } + return value + } + + private let b: UnsafeBufferPointer + private var i: Int + private let end: Int + + private init(_ buffer: UnsafeBufferPointer) { + b = buffer + i = 0 + end = buffer.count + } + + private func error(_ message: String) -> Error { Error(message: message, offset: i) } + + private mutating func skipWhitespace() { + while i < end { + switch b[i] { + case 0x20, 0x09, 0x0A, 0x0D: i += 1 + default: return + } + } + } + + private mutating func parseValue(depth: Int) throws(Error) -> JSONValue { + guard i < end else { throw error("Unexpected end of JSON input") } + switch b[i] { + case UInt8(ascii: "{"): return try parseObject(depth: depth) + case UInt8(ascii: "["): return try parseArray(depth: depth) + case UInt8(ascii: "\""): return .string(try parseString()) + case UInt8(ascii: "t"): try expectLiteral("true"); return .bool(true) + case UInt8(ascii: "f"): try expectLiteral("false"); return .bool(false) + case UInt8(ascii: "n"): try expectLiteral("null"); return .null + case UInt8(ascii: "-"), UInt8(ascii: "0")...UInt8(ascii: "9"): return .number(try parseNumber()) + default: throw error("Unexpected token") + } + } + + private mutating func expectLiteral(_ literal: StaticString) throws(Error) { + let n = literal.utf8CodeUnitCount + guard end - i >= n else { throw error("Unexpected end of JSON input") } + let p = literal.utf8Start + for k in 0.. JSONValue { + if depth >= Self.maxDepth { throw error("JSON nested too deeply") } + i += 1 // { + var object: [String: JSONValue] = [:] + skipWhitespace() + if i < end && b[i] == UInt8(ascii: "}") { + i += 1 + return .object(object) + } + while true { + skipWhitespace() + guard i < end else { throw error("Unexpected end of JSON input") } + guard b[i] == UInt8(ascii: "\"") else { throw error("Expected property name") } + let key = try parseString() + skipWhitespace() + guard i < end, b[i] == UInt8(ascii: ":") else { + throw error("Expected ':' after property name") + } + i += 1 + skipWhitespace() + object[key] = try parseValue(depth: depth + 1) + skipWhitespace() + guard i < end else { throw error("Unexpected end of JSON input") } + if b[i] == UInt8(ascii: ",") { + i += 1 + continue + } + if b[i] == UInt8(ascii: "}") { + i += 1 + return .object(object) + } + throw error("Expected ',' or '}' after property value") + } + } + + private mutating func parseArray(depth: Int) throws(Error) -> JSONValue { + if depth >= Self.maxDepth { throw error("JSON nested too deeply") } + i += 1 // [ + var array: [JSONValue] = [] + skipWhitespace() + if i < end && b[i] == UInt8(ascii: "]") { + i += 1 + return .array(array) + } + while true { + skipWhitespace() + array.append(try parseValue(depth: depth + 1)) + skipWhitespace() + guard i < end else { throw error("Unexpected end of JSON input") } + if b[i] == UInt8(ascii: ",") { + i += 1 + continue + } + if b[i] == UInt8(ascii: "]") { + i += 1 + return .array(array) + } + throw error("Expected ',' or ']' after array element") + } + } + + private mutating func parseString() throws(Error) -> String { + i += 1 // opening quote + let start = i + // Fast path: no escapes. + while i < end { + let c = b[i] + if c == UInt8(ascii: "\"") { + let s = String(decoding: UnsafeBufferPointer(rebasing: b[start..= 0xD800 && unit <= 0xDBFF { + // A high surrogate; combine with a following escaped low surrogate. + if end - i >= 6 && b[i] == UInt8(ascii: "\\") && b[i + 1] == UInt8(ascii: "u") { + let save = i + i += 2 + let low = try parseHex4() + if low >= 0xDC00 && low <= 0xDFFF { + unit = 0x10000 + ((unit - 0xD800) << 10) + (low - 0xDC00) + scalar = Unicode.Scalar(unit)! + } else { + i = save + scalar = "\u{FFFD}" + } + } else { + scalar = "\u{FFFD}" + } + } else if unit >= 0xDC00 && unit <= 0xDFFF { + scalar = "\u{FFFD}" + } else { + scalar = Unicode.Scalar(unit)! + } + bytes.append(contentsOf: UTF8.encode(scalar)!) + default: + throw error("Bad escaped character in JSON") + } + } + throw error("Unterminated string in JSON") + } + + private mutating func parseHex4() throws(Error) -> UInt32 { + guard end - i >= 4 else { throw error("Bad Unicode escape") } + var v: UInt32 = 0 + for _ in 0..<4 { + let c = b[i] + let d: UInt32 + switch c { + case UInt8(ascii: "0")...UInt8(ascii: "9"): d = UInt32(c - UInt8(ascii: "0")) + case UInt8(ascii: "a")...UInt8(ascii: "f"): d = UInt32(c - UInt8(ascii: "a") + 10) + case UInt8(ascii: "A")...UInt8(ascii: "F"): d = UInt32(c - UInt8(ascii: "A") + 10) + default: throw error("Bad Unicode escape") + } + v = v << 4 | d + i += 1 + } + return v + } + + private mutating func parseNumber() throws(Error) -> Double { + let start = i + if b[i] == UInt8(ascii: "-") { i += 1 } + guard i < end else { throw error("No number after minus sign") } + if b[i] == UInt8(ascii: "0") { + i += 1 + } else if b[i] >= UInt8(ascii: "1") && b[i] <= UInt8(ascii: "9") { + while i < end && b[i] >= UInt8(ascii: "0") && b[i] <= UInt8(ascii: "9") { i += 1 } + } else { + throw error("No number after minus sign") + } + var isInteger = true + if i < end && b[i] == UInt8(ascii: ".") { + isInteger = false + i += 1 + let fracStart = i + while i < end && b[i] >= UInt8(ascii: "0") && b[i] <= UInt8(ascii: "9") { i += 1 } + if i == fracStart { throw error("Unterminated fractional number") } + } + if i < end && (b[i] == UInt8(ascii: "e") || b[i] == UInt8(ascii: "E")) { + isInteger = false + i += 1 + if i < end && (b[i] == UInt8(ascii: "+") || b[i] == UInt8(ascii: "-")) { i += 1 } + let expStart = i + while i < end && b[i] >= UInt8(ascii: "0") && b[i] <= UInt8(ascii: "9") { i += 1 } + if i == expStart { throw error("Exponent part is missing a number") } + } + // Small integers are the common case (counters, versions, timestamps). + if isInteger && i - start <= 15 { + var v: Int64 = 0 + var k = start + let negative = b[k] == UInt8(ascii: "-") + if negative { k += 1 } + while k < i { + v = v * 10 + Int64(b[k] - UInt8(ascii: "0")) + k += 1 + } + // `-0` must stay negative zero, as JSON.parse("-0") does. + if negative { return v == 0 ? -0.0 : -Double(v) } + return Double(v) + } + let text = String(decoding: UnsafeBufferPointer(rebasing: b[start.. String { + var out = "" + write(value, into: &out) + return out + } + + public static func data(_ value: JSONValue) -> [UInt8] { + Array(stringify(value).utf8) + } + + public static func write(_ value: JSONValue, into out: inout String) { + switch value { + case .null: + out += "null" + case .bool(let b): + out += b ? "true" : "false" + case .number(let n): + out += n.isFinite ? JSNumber.format(n) : "null" + case .string(let s): + writeString(s, into: &out) + case .array(let a): + out += "[" + for (i, e) in a.enumerated() { + if i > 0 { out += "," } + write(e, into: &out) + } + out += "]" + case .object(let o): + out += "{" + var first = true + for key in o.keys.sorted(by: StringCompare.utf16Less) { + if !first { out += "," } + first = false + writeString(key, into: &out) + out += ":" + write(o[key]!, into: &out) + } + out += "}" + } + } + + static let hex: [Character] = Array("0123456789abcdef") + + public static func writeString(_ s: String, into out: inout String) { + out += "\"" + var needsEscape = false + for u in s.utf8 where u < 0x20 || u == 0x22 || u == 0x5C { + needsEscape = true + break + } + if !needsEscape { + out += s + out += "\"" + return + } + for scalar in s.unicodeScalars { + switch scalar.value { + case 0x22: out += "\\\"" + case 0x5C: out += "\\\\" + case 0x08: out += "\\b" + case 0x0C: out += "\\f" + case 0x0A: out += "\\n" + case 0x0D: out += "\\r" + case 0x09: out += "\\t" + case 0..<0x20: + out += "\\u00" + out.append(hex[Int(scalar.value >> 4)]) + out.append(hex[Int(scalar.value & 0xF)]) + default: + out.unicodeScalars.append(scalar) + } + } + out += "\"" + } +} diff --git a/swift/Sources/Zero/JSON/JSONValue.swift b/swift/Sources/Zero/JSON/JSONValue.swift new file mode 100644 index 0000000000..3bc528260a --- /dev/null +++ b/swift/Sources/Zero/JSON/JSONValue.swift @@ -0,0 +1,193 @@ +/// A JSON value, the data model Zero syncs and stores. +/// +/// Numbers are `Double`, as they are in JS: integers are exact up to 2^53. +/// Objects are unordered; everything that depends on key order (hashing, +/// encoding for comparison) sorts keys explicitly. +/// +/// Equality compares strings by code units, as JS does, not by Swift's +/// canonical equivalence: `"é"` and `"e\u{301}"` are different values. +public enum JSONValue: Sendable { + case null + case bool(Bool) + case number(Double) + case string(String) + case array([JSONValue]) + case object([String: JSONValue]) +} + +/// A row: column name to value. A missing column reads as `null`, as an +/// `undefined` property does in JS. +public typealias Row = [String: JSONValue] + +/// A value in a row or a query literal. +public typealias Value = JSONValue + +extension JSONValue: Hashable { + public static func == (lhs: JSONValue, rhs: JSONValue) -> Bool { + switch (lhs, rhs) { + case (.null, .null): true + case (.bool(let a), .bool(let b)): a == b + case (.number(let a), .number(let b)): a == b + case (.string(let a), .string(let b)): StringCompare.equal(a, b) + case (.array(let a), .array(let b)): a == b + case (.object(let a), .object(let b)): + a.count == b.count + && a.allSatisfy { k, v in b[k].map { $0 == v } ?? false } + default: false + } + } + + public func hash(into hasher: inout Hasher) { + switch self { + case .null: + hasher.combine(0) + case .bool(let b): + hasher.combine(1) + hasher.combine(b) + case .number(let n): + hasher.combine(2) + hasher.combine(n) + case .string(let s): + hasher.combine(3) + hasher.combine(s.utf8.count) + for byte in s.utf8 { hasher.combine(byte) } + case .array(let a): + hasher.combine(4) + hasher.combine(a) + case .object(let o): + hasher.combine(5) + // Order-independent: combine each entry's hash commutatively. + var acc = 0 + for (k, v) in o { + var h = Hasher() + h.combine(k) + h.combine(v) + acc ^= h.finalize() + } + hasher.combine(o.count) + hasher.combine(acc) + } + } +} + +extension JSONValue { + public var isNull: Bool { + if case .null = self { return true } + return false + } + + public var stringValue: String? { + if case .string(let s) = self { return s } + return nil + } + + public var numberValue: Double? { + if case .number(let n) = self { return n } + return nil + } + + public var boolValue: Bool? { + if case .bool(let b) = self { return b } + return nil + } + + public var arrayValue: [JSONValue]? { + if case .array(let a) = self { return a } + return nil + } + + public var objectValue: [String: JSONValue]? { + if case .object(let o) = self { return o } + return nil + } + + public subscript(key: String) -> JSONValue? { + if case .object(let o) = self { return o[key] } + return nil + } + + public subscript(index: Int) -> JSONValue? { + if case .array(let a) = self, a.indices.contains(index) { return a[index] } + return nil + } + + /// The JS `typeof`-style name of the value's type, for error messages. + public var typeName: String { + switch self { + case .null: "null" + case .bool: "boolean" + case .number: "number" + case .string: "string" + case .array: "array" + case .object: "object" + } + } +} + +extension JSONValue: ExpressibleByNilLiteral { + public init(nilLiteral: ()) { self = .null } +} + +extension JSONValue: ExpressibleByBooleanLiteral { + public init(booleanLiteral value: Bool) { self = .bool(value) } +} + +extension JSONValue: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { self = .number(Double(value)) } +} + +extension JSONValue: ExpressibleByFloatLiteral { + public init(floatLiteral value: Double) { self = .number(value) } +} + +extension JSONValue: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { self = .string(value) } +} + +extension JSONValue: ExpressibleByArrayLiteral { + public init(arrayLiteral elements: JSONValue...) { self = .array(elements) } +} + +extension JSONValue: ExpressibleByDictionaryLiteral { + public init(dictionaryLiteral elements: (String, JSONValue)...) { + var o: [String: JSONValue] = [:] + o.reserveCapacity(elements.count) + for (k, v) in elements { o[k] = v } + self = .object(o) + } +} + +extension JSONValue: CustomStringConvertible { + public var description: String { JSONSerializer.stringify(self) } +} + +extension JSONValue: Codable { + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let b = try? container.decode(Bool.self) { + self = .bool(b) + } else if let n = try? container.decode(Double.self) { + self = .number(n) + } else if let s = try? container.decode(String.self) { + self = .string(s) + } else if let a = try? container.decode([JSONValue].self) { + self = .array(a) + } else { + self = .object(try container.decode([String: JSONValue].self)) + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: try container.encodeNil() + case .bool(let b): try container.encode(b) + case .number(let n): try container.encode(n) + case .string(let s): try container.encode(s) + case .array(let a): try container.encode(a) + case .object(let o): try container.encode(o) + } + } +} diff --git a/swift/Sources/Zero/Query/Like.swift b/swift/Sources/Zero/Query/Like.swift new file mode 100644 index 0000000000..c8a286064a --- /dev/null +++ b/swift/Sources/Zero/Query/Like.swift @@ -0,0 +1,159 @@ +/// `LIKE` and `ILIKE` (`packages/zql/src/builder/like.ts`). +/// +/// JS compiles a pattern without wildcards to a (lowercased) string +/// comparison, and anything else to a non-Unicode `RegExp` with the `s` +/// flag. That regexp works on UTF-16 code units: `_` matches one code unit +/// (half of a surrogate pair), and `i` folds case per code unit through +/// simple uppercasing. This reproduces both paths. +public struct LikePattern: Sendable { + public struct InvalidPattern: Error, CustomStringConvertible { + public let description: String + } + + enum Token: Sendable, Equatable { + case any // % + case one // _ + case unit(UInt16) + } + + enum Matcher: Sendable { + case exact(String) + case lowercased(String) + case wildcard([Token], caseInsensitive: Bool) + } + + let matcher: Matcher + + public init(_ pattern: String, caseInsensitive: Bool) throws(InvalidPattern) { + let units = Array(pattern.utf16) + let hasSpecial = units.contains { $0 == 0x5F || $0 == 0x25 || $0 == 0x5C } + if !hasSpecial { + matcher = caseInsensitive ? .lowercased(JSCase.lowercase(pattern)) : .exact(pattern) + return + } + var tokens: [Token] = [] + var i = 0 + while i < units.count { + let c = units[i] + switch c { + case 0x25: tokens.append(.any) + case 0x5F: tokens.append(.one) + case 0x5C: + if i == units.count - 1 { + throw InvalidPattern(description: "LIKE pattern must not end with escape character") + } + i += 1 + tokens.append(.unit(caseInsensitive ? JSCase.canonicalize(units[i]) : units[i])) + default: + tokens.append(.unit(caseInsensitive ? JSCase.canonicalize(c) : c)) + } + i += 1 + } + matcher = .wildcard(tokens, caseInsensitive: caseInsensitive) + } + + public func matches(_ input: String) -> Bool { + switch matcher { + case .exact(let p): + // Code unit equality: Swift's `==` would equate canonically + // equivalent strings, which JS does not. + return input.utf16.elementsEqual(p.utf16) + case .lowercased(let p): + return JSCase.lowercase(input).utf16.elementsEqual(p.utf16) + case .wildcard(let tokens, let ci): + var units = Array(input.utf16) + if ci { + for k in units.indices { units[k] = JSCase.canonicalize(units[k]) } + } + return Self.match(tokens, units) + } + } + + /// Full-string wildcard match with backtracking to the last `%`. + static func match(_ p: [Token], _ s: [UInt16]) -> Bool { + var pi = 0 + var si = 0 + var starP = -1 + var starS = 0 + while si < s.count { + if pi < p.count { + switch p[pi] { + case .any: + starP = pi + starS = si + pi += 1 + continue + case .one: + pi += 1 + si += 1 + continue + case .unit(let u) where u == s[si]: + pi += 1 + si += 1 + continue + default: + break + } + } + if starP >= 0 { + pi = starP + 1 + starS += 1 + si = starS + continue + } + return false + } + while pi < p.count, p[pi] == .any { pi += 1 } + return pi == p.count + } +} + +/// JS case mapping. +enum JSCase { + /// `String.prototype.toLowerCase`: full Unicode lowercase mapping, with + /// the context-dependent final sigma. + static func lowercase(_ s: String) -> String { + let scalars = Array(s.unicodeScalars) + var out = String.UnicodeScalarView() + for (i, scalar) in scalars.enumerated() { + if scalar == "\u{03A3}" { + out.append(isFinalSigma(scalars, i) ? "\u{03C2}" : "\u{03C3}") + continue + } + out.append(contentsOf: scalar.properties.lowercaseMapping.unicodeScalars) + } + return String(out) + } + + /// Unicode's Final_Sigma condition: preceded by a cased letter (skipping + /// case-ignorable characters) and not followed by one. + static func isFinalSigma(_ scalars: [Unicode.Scalar], _ i: Int) -> Bool { + var before = false + var j = i - 1 + while j >= 0 { + let p = scalars[j].properties + if p.isCaseIgnorable { j -= 1; continue } + before = p.isCased + break + } + if !before { return false } + j = i + 1 + while j < scalars.count { + let p = scalars[j].properties + if p.isCaseIgnorable { j += 1; continue } + return !p.isCased + } + return true + } + + /// `Canonicalize` for a non-Unicode, ignore-case `RegExp`: uppercase the + /// code unit if that yields exactly one code unit, unless it would map a + /// non-ASCII unit to ASCII. + static func canonicalize(_ unit: UInt16) -> UInt16 { + guard let scalar = Unicode.Scalar(unit) else { return unit } // lone surrogate + let upper = scalar.properties.uppercaseMapping.utf16 + guard upper.count == 1, let cu = upper.first else { return unit } + if unit >= 128 && cu < 128 { return unit } + return cu + } +} diff --git a/swift/Sources/Zero/Query/TTL.swift b/swift/Sources/Zero/Query/TTL.swift new file mode 100644 index 0000000000..7438e6468e --- /dev/null +++ b/swift/Sources/Zero/Query/TTL.swift @@ -0,0 +1,117 @@ +/// Query time-to-live (`packages/zql/src/query/ttl.ts`). +/// +/// `forever` and negative durations never expire, `none` expires +/// immediately. Durations are clamped to 10 minutes when sent to the server. +public enum TTL: Sendable, Hashable { + case none + case forever + case milliseconds(Double) + /// A duration string as JS accepts it: a number followed by `s`, `m`, `h`, + /// `d`, or `y` (for example `"5m"`). + case string(String) + + public static func seconds(_ n: Double) -> TTL { .string(JSNumber.format(n) + "s") } + public static func minutes(_ n: Double) -> TTL { .string(JSNumber.format(n) + "m") } + public static func hours(_ n: Double) -> TTL { .string(JSNumber.format(n) + "h") } + public static func days(_ n: Double) -> TTL { .string(JSNumber.format(n) + "d") } + + public static let defaultTTL: TTL = .string("5m") + public static let defaultTTLMilliseconds: Double = 5 * 60 * 1000 + public static let defaultPreloadTTL: TTL = .none + public static let maxTTL: TTL = .string("10m") + public static let maxTTLMilliseconds: Double = 10 * 60 * 1000 + + static let multipliers: [Character: Double] = [ + "s": 1000, + "m": 60 * 1000, + "h": 60 * 60 * 1000, + "d": 24 * 60 * 60 * 1000, + "y": 365 * 24 * 60 * 60 * 1000, + ] + + /// `parseTTL`: milliseconds, with `-1` for never expiring. + public var parsed: Double { + switch self { + case .none: return 0 + case .forever: return -1 + case .milliseconds(let n): + if n.isNaN { return 0 } + return !n.isFinite || n < 0 ? -1 : n + case .string(let s): + if s == "none" { return 0 } + if s == "forever" { return -1 } + guard let unit = s.last, let multiplier = Self.multipliers[unit] else { return .nan } + return JSNumber.parse(String(s.dropLast())) * multiplier + } + } + + /// `compareTTL`: never-expiring sorts last. + public static func compare(_ a: TTL, _ b: TTL) -> Double { + let ap = a.parsed + let bp = b.parsed + if ap == -1 && bp != -1 { return 1 } + if ap != -1 && bp == -1 { return -1 } + return ap - bp + } + + /// `normalizeTTL`: the shortest equivalent spelling of a number of + /// milliseconds; strings are returned as is. + public var normalized: TTL { + guard case .milliseconds(let ttl) = self else { return self } + if ttl < 0 { return .forever } + if ttl == 0 { return .none } + var shortest = JSNumber.format(ttl) + let lengthOfNumber = shortest.count + for unit: Character in ["y", "d", "h", "m", "s"] { + let candidate = JSNumber.format(ttl / Self.multipliers[unit]!) + String(unit) + if candidate.count < shortest.count { + shortest = candidate + } + } + return shortest.count < lengthOfNumber ? .string(shortest) : self + } + + /// `clampTTL`: milliseconds, at most 10 minutes; never-expiring becomes 10 + /// minutes. + public var clamped: Double { + let p = parsed + if p == -1 || p > Self.maxTTLMilliseconds { + return Self.maxTTLMilliseconds + } + return p + } +} + +extension TTL: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + switch value { + case "none": self = .none + case "forever": self = .forever + default: self = .string(value) + } + } +} + +extension TTL: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { self = .milliseconds(Double(value)) } +} + +extension TTL { + /// The JSON form JS would hold: a string or a number. + public var json: JSONValue { + switch self { + case .none: "none" + case .forever: "forever" + case .milliseconds(let n): .number(n) + case .string(let s): .string(s) + } + } + + public init(json: JSONValue) throws(DecodingError) { + switch json { + case .number(let n): self = .milliseconds(n) + case .string(let s): self = TTL(stringLiteral: s) + default: throw DecodingError("Expected TTL string or number") + } + } +} diff --git a/swift/Sources/Zero/Util/JSSort.swift b/swift/Sources/Zero/Util/JSSort.swift new file mode 100644 index 0000000000..2649abc9bf --- /dev/null +++ b/swift/Sources/Zero/Util/JSSort.swift @@ -0,0 +1,64 @@ +/// `Array.prototype.sort` as V8 implements it. +/// +/// Some comparators the client ports are not consistent (the AST condition +/// comparator orders subqueries before conjunctions from both sides), so the +/// result depends on the exact sequence of comparisons. V8's TimSort, for +/// arrays shorter than its minimum run of 64, finds the leading run (reversing +/// it if strictly descending) and binary-insertion-sorts the rest; that is +/// reproduced here exactly. Longer arrays use the same insertion sort, which +/// matches V8 for consistent comparators (it is stable). +public enum JSSort { + public static func sorted(_ array: [T], by cmp: (T, T) -> Int) -> [T] { + var a = array + sort(&a, by: cmp) + return a + } + + public static func sort(_ a: inout [T], by cmp: (T, T) -> Int) { + let n = a.count + if n < 2 { return } + // CountAndMakeRun + var runLength = 2 + let descending = cmp(a[1], a[0]) < 0 + var previous = a[1] + var i = 2 + while i < n { + let current = a[i] + let order = cmp(current, previous) + if descending { + if order >= 0 { break } + } else { + if order < 0 { break } + } + previous = current + runLength += 1 + i += 1 + } + if descending { + a[0..> 1) + let order = cmp(pivot, a[mid]) + if order < 0 { + right = mid + } else { + left = mid + 1 + } + } + var p = start + while p > left { + a[p] = a[p - 1] + p -= 1 + } + a[left] = pivot + start += 1 + } + } +} diff --git a/swift/Sources/Zero/Util/StringCompare.swift b/swift/Sources/Zero/Util/StringCompare.swift new file mode 100644 index 0000000000..3f6a377553 --- /dev/null +++ b/swift/Sources/Zero/Util/StringCompare.swift @@ -0,0 +1,56 @@ +/// The two string orders Zero uses. +/// +/// - UTF-8 byte order (code point order) is what `compare-utf8` and SQLite +/// use. Row values, primary keys, and AST normalization sort this way. +/// - UTF-16 code unit order is what JS `<` and `Array#sort` use. Object keys +/// are sorted this way before hashing. +/// +/// Both differ from Swift's `String <`, which compares by Unicode canonical +/// equivalence. +public enum StringCompare { + /// `compareUTF8(a, b)`: negative, zero, or positive. + @inlinable + public static func utf8(_ a: String, _ b: String) -> Int { + // No `a == b` shortcut: Swift's `==` equates canonically equivalent + // strings ("é" and "e\u{301}"), which are distinct in Zero. + var ai = a.utf8.makeIterator() + var bi = b.utf8.makeIterator() + while true { + switch (ai.next(), bi.next()) { + case (nil, nil): return 0 + case (nil, _): return -1 + case (_, nil): return 1 + case let (x?, y?): + if x != y { return x < y ? -1 : 1 } + } + } + } + + @inlinable + public static func utf8Less(_ a: String, _ b: String) -> Bool { utf8(a, b) < 0 } + + /// JS `a < b ? -1 : a > b ? 1 : 0` for strings. + @inlinable + public static func utf16(_ a: String, _ b: String) -> Int { + var ai = a.utf16.makeIterator() + var bi = b.utf16.makeIterator() + while true { + switch (ai.next(), bi.next()) { + case (nil, nil): return 0 + case (nil, _): return -1 + case (_, nil): return 1 + case let (x?, y?): + if x != y { return x < y ? -1 : 1 } + } + } + } + + @inlinable + public static func utf16Less(_ a: String, _ b: String) -> Bool { utf16(a, b) < 0 } + + /// Code unit equality, as JS `===` compares strings. + @inlinable + public static func equal(_ a: String, _ b: String) -> Bool { + a.utf8.elementsEqual(b.utf8) + } +} diff --git a/swift/Tests/ConformanceTests/ASTConformanceTests.swift b/swift/Tests/ConformanceTests/ASTConformanceTests.swift new file mode 100644 index 0000000000..e3446df92c --- /dev/null +++ b/swift/Tests/ConformanceTests/ASTConformanceTests.swift @@ -0,0 +1,31 @@ +import Testing + +@testable import Zero + +@Suite(.enabled(if: Vectors.available)) +struct ASTConformanceTests { + static func check(_ c: JSONValue) throws { + let ast = try AST(json: c["ast"]!) + // Decoding then encoding is lossless. + #expect(ast.json == c["ast"]!) + let normalized = ast.normalized() + #expect(normalized.json == c["normalized"]!, "normalize \(c["ast"]!)") + #expect(QueryHash.hashOfAST(ast) == c["hash"]!.stringValue!, "hash \(c["ast"]!)") + } + + @Test func curated() throws { + for c in try Vectors.cases("ast") { try Self.check(c) } + } + + @Test(.enabled(if: Vectors.generatedAvailable)) func fuzz() throws { + for c in try Vectors.generatedCases("ast-fuzz") { try Self.check(c) } + } + + @Test func nameAndArgsHash() throws { + for c in try Vectors.cases("name-args-hash") { + let name = c["name"]!.stringValue! + let args = c["args"]!.arrayValue! + #expect(QueryHash.hashOfNameAndArgs(name, args) == c["hash"]!.stringValue!, "\(name) \(args)") + } + } +} diff --git a/swift/Tests/ConformanceTests/JSONConformanceTests.swift b/swift/Tests/ConformanceTests/JSONConformanceTests.swift new file mode 100644 index 0000000000..a0962aa5dd --- /dev/null +++ b/swift/Tests/ConformanceTests/JSONConformanceTests.swift @@ -0,0 +1,73 @@ +import Testing + +@testable import Zero + +func double(fromBits hex: String) -> Double { + Double(bitPattern: UInt64(hex, radix: 16)!) +} + +@Suite(.enabled(if: Vectors.available)) +struct JSONConformanceTests { + struct NumberFormat: Decodable { + let bits: String + let string: String + } + + @Test func numberFormat() throws { + for c in try Vectors.load("number-format", as: NumberFormat.self) { + #expect(JSNumber.format(double(fromBits: c.bits)) == c.string, "bits \(c.bits)") + } + } + + struct NumberParse: Decodable { + let input: String + let bits: String + } + + @Test func numberParse() throws { + for c in try Vectors.load("number-parse", as: NumberParse.self) { + let got = JSNumber.parse(c.input) + let want = double(fromBits: c.bits) + #expect( + got.bitPattern == want.bitPattern || (got.isNaN && want.isNaN), + "Number(\(String(reflecting: c.input))) = \(got), want \(want)") + } + } + + struct Parse: Decodable { + let input: String + let ok: Bool + let stringify: String? + } + + @Test func parse() throws { + for c in try Vectors.load("json-parse", as: Parse.self) { + let result = Result { () throws(JSONParser.Error) in try JSONParser.parse(c.input) } + switch result { + case .success(let value): + #expect(c.ok, "accepted \(String(reflecting: c.input))") + if let stringify = c.stringify { + // Key order differs (JS keeps insertion order), so compare the + // re-encodings as values. Re-encoding maps non-finite numbers to + // null on both sides. + let ours = try JSONParser.parse(JSONSerializer.stringify(value)) + #expect(ours == (try JSONParser.parse(stringify)), "\(String(reflecting: c.input))") + } + case .failure: + #expect(!c.ok, "rejected \(String(reflecting: c.input))") + } + } + } + + struct Stringify: Decodable { + let input: String + let stringify: String + } + + @Test func stringifyStrings() throws { + for c in try Vectors.load("json-stringify-strings", as: Stringify.self) { + #expect( + JSONSerializer.stringify(.string(c.input)) == c.stringify, "\(String(reflecting: c.input))") + } + } +} diff --git a/swift/Tests/ConformanceTests/PrimitiveConformanceTests.swift b/swift/Tests/ConformanceTests/PrimitiveConformanceTests.swift new file mode 100644 index 0000000000..7ea098ba9a --- /dev/null +++ b/swift/Tests/ConformanceTests/PrimitiveConformanceTests.swift @@ -0,0 +1,106 @@ +import Testing + +@testable import Zero + +@Suite(.enabled(if: Vectors.available)) +struct PrimitiveConformanceTests { + struct StringCompareCase: Decodable { + let a: String + let b: String + let utf8: Int + let utf16: Int + } + + @Test func stringCompare() throws { + for c in try Vectors.load("string-compare", as: StringCompareCase.self) { + #expect( + StringCompare.utf8(c.a, c.b).signum() == c.utf8, + "\(c.a.debugDescription) vs \(c.b.debugDescription)") + #expect( + StringCompare.utf16(c.a, c.b).signum() == c.utf16, + "\(c.a.debugDescription) vs \(c.b.debugDescription)") + } + } + + @Test func compareValues() throws { + for c in try Vectors.cases("compare-values") { + let a = c["a"]! + let b = c["b"]! + let expected = c["compare"]! + let got = Result { () throws(ValueCompare.IncomparableError) in + try ValueCompare.compareChecked(a, b) + } + switch (got, expected) { + case (.success(let n), .number(let want)): + #expect(Double(n.signum()) == want, "compare(\(a), \(b))") + case (.failure, .string("throws")): + break + default: + Issue.record("compare(\(a), \(b)) = \(got), want \(expected)") + } + #expect(ValueCompare.equal(a, b) == c["equal"]!.boolValue!, "equal(\(a), \(b))") + } + } + + struct HashCase: Decodable { + let input: String + let h32: UInt32 + let h64: String + let h128: String + } + + @Test func hash() throws { + for c in try Vectors.load("hash", as: HashCase.self) { + #expect(XXHash32.h32(c.input) == c.h32, "h32(\(c.input.debugDescription))") + #expect(String(XXHash32.h64(c.input)) == c.h64, "h64(\(c.input.debugDescription))") + #expect(String(XXHash32.h128(c.input)) == c.h128, "h128(\(c.input.debugDescription))") + } + } + + @Test func primaryKeyString() throws { + for c in try Vectors.cases("primary-key-string") { + let table = c["table"]!.stringValue! + let pk = c["primaryKey"]!.arrayValue!.map { $0.stringValue! } + let row = c["row"]!.objectValue! + #expect( + try Keys.primaryKeyString(table: table, primaryKey: pk, row: row) == c["key"]!.stringValue!, + "\(table) \(pk) \(row)") + } + } + + @Test func like() throws { + for c in try Vectors.cases("like") { + let pattern = c["pattern"]!.stringValue! + let ci = c["flags"]!.stringValue! == "i" + let compiled = Result { () throws(LikePattern.InvalidPattern) in + try LikePattern(pattern, caseInsensitive: ci) + } + guard case .success(let like) = compiled else { + #expect(c["throws"]!.boolValue!, "pattern \(pattern.debugDescription) should compile") + continue + } + #expect(!c["throws"]!.boolValue!, "pattern \(pattern.debugDescription) should throw") + for r in c["results"]!.arrayValue! { + let input = r["input"]!.stringValue! + #expect( + like.matches(input) == r["match"]!.boolValue!, + "\(input.debugDescription) \(ci ? "ILIKE" : "LIKE") \(pattern.debugDescription)") + } + } + } + + @Test func ttl() throws { + let cases = try Vectors.cases("ttl") + let ttls = try cases.map { try TTL(json: $0["ttl"]!) } + for (c, ttl) in zip(cases, ttls) { + #expect(ttl.parsed == c["parsed"]!.numberValue!, "parseTTL(\(ttl))") + #expect(ttl.normalized.json == c["normalized"]!, "normalizeTTL(\(ttl))") + #expect(ttl.clamped == c["clamped"]!.numberValue!, "clampTTL(\(ttl))") + let signs = c["compare"]!.arrayValue!.map { $0.numberValue! } + for (other, want) in zip(ttls, signs) { + let got = TTL.compare(ttl, other) + #expect((got > 0 ? 1.0 : got < 0 ? -1.0 : 0.0) == want, "compareTTL(\(ttl), \(other))") + } + } + } +} diff --git a/swift/Tests/ConformanceTests/Vectors.swift b/swift/Tests/ConformanceTests/Vectors.swift index 2c10efc129..d972eeefab 100644 --- a/swift/Tests/ConformanceTests/Vectors.swift +++ b/swift/Tests/ConformanceTests/Vectors.swift @@ -1,4 +1,5 @@ import Foundation +import Zero /// Locates and decodes the vector files in `packages/zero-conformance/vectors`. /// @@ -21,6 +22,24 @@ enum Vectors { static var available: Bool { directory != nil } + /// `packages/zero-conformance/generated`: seeded fuzz corpora that are not + /// checked in. Run `pnpm run generate` there (CI does) to create them. + static let generatedDirectory: URL? = { + guard let directory else { return nil } + let url = directory.deletingLastPathComponent().appendingPathComponent( + "generated", isDirectory: true) + return FileManager.default.fileExists(atPath: url.path) ? url : nil + }() + + static var generatedAvailable: Bool { generatedDirectory != nil } + + static func generatedCases(_ name: String) throws -> [JSONValue] { + guard let generatedDirectory else { throw VectorError.missingDirectory } + let data = try Data(contentsOf: generatedDirectory.appendingPathComponent("\(name).json")) + let file = try JSONParser.parse(Array(data)) + return file["cases"]?.arrayValue ?? [] + } + struct File: Decodable { let formatVersion: Int let kind: String @@ -40,6 +59,17 @@ enum Vectors { return file.cases } + /// The cases of a vector file parsed with the client's own JSON parser, + /// for vectors that carry arbitrary JSON values. + static func cases(_ name: String) throws -> [JSONValue] { + let file = try JSONParser.parse(Array(try data(name))) + guard file["formatVersion"] == .number(Double(supportedFormatVersion)) else { + throw VectorError.unsupportedFormat( + name: name, version: Int(file["formatVersion"]?.numberValue ?? -1)) + } + return file["cases"]?.arrayValue ?? [] + } + /// Raw bytes of a vector file, for suites that decode it with the client's /// own JSON parser. static func data(_ name: String) throws -> Data { From 68b9132419b1ab9cd4fc63ceab204a28e70abc5a Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 19 Oct 2026 05:39:25 +0000 Subject: [PATCH 005/104] 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 --- .oxfmtrc.json | 1 + .prettierignore | 2 +- packages/zero-conformance/src/ivm/generate.ts | 429 +++++++++++ packages/zero-conformance/src/ivm/harness.ts | 321 ++++++++ packages/zero-conformance/src/ivm/ivm.test.ts | 320 ++++++++ packages/zero-conformance/src/vectors.ts | 19 +- packages/zero-conformance/vectors/ivm.json | 36 + swift/Sources/Zero/Builder/Builder.swift | 597 +++++++++++++++ .../Zero/Builder/PredicatePushdown.swift | 121 +++ swift/Sources/Zero/IVM/Change.swift | 139 ++++ swift/Sources/Zero/IVM/Constraint.swift | 122 +++ swift/Sources/Zero/IVM/Data.swift | 52 +- swift/Sources/Zero/IVM/Filter.swift | 392 ++++++++++ swift/Sources/Zero/IVM/FlippedJoin.swift | 327 ++++++++ swift/Sources/Zero/IVM/Join.swift | 351 +++++++++ swift/Sources/Zero/IVM/JoinUtils.swift | 269 +++++++ swift/Sources/Zero/IVM/MemorySource.swift | 705 ++++++++++++++++++ swift/Sources/Zero/IVM/Operator.swift | 187 +++++ swift/Sources/Zero/IVM/Stream.swift | 89 +++ swift/Sources/Zero/IVM/Take.swift | 650 ++++++++++++++++ swift/Sources/Zero/IVM/UnionFan.swift | 430 +++++++++++ swift/Sources/Zero/IVM/View.swift | 441 +++++++++++ swift/Sources/Zero/Query/AnyQuery.swift | 403 ++++++++++ swift/Sources/Zero/Query/Predicate.swift | 218 ++++++ swift/Sources/Zero/Schema/Schema.swift | 224 ++++++ swift/Sources/Zero/Util/BTree.swift | 316 ++++++++ swift/Sources/Zero/Util/StringCompare.swift | 20 + .../IVMConformanceTests.swift | 200 +++++ swift/Tests/ConformanceTests/Vectors.swift | 16 + swift/Tests/ZeroTests/IVMTests.swift | 79 ++ 30 files changed, 7473 insertions(+), 3 deletions(-) create mode 100644 packages/zero-conformance/src/ivm/generate.ts create mode 100644 packages/zero-conformance/src/ivm/harness.ts create mode 100644 packages/zero-conformance/src/ivm/ivm.test.ts create mode 100644 packages/zero-conformance/vectors/ivm.json create mode 100644 swift/Sources/Zero/Builder/Builder.swift create mode 100644 swift/Sources/Zero/Builder/PredicatePushdown.swift create mode 100644 swift/Sources/Zero/IVM/Change.swift create mode 100644 swift/Sources/Zero/IVM/Constraint.swift create mode 100644 swift/Sources/Zero/IVM/Filter.swift create mode 100644 swift/Sources/Zero/IVM/FlippedJoin.swift create mode 100644 swift/Sources/Zero/IVM/Join.swift create mode 100644 swift/Sources/Zero/IVM/JoinUtils.swift create mode 100644 swift/Sources/Zero/IVM/MemorySource.swift create mode 100644 swift/Sources/Zero/IVM/Operator.swift create mode 100644 swift/Sources/Zero/IVM/Stream.swift create mode 100644 swift/Sources/Zero/IVM/Take.swift create mode 100644 swift/Sources/Zero/IVM/UnionFan.swift create mode 100644 swift/Sources/Zero/IVM/View.swift create mode 100644 swift/Sources/Zero/Query/AnyQuery.swift create mode 100644 swift/Sources/Zero/Query/Predicate.swift create mode 100644 swift/Sources/Zero/Schema/Schema.swift create mode 100644 swift/Sources/Zero/Util/BTree.swift create mode 100644 swift/Tests/ConformanceTests/IVMConformanceTests.swift create mode 100644 swift/Tests/ZeroTests/IVMTests.swift diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 520512f33c..6bc75a1069 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -12,6 +12,7 @@ "sortPackageJson": true, "ignorePatterns": [ "node_modules", + "packages/zero-conformance/vectors", "dist", "lib", "*.log", diff --git a/.prettierignore b/.prettierignore index 352e066788..7d991ce1ee 100644 --- a/.prettierignore +++ b/.prettierignore @@ -15,4 +15,4 @@ vendor !/renderer/src/constants.ts **/bot-recordings.ts .sst -prod/sst/sst-env.d.ts \ No newline at end of file +prod/sst/sst-env.d.tspackages/zero-conformance/vectors diff --git a/packages/zero-conformance/src/ivm/generate.ts b/packages/zero-conformance/src/ivm/generate.ts new file mode 100644 index 0000000000..e729c6db85 --- /dev/null +++ b/packages/zero-conformance/src/ivm/generate.ts @@ -0,0 +1,429 @@ +import type {JSONValue} from '../../../shared/src/json.ts'; +import type {SimpleOperator} from '../../../zero-protocol/src/ast.ts'; +import type {Row} from '../../../zero-protocol/src/data.ts'; +import type {Schema} from '../../../zero-types/src/schema.ts'; +import type {Random} from '../random.ts'; +import type {Expr, Op, Program, Push} from './harness.ts'; + +/** Strings for text columns: orderings differ between UTF-8 and UTF-16. */ +export const texts = [ + '', + 'a', + 'A', + 'b', + 'ab', + 'abc', + 'Bug', + 'bug', + 'é', + 'é', + '\u{1f600}', + '', + 'z%', + 'a_c', + '中文', +]; + +export const numbers = [ + 0, + -0, + 1, + 2, + 2, + 3.5, + -1, + 2 ** 31, + 2 ** 53 - 1, + 1e21, + 0.1, +]; + +const issueIDs = ['i1', 'i2', 'i3', 'i4', 'i5', 'i6', 'i7', 'i8']; +const userIDs = ['u1', 'u2', 'u3', 'u4']; +const labelIDs = ['l1', 'l2', 'l3', 'l4']; +const commentIDs = Array.from({length: 12}, (_, i) => `c${i + 1}`); +const revisionIDs = Array.from({length: 6}, (_, i) => `r${i + 1}`); + +/** Values that rows of each table and column draw from. */ +function columnValue(r: Random, table: string, column: string): JSONValue { + switch (`${table}.${column}`) { + case 'issue.ownerId': + return r.bool(0.2) ? null : r.pick(userIDs); + case 'issue.closed': + return r.bool(); + case 'issue.createdAt': + case 'comment.createdAt': + return r.pick(numbers); + case 'comment.issueId': + return r.bool(0.1) ? 'i-missing' : r.pick(issueIDs); + case 'comment.authorId': + case 'revision.authorId': + return r.pick(userIDs); + case 'revision.commentId': + return r.pick(commentIDs); + case 'issueLabel.issueId': + return r.pick(issueIDs); + case 'issueLabel.labelId': + return r.pick(labelIDs); + case 'user.metadata': + return r.bool(0.4) + ? null + : {registrar: r.pick(['github', 'google']), email: r.pick(texts)}; + default: + return r.pick(texts); + } +} + +function idsFor(table: string): string[] { + switch (table) { + case 'issue': + return issueIDs; + case 'user': + return userIDs; + case 'label': + return labelIDs; + case 'comment': + return commentIDs; + case 'revision': + return revisionIDs; + default: + return []; + } +} + +function makeRow(r: Random, schema: Schema, table: string, id?: string): Row { + const row: Record = {}; + for (const column of Object.keys(schema.tables[table].columns)) { + row[column] = + column === 'id' && id !== undefined ? id : columnValue(r, table, column); + } + return row as Row; +} + +function pkString(schema: Schema, table: string, row: Row): string { + return JSON.stringify(schema.tables[table].primaryKey.map(k => row[k])); +} + +/** Rows for every table; roughly 70% of each table's ids are present. */ +export function makeData(r: Random, schema: Schema): Record { + const data: Record = {}; + for (const table of Object.keys(schema.tables)) { + const rows: Row[] = []; + if (table === 'issueLabel') { + const seen = new Set(); + for (let i = 0; i < 12; i++) { + const row = makeRow(r, schema, table); + const key = pkString(schema, table, row); + if (!seen.has(key)) { + seen.add(key); + rows.push(row); + } + } + } else { + for (const id of idsFor(table)) { + if (r.bool(0.7)) { + rows.push(makeRow(r, schema, table, id)); + } + } + } + data[table] = rows; + } + return data; +} + +/** A sequence of valid source changes against `data`. */ +export function makePushes( + r: Random, + schema: Schema, + data: Record, + count: number, +): Push[] { + const current = new Map>(); + for (const [table, rows] of Object.entries(data)) { + current.set( + table, + new Map(rows.map(row => [pkString(schema, table, row), row])), + ); + } + const tables = Object.keys(schema.tables); + const pushes: Push[] = []; + while (pushes.length < count) { + const table = r.pick(tables); + const rows = current.get(table)!; + const kind = r.int(3); + if (kind === 0) { + const ids = idsFor(table); + const candidates = + table === 'issueLabel' + ? [makeRow(r, schema, table)] + : ids.map(id => makeRow(r, schema, table, id)); + const row = candidates.find(c => !rows.has(pkString(schema, table, c))); + if (!row) { + continue; + } + rows.set(pkString(schema, table, row), row); + pushes.push({table, type: 'add', row}); + } else if (rows.size > 0) { + const existing = r.pick([...rows.values()]); + const key = pkString(schema, table, existing); + if (kind === 1) { + rows.delete(key); + pushes.push({table, type: 'remove', row: existing}); + } else { + const pk = schema.tables[table].primaryKey; + const columns = Object.keys(schema.tables[table].columns).filter( + c => !pk.includes(c), + ); + if (columns.length === 0) { + continue; + } + const row = {...existing} as Record; + const n = 1 + r.int(2); + for (let i = 0; i < n; i++) { + const column = r.pick(columns); + row[column] = columnValue(r, table, column); + } + rows.set(key, row as Row); + pushes.push({table, type: 'edit', row: row as Row, oldRow: existing}); + } + } + } + return pushes; +} + +const stringOps: SimpleOperator[] = [ + '=', + '!=', + '<', + '>', + '<=', + '>=', + 'LIKE', + 'NOT LIKE', + 'ILIKE', + 'NOT ILIKE', + 'IN', + 'NOT IN', + 'IS', + 'IS NOT', +]; +const numberOps: SimpleOperator[] = [ + '=', + '!=', + '<', + '>', + '<=', + '>=', + 'IN', + 'NOT IN', +]; +const likePatterns = [ + '%', + 'a%', + '%b%', + 'A_C', + '_', + '%é%', + 'bug', + 'z\\%', + '%\u{1f600}', +]; + +function comparison( + r: Random, + schema: Schema, + table: string, +): [string, SimpleOperator, JSONValue] | undefined { + const columns = Object.entries(schema.tables[table].columns).filter( + ([, v]) => v.type !== 'json', + ); + const [column, value] = r.pick(columns); + if (value.type === 'boolean') { + return [column, r.pick(['=', '!='] as const), r.bool()]; + } + if (value.type === 'number') { + const op = r.pick(numberOps); + if (op === 'IN' || op === 'NOT IN') { + return [column, op, [r.pick(numbers), r.pick(numbers)]]; + } + return [column, op, r.pick(numbers)]; + } + const op = r.pick(stringOps); + const domain = [...idsFor(table), ...texts, ...userIDs, ...issueIDs]; + switch (op) { + case 'LIKE': + case 'NOT LIKE': + case 'ILIKE': + case 'NOT ILIKE': + return [column, op, r.pick(likePatterns)]; + case 'IN': + case 'NOT IN': + return [column, op, [r.pick(domain), r.pick(domain), r.pick(domain)]]; + case 'IS': + case 'IS NOT': + return [column, op, r.bool(0.5) ? null : r.pick(domain)]; + default: + return [column, op, r.pick(domain)]; + } +} + +type Context = {depth: number; inJunction: boolean; inExists: boolean}; + +function relationshipsOf(schema: Schema, table: string) { + return Object.entries(schema.relationships[table] ?? {}); +} + +function existsOp(r: Random, schema: Schema, table: string, ctx: Context) { + const rels = relationshipsOf(schema, table); + if (rels.length === 0 || ctx.depth > 2) { + return undefined; + } + const [rel, hops] = r.pick(rels); + const dest = hops.at(-1)!.destSchema; + const sub = r.bool(0.6) + ? subOps(r, schema, dest, { + depth: ctx.depth + 1, + inJunction: hops.length > 1, + inExists: true, + }) + : undefined; + return { + rel, + sub, + flip: r.bool(0.3) ? r.bool() : undefined, + scalar: r.bool(0.1) ? true : undefined, + }; +} + +function expr( + r: Random, + schema: Schema, + table: string, + ctx: Context, + depth = 0, + // The client rejects NOT EXISTS, so a negated expression has no subqueries. + negated = false, +): Expr { + const k = r.int(depth > 1 ? 2 : 6); + if (k <= 1) { + const c = comparison(r, schema, table); + if (c) { + return {cmp: c}; + } + } + if (k === 5) { + return {not: expr(r, schema, table, ctx, depth + 1, true)}; + } + if (k === 2 && !negated) { + const e = existsOp(r, schema, table, ctx); + if (e) { + return {exists: e.rel, sub: e.sub, flip: e.flip, scalar: e.scalar}; + } + } + const n = 1 + r.int(3); + const parts = Array.from({length: n}, () => + expr(r, schema, table, ctx, depth + 1, negated), + ); + return r.bool() ? {and: parts} : {or: parts}; +} + +/** Ops for a subquery, avoiding what the builder rejects there. */ +function subOps(r: Random, schema: Schema, table: string, ctx: Context): Op[] { + const ops: Op[] = []; + const n = r.int(3); + for (let i = 0; i < n; i++) { + ops.push(...randomOps(r, schema, table, ctx, 1)); + } + return ops; +} + +function randomOps( + r: Random, + schema: Schema, + table: string, + ctx: Context, + count: number, + data?: Record, +): Op[] { + const ops: Op[] = []; + for (let i = 0; i < count; i++) { + const k = r.int(10); + if (k <= 2) { + const c = comparison(r, schema, table); + if (c) { + ops.push( + c[1] === '=' && r.bool() + ? {op: 'where', field: c[0], value: c[2]} + : {op: 'where', field: c[0], cmp: c[1], value: c[2]}, + ); + } + } else if (k === 3) { + ops.push({op: 'whereExpr', expr: expr(r, schema, table, ctx)}); + } else if (k === 4) { + const e = existsOp(r, schema, table, ctx); + if (e) { + ops.push({op: 'whereExists', ...e}); + } + } else if (k === 5 && !ctx.inExists && ctx.depth <= 2) { + const rels = relationshipsOf(schema, table); + if (rels.length > 0) { + const [rel, hops] = r.pick(rels); + const dest = hops.at(-1)!.destSchema; + ops.push({ + op: 'related', + rel, + sub: r.bool(0.7) + ? subOps(r, schema, dest, { + depth: ctx.depth + 1, + inJunction: hops.length > 1, + inExists: false, + }) + : undefined, + }); + } + } else if (k === 6 && !ctx.inJunction) { + const columns = Object.entries(schema.tables[table].columns) + .filter(([, v]) => v.type !== 'json') + .map(([c]) => c); + ops.push({ + op: 'orderBy', + field: r.pick(columns), + dir: r.pick(['asc', 'desc']), + }); + } else if (k === 7 && !ctx.inJunction) { + ops.push({op: 'limit', n: r.pick([0, 1, 2, 3, 5])}); + } else if (k === 8 && !ctx.inJunction && ctx.depth === 0 && data) { + const rows = data[table]; + if (rows.length > 0) { + ops.push({ + op: 'start', + row: r.pick(rows), + inclusive: r.bool(0.3) ? r.bool() : undefined, + }); + } + } else if (k === 9 && !ctx.inJunction && !ctx.inExists && r.bool(0.3)) { + ops.push({op: 'one'}); + } + } + return ops; +} + +/** A random query program over `schema`. */ +export function makeProgram( + r: Random, + schema: Schema, + data: Record, +): Program { + const table = r.pick(['issue', 'issue', 'comment', 'user', 'label']); + return { + table, + ops: randomOps( + r, + schema, + table, + {depth: 0, inJunction: false, inExists: false}, + 1 + r.int(5), + data, + ), + }; +} diff --git a/packages/zero-conformance/src/ivm/harness.ts b/packages/zero-conformance/src/ivm/harness.ts new file mode 100644 index 0000000000..1504090b99 --- /dev/null +++ b/packages/zero-conformance/src/ivm/harness.ts @@ -0,0 +1,321 @@ +import type {JSONValue} from '../../../shared/src/json.ts'; +import type {AST, SimpleOperator} from '../../../zero-protocol/src/ast.ts'; +import type {Row} from '../../../zero-protocol/src/data.ts'; +import type {Format} from '../../../zero-types/src/format.ts'; +import type {Schema} from '../../../zero-types/src/schema.ts'; +import {buildPipeline} from '../../../zql/src/builder/builder.ts'; +import {TestBuilderDelegate} from '../../../zql/src/builder/test-builder-delegate.ts'; +import {ArrayView} from '../../../zql/src/ivm/array-view.ts'; +import type {Change} from '../../../zql/src/ivm/change.ts'; +import {MemorySource} from '../../../zql/src/ivm/memory-source.ts'; +import type { + FetchRequest, + Input, + InputBase, + Operator, + Output, +} from '../../../zql/src/ivm/operator.ts'; +import { + makeSourceChangeAdd, + makeSourceChangeEdit, + makeSourceChangeRemove, +} from '../../../zql/src/ivm/source.ts'; +import {consume} from '../../../zql/src/ivm/stream.ts'; +import {asQueryImpl, newQuery} from '../../../zql/src/query/query-impl.ts'; +import type {AnyQuery} from '../../../zql/src/query/query.ts'; + +/** + * A query built through the builder API, written as data so the Swift + * builder can replay it. + */ +export type Program = {table: string; ops: Op[]}; + +export type Op = + | {op: 'where'; field: string; cmp?: SimpleOperator; value: JSONValue} + | {op: 'whereExpr'; expr: Expr} + | { + op: 'whereExists'; + rel: string; + sub?: Op[] | undefined; + flip?: boolean | undefined; + scalar?: boolean | undefined; + } + | {op: 'related'; rel: string; sub?: Op[] | undefined} + | {op: 'orderBy'; field: string; dir: 'asc' | 'desc'} + | {op: 'limit'; n: number} + | {op: 'start'; row: Row; inclusive?: boolean | undefined} + | {op: 'one'}; + +export type Expr = + | {cmp: [field: string, op: SimpleOperator, value: JSONValue]} + | {and: Expr[]} + | {or: Expr[]} + | {not: Expr} + | { + exists: string; + sub?: Op[] | undefined; + flip?: boolean | undefined; + scalar?: boolean | undefined; + }; + +// oxlint-disable-next-line @typescript-eslint/no-explicit-any +type AnyEB = any; + +function applyOps(q: AnyQuery, ops: readonly Op[]): AnyQuery { + for (const op of ops) { + q = applyOp(q, op); + } + return q; +} + +function sub(ops: Op[] | undefined) { + return ops ? (q: AnyQuery) => applyOps(q, ops) : undefined; +} + +function existsOptions(o: { + flip?: boolean | undefined; + scalar?: boolean | undefined; +}) { + if (o.flip === undefined && o.scalar === undefined) { + return undefined; + } + return { + ...(o.flip !== undefined ? {flip: o.flip} : {}), + ...(o.scalar !== undefined ? {scalar: o.scalar} : {}), + }; +} + +function applyOp(q: AnyQuery, op: Op): AnyQuery { + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + const query = q as any; + switch (op.op) { + case 'where': + return op.cmp === undefined + ? query.where(op.field, op.value) + : query.where(op.field, op.cmp, op.value); + case 'whereExpr': + return query.where((eb: AnyEB) => buildExpr(eb, op.expr)); + case 'whereExists': { + const cb = sub(op.sub); + const options = existsOptions(op); + if (cb) { + return query.whereExists(op.rel, cb, options); + } + return options + ? query.whereExists(op.rel, options) + : query.whereExists(op.rel); + } + case 'related': { + const cb = sub(op.sub); + return cb ? query.related(op.rel, cb) : query.related(op.rel); + } + case 'orderBy': + return query.orderBy(op.field, op.dir); + case 'limit': + return query.limit(op.n); + case 'start': + return op.inclusive === undefined + ? query.start(op.row) + : query.start(op.row, {inclusive: op.inclusive}); + case 'one': + return query.one(); + } +} + +function buildExpr(eb: AnyEB, expr: Expr): unknown { + if ('cmp' in expr) { + return eb.cmp(expr.cmp[0], expr.cmp[1], expr.cmp[2]); + } + if ('and' in expr) { + return eb.and(...expr.and.map(e => buildExpr(eb, e))); + } + if ('or' in expr) { + return eb.or(...expr.or.map(e => buildExpr(eb, e))); + } + if ('not' in expr) { + return eb.not(buildExpr(eb, expr.not)); + } + const cb = sub(expr.sub); + return eb.exists(expr.exists, cb, existsOptions(expr)); +} + +/** Builds `program` with the JS query builder. */ +export function buildProgram( + schema: Schema, + program: Program, +): {ast: AST; format: Format} { + const q = applyOps(newQuery(schema, program.table) as AnyQuery, program.ops); + const impl = asQueryImpl(q); + return {ast: impl.ast, format: impl.format}; +} + +/** The client schema as JSON, with column order spelled out. */ +export function schemaJSON(schema: Schema): JSONValue { + const tables: Record = {}; + for (const [name, table] of Object.entries(schema.tables)) { + const columns: Record = {}; + for (const [column, value] of Object.entries(table.columns)) { + columns[column] = { + type: value.type, + ...(value.optional ? {optional: true} : {}), + ...(value.serverName ? {serverName: value.serverName} : {}), + }; + } + tables[name] = { + name: table.name, + ...(table.serverName ? {serverName: table.serverName} : {}), + columns, + columnOrder: Object.keys(table.columns), + primaryKey: [...table.primaryKey], + }; + } + const relationships: Record = {}; + for (const [table, rels] of Object.entries(schema.relationships)) { + const out: Record = {}; + for (const [name, hops] of Object.entries(rels)) { + out[name] = hops.map(h => ({ + sourceField: [...h.sourceField], + destField: [...h.destField], + destSchema: h.destSchema, + cardinality: h.cardinality, + })); + } + relationships[table] = out; + } + return {tables, relationships}; +} + +export type Push = { + table: string; + type: 'add' | 'remove' | 'edit'; + row: Row; + oldRow?: Row | undefined; +}; + +/** A change as the view received it, without relationships. */ +export type RecordedChange = + | {type: 'add' | 'remove'; row: Row} + | {type: 'edit'; row: Row; oldRow: Row} + | { + type: 'child'; + row: Row; + child: {relationshipName: string; change: RecordedChange}; + }; + +function record(change: Change): RecordedChange { + switch (change[0]) { + case 0: + return {type: 'add', row: change[1].row}; + case 1: + return {type: 'remove', row: change[1].row}; + case 2: + return {type: 'edit', row: change[1].row, oldRow: change[2].row}; + case 3: + return { + type: 'child', + row: change[1].row, + child: { + relationshipName: change[2].relationshipName, + change: record(change[2].change), + }, + }; + } +} + +/** Records what the pipeline pushes to the view. */ +class Recorder implements Operator { + readonly #input: Input; + #output: Output | undefined; + readonly changes: RecordedChange[] = []; + + constructor(input: Input) { + this.#input = input; + input.setOutput(this); + } + + setOutput(output: Output): void { + this.#output = output; + } + + getSchema() { + return this.#input.getSchema(); + } + + destroy(): void { + this.#input.destroy(); + } + + fetch(req: FetchRequest) { + return this.#input.fetch(req); + } + + *push(change: Change, _pusher: InputBase) { + this.changes.push(record(change)); + yield* this.#output!.push(change, this); + } + + *reconcile(_pusher: InputBase) { + if (this.#output?.reconcile) { + yield* this.#output.reconcile(this); + } + } +} + +export type Step = {changes: RecordedChange[]; data: JSONValue}; + +export type QueryResult = {error: string} | {initial: JSONValue; steps: Step[]}; + +function plain(data: unknown): JSONValue { + // Drops the view's symbol keys and undefined singular relationships. + return JSON.parse(JSON.stringify(data ?? null)); +} + +/** + * Materializes `ast` over sources loaded with `data`, applies `pushes` one + * by one, and records the view after each. + */ +export function runQuery( + schema: Schema, + data: Record, + ast: AST, + format: Format, + pushes: readonly Push[], +): QueryResult { + const sources: Record = {}; + for (const [name, table] of Object.entries(schema.tables)) { + const source = new MemorySource(name, table.columns, table.primaryKey); + for (const row of data[name] ?? []) { + consume(source.push(makeSourceChangeAdd(row))); + } + sources[name] = source; + } + const delegate = new TestBuilderDelegate(sources); + let recorder: Recorder; + let view: ArrayView; + try { + const input = buildPipeline(ast, delegate, 'q'); + recorder = new Recorder(input); + view = new ArrayView(recorder, format, true, () => {}); + } catch (e) { + return {error: String(e instanceof Error ? e.message : e)}; + } + const initial = plain(view.data); + const steps: Step[] = []; + for (const push of pushes) { + recorder.changes.length = 0; + const change = + push.type === 'edit' + ? makeSourceChangeEdit(push.row, push.oldRow!) + : push.type === 'add' + ? makeSourceChangeAdd(push.row) + : makeSourceChangeRemove(push.row); + try { + consume(sources[push.table].push(change)); + } catch (e) { + return {error: String(e instanceof Error ? e.message : e)}; + } + view.flush(); + steps.push({changes: [...recorder.changes], data: plain(view.data)}); + } + return {initial, steps}; +} diff --git a/packages/zero-conformance/src/ivm/ivm.test.ts b/packages/zero-conformance/src/ivm/ivm.test.ts new file mode 100644 index 0000000000..08f9d6cbda --- /dev/null +++ b/packages/zero-conformance/src/ivm/ivm.test.ts @@ -0,0 +1,320 @@ +import {test} from 'vitest'; +import type {Row} from '../../../zero-protocol/src/data.ts'; +import {schema} from '../../../zql/src/query/test/test-schemas.ts'; +import {Random} from '../random.ts'; +import {emitGeneratedVectors, emitVectors} from '../vectors.ts'; +import {makeData, makePushes, makeProgram} from './generate.ts'; +import { + buildProgram, + runQuery, + schemaJSON, + type Program, + type Push, + type QueryResult, +} from './harness.ts'; + +type Dataset = {data: Record; pushes: Push[]}; + +type Case = { + name: string; + dataset: number; + program: Program; + ast: unknown; + format: unknown; + result: QueryResult; +}; + +function datasets(seed: number, count: number, pushes: number): Dataset[] { + const r = new Random(seed); + return Array.from({length: count}, () => { + const data = makeData(r, schema); + return {data, pushes: makePushes(r, schema, data, pushes)}; + }); +} + +const p = (table: string, ...ops: Program['ops']): Program => ({table, ops}); + +/** Queries that between them reach every client operator. */ +const curated: [string, Program][] = [ + ['table scan', p('issue')], + ['where equals', p('issue', {op: 'where', field: 'closed', value: false})], + [ + 'where string order', + p('issue', {op: 'where', field: 'title', cmp: '>', value: 'a'}), + ], + [ + 'where like', + p('issue', {op: 'where', field: 'title', cmp: 'ILIKE', value: '%b%'}), + ], + [ + 'where in', + p('comment', { + op: 'where', + field: 'issueId', + cmp: 'IN', + value: ['i1', 'i2'], + }), + ], + [ + 'where is null', + p('issue', {op: 'where', field: 'ownerId', cmp: 'IS', value: null}), + ], + ['primary key lookup', p('issue', {op: 'where', field: 'id', value: 'i3'})], + ['order desc', p('issue', {op: 'orderBy', field: 'createdAt', dir: 'desc'})], + ['order by text', p('comment', {op: 'orderBy', field: 'text', dir: 'asc'})], + [ + 'limit', + p( + 'issue', + {op: 'orderBy', field: 'title', dir: 'asc'}, + {op: 'limit', n: 3}, + ), + ], + [ + 'limit 1', + p( + 'issue', + {op: 'orderBy', field: 'createdAt', dir: 'desc'}, + {op: 'limit', n: 1}, + ), + ], + ['limit 0', p('issue', {op: 'limit', n: 0})], + ['one', p('issue', {op: 'where', field: 'id', value: 'i2'}, {op: 'one'})], + [ + 'start exclusive', + p('issue', {op: 'start', row: {id: 'i3'}}, {op: 'limit', n: 3}), + ], + [ + 'start inclusive desc', + p( + 'issue', + {op: 'orderBy', field: 'id', dir: 'desc'}, + {op: 'start', row: {id: 'i5'}, inclusive: true}, + ), + ], + ['related many', p('issue', {op: 'related', rel: 'comments'})], + ['related one', p('comment', {op: 'related', rel: 'author'})], + ['related junction', p('issue', {op: 'related', rel: 'labels'})], + [ + 'related limited', + p('issue', { + op: 'related', + rel: 'comments', + sub: [ + {op: 'orderBy', field: 'createdAt', dir: 'desc'}, + {op: 'limit', n: 2}, + ], + }), + ], + [ + 'related nested', + p('user', { + op: 'related', + rel: 'issues', + sub: [ + { + op: 'related', + rel: 'comments', + sub: [{op: 'related', rel: 'revisions'}], + }, + {op: 'related', rel: 'labels'}, + ], + }), + ], + [ + 'related one()', + p('issue', {op: 'related', rel: 'comments', sub: [{op: 'one'}]}), + ], + ['exists', p('issue', {op: 'whereExists', rel: 'comments'})], + [ + 'exists filtered', + p('issue', { + op: 'whereExists', + rel: 'comments', + sub: [{op: 'where', field: 'authorId', value: 'u1'}], + }), + ], + ['exists junction', p('issue', {op: 'whereExists', rel: 'labels'})], + [ + 'exists with limit', + p( + 'issue', + {op: 'whereExists', rel: 'comments'}, + {op: 'orderBy', field: 'title', dir: 'desc'}, + {op: 'limit', n: 2}, + ), + ], + [ + 'exists flipped', + p('issue', {op: 'whereExists', rel: 'comments', flip: true}), + ], + [ + 'exists flipped junction limited', + p( + 'issue', + { + op: 'whereExists', + rel: 'labels', + flip: true, + sub: [{op: 'where', field: 'name', cmp: '!=', value: 'a'}], + }, + {op: 'limit', n: 3}, + ), + ], + [ + 'or of exists', + p('issue', { + op: 'whereExpr', + expr: { + or: [ + { + exists: 'comments', + sub: [{op: 'where', field: 'authorId', value: 'u2'}], + }, + {cmp: ['closed', '=', true]}, + ], + }, + }), + ], + [ + 'or of flipped exists', + p( + 'issue', + { + op: 'whereExpr', + expr: { + or: [ + {exists: 'comments', flip: true}, + { + exists: 'owner', + flip: true, + sub: [{op: 'where', field: 'name', value: 'a'}], + }, + {cmp: ['title', '=', 'b']}, + ], + }, + }, + {op: 'related', rel: 'owner'}, + ), + ], + [ + 'and of conditions', + p('issue', { + op: 'whereExpr', + expr: { + and: [ + {cmp: ['closed', '=', false]}, + {or: [{cmp: ['createdAt', '>', 1]}, {cmp: ['title', 'LIKE', 'a%']}]}, + {not: {cmp: ['ownerId', 'IS', null]}}, + ], + }, + }), + ], + [ + 'exists and related', + p( + 'comment', + { + op: 'whereExists', + rel: 'issue', + sub: [{op: 'where', field: 'closed', value: false}], + }, + {op: 'related', rel: 'issue', sub: [{op: 'related', rel: 'owner'}]}, + {op: 'related', rel: 'revisions'}, + ), + ], + [ + 'nested exists', + p('user', { + op: 'whereExists', + rel: 'issues', + sub: [{op: 'whereExists', rel: 'labels'}], + }), + ], + [ + 'pushdown', + p( + 'issue', + {op: 'where', field: 'id', cmp: 'IN', value: ['i1', 'i4', 'i6']}, + {op: 'related', rel: 'comments', sub: [{op: 'limit', n: 1}]}, + ), + ], + [ + 'label issues', + p('label', { + op: 'related', + rel: 'issues', + sub: [{op: 'related', rel: 'owner'}], + }), + ], +]; + +function makeCase( + name: string, + dataset: number, + data: Dataset, + program: Program, +): Case | undefined { + let built; + try { + built = buildProgram(schema, program); + } catch { + return undefined; + } + return { + name, + dataset, + program, + ast: built.ast, + format: built.format, + result: runQuery(schema, data.data, built.ast, built.format, data.pushes), + }; +} + +test('ivm curated', () => { + const sets = datasets(7, 1, 25); + const cases: Case[] = []; + for (const [name, program] of curated) { + sets.forEach((set, i) => { + const c = makeCase(name, i, set, program); + if (c) { + cases.push(c); + } + }); + } + emitVectors( + 'ivm', + 'ivm', + 'Queries over the zql test schema: the view after hydration and after each source push, and the changes the view received.', + cases, + {schema: schemaJSON(schema), datasets: sets}, + {compact: true}, + ); +}); + +// IVM_FUZZ_SEED and IVM_FUZZ_CASES widen the search for a local deep run. +const fuzzSeed = Number(process.env.IVM_FUZZ_SEED ?? 99); +const fuzzCases = Number(process.env.IVM_FUZZ_CASES ?? 600); + +test('ivm fuzz', () => { + const sets = datasets(1234, 10, 25); + const r = new Random(fuzzSeed); + const cases: Case[] = []; + let attempts = 0; + while (cases.length < fuzzCases && attempts < fuzzCases * 8) { + attempts++; + const dataset = r.int(sets.length); + const program = makeProgram(r, schema, sets[dataset].data); + const c = makeCase(`fuzz ${attempts}`, dataset, sets[dataset], program); + if (c && !('error' in c.result)) { + cases.push(c); + } + } + emitGeneratedVectors( + 'ivm-fuzz', + 'ivm', + 'Seeded random queries and pushes over the zql test schema.', + cases, + {schema: schemaJSON(schema), datasets: sets}, + ); +}); diff --git a/packages/zero-conformance/src/vectors.ts b/packages/zero-conformance/src/vectors.ts index e3cc288f6b..b85c4a5f71 100644 --- a/packages/zero-conformance/src/vectors.ts +++ b/packages/zero-conformance/src/vectors.ts @@ -17,6 +17,8 @@ export type VectorFile = { formatVersion: number; kind: string; description: string; + /** Fixtures the cases share, such as schemas and datasets. */ + shared?: Record | undefined; cases: T[]; }; @@ -31,14 +33,20 @@ export function emitVectors( kind: string, description: string, cases: T[], + shared?: Record, + {compact = false}: {compact?: boolean} = {}, ): void { const file: VectorFile = { formatVersion: VECTOR_FORMAT_VERSION, kind, description, + ...(shared ? {shared} : {}), cases, }; - const text = JSON.stringify(file, null, 2) + '\n'; + // Large files go one case per line, to keep diffs readable. + const text = compact + ? compactText(file) + : JSON.stringify(file, null, 2) + '\n'; const path = vectorsDir + name + '.json'; if (updateVectors) { writeFileSync(path, text); @@ -68,13 +76,22 @@ export function emitGeneratedVectors( kind: string, description: string, cases: T[], + shared?: Record, ): void { const file: VectorFile = { formatVersion: VECTOR_FORMAT_VERSION, kind, description, + ...(shared ? {shared} : {}), cases, }; mkdirSync(generatedDir, {recursive: true}); writeFileSync(generatedDir + name + '.json', JSON.stringify(file) + '\n'); } + +function compactText(file: VectorFile): string { + const {cases, ...rest} = file; + const head = JSON.stringify(rest); + const lines = cases.map(c => JSON.stringify(c)); + return `${head.slice(0, -1)},"cases":[\n${lines.join(',\n')}\n]}\n`; +} diff --git a/packages/zero-conformance/vectors/ivm.json b/packages/zero-conformance/vectors/ivm.json new file mode 100644 index 0000000000..338280ccbd --- /dev/null +++ b/packages/zero-conformance/vectors/ivm.json @@ -0,0 +1,36 @@ +{"formatVersion":1,"kind":"ivm","description":"Queries over the zql test schema: the view after hydration and after each source push, and the changes the view received.","shared":{"schema":{"tables":{"issue":{"name":"issue","serverName":"issues","columns":{"id":{"type":"string"},"title":{"type":"string"},"description":{"type":"string"},"closed":{"type":"boolean"},"ownerId":{"type":"string","optional":true,"serverName":"owner_id"},"createdAt":{"type":"number"}},"columnOrder":["id","title","description","closed","ownerId","createdAt"],"primaryKey":["id"]},"user":{"name":"user","serverName":"users","columns":{"id":{"type":"string"},"name":{"type":"string"},"metadata":{"type":"json","optional":true}},"columnOrder":["id","name","metadata"],"primaryKey":["id"]},"comment":{"name":"comment","serverName":"comments","columns":{"id":{"type":"string"},"authorId":{"type":"string"},"issueId":{"type":"string","serverName":"issue_id"},"text":{"type":"string"},"createdAt":{"type":"number"}},"columnOrder":["id","authorId","issueId","text","createdAt"],"primaryKey":["id"]},"revision":{"name":"revision","columns":{"id":{"type":"string"},"authorId":{"type":"string"},"commentId":{"type":"string"},"text":{"type":"string"}},"columnOrder":["id","authorId","commentId","text"],"primaryKey":["id"]},"label":{"name":"label","columns":{"id":{"type":"string"},"name":{"type":"string"}},"columnOrder":["id","name"],"primaryKey":["id"]},"issueLabel":{"name":"issueLabel","columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"columnOrder":["issueId","labelId"],"primaryKey":["issueId","labelId"]}},"relationships":{"issue":{"owner":[{"sourceField":["ownerId"],"destField":["id"],"destSchema":"user","cardinality":"one"}],"comments":[{"sourceField":["id"],"destField":["issueId"],"destSchema":"comment","cardinality":"many"}],"oneComment":[{"sourceField":["id"],"destField":["issueId"],"destSchema":"comment","cardinality":"one"}],"ownerComments":[{"sourceField":["ownerId"],"destField":["authorId"],"destSchema":"comment","cardinality":"many"}],"labels":[{"sourceField":["id"],"destField":["issueId"],"destSchema":"issueLabel","cardinality":"many"},{"sourceField":["labelId"],"destField":["id"],"destSchema":"label","cardinality":"many"}]},"user":{"issues":[{"sourceField":["id"],"destField":["ownerId"],"destSchema":"issue","cardinality":"many"}]},"comment":{"issue":[{"sourceField":["issueId"],"destField":["id"],"destSchema":"issue","cardinality":"one"}],"revisions":[{"sourceField":["id"],"destField":["commentId"],"destSchema":"revision","cardinality":"many"}],"author":[{"sourceField":["authorId"],"destField":["id"],"destSchema":"user","cardinality":"one"}]},"revision":{"comment":[{"sourceField":["commentId"],"destField":["id"],"destSchema":"comment","cardinality":"one"}],"author":[{"sourceField":["authorId"],"destField":["id"],"destSchema":"user","cardinality":"one"}]},"label":{"issues":[{"sourceField":["id"],"destField":["labelId"],"destSchema":"issueLabel","cardinality":"many"},{"sourceField":["issueId"],"destField":["id"],"destSchema":"issue","cardinality":"many"}]}}},"datasets":[{"data":{"issue":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}],"user":[{"id":"u3","name":"ab","metadata":null},{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}],"comment":[{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0},{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}],"revision":[{"id":"r1","authorId":"u2","commentId":"c3","text":"z%"},{"id":"r4","authorId":"u4","commentId":"c8","text":""},{"id":"r6","authorId":"u1","commentId":"c8","text":""}],"label":[{"id":"l2","name":"é"},{"id":"l3","name":"bug"},{"id":"l4","name":"b"}],"issueLabel":[{"issueId":"i4","labelId":"l2"},{"issueId":"i2","labelId":"l1"},{"issueId":"i1","labelId":"l4"},{"issueId":"i8","labelId":"l1"},{"issueId":"i2","labelId":"l2"},{"issueId":"i4","labelId":"l3"},{"issueId":"i3","labelId":"l2"},{"issueId":"i7","labelId":"l3"},{"issueId":"i7","labelId":"l2"},{"issueId":"i5","labelId":"l2"}]},"pushes":[{"table":"revision","type":"edit","row":{"id":"r1","authorId":"u2","commentId":"c3","text":"z%"},"oldRow":{"id":"r1","authorId":"u2","commentId":"c3","text":"z%"}},{"table":"label","type":"remove","row":{"id":"l3","name":"bug"}},{"table":"label","type":"remove","row":{"id":"l4","name":"b"}},{"table":"revision","type":"remove","row":{"id":"r4","authorId":"u4","commentId":"c8","text":""}},{"table":"revision","type":"add","row":{"id":"r2","authorId":"u4","commentId":"c6","text":"Bug"}},{"table":"label","type":"edit","row":{"id":"l2","name":"a"},"oldRow":{"id":"l2","name":"é"}},{"table":"label","type":"edit","row":{"id":"l2","name":"ab"},"oldRow":{"id":"l2","name":"a"}},{"table":"revision","type":"remove","row":{"id":"r2","authorId":"u4","commentId":"c6","text":"Bug"}},{"table":"label","type":"remove","row":{"id":"l2","name":"ab"}},{"table":"revision","type":"remove","row":{"id":"r1","authorId":"u2","commentId":"c3","text":"z%"}},{"table":"issue","type":"edit","row":{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},"oldRow":{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2}},{"table":"issue","type":"add","row":{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}},{"table":"user","type":"edit","row":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}},"oldRow":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"table":"issue","type":"edit","row":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},"oldRow":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}},{"table":"revision","type":"remove","row":{"id":"r6","authorId":"u1","commentId":"c8","text":""}},{"table":"issue","type":"edit","row":{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},"oldRow":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}},{"table":"revision","type":"add","row":{"id":"r1","authorId":"u2","commentId":"c12","text":"😀"}},{"table":"comment","type":"add","row":{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648}},{"table":"label","type":"add","row":{"id":"l1","name":"中文"}},{"table":"comment","type":"edit","row":{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1},"oldRow":{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}},{"table":"revision","type":"edit","row":{"id":"r1","authorId":"u1","commentId":"c3","text":"😀"},"oldRow":{"id":"r1","authorId":"u2","commentId":"c12","text":"😀"}},{"table":"label","type":"add","row":{"id":"l2","name":"a"}},{"table":"issueLabel","type":"remove","row":{"issueId":"i7","labelId":"l3"}},{"table":"comment","type":"remove","row":{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5}},{"table":"comment","type":"remove","row":{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1}}]}]},"cases":[ +{"name":"table scan","dataset":0,"program":{"table":"issue","ops":[]},"ast":{"table":"issue"},"format":{"singular":false,"relationships":{}},"result":{"initial":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}],"steps":[{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[{"type":"edit","row":{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},"oldRow":{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[{"type":"add","row":{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[{"type":"edit","row":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},"oldRow":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[{"type":"edit","row":{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},"oldRow":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]}]}}, +{"name":"where equals","dataset":0,"program":{"table":"issue","ops":[{"op":"where","field":"closed","value":false}]},"ast":{"table":"issue","where":{"type":"simple","op":"=","left":{"type":"column","name":"closed"},"right":{"type":"literal","value":false}}},"format":{"singular":false,"relationships":{}},"result":{"initial":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}],"steps":[{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[{"type":"add","row":{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]}]}}, +{"name":"where string order","dataset":0,"program":{"table":"issue","ops":[{"op":"where","field":"title","cmp":">","value":"a"}]},"ast":{"table":"issue","where":{"type":"simple","op":">","left":{"type":"column","name":"title"},"right":{"type":"literal","value":"a"}}},"format":{"singular":false,"relationships":{}},"result":{"initial":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}],"steps":[{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[{"type":"edit","row":{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},"oldRow":{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2}}],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[{"type":"add","row":{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}}],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[{"type":"edit","row":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},"oldRow":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}}],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[{"type":"edit","row":{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},"oldRow":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}}],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]}]}}, +{"name":"where like","dataset":0,"program":{"table":"issue","ops":[{"op":"where","field":"title","cmp":"ILIKE","value":"%b%"}]},"ast":{"table":"issue","where":{"type":"simple","op":"ILIKE","left":{"type":"column","name":"title"},"right":{"type":"literal","value":"%b%"}}},"format":{"singular":false,"relationships":{}},"result":{"initial":[],"steps":[{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]}]}}, +{"name":"where in","dataset":0,"program":{"table":"comment","ops":[{"op":"where","field":"issueId","cmp":"IN","value":["i1","i2"]}]},"ast":{"table":"comment","where":{"type":"simple","op":"IN","left":{"type":"column","name":"issueId"},"right":{"type":"literal","value":["i1","i2"]}}},"format":{"singular":false,"relationships":{}},"result":{"initial":[],"steps":[{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]}]}}, +{"name":"where is null","dataset":0,"program":{"table":"issue","ops":[{"op":"where","field":"ownerId","cmp":"IS","value":null}]},"ast":{"table":"issue","where":{"type":"simple","op":"IS","left":{"type":"column","name":"ownerId"},"right":{"type":"literal","value":null}}},"format":{"singular":false,"relationships":{}},"result":{"initial":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}],"steps":[{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[{"type":"edit","row":{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},"oldRow":{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2}}],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[{"type":"edit","row":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},"oldRow":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}}],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[{"type":"edit","row":{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},"oldRow":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}}],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]}]}}, +{"name":"primary key lookup","dataset":0,"program":{"table":"issue","ops":[{"op":"where","field":"id","value":"i3"}]},"ast":{"table":"issue","where":{"type":"simple","op":"=","left":{"type":"column","name":"id"},"right":{"type":"literal","value":"i3"}}},"format":{"singular":false,"relationships":{}},"result":{"initial":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}],"steps":[{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]}]}}, +{"name":"order desc","dataset":0,"program":{"table":"issue","ops":[{"op":"orderBy","field":"createdAt","dir":"desc"}]},"ast":{"table":"issue","orderBy":[["createdAt","desc"]]},"format":{"singular":false,"relationships":{}},"result":{"initial":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}],"steps":[{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[{"type":"edit","row":{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},"oldRow":{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2}}],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0}]},{"changes":[{"type":"add","row":{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}}],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0}]},{"changes":[{"type":"edit","row":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},"oldRow":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}}],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0}]},{"changes":[{"type":"edit","row":{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},"oldRow":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}}],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0}]}]}}, +{"name":"order by text","dataset":0,"program":{"table":"comment","ops":[{"op":"orderBy","field":"text","dir":"asc"}]},"ast":{"table":"comment","orderBy":[["text","asc"]]},"format":{"singular":false,"relationships":{}},"result":{"initial":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1}],"steps":[{"changes":[],"data":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1}]},{"changes":[],"data":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1}]},{"changes":[],"data":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1}]},{"changes":[],"data":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1}]},{"changes":[],"data":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1}]},{"changes":[],"data":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1}]},{"changes":[],"data":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1}]},{"changes":[],"data":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1}]},{"changes":[],"data":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1}]},{"changes":[],"data":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1}]},{"changes":[],"data":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1}]},{"changes":[],"data":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1}]},{"changes":[],"data":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1}]},{"changes":[],"data":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1}]},{"changes":[],"data":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1}]},{"changes":[],"data":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1}]},{"changes":[],"data":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1}]},{"changes":[{"type":"add","row":{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648}}],"data":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1},{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1}]},{"changes":[],"data":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1},{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1}]},{"changes":[{"type":"edit","row":{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1},"oldRow":{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}}],"data":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1},{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1},{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1}]},{"changes":[],"data":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1},{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1},{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1}]},{"changes":[],"data":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1},{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1},{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1}]},{"changes":[],"data":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1},{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1},{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1}]},{"changes":[{"type":"remove","row":{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5}}],"data":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1},{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1},{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1}]},{"changes":[{"type":"remove","row":{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1}}],"data":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1},{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1}]}]}}, +{"name":"limit","dataset":0,"program":{"table":"issue","ops":[{"op":"orderBy","field":"title","dir":"asc"},{"op":"limit","n":3}]},"ast":{"table":"issue","limit":3,"orderBy":[["title","asc"]]},"format":{"singular":false,"relationships":{}},"result":{"initial":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}],"steps":[{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]}]}}, +{"name":"limit 1","dataset":0,"program":{"table":"issue","ops":[{"op":"orderBy","field":"createdAt","dir":"desc"},{"op":"limit","n":1}]},"ast":{"table":"issue","limit":1,"orderBy":[["createdAt","desc"]]},"format":{"singular":false,"relationships":{}},"result":{"initial":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}],"steps":[{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}]},{"changes":[{"type":"edit","row":{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},"oldRow":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}}],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991}]}]}}, +{"name":"limit 0","dataset":0,"program":{"table":"issue","ops":[{"op":"limit","n":0}]},"ast":{"table":"issue","limit":0},"format":{"singular":false,"relationships":{}},"result":{"initial":[],"steps":[{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]}]}}, +{"name":"one","dataset":0,"program":{"table":"issue","ops":[{"op":"where","field":"id","value":"i2"},{"op":"one"}]},"ast":{"table":"issue","where":{"type":"simple","op":"=","left":{"type":"column","name":"id"},"right":{"type":"literal","value":"i2"}},"limit":1},"format":{"singular":true,"relationships":{}},"result":{"initial":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},"steps":[{"changes":[],"data":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}},{"changes":[],"data":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}},{"changes":[],"data":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}},{"changes":[],"data":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}},{"changes":[],"data":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}},{"changes":[],"data":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}},{"changes":[],"data":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}},{"changes":[],"data":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}},{"changes":[],"data":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}},{"changes":[],"data":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}},{"changes":[],"data":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}},{"changes":[],"data":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}},{"changes":[],"data":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}},{"changes":[],"data":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}},{"changes":[],"data":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}},{"changes":[{"type":"edit","row":{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},"oldRow":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}}],"data":{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991}},{"changes":[],"data":{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991}},{"changes":[],"data":{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991}},{"changes":[],"data":{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991}},{"changes":[],"data":{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991}},{"changes":[],"data":{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991}},{"changes":[],"data":{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991}},{"changes":[],"data":{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991}},{"changes":[],"data":{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991}},{"changes":[],"data":{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991}}]}}, +{"name":"start exclusive","dataset":0,"program":{"table":"issue","ops":[{"op":"start","row":{"id":"i3"}},{"op":"limit","n":3}]},"ast":{"table":"issue","start":{"row":{"id":"i3"},"exclusive":true},"limit":3},"format":{"singular":false,"relationships":{}},"result":{"initial":[{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}],"steps":[{"changes":[],"data":[{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[{"type":"edit","row":{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},"oldRow":{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2}}],"data":[{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[{"type":"remove","row":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}},{"type":"add","row":{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}}],"data":[{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}]},{"changes":[],"data":[{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}]},{"changes":[],"data":[{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}]},{"changes":[],"data":[{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}]},{"changes":[],"data":[{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}]},{"changes":[],"data":[{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}]},{"changes":[],"data":[{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}]},{"changes":[],"data":[{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}]},{"changes":[],"data":[{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}]},{"changes":[],"data":[{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}]},{"changes":[],"data":[{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}]},{"changes":[],"data":[{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}]},{"changes":[],"data":[{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}]},{"changes":[],"data":[{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}]}]}}, +{"name":"start inclusive desc","dataset":0,"program":{"table":"issue","ops":[{"op":"orderBy","field":"id","dir":"desc"},{"op":"start","row":{"id":"i5"},"inclusive":true}]},"ast":{"table":"issue","start":{"row":{"id":"i5"},"exclusive":false},"orderBy":[["id","desc"]]},"format":{"singular":false,"relationships":{}},"result":{"initial":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5}],"steps":[{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[{"type":"edit","row":{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},"oldRow":{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2}}],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[{"type":"edit","row":{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},"oldRow":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}}],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5}]}]}}, +{"name":"related many","dataset":0,"program":{"table":"issue","ops":[{"op":"related","rel":"comments"}]},"ast":{"table":"issue","related":[{"correlation":{"parentField":["id"],"childField":["issueId"]},"subquery":{"table":"comment","alias":"comments"},"system":"client"}]},"format":{"singular":false,"relationships":{"comments":{"relationships":{},"singular":false}}},"result":{"initial":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5}]}],"steps":[{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5}]}]},{"changes":[{"type":"edit","row":{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},"oldRow":{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5}]}]},{"changes":[{"type":"add","row":{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5}]}]},{"changes":[{"type":"edit","row":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},"oldRow":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5}]}]},{"changes":[{"type":"edit","row":{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},"oldRow":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5}]}]},{"changes":[{"type":"child","row":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},"child":{"relationshipName":"comments","change":{"type":"add","row":{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648}}}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5}]}]},{"changes":[{"type":"child","row":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},"child":{"relationshipName":"comments","change":{"type":"edit","row":{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1},"oldRow":{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}}}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5}]}]},{"changes":[{"type":"child","row":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"child":{"relationshipName":"comments","change":{"type":"remove","row":{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5}}}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}]}]}]}}, +{"name":"related one","dataset":0,"program":{"table":"comment","ops":[{"op":"related","rel":"author"}]},"ast":{"table":"comment","related":[{"correlation":{"parentField":["authorId"],"childField":["id"]},"subquery":{"table":"user","alias":"author"},"system":"client"}]},"format":{"singular":false,"relationships":{"author":{"relationships":{},"singular":true}}},"result":{"initial":[{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}}],"steps":[{"changes":[],"data":[{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}}]},{"changes":[],"data":[{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}}]},{"changes":[],"data":[{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}}]},{"changes":[],"data":[{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}}]},{"changes":[],"data":[{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}}]},{"changes":[],"data":[{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}}]},{"changes":[],"data":[{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}}]},{"changes":[],"data":[{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}}]},{"changes":[],"data":[{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}}]},{"changes":[],"data":[{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}}]},{"changes":[],"data":[{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}}]},{"changes":[],"data":[{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}}]},{"changes":[{"type":"child","row":{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},"child":{"relationshipName":"author","change":{"type":"edit","row":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}},"oldRow":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}}}},{"type":"child","row":{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},"child":{"relationshipName":"author","change":{"type":"edit","row":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}},"oldRow":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}}}},{"type":"child","row":{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1},"child":{"relationshipName":"author","change":{"type":"edit","row":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}},"oldRow":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}}}},{"type":"child","row":{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2},"child":{"relationshipName":"author","change":{"type":"edit","row":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}},"oldRow":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}}}},{"type":"child","row":{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2},"child":{"relationshipName":"author","change":{"type":"edit","row":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}},"oldRow":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}}}},{"type":"child","row":{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1},"child":{"relationshipName":"author","change":{"type":"edit","row":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}},"oldRow":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}}}}],"data":[{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}}]},{"changes":[],"data":[{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}}]},{"changes":[],"data":[{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}}]},{"changes":[],"data":[{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}}]},{"changes":[],"data":[{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}}]},{"changes":[{"type":"add","row":{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648}}],"data":[{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}}]},{"changes":[],"data":[{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}}]},{"changes":[{"type":"edit","row":{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1},"oldRow":{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}}],"data":[{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}}]},{"changes":[],"data":[{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}}]},{"changes":[],"data":[{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}}]},{"changes":[],"data":[{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}}]},{"changes":[{"type":"remove","row":{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5}}],"data":[{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1},{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}}]},{"changes":[{"type":"remove","row":{"id":"c1","authorId":"u2","issueId":"i-missing","text":"😀","createdAt":0.1}}],"data":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c5","authorId":"u4","issueId":"i-missing","text":"bug","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0,"author":{"id":"u3","name":"ab","metadata":null}},{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"author":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}}]}]}}, +{"name":"related junction","dataset":0,"program":{"table":"issue","ops":[{"op":"related","rel":"labels"}]},"ast":{"table":"issue","related":[{"correlation":{"parentField":["id"],"childField":["issueId"]},"hidden":true,"subquery":{"table":"issueLabel","alias":"labels","related":[{"correlation":{"parentField":["labelId"],"childField":["id"]},"subquery":{"table":"label","alias":"labels"},"system":"client"}]},"system":"client"}]},"format":{"singular":false,"relationships":{"labels":{"relationships":{},"singular":false}}},"result":{"initial":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[{"id":"l4","name":"b"}]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"labels":[{"id":"l2","name":"é"}]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"labels":[{"id":"l2","name":"é"}]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"labels":[{"id":"l2","name":"é"},{"id":"l3","name":"bug"}]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"labels":[{"id":"l2","name":"é"}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"labels":[{"id":"l2","name":"é"},{"id":"l3","name":"bug"}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]}],"steps":[{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[{"id":"l4","name":"b"}]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"labels":[{"id":"l2","name":"é"}]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"labels":[{"id":"l2","name":"é"}]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"labels":[{"id":"l2","name":"é"},{"id":"l3","name":"bug"}]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"labels":[{"id":"l2","name":"é"}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"labels":[{"id":"l2","name":"é"},{"id":"l3","name":"bug"}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]}]},{"changes":[{"type":"child","row":{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},"child":{"relationshipName":"labels","change":{"type":"child","row":{"issueId":"i4","labelId":"l3"},"child":{"relationshipName":"labels","change":{"type":"remove","row":{"id":"l3","name":"bug"}}}}}},{"type":"child","row":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},"child":{"relationshipName":"labels","change":{"type":"child","row":{"issueId":"i7","labelId":"l3"},"child":{"relationshipName":"labels","change":{"type":"remove","row":{"id":"l3","name":"bug"}}}}}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[{"id":"l4","name":"b"}]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"labels":[{"id":"l2","name":"é"}]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"labels":[{"id":"l2","name":"é"}]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"labels":[{"id":"l2","name":"é"}]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"labels":[{"id":"l2","name":"é"}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"labels":[{"id":"l2","name":"é"}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]}]},{"changes":[{"type":"child","row":{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},"child":{"relationshipName":"labels","change":{"type":"child","row":{"issueId":"i1","labelId":"l4"},"child":{"relationshipName":"labels","change":{"type":"remove","row":{"id":"l4","name":"b"}}}}}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"labels":[{"id":"l2","name":"é"}]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"labels":[{"id":"l2","name":"é"}]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"labels":[{"id":"l2","name":"é"}]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"labels":[{"id":"l2","name":"é"}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"labels":[{"id":"l2","name":"é"}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"labels":[{"id":"l2","name":"é"}]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"labels":[{"id":"l2","name":"é"}]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"labels":[{"id":"l2","name":"é"}]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"labels":[{"id":"l2","name":"é"}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"labels":[{"id":"l2","name":"é"}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"labels":[{"id":"l2","name":"é"}]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"labels":[{"id":"l2","name":"é"}]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"labels":[{"id":"l2","name":"é"}]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"labels":[{"id":"l2","name":"é"}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"labels":[{"id":"l2","name":"é"}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]}]},{"changes":[{"type":"child","row":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},"child":{"relationshipName":"labels","change":{"type":"child","row":{"issueId":"i2","labelId":"l2"},"child":{"relationshipName":"labels","change":{"type":"edit","row":{"id":"l2","name":"a"},"oldRow":{"id":"l2","name":"é"}}}}}},{"type":"child","row":{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},"child":{"relationshipName":"labels","change":{"type":"child","row":{"issueId":"i3","labelId":"l2"},"child":{"relationshipName":"labels","change":{"type":"edit","row":{"id":"l2","name":"a"},"oldRow":{"id":"l2","name":"é"}}}}}},{"type":"child","row":{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},"child":{"relationshipName":"labels","change":{"type":"child","row":{"issueId":"i4","labelId":"l2"},"child":{"relationshipName":"labels","change":{"type":"edit","row":{"id":"l2","name":"a"},"oldRow":{"id":"l2","name":"é"}}}}}},{"type":"child","row":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},"child":{"relationshipName":"labels","change":{"type":"child","row":{"issueId":"i5","labelId":"l2"},"child":{"relationshipName":"labels","change":{"type":"edit","row":{"id":"l2","name":"a"},"oldRow":{"id":"l2","name":"é"}}}}}},{"type":"child","row":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},"child":{"relationshipName":"labels","change":{"type":"child","row":{"issueId":"i7","labelId":"l2"},"child":{"relationshipName":"labels","change":{"type":"edit","row":{"id":"l2","name":"a"},"oldRow":{"id":"l2","name":"é"}}}}}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"labels":[{"id":"l2","name":"a"}]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"labels":[{"id":"l2","name":"a"}]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"labels":[{"id":"l2","name":"a"}]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"labels":[{"id":"l2","name":"a"}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"labels":[{"id":"l2","name":"a"}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]}]},{"changes":[{"type":"child","row":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},"child":{"relationshipName":"labels","change":{"type":"child","row":{"issueId":"i2","labelId":"l2"},"child":{"relationshipName":"labels","change":{"type":"edit","row":{"id":"l2","name":"ab"},"oldRow":{"id":"l2","name":"a"}}}}}},{"type":"child","row":{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},"child":{"relationshipName":"labels","change":{"type":"child","row":{"issueId":"i3","labelId":"l2"},"child":{"relationshipName":"labels","change":{"type":"edit","row":{"id":"l2","name":"ab"},"oldRow":{"id":"l2","name":"a"}}}}}},{"type":"child","row":{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},"child":{"relationshipName":"labels","change":{"type":"child","row":{"issueId":"i4","labelId":"l2"},"child":{"relationshipName":"labels","change":{"type":"edit","row":{"id":"l2","name":"ab"},"oldRow":{"id":"l2","name":"a"}}}}}},{"type":"child","row":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},"child":{"relationshipName":"labels","change":{"type":"child","row":{"issueId":"i5","labelId":"l2"},"child":{"relationshipName":"labels","change":{"type":"edit","row":{"id":"l2","name":"ab"},"oldRow":{"id":"l2","name":"a"}}}}}},{"type":"child","row":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},"child":{"relationshipName":"labels","change":{"type":"child","row":{"issueId":"i7","labelId":"l2"},"child":{"relationshipName":"labels","change":{"type":"edit","row":{"id":"l2","name":"ab"},"oldRow":{"id":"l2","name":"a"}}}}}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"labels":[{"id":"l2","name":"ab"}]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"labels":[{"id":"l2","name":"ab"}]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"labels":[{"id":"l2","name":"ab"}]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"labels":[{"id":"l2","name":"ab"}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"labels":[{"id":"l2","name":"ab"}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"labels":[{"id":"l2","name":"ab"}]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"labels":[{"id":"l2","name":"ab"}]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"labels":[{"id":"l2","name":"ab"}]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"labels":[{"id":"l2","name":"ab"}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"labels":[{"id":"l2","name":"ab"}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]}]},{"changes":[{"type":"child","row":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},"child":{"relationshipName":"labels","change":{"type":"child","row":{"issueId":"i2","labelId":"l2"},"child":{"relationshipName":"labels","change":{"type":"remove","row":{"id":"l2","name":"ab"}}}}}},{"type":"child","row":{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},"child":{"relationshipName":"labels","change":{"type":"child","row":{"issueId":"i3","labelId":"l2"},"child":{"relationshipName":"labels","change":{"type":"remove","row":{"id":"l2","name":"ab"}}}}}},{"type":"child","row":{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},"child":{"relationshipName":"labels","change":{"type":"child","row":{"issueId":"i4","labelId":"l2"},"child":{"relationshipName":"labels","change":{"type":"remove","row":{"id":"l2","name":"ab"}}}}}},{"type":"child","row":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},"child":{"relationshipName":"labels","change":{"type":"child","row":{"issueId":"i5","labelId":"l2"},"child":{"relationshipName":"labels","change":{"type":"remove","row":{"id":"l2","name":"ab"}}}}}},{"type":"child","row":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},"child":{"relationshipName":"labels","change":{"type":"child","row":{"issueId":"i7","labelId":"l2"},"child":{"relationshipName":"labels","change":{"type":"remove","row":{"id":"l2","name":"ab"}}}}}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"labels":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"labels":[]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"labels":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"labels":[]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"labels":[]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"labels":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"labels":[]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"labels":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"labels":[]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"labels":[]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]}]},{"changes":[{"type":"edit","row":{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},"oldRow":{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"labels":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"labels":[]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"labels":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"labels":[]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"labels":[]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]}]},{"changes":[{"type":"add","row":{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"labels":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"labels":[]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"labels":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"labels":[]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"labels":[]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"labels":[]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"labels":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"labels":[]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"labels":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"labels":[]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"labels":[]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"labels":[]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]}]},{"changes":[{"type":"edit","row":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},"oldRow":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"labels":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"labels":[]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"labels":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"labels":[]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"labels":[]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"labels":[]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"labels":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"labels":[]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"labels":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"labels":[]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"labels":[]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"labels":[]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]}]},{"changes":[{"type":"edit","row":{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},"oldRow":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991,"labels":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"labels":[]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"labels":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"labels":[]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"labels":[]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"labels":[]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991,"labels":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"labels":[]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"labels":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"labels":[]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"labels":[]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"labels":[]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991,"labels":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"labels":[]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"labels":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"labels":[]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"labels":[]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"labels":[]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]}]},{"changes":[{"type":"child","row":{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},"child":{"relationshipName":"labels","change":{"type":"child","row":{"issueId":"i2","labelId":"l1"},"child":{"relationshipName":"labels","change":{"type":"add","row":{"id":"l1","name":"中文"}}}}}},{"type":"child","row":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"child":{"relationshipName":"labels","change":{"type":"child","row":{"issueId":"i8","labelId":"l1"},"child":{"relationshipName":"labels","change":{"type":"add","row":{"id":"l1","name":"中文"}}}}}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991,"labels":[{"id":"l1","name":"中文"}]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"labels":[]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"labels":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"labels":[]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"labels":[]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"labels":[]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[{"id":"l1","name":"中文"}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991,"labels":[{"id":"l1","name":"中文"}]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"labels":[]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"labels":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"labels":[]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"labels":[]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"labels":[]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[{"id":"l1","name":"中文"}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991,"labels":[{"id":"l1","name":"中文"}]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"labels":[]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"labels":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"labels":[]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"labels":[]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"labels":[]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[{"id":"l1","name":"中文"}]}]},{"changes":[{"type":"child","row":{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},"child":{"relationshipName":"labels","change":{"type":"child","row":{"issueId":"i2","labelId":"l2"},"child":{"relationshipName":"labels","change":{"type":"add","row":{"id":"l2","name":"a"}}}}}},{"type":"child","row":{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},"child":{"relationshipName":"labels","change":{"type":"child","row":{"issueId":"i3","labelId":"l2"},"child":{"relationshipName":"labels","change":{"type":"add","row":{"id":"l2","name":"a"}}}}}},{"type":"child","row":{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},"child":{"relationshipName":"labels","change":{"type":"child","row":{"issueId":"i4","labelId":"l2"},"child":{"relationshipName":"labels","change":{"type":"add","row":{"id":"l2","name":"a"}}}}}},{"type":"child","row":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},"child":{"relationshipName":"labels","change":{"type":"child","row":{"issueId":"i5","labelId":"l2"},"child":{"relationshipName":"labels","change":{"type":"add","row":{"id":"l2","name":"a"}}}}}},{"type":"child","row":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},"child":{"relationshipName":"labels","change":{"type":"child","row":{"issueId":"i7","labelId":"l2"},"child":{"relationshipName":"labels","change":{"type":"add","row":{"id":"l2","name":"a"}}}}}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991,"labels":[{"id":"l1","name":"中文"},{"id":"l2","name":"a"}]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"labels":[{"id":"l2","name":"a"}]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"labels":[{"id":"l2","name":"a"}]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"labels":[{"id":"l2","name":"a"}]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"labels":[]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"labels":[{"id":"l2","name":"a"}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[{"id":"l1","name":"中文"}]}]},{"changes":[{"type":"child","row":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},"child":{"relationshipName":"labels","change":{"type":"remove","row":{"issueId":"i7","labelId":"l3"}}}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991,"labels":[{"id":"l1","name":"中文"},{"id":"l2","name":"a"}]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"labels":[{"id":"l2","name":"a"}]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"labels":[{"id":"l2","name":"a"}]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"labels":[{"id":"l2","name":"a"}]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"labels":[]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"labels":[{"id":"l2","name":"a"}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[{"id":"l1","name":"中文"}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991,"labels":[{"id":"l1","name":"中文"},{"id":"l2","name":"a"}]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"labels":[{"id":"l2","name":"a"}]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"labels":[{"id":"l2","name":"a"}]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"labels":[{"id":"l2","name":"a"}]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"labels":[]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"labels":[{"id":"l2","name":"a"}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[{"id":"l1","name":"中文"}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[]},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991,"labels":[{"id":"l1","name":"中文"},{"id":"l2","name":"a"}]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"labels":[{"id":"l2","name":"a"}]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"labels":[{"id":"l2","name":"a"}]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"labels":[{"id":"l2","name":"a"}]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"labels":[]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"labels":[{"id":"l2","name":"a"}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"labels":[{"id":"l1","name":"中文"}]}]}]}}, +{"name":"related limited","dataset":0,"program":{"table":"issue","ops":[{"op":"related","rel":"comments","sub":[{"op":"orderBy","field":"createdAt","dir":"desc"},{"op":"limit","n":2}]}]},"ast":{"table":"issue","related":[{"correlation":{"parentField":["id"],"childField":["issueId"]},"subquery":{"table":"comment","alias":"comments","limit":2,"orderBy":[["createdAt","desc"]]},"system":"client"}]},"format":{"singular":false,"relationships":{"comments":{"relationships":{},"singular":false}}},"result":{"initial":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}]}],"steps":[{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}]}]},{"changes":[{"type":"edit","row":{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},"oldRow":{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}]}]},{"changes":[{"type":"add","row":{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}]}]},{"changes":[{"type":"edit","row":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},"oldRow":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}]}]},{"changes":[{"type":"edit","row":{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},"oldRow":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}]}]},{"changes":[{"type":"child","row":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},"child":{"relationshipName":"comments","change":{"type":"add","row":{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648}}}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}]}]},{"changes":[{"type":"child","row":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},"child":{"relationshipName":"comments","change":{"type":"edit","row":{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1},"oldRow":{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}}}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5},{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}]}]},{"changes":[{"type":"child","row":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"child":{"relationshipName":"comments","change":{"type":"remove","row":{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5}}}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991,"comments":[]},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":[{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648},{"id":"c8","authorId":"u3","issueId":"i3","text":"b","createdAt":0}]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":[{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1}]},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}]}]}]}}, +{"name":"related nested","dataset":0,"program":{"table":"user","ops":[{"op":"related","rel":"issues","sub":[{"op":"related","rel":"comments","sub":[{"op":"related","rel":"revisions"}]},{"op":"related","rel":"labels"}]}]},"ast":{"table":"user","related":[{"correlation":{"parentField":["id"],"childField":["ownerId"]},"subquery":{"table":"issue","alias":"issues","related":[{"correlation":{"parentField":["id"],"childField":["issueId"]},"subquery":{"table":"comment","alias":"comments","related":[{"correlation":{"parentField":["id"],"childField":["commentId"]},"subquery":{"table":"revision","alias":"revisions"},"system":"client"}]},"system":"client"},{"correlation":{"parentField":["id"],"childField":["issueId"]},"hidden":true,"subquery":{"table":"issueLabel","alias":"labels","related":[{"correlation":{"parentField":["labelId"],"childField":["id"]},"subquery":{"table":"label","alias":"labels"},"system":"client"}]},"system":"client"}]},"system":"client"}]},"format":{"singular":false,"relationships":{"issues":{"relationships":{"comments":{"relationships":{"revisions":{"relationships":{},"singular":false}},"singular":false},"labels":{"relationships":{},"singular":false}},"singular":false}}},"result":{"initial":[{"id":"u3","name":"ab","metadata":null,"issues":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"revisions":[]}],"labels":[{"id":"l2","name":"é"}]}]},{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"},"issues":[]}],"steps":[{"changes":[],"data":[{"id":"u3","name":"ab","metadata":null,"issues":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"revisions":[]}],"labels":[{"id":"l2","name":"é"}]}]},{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"},"issues":[]}]},{"changes":[],"data":[{"id":"u3","name":"ab","metadata":null,"issues":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"revisions":[]}],"labels":[{"id":"l2","name":"é"}]}]},{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"},"issues":[]}]},{"changes":[],"data":[{"id":"u3","name":"ab","metadata":null,"issues":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"revisions":[]}],"labels":[{"id":"l2","name":"é"}]}]},{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"},"issues":[]}]},{"changes":[],"data":[{"id":"u3","name":"ab","metadata":null,"issues":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"revisions":[]}],"labels":[{"id":"l2","name":"é"}]}]},{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"},"issues":[]}]},{"changes":[],"data":[{"id":"u3","name":"ab","metadata":null,"issues":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"revisions":[]}],"labels":[{"id":"l2","name":"é"}]}]},{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"},"issues":[]}]},{"changes":[{"type":"child","row":{"id":"u3","name":"ab","metadata":null},"child":{"relationshipName":"issues","change":{"type":"child","row":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},"child":{"relationshipName":"labels","change":{"type":"child","row":{"issueId":"i5","labelId":"l2"},"child":{"relationshipName":"labels","change":{"type":"edit","row":{"id":"l2","name":"a"},"oldRow":{"id":"l2","name":"é"}}}}}}}}],"data":[{"id":"u3","name":"ab","metadata":null,"issues":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"revisions":[]}],"labels":[{"id":"l2","name":"a"}]}]},{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"},"issues":[]}]},{"changes":[{"type":"child","row":{"id":"u3","name":"ab","metadata":null},"child":{"relationshipName":"issues","change":{"type":"child","row":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},"child":{"relationshipName":"labels","change":{"type":"child","row":{"issueId":"i5","labelId":"l2"},"child":{"relationshipName":"labels","change":{"type":"edit","row":{"id":"l2","name":"ab"},"oldRow":{"id":"l2","name":"a"}}}}}}}}],"data":[{"id":"u3","name":"ab","metadata":null,"issues":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"revisions":[]}],"labels":[{"id":"l2","name":"ab"}]}]},{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"},"issues":[]}]},{"changes":[],"data":[{"id":"u3","name":"ab","metadata":null,"issues":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"revisions":[]}],"labels":[{"id":"l2","name":"ab"}]}]},{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"},"issues":[]}]},{"changes":[{"type":"child","row":{"id":"u3","name":"ab","metadata":null},"child":{"relationshipName":"issues","change":{"type":"child","row":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},"child":{"relationshipName":"labels","change":{"type":"child","row":{"issueId":"i5","labelId":"l2"},"child":{"relationshipName":"labels","change":{"type":"remove","row":{"id":"l2","name":"ab"}}}}}}}}],"data":[{"id":"u3","name":"ab","metadata":null,"issues":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"revisions":[]}],"labels":[]}]},{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"},"issues":[]}]},{"changes":[],"data":[{"id":"u3","name":"ab","metadata":null,"issues":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"revisions":[]}],"labels":[]}]},{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"},"issues":[]}]},{"changes":[],"data":[{"id":"u3","name":"ab","metadata":null,"issues":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"revisions":[]}],"labels":[]}]},{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"},"issues":[]}]},{"changes":[{"type":"child","row":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}},"child":{"relationshipName":"issues","change":{"type":"add","row":{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}}}}],"data":[{"id":"u3","name":"ab","metadata":null,"issues":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"revisions":[]}],"labels":[]}]},{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"},"issues":[{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"revisions":[]}],"labels":[]}]}]},{"changes":[{"type":"edit","row":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}},"oldRow":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}}],"data":[{"id":"u3","name":"ab","metadata":null,"issues":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"revisions":[]}],"labels":[]}]},{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"},"issues":[{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"revisions":[]}],"labels":[]}]}]},{"changes":[],"data":[{"id":"u3","name":"ab","metadata":null,"issues":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"revisions":[]}],"labels":[]}]},{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"},"issues":[{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"revisions":[]}],"labels":[]}]}]},{"changes":[],"data":[{"id":"u3","name":"ab","metadata":null,"issues":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"revisions":[]}],"labels":[]}]},{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"},"issues":[{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"revisions":[]}],"labels":[]}]}]},{"changes":[],"data":[{"id":"u3","name":"ab","metadata":null,"issues":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"revisions":[]}],"labels":[]}]},{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"},"issues":[{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"revisions":[]}],"labels":[]}]}]},{"changes":[],"data":[{"id":"u3","name":"ab","metadata":null,"issues":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"revisions":[]}],"labels":[]}]},{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"},"issues":[{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"revisions":[]}],"labels":[]}]}]},{"changes":[{"type":"child","row":{"id":"u3","name":"ab","metadata":null},"child":{"relationshipName":"issues","change":{"type":"child","row":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},"child":{"relationshipName":"comments","change":{"type":"add","row":{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648}}}}}}],"data":[{"id":"u3","name":"ab","metadata":null,"issues":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648,"revisions":[]},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"revisions":[]}],"labels":[]}]},{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"},"issues":[{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"revisions":[]}],"labels":[]}]}]},{"changes":[],"data":[{"id":"u3","name":"ab","metadata":null,"issues":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648,"revisions":[]},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"revisions":[]}],"labels":[]}]},{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"},"issues":[{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"revisions":[]}],"labels":[]}]}]},{"changes":[],"data":[{"id":"u3","name":"ab","metadata":null,"issues":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648,"revisions":[]},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"revisions":[]}],"labels":[]}]},{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"},"issues":[{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"revisions":[]}],"labels":[]}]}]},{"changes":[],"data":[{"id":"u3","name":"ab","metadata":null,"issues":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648,"revisions":[]},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"revisions":[]}],"labels":[]}]},{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"},"issues":[{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"revisions":[]}],"labels":[]}]}]},{"changes":[{"type":"child","row":{"id":"u3","name":"ab","metadata":null},"child":{"relationshipName":"issues","change":{"type":"child","row":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},"child":{"relationshipName":"labels","change":{"type":"child","row":{"issueId":"i5","labelId":"l2"},"child":{"relationshipName":"labels","change":{"type":"add","row":{"id":"l2","name":"a"}}}}}}}}],"data":[{"id":"u3","name":"ab","metadata":null,"issues":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648,"revisions":[]},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"revisions":[]}],"labels":[{"id":"l2","name":"a"}]}]},{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"},"issues":[{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"revisions":[]}],"labels":[]}]}]},{"changes":[],"data":[{"id":"u3","name":"ab","metadata":null,"issues":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648,"revisions":[]},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"revisions":[]}],"labels":[{"id":"l2","name":"a"}]}]},{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"},"issues":[{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"revisions":[]}],"labels":[]}]}]},{"changes":[],"data":[{"id":"u3","name":"ab","metadata":null,"issues":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648,"revisions":[]},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"revisions":[]}],"labels":[{"id":"l2","name":"a"}]}]},{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"},"issues":[{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"revisions":[]}],"labels":[]}]}]},{"changes":[],"data":[{"id":"u3","name":"ab","metadata":null,"issues":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":[{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648,"revisions":[]},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"revisions":[]}],"labels":[{"id":"l2","name":"a"}]}]},{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"},"issues":[{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1,"revisions":[]}],"labels":[]}]}]}]}}, +{"name":"related one()","dataset":0,"program":{"table":"issue","ops":[{"op":"related","rel":"comments","sub":[{"op":"one"}]}]},"ast":{"table":"issue","related":[{"correlation":{"parentField":["id"],"childField":["issueId"]},"subquery":{"table":"comment","alias":"comments","limit":1},"system":"client"}]},"format":{"singular":false,"relationships":{"comments":{"relationships":{},"singular":true}}},"result":{"initial":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648}},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}}],"steps":[{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648}},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648}},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648}},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648}},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648}},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648}},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648}},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648}},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648}},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648}},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}}]},{"changes":[{"type":"edit","row":{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},"oldRow":{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648}},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}}]},{"changes":[{"type":"add","row":{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648}},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648}},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}}]},{"changes":[{"type":"edit","row":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},"oldRow":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648}},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648}},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}}]},{"changes":[{"type":"edit","row":{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},"oldRow":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648}},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648}},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}}]},{"changes":[{"type":"child","row":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},"child":{"relationshipName":"comments","change":{"type":"remove","row":{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2}}}},{"type":"child","row":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},"child":{"relationshipName":"comments","change":{"type":"add","row":{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648}}}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648}},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648}},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648}},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648}},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}}]},{"changes":[{"type":"child","row":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},"child":{"relationshipName":"comments","change":{"type":"edit","row":{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1},"oldRow":{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}}}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648}},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648}},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1}},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648}},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648}},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1}},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648}},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648}},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1}},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648}},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648}},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1}},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648}},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648}},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1}},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1,"comments":{"id":"c10","authorId":"u3","issueId":"i3","text":"中文","createdAt":2147483648}},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"comments":{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648}},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2,"comments":{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1}},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5,"comments":{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1}}]}]}}, +{"name":"exists","dataset":0,"program":{"table":"issue","ops":[{"op":"whereExists","rel":"comments"}]},"ast":{"table":"issue","where":{"type":"correlatedSubquery","related":{"correlation":{"parentField":["id"],"childField":["issueId"]},"subquery":{"table":"comment","alias":"zsubq_comments"},"system":"client"},"op":"EXISTS"}},"format":{"singular":false,"relationships":{}},"result":{"initial":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}],"steps":[{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[{"type":"add","row":{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}}],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[{"type":"edit","row":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},"oldRow":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}}],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[{"type":"child","row":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},"child":{"relationshipName":"zsubq_comments","change":{"type":"add","row":{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648}}}}],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[{"type":"child","row":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},"child":{"relationshipName":"zsubq_comments","change":{"type":"edit","row":{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1},"oldRow":{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}}}}],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[{"type":"child","row":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"child":{"relationshipName":"zsubq_comments","change":{"type":"remove","row":{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5}}}}],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]}]}}, +{"name":"exists filtered","dataset":0,"program":{"table":"issue","ops":[{"op":"whereExists","rel":"comments","sub":[{"op":"where","field":"authorId","value":"u1"}]}]},"ast":{"table":"issue","where":{"type":"correlatedSubquery","related":{"correlation":{"parentField":["id"],"childField":["issueId"]},"subquery":{"table":"comment","alias":"zsubq_comments","where":{"type":"simple","op":"=","left":{"type":"column","name":"authorId"},"right":{"type":"literal","value":"u1"}}},"system":"client"},"op":"EXISTS"}},"format":{"singular":false,"relationships":{}},"result":{"initial":[],"steps":[{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]}]}}, +{"name":"exists junction","dataset":0,"program":{"table":"issue","ops":[{"op":"whereExists","rel":"labels"}]},"ast":{"table":"issue","where":{"type":"correlatedSubquery","related":{"correlation":{"parentField":["id"],"childField":["issueId"]},"subquery":{"table":"issueLabel","alias":"zsubq_labels","where":{"type":"correlatedSubquery","related":{"correlation":{"parentField":["labelId"],"childField":["id"]},"subquery":{"table":"label","alias":"zsubq_zhidden_labels"},"system":"client"},"op":"EXISTS"}},"system":"client"},"op":"EXISTS"}},"format":{"singular":false,"relationships":{}},"result":{"initial":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}],"steps":[{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[{"type":"child","row":{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},"child":{"relationshipName":"zsubq_labels","change":{"type":"remove","row":{"issueId":"i4","labelId":"l3"}}}},{"type":"child","row":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},"child":{"relationshipName":"zsubq_labels","change":{"type":"remove","row":{"issueId":"i7","labelId":"l3"}}}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[{"type":"remove","row":{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5}}],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[{"type":"child","row":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},"child":{"relationshipName":"zsubq_labels","change":{"type":"child","row":{"issueId":"i2","labelId":"l2"},"child":{"relationshipName":"zsubq_zhidden_labels","change":{"type":"edit","row":{"id":"l2","name":"a"},"oldRow":{"id":"l2","name":"é"}}}}}},{"type":"child","row":{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},"child":{"relationshipName":"zsubq_labels","change":{"type":"child","row":{"issueId":"i3","labelId":"l2"},"child":{"relationshipName":"zsubq_zhidden_labels","change":{"type":"edit","row":{"id":"l2","name":"a"},"oldRow":{"id":"l2","name":"é"}}}}}},{"type":"child","row":{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},"child":{"relationshipName":"zsubq_labels","change":{"type":"child","row":{"issueId":"i4","labelId":"l2"},"child":{"relationshipName":"zsubq_zhidden_labels","change":{"type":"edit","row":{"id":"l2","name":"a"},"oldRow":{"id":"l2","name":"é"}}}}}},{"type":"child","row":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},"child":{"relationshipName":"zsubq_labels","change":{"type":"child","row":{"issueId":"i5","labelId":"l2"},"child":{"relationshipName":"zsubq_zhidden_labels","change":{"type":"edit","row":{"id":"l2","name":"a"},"oldRow":{"id":"l2","name":"é"}}}}}},{"type":"child","row":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},"child":{"relationshipName":"zsubq_labels","change":{"type":"child","row":{"issueId":"i7","labelId":"l2"},"child":{"relationshipName":"zsubq_zhidden_labels","change":{"type":"edit","row":{"id":"l2","name":"a"},"oldRow":{"id":"l2","name":"é"}}}}}}],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[{"type":"child","row":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},"child":{"relationshipName":"zsubq_labels","change":{"type":"child","row":{"issueId":"i2","labelId":"l2"},"child":{"relationshipName":"zsubq_zhidden_labels","change":{"type":"edit","row":{"id":"l2","name":"ab"},"oldRow":{"id":"l2","name":"a"}}}}}},{"type":"child","row":{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},"child":{"relationshipName":"zsubq_labels","change":{"type":"child","row":{"issueId":"i3","labelId":"l2"},"child":{"relationshipName":"zsubq_zhidden_labels","change":{"type":"edit","row":{"id":"l2","name":"ab"},"oldRow":{"id":"l2","name":"a"}}}}}},{"type":"child","row":{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},"child":{"relationshipName":"zsubq_labels","change":{"type":"child","row":{"issueId":"i4","labelId":"l2"},"child":{"relationshipName":"zsubq_zhidden_labels","change":{"type":"edit","row":{"id":"l2","name":"ab"},"oldRow":{"id":"l2","name":"a"}}}}}},{"type":"child","row":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},"child":{"relationshipName":"zsubq_labels","change":{"type":"child","row":{"issueId":"i5","labelId":"l2"},"child":{"relationshipName":"zsubq_zhidden_labels","change":{"type":"edit","row":{"id":"l2","name":"ab"},"oldRow":{"id":"l2","name":"a"}}}}}},{"type":"child","row":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},"child":{"relationshipName":"zsubq_labels","change":{"type":"child","row":{"issueId":"i7","labelId":"l2"},"child":{"relationshipName":"zsubq_zhidden_labels","change":{"type":"edit","row":{"id":"l2","name":"ab"},"oldRow":{"id":"l2","name":"a"}}}}}}],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[{"type":"remove","row":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}},{"type":"remove","row":{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}},{"type":"remove","row":{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2}},{"type":"remove","row":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648}},{"type":"remove","row":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}}],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[{"type":"add","row":{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991}},{"type":"add","row":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}}],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[{"type":"child","row":{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},"child":{"relationshipName":"zsubq_labels","change":{"type":"add","row":{"issueId":"i2","labelId":"l2"}}}},{"type":"add","row":{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}},{"type":"add","row":{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0}},{"type":"add","row":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648}},{"type":"add","row":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}}],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]}]}}, +{"name":"exists with limit","dataset":0,"program":{"table":"issue","ops":[{"op":"whereExists","rel":"comments"},{"op":"orderBy","field":"title","dir":"desc"},{"op":"limit","n":2}]},"ast":{"table":"issue","where":{"type":"correlatedSubquery","related":{"correlation":{"parentField":["id"],"childField":["issueId"]},"subquery":{"table":"comment","alias":"zsubq_comments"},"system":"client"},"op":"EXISTS"},"limit":2,"orderBy":[["title","desc"]]},"format":{"singular":false,"relationships":{}},"result":{"initial":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}],"steps":[{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[{"type":"remove","row":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}},{"type":"add","row":{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}}],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}]},{"changes":[{"type":"child","row":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},"child":{"relationshipName":"zsubq_comments","change":{"type":"add","row":{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648}}}}],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}]},{"changes":[],"data":[{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}]}]}}, +{"name":"exists flipped","dataset":0,"program":{"table":"issue","ops":[{"op":"whereExists","rel":"comments","flip":true}]},"ast":{"table":"issue","where":{"type":"correlatedSubquery","related":{"correlation":{"parentField":["id"],"childField":["issueId"]},"subquery":{"table":"comment","alias":"zsubq_comments"},"system":"client"},"op":"EXISTS","flip":true}},"format":{"singular":false,"relationships":{}},"result":{"initial":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}],"steps":[{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[{"type":"add","row":{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}}],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[{"type":"edit","row":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},"oldRow":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}}],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[{"type":"child","row":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},"child":{"relationshipName":"zsubq_comments","change":{"type":"add","row":{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648}}}}],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[{"type":"child","row":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},"child":{"relationshipName":"zsubq_comments","change":{"type":"edit","row":{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1},"oldRow":{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}}}}],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[{"type":"child","row":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"child":{"relationshipName":"zsubq_comments","change":{"type":"remove","row":{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5}}}}],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]}]}}, +{"name":"exists flipped junction limited","dataset":0,"program":{"table":"issue","ops":[{"op":"whereExists","rel":"labels","flip":true,"sub":[{"op":"where","field":"name","cmp":"!=","value":"a"}]},{"op":"limit","n":3}]},"ast":{"table":"issue","where":{"type":"correlatedSubquery","related":{"correlation":{"parentField":["id"],"childField":["issueId"]},"subquery":{"table":"issueLabel","alias":"zsubq_labels","where":{"type":"correlatedSubquery","related":{"correlation":{"parentField":["labelId"],"childField":["id"]},"subquery":{"table":"label","alias":"zsubq_zhidden_labels","where":{"type":"simple","op":"!=","left":{"type":"column","name":"name"},"right":{"type":"literal","value":"a"}}},"system":"client"},"op":"EXISTS","flip":true}},"system":"client"},"op":"EXISTS","flip":true},"limit":3},"format":{"singular":false,"relationships":{}},"result":{"initial":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}],"steps":[{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}]},{"changes":[{"type":"remove","row":{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5}},{"type":"add","row":{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2}}],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[{"type":"remove","row":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}},{"type":"remove","row":{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}},{"type":"remove","row":{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2}}],"data":[]},{"changes":[{"type":"add","row":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}},{"type":"add","row":{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}},{"type":"add","row":{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2}}],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[{"type":"remove","row":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}},{"type":"remove","row":{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1}},{"type":"remove","row":{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2}}],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[{"type":"add","row":{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991}},{"type":"add","row":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}}],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]}]}}, +{"name":"or of exists","dataset":0,"program":{"table":"issue","ops":[{"op":"whereExpr","expr":{"or":[{"exists":"comments","sub":[{"op":"where","field":"authorId","value":"u2"}]},{"cmp":["closed","=",true]}]}}]},"ast":{"table":"issue","where":{"type":"or","conditions":[{"type":"simple","op":"=","left":{"type":"column","name":"closed"},"right":{"type":"literal","value":true}},{"type":"correlatedSubquery","related":{"correlation":{"parentField":["id"],"childField":["issueId"]},"subquery":{"table":"comment","alias":"zsubq_comments","where":{"type":"simple","op":"=","left":{"type":"column","name":"authorId"},"right":{"type":"literal","value":"u2"}}},"system":"client"},"op":"EXISTS"}]}},"format":{"singular":false,"relationships":{}},"result":{"initial":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}],"steps":[{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[{"type":"remove","row":{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2}}],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[{"type":"add","row":{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}}],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[{"type":"edit","row":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},"oldRow":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}}],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[{"type":"edit","row":{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},"oldRow":{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991}}],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"changes":[],"data":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]}]}}, +{"name":"or of flipped exists","dataset":0,"program":{"table":"issue","ops":[{"op":"whereExpr","expr":{"or":[{"exists":"comments","flip":true},{"exists":"owner","flip":true,"sub":[{"op":"where","field":"name","value":"a"}]},{"cmp":["title","=","b"]}]}},{"op":"related","rel":"owner"}]},"ast":{"table":"issue","where":{"type":"or","conditions":[{"type":"simple","op":"=","left":{"type":"column","name":"title"},"right":{"type":"literal","value":"b"}},{"type":"correlatedSubquery","related":{"correlation":{"parentField":["id"],"childField":["issueId"]},"subquery":{"table":"comment","alias":"zsubq_comments"},"system":"client"},"op":"EXISTS","flip":true},{"type":"correlatedSubquery","related":{"correlation":{"parentField":["ownerId"],"childField":["id"]},"subquery":{"table":"user","alias":"zsubq_owner","where":{"type":"simple","op":"=","left":{"type":"column","name":"name"},"right":{"type":"literal","value":"a"}}},"system":"client"},"op":"EXISTS","flip":true}]},"related":[{"correlation":{"parentField":["ownerId"],"childField":["id"]},"subquery":{"table":"user","alias":"owner"},"system":"client"}]},"format":{"singular":false,"relationships":{"owner":{"relationships":{},"singular":true}}},"result":{"initial":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}],"steps":[{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[{"type":"add","row":{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}}],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"owner":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[{"type":"child","row":{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},"child":{"relationshipName":"zsubq_owner_1","change":{"type":"edit","row":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}},"oldRow":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}}}},{"type":"child","row":{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648},"child":{"relationshipName":"owner","change":{"type":"edit","row":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}},"oldRow":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"A"}}}}}],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"owner":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[{"type":"remove","row":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}},{"type":"add","row":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}}],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"owner":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"owner":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"owner":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"owner":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[{"type":"child","row":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},"child":{"relationshipName":"zsubq_comments_0","change":{"type":"add","row":{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648}}}}],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"owner":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"owner":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[{"type":"child","row":{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},"child":{"relationshipName":"zsubq_comments_0","change":{"type":"edit","row":{"id":"c3","authorId":"u4","issueId":"i7","text":"","createdAt":0.1},"oldRow":{"id":"c3","authorId":"u4","issueId":"i7","text":"é","createdAt":0.1}}}}],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"owner":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"owner":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"owner":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"owner":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[{"type":"child","row":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"child":{"relationshipName":"zsubq_comments_0","change":{"type":"remove","row":{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5}}}}],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"owner":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"owner":{"id":"u4","name":"a","metadata":{"registrar":"github","email":"a"}}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]}]}}, +{"name":"and of conditions","dataset":0,"program":{"table":"issue","ops":[{"op":"whereExpr","expr":{"and":[{"cmp":["closed","=",false]},{"or":[{"cmp":["createdAt",">",1]},{"cmp":["title","LIKE","a%"]}]},{"not":{"cmp":["ownerId","IS",null]}}]}}]},"ast":{"table":"issue","where":{"type":"and","conditions":[{"type":"simple","op":"=","left":{"type":"column","name":"closed"},"right":{"type":"literal","value":false}},{"type":"simple","op":"IS NOT","left":{"type":"column","name":"ownerId"},"right":{"type":"literal","value":null}},{"type":"or","conditions":[{"type":"simple","op":">","left":{"type":"column","name":"createdAt"},"right":{"type":"literal","value":1}},{"type":"simple","op":"LIKE","left":{"type":"column","name":"title"},"right":{"type":"literal","value":"a%"}}]}]}},"format":{"singular":false,"relationships":{}},"result":{"initial":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}],"steps":[{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]}]}}, +{"name":"exists and related","dataset":0,"program":{"table":"comment","ops":[{"op":"whereExists","rel":"issue","sub":[{"op":"where","field":"closed","value":false}]},{"op":"related","rel":"issue","sub":[{"op":"related","rel":"owner"}]},{"op":"related","rel":"revisions"}]},"ast":{"table":"comment","where":{"type":"correlatedSubquery","related":{"correlation":{"parentField":["issueId"],"childField":["id"]},"subquery":{"table":"issue","alias":"zsubq_issue","where":{"type":"simple","op":"=","left":{"type":"column","name":"closed"},"right":{"type":"literal","value":false}}},"system":"client"},"op":"EXISTS"},"related":[{"correlation":{"parentField":["issueId"],"childField":["id"]},"subquery":{"table":"issue","alias":"issue","related":[{"correlation":{"parentField":["ownerId"],"childField":["id"]},"subquery":{"table":"user","alias":"owner"},"system":"client"}]},"system":"client"},{"correlation":{"parentField":["id"],"childField":["commentId"]},"subquery":{"table":"revision","alias":"revisions"},"system":"client"}]},"format":{"singular":false,"relationships":{"issue":{"relationships":{"owner":{"relationships":{},"singular":true}},"singular":true},"revisions":{"relationships":{},"singular":false}}},"result":{"initial":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"issue":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},"revisions":[]}],"steps":[{"changes":[],"data":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"issue":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},"revisions":[]}]},{"changes":[],"data":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"issue":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},"revisions":[]}]},{"changes":[],"data":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"issue":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},"revisions":[]}]},{"changes":[],"data":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"issue":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},"revisions":[]}]},{"changes":[],"data":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"issue":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},"revisions":[]}]},{"changes":[],"data":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"issue":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},"revisions":[]}]},{"changes":[],"data":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"issue":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},"revisions":[]}]},{"changes":[],"data":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"issue":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},"revisions":[]}]},{"changes":[],"data":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"issue":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},"revisions":[]}]},{"changes":[],"data":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"issue":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},"revisions":[]}]},{"changes":[],"data":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"issue":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},"revisions":[]}]},{"changes":[],"data":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"issue":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},"revisions":[]}]},{"changes":[],"data":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"issue":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},"revisions":[]}]},{"changes":[],"data":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"issue":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},"revisions":[]}]},{"changes":[],"data":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"issue":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},"revisions":[]}]},{"changes":[],"data":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"issue":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},"revisions":[]}]},{"changes":[{"type":"child","row":{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},"child":{"relationshipName":"revisions","change":{"type":"add","row":{"id":"r1","authorId":"u2","commentId":"c12","text":"😀"}}}}],"data":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[{"id":"r1","authorId":"u2","commentId":"c12","text":"😀"}]},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"issue":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},"revisions":[]}]},{"changes":[{"type":"add","row":{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648}}],"data":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[{"id":"r1","authorId":"u2","commentId":"c12","text":"😀"}]},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648,"issue":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},"revisions":[]},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"issue":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},"revisions":[]}]},{"changes":[],"data":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[{"id":"r1","authorId":"u2","commentId":"c12","text":"😀"}]},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648,"issue":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},"revisions":[]},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"issue":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},"revisions":[]}]},{"changes":[],"data":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[{"id":"r1","authorId":"u2","commentId":"c12","text":"😀"}]},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648,"issue":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},"revisions":[]},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"issue":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},"revisions":[]}]},{"changes":[{"type":"child","row":{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1},"child":{"relationshipName":"revisions","change":{"type":"remove","row":{"id":"r1","authorId":"u2","commentId":"c12","text":"😀"}}}}],"data":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648,"issue":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},"revisions":[]},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"issue":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},"revisions":[]}]},{"changes":[],"data":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648,"issue":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},"revisions":[]},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"issue":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},"revisions":[]}]},{"changes":[],"data":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648,"issue":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},"revisions":[]},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"issue":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},"revisions":[]}]},{"changes":[{"type":"remove","row":{"id":"c2","authorId":"u4","issueId":"i8","text":"a","createdAt":3.5}}],"data":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648,"issue":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},"revisions":[]},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"issue":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},"revisions":[]}]},{"changes":[],"data":[{"id":"c12","authorId":"u4","issueId":"i8","text":"bug","createdAt":-1,"issue":{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5},"revisions":[]},{"id":"c4","authorId":"u3","issueId":"i5","text":"Bug","createdAt":2147483648,"issue":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},"revisions":[]},{"id":"c7","authorId":"u4","issueId":"i5","text":"z%","createdAt":2,"issue":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},"revisions":[]}]}]}}, +{"name":"nested exists","dataset":0,"program":{"table":"user","ops":[{"op":"whereExists","rel":"issues","sub":[{"op":"whereExists","rel":"labels"}]}]},"ast":{"table":"user","where":{"type":"correlatedSubquery","related":{"correlation":{"parentField":["id"],"childField":["ownerId"]},"subquery":{"table":"issue","alias":"zsubq_issues","where":{"type":"correlatedSubquery","related":{"correlation":{"parentField":["id"],"childField":["issueId"]},"subquery":{"table":"issueLabel","alias":"zsubq_labels","where":{"type":"correlatedSubquery","related":{"correlation":{"parentField":["labelId"],"childField":["id"]},"subquery":{"table":"label","alias":"zsubq_zhidden_labels"},"system":"client"},"op":"EXISTS"}},"system":"client"},"op":"EXISTS"}},"system":"client"},"op":"EXISTS"}},"format":{"singular":false,"relationships":{}},"result":{"initial":[{"id":"u3","name":"ab","metadata":null}],"steps":[{"changes":[],"data":[{"id":"u3","name":"ab","metadata":null}]},{"changes":[],"data":[{"id":"u3","name":"ab","metadata":null}]},{"changes":[],"data":[{"id":"u3","name":"ab","metadata":null}]},{"changes":[],"data":[{"id":"u3","name":"ab","metadata":null}]},{"changes":[],"data":[{"id":"u3","name":"ab","metadata":null}]},{"changes":[{"type":"child","row":{"id":"u3","name":"ab","metadata":null},"child":{"relationshipName":"zsubq_issues","change":{"type":"child","row":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},"child":{"relationshipName":"zsubq_labels","change":{"type":"child","row":{"issueId":"i5","labelId":"l2"},"child":{"relationshipName":"zsubq_zhidden_labels","change":{"type":"edit","row":{"id":"l2","name":"a"},"oldRow":{"id":"l2","name":"é"}}}}}}}}],"data":[{"id":"u3","name":"ab","metadata":null}]},{"changes":[{"type":"child","row":{"id":"u3","name":"ab","metadata":null},"child":{"relationshipName":"zsubq_issues","change":{"type":"child","row":{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648},"child":{"relationshipName":"zsubq_labels","change":{"type":"child","row":{"issueId":"i5","labelId":"l2"},"child":{"relationshipName":"zsubq_zhidden_labels","change":{"type":"edit","row":{"id":"l2","name":"ab"},"oldRow":{"id":"l2","name":"a"}}}}}}}}],"data":[{"id":"u3","name":"ab","metadata":null}]},{"changes":[],"data":[{"id":"u3","name":"ab","metadata":null}]},{"changes":[{"type":"remove","row":{"id":"u3","name":"ab","metadata":null}}],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[{"type":"add","row":{"id":"u3","name":"ab","metadata":null}}],"data":[{"id":"u3","name":"ab","metadata":null}]},{"changes":[],"data":[{"id":"u3","name":"ab","metadata":null}]},{"changes":[],"data":[{"id":"u3","name":"ab","metadata":null}]},{"changes":[],"data":[{"id":"u3","name":"ab","metadata":null}]}]}}, +{"name":"pushdown","dataset":0,"program":{"table":"issue","ops":[{"op":"where","field":"id","cmp":"IN","value":["i1","i4","i6"]},{"op":"related","rel":"comments","sub":[{"op":"limit","n":1}]}]},"ast":{"table":"issue","where":{"type":"simple","op":"IN","left":{"type":"column","name":"id"},"right":{"type":"literal","value":["i1","i4","i6"]}},"related":[{"correlation":{"parentField":["id"],"childField":["issueId"]},"subquery":{"table":"comment","alias":"comments","limit":1},"system":"client"}]},"format":{"singular":false,"relationships":{"comments":{"relationships":{},"singular":false}}},"result":{"initial":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"comments":[]}],"steps":[{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"comments":[]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"comments":[]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"comments":[]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"comments":[]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"comments":[]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"comments":[]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"comments":[]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"comments":[]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"comments":[]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2,"comments":[]}]},{"changes":[{"type":"edit","row":{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},"oldRow":{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]}]},{"changes":[{"type":"add","row":{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648}}],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]}]},{"changes":[],"data":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5,"comments":[]},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0,"comments":[]},{"id":"i6","title":"中文","description":"a","closed":true,"ownerId":"u4","createdAt":2147483648,"comments":[{"id":"c9","authorId":"u4","issueId":"i6","text":"","createdAt":-1}]}]}]}}, +{"name":"label issues","dataset":0,"program":{"table":"label","ops":[{"op":"related","rel":"issues","sub":[{"op":"related","rel":"owner"}]}]},"ast":{"table":"label","related":[{"correlation":{"parentField":["id"],"childField":["labelId"]},"hidden":true,"subquery":{"table":"issueLabel","alias":"issues","related":[{"correlation":{"parentField":["issueId"],"childField":["id"]},"subquery":{"table":"issue","alias":"issues","related":[{"correlation":{"parentField":["ownerId"],"childField":["id"]},"subquery":{"table":"user","alias":"owner"},"system":"client"}]},"system":"client"}]},"system":"client"}]},"format":{"singular":false,"relationships":{"issues":{"relationships":{"owner":{"relationships":{},"singular":true}},"singular":false}}},"result":{"initial":[{"id":"l2","name":"é","issues":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"id":"l3","name":"bug","issues":[{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"id":"l4","name":"b","issues":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5}]}],"steps":[{"changes":[],"data":[{"id":"l2","name":"é","issues":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"id":"l3","name":"bug","issues":[{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"id":"l4","name":"b","issues":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5}]}]},{"changes":[{"type":"remove","row":{"id":"l3","name":"bug"}}],"data":[{"id":"l2","name":"é","issues":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]},{"id":"l4","name":"b","issues":[{"id":"i1","title":"","description":"中文","closed":false,"ownerId":"u2","createdAt":3.5}]}]},{"changes":[{"type":"remove","row":{"id":"l4","name":"b"}}],"data":[{"id":"l2","name":"é","issues":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]}]},{"changes":[],"data":[{"id":"l2","name":"é","issues":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]}]},{"changes":[],"data":[{"id":"l2","name":"é","issues":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]}]},{"changes":[{"type":"edit","row":{"id":"l2","name":"a"},"oldRow":{"id":"l2","name":"é"}}],"data":[{"id":"l2","name":"a","issues":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]}]},{"changes":[{"type":"edit","row":{"id":"l2","name":"ab"},"oldRow":{"id":"l2","name":"a"}}],"data":[{"id":"l2","name":"ab","issues":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]}]},{"changes":[],"data":[{"id":"l2","name":"ab","issues":[{"id":"i2","title":"é","description":"😀","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":true,"ownerId":null,"createdAt":2},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]}]},{"changes":[{"type":"remove","row":{"id":"l2","name":"ab"}}],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[],"data":[]},{"changes":[{"type":"add","row":{"id":"l1","name":"中文"}}],"data":[{"id":"l1","name":"中文","issues":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]}]},{"changes":[],"data":[{"id":"l1","name":"中文","issues":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]}]},{"changes":[],"data":[{"id":"l1","name":"中文","issues":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]}]},{"changes":[{"type":"add","row":{"id":"l2","name":"a"}}],"data":[{"id":"l1","name":"中文","issues":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"id":"l2","name":"a","issues":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]}]},{"changes":[],"data":[{"id":"l1","name":"中文","issues":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"id":"l2","name":"a","issues":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]}]},{"changes":[],"data":[{"id":"l1","name":"中文","issues":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"id":"l2","name":"a","issues":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]}]},{"changes":[],"data":[{"id":"l1","name":"中文","issues":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i8","title":"","description":"a","closed":false,"ownerId":"u2","createdAt":3.5}]},{"id":"l2","name":"a","issues":[{"id":"i2","title":"é","description":"a_c","closed":true,"ownerId":null,"createdAt":9007199254740991},{"id":"i3","title":"A","description":"abc","closed":true,"ownerId":"u2","createdAt":0.1},{"id":"i4","title":"a_c","description":"A","closed":false,"ownerId":null,"createdAt":0},{"id":"i5","title":"","description":"a","closed":false,"ownerId":"u3","createdAt":2147483648,"owner":{"id":"u3","name":"ab","metadata":null}},{"id":"i7","title":"a_c","description":"z%","closed":true,"ownerId":null,"createdAt":2}]}]}]}} +]} diff --git a/swift/Sources/Zero/Builder/Builder.swift b/swift/Sources/Zero/Builder/Builder.swift new file mode 100644 index 0000000000..4aeb6b3e30 --- /dev/null +++ b/swift/Sources/Zero/Builder/Builder.swift @@ -0,0 +1,597 @@ +/// Connects a pipeline being built to its environment +/// (`BuilderDelegate` in `packages/zql/src/builder/builder.ts`). +public protocol BuilderDelegate: AnyObject { + /// Apply `where` filters even when the source applies them all. + var applyFiltersAnyway: Bool { get } + /// Allow NOT EXISTS. Only the server does: a client cannot tell a row + /// that does not exist from one that is not synced. + var enableNotExists: Bool { get } + /// Do not copy a parent's conditions on correlation columns into its + /// subqueries. + var disableCorrelatedPredicatePushdown: Bool { get } + func getSource(_ tableName: String) -> (any Source)? + func decorateInput(_ input: any Input, name: String) -> any Input + func addEdge(_ source: any InputBase, _ dest: any InputBase) + func decorateFilterInput(_ input: any FilterInput, name: String) -> any FilterInput + func decorateSourceInput(_ input: any SourceInput, queryID: String) -> any Input + func mapAst(_ ast: AST) -> AST +} + +extension BuilderDelegate { + public var applyFiltersAnyway: Bool { false } + public var enableNotExists: Bool { false } + public var disableCorrelatedPredicatePushdown: Bool { false } + public func decorateInput(_ input: any Input, name: String) -> any Input { input } + public func addEdge(_ source: any InputBase, _ dest: any InputBase) {} + public func decorateFilterInput(_ input: any FilterInput, name: String) -> any FilterInput { + input + } + public func decorateSourceInput(_ input: any SourceInput, queryID: String) -> any Input { input } + public func mapAst(_ ast: AST) -> AST { ast } +} + +/// The limit put on EXISTS subqueries: one row is enough, a few more keep +/// a removal from emptying it. +let existsLimit = 3 +let permissionsExistsLimit = 1 + +/// Builds a pipeline from an AST (`buildPipeline`). +public func buildPipeline( + _ ast: AST, _ delegate: any BuilderDelegate, queryID: String +) + throws(PipelineError) -> any Input +{ + var ast = delegate.mapAst(ast) + ast = try completeOrdering(ast) { (table) throws(PipelineError) in + guard let source = delegate.getSource(table) else { + throw PipelineError("Source not found: \(table)") + } + return source.tableSchema.primaryKey + } + if !delegate.disableCorrelatedPredicatePushdown { + ast = pushDownCorrelatedPredicates(ast) { delegate.getSource($0)?.tableSchema.columns ?? [:] } + } + return try buildPipelineInternal(ast, delegate, queryID, "") +} + +/// Values for static parameters (`StaticQueryParameters`). +public struct StaticQueryParameters { + public var authData: [String: JSONValue]? + public var preMutationRow: Row? + + public init(authData: [String: JSONValue]?, preMutationRow: Row? = nil) { + self.authData = authData + self.preMutationRow = preMutationRow + } +} + +/// `bindStaticParameters`: replaces static parameters with their values. +public func bindStaticParameters(_ ast: AST, _ params: StaticQueryParameters?) -> AST { + func bindValue(_ v: AST.ValuePosition) -> AST.ValuePosition { + guard case .static(let anchor, let field) = v else { return v } + guard let params else { preconditionFailure("Static query params do not exist") } + let anchorValue: [String: JSONValue]? = + anchor == .authData ? params.authData : params.preMutationRow + guard let anchorValue else { return .literal(.null) } + switch field { + case .single(let f): + return .literal(anchorValue[f] ?? .null) + case .path(let path): + var current: JSONValue? = .object(anchorValue) + for f in path { current = current?.objectValue?[f] } + return .literal(current ?? .null) + } + } + func bindCondition(_ c: AST.Condition) -> AST.Condition { + switch c { + case .simple(let op, let left, let right): + return .simple(op: op, left: bindValue(left), right: bindValue(right)) + case .correlatedSubquery(var related, let op, let flip, let scalar): + related.subquery = visit(related.subquery) + return .correlatedSubquery(related: related, op: op, flip: flip, scalar: scalar) + case .and(let cs): return .and(cs.map(bindCondition)) + case .or(let cs): return .or(cs.map(bindCondition)) + } + } + func visit(_ node: AST) -> AST { + var n = node + n.where = node.where.map(bindCondition) + n.related = node.related?.map { sq in + var s = sq + s.subquery = visit(sq.subquery) + return s + } + return n + } + return visit(ast) +} + +/// Throws if `condition` has a NOT EXISTS outside nested subqueries. +func assertNoNotExists(_ condition: AST.Condition) throws(PipelineError) { + switch condition { + case .simple: return + case .correlatedSubquery(_, let op, _, _): + if op == .notExists { + throw PipelineError( + "not(exists()) is not supported on the client - see https://bugs.rocicorp.dev/issue/3438") + } + case .and(let cs), .or(let cs): + for c in cs { try assertNoNotExists(c) } + } +} + +struct CSQCondition { + var related: AST.CorrelatedSubquery + var op: AST.CorrelatedSubqueryOperator + var flip: Bool? +} + +func buildPipelineInternal( + _ astIn: AST, + _ delegate: any BuilderDelegate, + _ queryID: String, + _ name: String, + partitionKey: [String]? = nil, + isNonFlippedExistsChild: Bool = false +) throws(PipelineError) -> any Input { + guard let source = delegate.getSource(astIn.table) else { + throw PipelineError("Source not found: \(astIn.table)") + } + let ast = uniquifyCorrelatedSubqueryConditionAliases(astIn) + if !delegate.enableNotExists, let w = ast.where { try assertNoNotExists(w) } + + let csqConditions = gatherCorrelatedSubqueryQueryConditions(ast.where) + var splitEditKeys = Set(partitionKey ?? []) + for csq in csqConditions { + for key in csq.related.correlation.parentField { splitEditKeys.insert(key) } + } + for csq in ast.related ?? [] { + for key in csq.correlation.parentField { splitEditKeys.insert(key) } + } + if isNonFlippedExistsChild { + precondition(ast.start == nil, "EXISTS subqueries must not have start") + precondition(ast.related == nil, "EXISTS subqueries must not have related") + } + + // Cap needs an unordered connection, but a flipped subquery in the where + // builds a UnionFanIn, which needs order. Fall back to Take then. + let useCap = + isNonFlippedExistsChild && !(ast.where.map(conditionIncludesFlippedSubqueryAtAnyLevel) ?? false) + + let conn = try source.connect( + sort: useCap ? nil : ast.orderBy!, filters: ast.where, splitEditKeys: splitEditKeys) + var end: any Input = delegate.decorateSourceInput(conn, queryID: queryID) + end = delegate.decorateInput(end, name: "\(name):source(\(ast.table))") + let fullyAppliedFilters = conn.fullyAppliedFilters + + if let start = ast.start { + let skip = Skip(end, start) + delegate.addEdge(end, skip) + end = delegate.decorateInput(skip, name: "\(name):skip)") + } + + var takeGate: TakeGate? + if ast.limit != nil && !useCap { + let gate = TakeGate(end) + delegate.addEdge(end, gate) + end = delegate.decorateInput(gate, name: "\(name):take-gate") + takeGate = gate + } + + // The parent indexes of the EXISTS joins, kept by a JoinIndexTap after + // the limit. + var existsParentIndexes: [JoinIndex] = [] + for csq in csqConditions where csq.flip != true { + let parentIndex = JoinIndex(csq.related.correlation.parentField, end.getSchema().primaryKey) + existsParentIndexes.append(parentIndex) + var related = csq.related + related.subquery.limit = Double( + related.system == .permissions ? permissionsExistsLimit : existsLimit) + end = try applyCorrelatedSubQuery( + related, delegate, queryID, end, name, fromCondition: true, parentPartitionKey: nil, + boundProvider: takeGate, exists: ExistsJoin(op: csq.op, parentIndex: parentIndex)) + } + + if let w = ast.where, !fullyAppliedFilters || delegate.applyFiltersAnyway { + end = try applyWhere( + end, w, delegate, name, partitionKey, takeGate, &existsParentIndexes, trackIndexes: true) + } + + if let limitValue = ast.limit { + let limit = Int(limitValue) + if useCap { + let cap = Cap(end, limit: limit, partitionKey: partitionKey) + delegate.addEdge(end, cap) + end = delegate.decorateInput(cap, name: "\(name):cap") + } else { + let take = try Take(end, limit: limit, partitionKey: partitionKey) + delegate.addEdge(end, take) + end = delegate.decorateInput(take, name: "\(name):take") + if let takeGate { + takeGate.setBoundProvider(take) + take.setTakeGate(takeGate) + } + } + } + + if !existsParentIndexes.isEmpty { + let tap = JoinIndexTap(end, existsParentIndexes) + delegate.addEdge(end, tap) + end = delegate.decorateInput(tap, name: "\(name):join-index") + } + + if let related = ast.related { + // Dedupe by alias, last one wins, keeping the first one's position. + var order: [String] = [] + var byAlias: [String: AST.CorrelatedSubquery] = [:] + for csq in related { + let alias = csq.subquery.alias ?? "" + if byAlias[alias] == nil { order.append(alias) } + byAlias[alias] = csq + } + for alias in order { + end = try applyCorrelatedSubQuery( + byAlias[alias]!, delegate, queryID, end, name, fromCondition: false, + parentPartitionKey: partitionKey) + } + } + return end +} + +func applyWhere( + _ input: any Input, + _ condition: AST.Condition, + _ delegate: any BuilderDelegate, + _ name: String, + _ parentPartitionKey: [String]?, + _ boundProvider: (any TakeBoundProvider)?, + _ existsParentIndexes: inout [JoinIndex], + trackIndexes: Bool +) throws(PipelineError) -> any Input { + if !conditionIncludesFlippedSubqueryAtAnyLevel(condition) { + return try buildFilterPipeline( + input, delegate, + { (filterInput) throws(PipelineError) in + try applyFilter(filterInput, condition, delegate, name) + }, + transformFilters(condition).filters) + } + return try applyFilterWithFlips( + input, condition, delegate, name, parentPartitionKey, boundProvider, &existsParentIndexes, + trackIndexes: trackIndexes) +} + +func applyFilterWithFlips( + _ input: any Input, + _ condition: AST.Condition, + _ delegate: any BuilderDelegate, + _ name: String, + _ parentPartitionKey: [String]?, + _ boundProvider: (any TakeBoundProvider)?, + _ existsParentIndexes: inout [JoinIndex], + trackIndexes: Bool +) throws(PipelineError) -> any Input { + var end = input + switch condition { + case .simple: + preconditionFailure("Simple conditions cannot have flips") + case .and(let conditions): + let (withFlipped, withoutFlipped) = partitionBranches( + conditions, conditionIncludesFlippedSubqueryAtAnyLevel) + if !withoutFlipped.isEmpty { + let andCondition = AST.Condition.and(withoutFlipped) + end = try buildFilterPipeline( + input, delegate, + { (filterInput) throws(PipelineError) in + try applyAnd(filterInput, withoutFlipped, delegate, name) + }, + transformFilters(andCondition).filters) + } + precondition(!withFlipped.isEmpty, "Impossible to have no flips here") + for cond in withFlipped { + end = try applyFilterWithFlips( + end, cond, delegate, name, parentPartitionKey, boundProvider, &existsParentIndexes, + trackIndexes: trackIndexes) + } + case .or(let conditions): + let (withFlipped, withoutFlipped) = partitionBranches( + conditions, conditionIncludesFlippedSubqueryAtAnyLevel) + precondition(!withFlipped.isEmpty, "Impossible to have no flips here") + let ufo = UnionFanOut(end) + delegate.addEdge(end, ufo) + end = delegate.decorateInput(ufo, name: "\(name):ufo") + var branches: [any Input] = [] + if !withoutFlipped.isEmpty { + let orCondition = AST.Condition.or(withoutFlipped) + branches.append( + try buildFilterPipeline( + end, delegate, + { (filterInput) throws(PipelineError) in + try applyOr(filterInput, withoutFlipped, delegate, name) + }, + transformFilters(orCondition).filters)) + } + for cond in withFlipped { + branches.append( + try applyFilterWithFlips( + end, cond, delegate, name, parentPartitionKey, boundProvider, &existsParentIndexes, + trackIndexes: trackIndexes)) + } + let ufi = UnionFanIn(ufo, branches) + for branch in branches { delegate.addEdge(branch, ufi) } + end = delegate.decorateInput(ufi, name: "\(name):ufi") + case .correlatedSubquery(let sq, _, _, _): + let child = try buildPipelineInternal( + sq.subquery, delegate, "", "\(name).\(sq.subquery.alias ?? "")", + partitionKey: sq.correlation.childField, isNonFlippedExistsChild: false) + var parentIndex: JoinIndex? + if trackIndexes { + let index = JoinIndex( + sq.correlation.parentField, end.getSchema().primaryKey, parentPartitionKey) + existsParentIndexes.append(index) + parentIndex = index + } + guard let alias = sq.subquery.alias else { preconditionFailure("Subquery must have an alias") } + let flippedJoin = FlippedJoin( + parent: end, child: child, parentKey: sq.correlation.parentField, + childKey: sq.correlation.childField, relationshipName: alias, hidden: sq.hidden ?? false, + system: sq.system ?? .client, parentPartitionKey: parentPartitionKey, + boundProvider: boundProvider, parentIndex: parentIndex) + delegate.addEdge(end, flippedJoin) + delegate.addEdge(child, flippedJoin) + end = delegate.decorateInput(flippedJoin, name: "\(name):flipped-join(\(alias))") + } + return end +} + +func applyFilter( + _ input: any FilterInput, _ condition: AST.Condition, _ delegate: any BuilderDelegate, + _ name: String +) throws(PipelineError) -> any FilterInput { + switch condition { + case .and(let cs): return try applyAnd(input, cs, delegate, name) + case .or(let cs): return try applyOr(input, cs, delegate, name) + case .correlatedSubquery(let related, let op, _, _): + return applyCorrelatedSubqueryCondition(input, related, op, delegate, name) + case .simple: + return try applySimpleCondition(input, delegate, condition) + } +} + +func applyAnd( + _ input: any FilterInput, _ conditions: [AST.Condition], _ delegate: any BuilderDelegate, + _ name: String +) throws(PipelineError) -> any FilterInput { + var input = input + for c in conditions { input = try applyFilter(input, c, delegate, name) } + return input +} + +func applyOr( + _ input: any FilterInput, _ conditions: [AST.Condition], _ delegate: any BuilderDelegate, + _ name: String +) throws(PipelineError) -> any FilterInput { + let (subqueryConditions, otherConditions) = groupSubqueryConditions(conditions) + // Without subqueries no fan-out is needed. + if subqueryConditions.isEmpty { + let filter = Filter(input, try createPredicate(.or(otherConditions))) + delegate.addEdge(input, filter) + return filter + } + let fanOut = FanOut(input) + delegate.addEdge(input, fanOut) + var branches: [any FilterInput] = [] + for c in subqueryConditions { branches.append(try applyFilter(fanOut, c, delegate, name)) } + if !otherConditions.isEmpty { + let filter = Filter(fanOut, try createPredicate(.or(otherConditions))) + delegate.addEdge(fanOut, filter) + branches.append(filter) + } + let ret = FanIn(fanOut, branches) + for branch in branches { delegate.addEdge(branch, ret) } + fanOut.setFanIn(ret) + return ret +} + +func groupSubqueryConditions( + _ conditions: [AST.Condition] +) -> ( + subquery: [AST.Condition], other: [AST.Condition] +) { + var subquery: [AST.Condition] = [] + var other: [AST.Condition] = [] + for c in conditions { + if isNotAndDoesNotContainSubquery(c) { other.append(c) } else { subquery.append(c) } + } + return (subquery, other) +} + +func isNotAndDoesNotContainSubquery(_ condition: AST.Condition) -> Bool { + switch condition { + case .correlatedSubquery: false + case .simple: true + case .and(let cs), .or(let cs): cs.allSatisfy(isNotAndDoesNotContainSubquery) + } +} + +func applySimpleCondition( + _ input: any FilterInput, _ delegate: any BuilderDelegate, _ condition: AST.Condition +) throws(PipelineError) -> any FilterInput { + let filter = Filter(input, try createPredicate(condition)) + if case .simple(let op, let left, let right) = condition { + _ = delegate.decorateFilterInput( + filter, name: "\(valuePosName(left)):\(op.rawValue):\(valuePosName(right))") + } + delegate.addEdge(input, filter) + return filter +} + +func valuePosName(_ v: AST.ValuePosition) -> String { + switch v { + case .static(_, .single(let f)): f + case .static(_, .path(let p)): p.joined(separator: ",") + case .literal(let value): JSString.from(value) + case .column(let name): name + } +} + +func applyCorrelatedSubQuery( + _ sq: AST.CorrelatedSubquery, + _ delegate: any BuilderDelegate, + _ queryID: String, + _ end: any Input, + _ name: String, + fromCondition: Bool, + parentPartitionKey: [String]?, + boundProvider: (any TakeBoundProvider)? = nil, + exists: ExistsJoin? = nil +) throws(PipelineError) -> any Input { + // Only omitted for conditions: `related` with `limit(0)` still gets an + // empty array. + if sq.subquery.limit == 0 && fromCondition { return end } + guard let alias = sq.subquery.alias else { preconditionFailure("Subquery must have an alias") } + let child = try buildPipelineInternal( + sq.subquery, delegate, queryID, "\(name).\(alias)", partitionKey: sq.correlation.childField, + isNonFlippedExistsChild: fromCondition) + let join = Join( + parent: end, child: child, parentKey: sq.correlation.parentField, + childKey: sq.correlation.childField, relationshipName: alias, hidden: sq.hidden ?? false, + system: sq.system ?? .client, parentPartitionKey: fromCondition ? nil : parentPartitionKey, + boundProvider: boundProvider, exists: exists) + delegate.addEdge(end, join) + delegate.addEdge(child, join) + return delegate.decorateInput(join, name: "\(name):join(\(alias))") +} + +func applyCorrelatedSubqueryCondition( + _ input: any FilterInput, + _ related: AST.CorrelatedSubquery, + _ op: AST.CorrelatedSubqueryOperator, + _ delegate: any BuilderDelegate, + _ name: String +) -> any FilterInput { + if related.subquery.limit == 0 { + let result = op != .exists + let filter = Filter(input) { _ in result } + delegate.addEdge(input, filter) + return filter + } + let alias = related.subquery.alias! + let exists = Exists(input, alias, related.correlation.parentField, op) + delegate.addEdge(input, exists) + return delegate.decorateFilterInput(exists, name: "\(name):exists(\(alias))") +} + +func gatherCorrelatedSubqueryQueryConditions(_ condition: AST.Condition?) -> [CSQCondition] { + var csqs: [CSQCondition] = [] + func gather(_ c: AST.Condition) { + switch c { + case .correlatedSubquery(let related, let op, let flip, _): + csqs.append(CSQCondition(related: related, op: op, flip: flip)) + case .and(let cs), .or(let cs): + for x in cs { gather(x) } + case .simple: + break + } + } + if let condition { gather(condition) } + return csqs +} + +/// Suffixes the aliases of a where's subqueries with a counter so that +/// each gets its own relationship. +func uniquifyCorrelatedSubqueryConditionAliases(_ ast: AST) -> AST { + guard let w = ast.where else { return ast } + switch w { + case .and, .or: break + default: return ast + } + var count = 0 + func uniquify(_ c: AST.Condition) -> AST.Condition { + switch c { + case .simple: return c + case .correlatedSubquery(var related, let op, let flip, let scalar): + related.subquery.alias = (related.subquery.alias ?? "") + "_" + String(count) + count += 1 + return .correlatedSubquery(related: related, op: op, flip: flip, scalar: scalar) + case .and(let cs): return .and(cs.map(uniquify)) + case .or(let cs): return .or(cs.map(uniquify)) + } + } + var result = ast + result.where = uniquify(w) + return result +} + +func conditionIncludesFlippedSubqueryAtAnyLevel(_ cond: AST.Condition) -> Bool { + switch cond { + case .correlatedSubquery(_, _, let flip, _): flip ?? false + case .and(let cs), .or(let cs): cs.contains(where: conditionIncludesFlippedSubqueryAtAnyLevel) + case .simple: false + } +} + +func partitionBranches( + _ conditions: [AST.Condition], _ predicate: (AST.Condition) -> Bool +) -> ( + [AST.Condition], [AST.Condition] +) { + var matched: [AST.Condition] = [] + var notMatched: [AST.Condition] = [] + for c in conditions { + if predicate(c) { matched.append(c) } else { notMatched.append(c) } + } + return (matched, notMatched) +} + +/// `completeOrdering`: appends missing primary key columns to every +/// ordering in the AST. +func completeOrdering( + _ ast: AST, _ getPrimaryKey: (String) throws(PipelineError) -> [String] +) throws(PipelineError) -> AST { + let primaryKey = try getPrimaryKey(ast.table) + var result = ast + if let related = ast.related { + var out: [AST.CorrelatedSubquery] = [] + for r in related { + var c = r + c.subquery = try completeOrdering(r.subquery, getPrimaryKey) + out.append(c) + } + result.related = out + } + if let w = ast.where { + result.where = try completeOrderingInCondition(w, getPrimaryKey) + } + result.orderBy = addPrimaryKeys(primaryKey, ast.orderBy) + return result +} + +private func completeOrderingInCondition( + _ c: AST.Condition, _ getPrimaryKey: (String) throws(PipelineError) -> [String] +) throws(PipelineError) -> AST.Condition { + switch c { + case .simple: return c + case .correlatedSubquery(var related, let op, let flip, let scalar): + related.subquery = try completeOrdering(related.subquery, getPrimaryKey) + return .correlatedSubquery(related: related, op: op, flip: flip, scalar: scalar) + case .and(let cs): + var out: [AST.Condition] = [] + for x in cs { out.append(try completeOrderingInCondition(x, getPrimaryKey)) } + return .and(out) + case .or(let cs): + var out: [AST.Condition] = [] + for x in cs { out.append(try completeOrderingInCondition(x, getPrimaryKey)) } + return .or(out) + } +} + +func addPrimaryKeys(_ primaryKey: [String], _ orderBy: AST.Ordering?) -> AST.Ordering { + let orderBy = orderBy ?? [] + var toAdd = primaryKey + var seen = Set() + toAdd = toAdd.filter { seen.insert($0).inserted } + for part in orderBy { toAdd.removeAll { $0 == part.field } } + if toAdd.isEmpty { return orderBy } + return orderBy + toAdd.map { AST.OrderPart($0, .asc) } +} diff --git a/swift/Sources/Zero/Builder/PredicatePushdown.swift b/swift/Sources/Zero/Builder/PredicatePushdown.swift new file mode 100644 index 0000000000..880bc49a01 --- /dev/null +++ b/swift/Sources/Zero/Builder/PredicatePushdown.swift @@ -0,0 +1,121 @@ +/// The longest `IN` list copied into a child. +let maxPushedInValues = 32 + +/// Copies a parent's conditions on correlation columns into its subqueries +/// (`correlated-predicate-pushdown.ts`). The result is the same; child rows +/// that cannot join to a parent that matters are filtered out early. +func pushDownCorrelatedPredicates( + _ ast: AST, _ columnsOf: @escaping (String) -> [String: ColumnSchema] +) -> AST { + func visit(_ ast: AST) -> AST { + let w = ast.where.map { visitCondition($0, ast.table, []) } + let facts = w.map(simpleConjuncts) ?? [] + var result = ast + result.where = w + result.related = ast.related?.map { csq in + var c = csq + c.subquery = visit(pushInto(csq, facts, ast.table)) + return c + } + return result + } + + func visitCondition( + _ c: AST.Condition, _ table: String, _ facts: [AST.Condition] + ) + -> AST.Condition + { + switch c { + case .simple: + return c + case .and: + return visitConjunction(c, table, facts + simpleConjuncts(c)) + case .or(let cs): + return .or(cs.map { visitCondition($0, table, facts) }) + case .correlatedSubquery(var related, let op, let flip, let scalar): + related.subquery = visit(pushInto(related, facts, table)) + return .correlatedSubquery(related: related, op: op, flip: flip, scalar: scalar) + } + } + + // `facts` already has the simple conjuncts of `c` and its nested ANDs. + func visitConjunction( + _ c: AST.Condition, _ table: String, _ facts: [AST.Condition] + ) + -> AST.Condition + { + guard case .and(let cs) = c else { return c } + return .and( + cs.map { x in + if case .and = x { return visitConjunction(x, table, facts) } + return visitCondition(x, table, facts) + }) + } + + func pushInto( + _ csq: AST.CorrelatedSubquery, _ facts: [AST.Condition], _ parentTable: String + ) + -> AST + { + let parentField = csq.correlation.parentField + let childField = csq.correlation.childField + let subquery = csq.subquery + var conjuncts = subquery.where.map(simpleConjuncts) ?? [] + var added: [AST.Condition] = [] + for fact in facts { + guard isPushable(fact), case .simple(let op, .column(let name), let right) = fact else { + continue + } + for i in 0.. [AST.Condition] { + switch c { + case .simple: [c] + case .and(let cs): cs.flatMap(simpleConjuncts) + default: [] + } +} + +private func isPushable(_ c: AST.Condition) -> Bool { + guard case .simple(let op, .column, .literal(let value)) = c else { return false } + switch op { + case .eq, .is: return true + case .in: + if case .array(let a) = value { return a.count <= maxPushedInValues } + return false + default: return false + } +} + +private func isSameCondition(_ a: AST.Condition, _ b: AST.Condition) -> Bool { + guard case .simple(let opA, .column(let nameA), .literal(let va)) = a, + case .simple(let opB, .column(let nameB), .literal(let vb)) = b + else { return false } + return opA == opB && nameA == nameB && va == vb +} + +private func haveSameScalarType(_ parent: ColumnSchema?, _ child: ColumnSchema?) -> Bool { + guard let parent, let child else { return false } + return parent.type == child.type && parent.type != .json +} diff --git a/swift/Sources/Zero/IVM/Change.swift b/swift/Sources/Zero/IVM/Change.swift new file mode 100644 index 0000000000..fbd017b108 --- /dev/null +++ b/swift/Sources/Zero/IVM/Change.swift @@ -0,0 +1,139 @@ +/// A row flowing through a pipeline, plus its relationships +/// (`packages/zql/src/ivm/data.ts`). Relationships are generated lazily as +/// they are read. +public struct Node { + public var row: Row + public var relationships: Relationships + + @inlinable + public init(row: Row, relationships: Relationships = Relationships()) { + self.row = row + self.relationships = relationships + } + + /// This node with `stream` as its relationship `name`. + @inlinable + func with(_ name: String, _ stream: @escaping () -> NodeStream) -> Node { + var copy = self + copy.relationships[name] = stream + return copy + } +} + +/// A node's relationships, in insertion order like a JS object's keys. Nodes +/// carry a handful at most, so lookups are linear. +public struct Relationships: Sequence { + public typealias Stream = () -> NodeStream + @usableFromInline var entries: [(name: String, stream: Stream)] + + @inlinable + public init() { entries = [] } + + @inlinable + public var isEmpty: Bool { entries.isEmpty } + + @inlinable + public var names: [String] { entries.map(\.name) } + + @inlinable + public subscript(name: String) -> Stream? { + get { + for entry in entries where entry.name == name { return entry.stream } + return nil + } + set { + if let i = entries.firstIndex(where: { $0.name == name }) { + if let newValue { + entries[i].stream = newValue + } else { + entries.remove(at: i) + } + } else if let newValue { + entries.append((name, newValue)) + } + } + } + + /// `{...self, ...other}`: `other` wins, keys keep their first position. + @inlinable + public func merging(_ other: Relationships) -> Relationships { + var result = self + for entry in other.entries { result[entry.name] = entry.stream } + return result + } + + @inlinable + public func makeIterator() -> IndexingIterator<[(name: String, stream: Stream)]> { + entries.makeIterator() + } +} + +/// The kinds of change (`change-type-enum.ts`). +public enum ChangeType: Int, Sendable, Hashable { + case add = 0 + case remove = 1 + case edit = 2 + case child = 3 +} + +/// A change to a child relationship of a node. +public struct ChildChange { + public var relationshipName: String + public var change: Change + + @inlinable + public init(relationshipName: String, change: Change) { + self.relationshipName = relationshipName + self.change = change + } +} + +/// A change pushed through a pipeline (`packages/zql/src/ivm/change.ts`). +public indirect enum Change { + case add(Node) + case remove(Node) + case child(Node, ChildChange) + case edit(Node, oldNode: Node) + + @inlinable + public var type: ChangeType { + switch self { + case .add: .add + case .remove: .remove + case .child: .child + case .edit: .edit + } + } + + /// The node, or the new node of an edit. + @inlinable + public var node: Node { + switch self { + case .add(let n), .remove(let n), .child(let n, _), .edit(let n, _): n + } + } +} + +/// A change to a source's rows (`packages/zql/src/ivm/source.ts`). +public enum SourceChange: Sendable { + case add(Row) + case remove(Row) + case edit(Row, oldRow: Row) + + @inlinable + public var type: ChangeType { + switch self { + case .add: .add + case .remove: .remove + case .edit: .edit + } + } + + /// The row, or the new row of an edit. + @inlinable + public var row: Row { + switch self { + case .add(let r), .remove(let r), .edit(let r, _): r + } + } +} diff --git a/swift/Sources/Zero/IVM/Constraint.swift b/swift/Sources/Zero/IVM/Constraint.swift new file mode 100644 index 0000000000..9eb6be0f34 --- /dev/null +++ b/swift/Sources/Zero/IVM/Constraint.swift @@ -0,0 +1,122 @@ +/// Column values a fetch must match (`packages/zql/src/ivm/constraint.ts`). +/// +/// Keys keep insertion order, like the JS object: a `MemorySource` builds +/// its index sort from it. A value is `nil` where JS has `undefined`. +public struct Constraint { + public var entries: [(key: String, value: Value?)] + + @inlinable + public init(_ entries: [(key: String, value: Value?)] = []) { + self.entries = entries + } + + @inlinable + public var keys: [String] { entries.map(\.key) } + + @inlinable + public var count: Int { entries.count } + + @inlinable + public func has(_ key: String) -> Bool { + entries.contains { $0.key == key } + } + + /// The value for `key`; `nil` when absent or `undefined`. + @inlinable + public subscript(key: String) -> Value? { + get { + for e in entries where e.key == key { return e.value } + return nil + } + } + + @inlinable + public mutating func set(_ key: String, _ value: Value?) { + if let i = entries.firstIndex(where: { $0.key == key }) { + entries[i].value = value + } else { + entries.append((key, value)) + } + } + + /// `{...self, ...other}`. + @inlinable + public func merging(_ other: Constraint) -> Constraint { + var result = self + for e in other.entries { result.set(e.key, e.value) } + return result + } + + /// `constraintMatchesRow`. + @inlinable + public func matches(_ row: Row) -> Bool { + for e in entries where !ValueCompare.equal(row[e.key], e.value) { + return false + } + return true + } + + /// `constraintsAreCompatible`: no shared key has different values. + public func isCompatible(with other: Constraint) -> Bool { + for e in entries where other.has(e.key) && !ValueCompare.equal(e.value, other[e.key]) { + return false + } + return true + } + + /// `constraintMatchesPrimaryKey`. + public func matchesPrimaryKey(_ primaryKey: [String]) -> Bool { + keyMatchesPrimaryKey(keys, primaryKey) + } + + /// `{key: row[key]}` for each of `keys`. + static func from(_ row: Row, _ keys: [String]) -> Constraint { + Constraint(keys.map { ($0, row[$0]) }) + } +} + +/// `keyMatchesPrimaryKey`. The primary key is sorted; `key` need not be. +func keyMatchesPrimaryKey(_ key: [String], _ primaryKey: [String]) -> Bool { + if key.count != primaryKey.count { return false } + let sorted = key.sorted(by: StringCompare.utf16Less) + for i in 0.. [AST.Condition] { + switch condition { + case .and(let conditions): + return conditions.flatMap(pullSimpleAndComponents) + case .simple: + return [condition] + case .or(let conditions) where conditions.count == 1: + return pullSimpleAndComponents(conditions[0]) + default: + return [] + } +} + +/// `primaryKeyConstraintFromFilters`: the primary key lookup the filters +/// amount to, if they do. +func primaryKeyConstraintFromFilters( + _ condition: AST.Condition?, _ primaryKey: [String] +) -> Constraint? { + guard let condition else { return nil } + let conditions = pullSimpleAndComponents(condition) + if conditions.isEmpty { return nil } + var ret = Constraint() + for case .simple(let op, let left, let right) in conditions where op == .eq { + if case .column(let name) = left { + guard case .literal(let value) = right else { + preconditionFailure("extractColumn: expected right side to be literal") + } + if !primaryKey.contains(name) { continue } + ret.set(name, value) + } + } + if ret.count != primaryKey.count { return nil } + return ret +} diff --git a/swift/Sources/Zero/IVM/Data.swift b/swift/Sources/Zero/IVM/Data.swift index 0112ac5f56..7447acd36d 100644 --- a/swift/Sources/Zero/IVM/Data.swift +++ b/swift/Sources/Zero/IVM/Data.swift @@ -77,16 +77,66 @@ public enum ValueCompare { public static func equal(_ a: Value, _ b: Value) -> Bool { switch (a, b) { case (.null, _), (_, .null): return false - case (.string(let x), .string(let y)): return x == y + case (.string(let x), .string(let y)): return StringCompare.equal(x, y) case (.number(let x), .number(let y)): return x == y case (.bool(let x), .bool(let y)): return x == y default: return false } } + /// `compareValues` on possibly missing values: `undefined` reads as `null`. + @inlinable + public static func compare(_ a: Value?, _ b: Value?) -> Int { + compare(a ?? .null, b ?? .null) + } + + /// `valuesEqual` on possibly missing values: `undefined` equals nothing. + @inlinable + public static func equal(_ a: Value?, _ b: Value?) -> Bool { + guard let a, let b else { return false } + return equal(a, b) + } + + /// JS `===` on possibly missing values. Strings compare by code unit; + /// arrays and objects are never identical, since rows are decoded fresh. + @inlinable + public static func identical(_ a: Value?, _ b: Value?) -> Bool { + switch (a, b) { + case (nil, nil): return true + case (nil, _), (_, nil): return false + case (.null?, .null?): return true + case (.string(let x)?, .string(let y)?): return StringCompare.equal(x, y) + case (.number(let x)?, .number(let y)?): return x == y + case (.bool(let x)?, .bool(let y)?): return x == y + default: return false + } + } + /// A row's value for `column`; a missing column reads as `null`. @inlinable public static func value(_ row: Row, _ column: String) -> Value { row[column] ?? .null } } + +/// Orders rows (`Comparator` in `data.ts`). +public typealias Comparator = (Row, Row) -> Int + +/// `makeComparator(order, reverse)`. +public func makeComparator(_ order: AST.Ordering, reverse: Bool = false) -> Comparator { + if order.count == 1 && order[0].direction == .asc && !reverse { + let field = order[0].field + return { a, b in ValueCompare.compare(a[field], b[field]) } + } + let parts = order.map { ($0.field, $0.direction == .asc) } + return { a, b in + for (field, asc) in parts { + let comp = ValueCompare.compare(a[field], b[field]) + if comp != 0 { + let result = asc ? comp : -comp + return reverse ? -result : result + } + } + return 0 + } +} diff --git a/swift/Sources/Zero/IVM/Filter.swift b/swift/Sources/Zero/IVM/Filter.swift new file mode 100644 index 0000000000..ebf69e4cde --- /dev/null +++ b/swift/Sources/Zero/IVM/Filter.swift @@ -0,0 +1,392 @@ +/// `filterPush` (`packages/zql/src/ivm/filter-push.ts`). +func filterPush( + _ change: Change, _ output: (any Output)?, _ pusher: any InputBase, _ predicate: RowPredicate? +) { + guard let predicate else { + output?.push(change, pusher: pusher) + return + } + switch change { + case .add(let node), .remove(let node), .child(let node, _): + if predicate(node.row) { output?.push(change, pusher: pusher) } + case .edit(let node, let oldNode): + maybeSplitAndPushEditChange(node, oldNode, predicate, output, pusher) + } +} + +/// `maybeSplitAndPushEditChange`: an edit whose row enters or leaves the +/// predicate becomes an add or a remove. +func maybeSplitAndPushEditChange( + _ node: Node, _ oldNode: Node, _ predicate: RowPredicate, _ output: (any Output)?, + _ pusher: any InputBase +) { + let oldWasPresent = predicate(oldNode.row) + let newIsPresent = predicate(node.row) + if oldWasPresent && newIsPresent { + output?.push(.edit(node, oldNode: oldNode), pusher: pusher) + } else if oldWasPresent { + output?.push(.remove(oldNode), pusher: pusher) + } else if newIsPresent { + output?.push(.add(node), pusher: pusher) + } +} + +/// Filters through a pure predicate (`packages/zql/src/ivm/filter.ts`). +final class Filter: FilterOperator { + let input: any FilterInput + let predicate: RowPredicate + weak var output: (any FilterOutput)? + + init(_ input: any FilterInput, _ predicate: @escaping RowPredicate) { + self.input = input + self.predicate = predicate + input.setFilterOutput(self) + } + + func beginFilter() { output?.beginFilter() } + func endFilter() { output?.endFilter() } + + func filter(_ node: Node) -> Bool { + predicate(node.row) ? (output?.filter(node) ?? false) : false + } + + func setFilterOutput(_ output: any FilterOutput) { self.output = output } + func destroy() { input.destroy() } + func getSchema() -> SourceSchema { input.getSchema() } + + func push(_ change: Change, pusher: any InputBase) { + filterPush(change, output, self, predicate) + } + + func reconcile(pusher: any InputBase) { output?.reconcile(pusher: self) } +} + +/// Adapts an `Input` to the `where` sub-graph (`filter-operators.ts`). +final class FilterStart: FilterInput, Output { + let input: any Input + let condition: AST.Condition? + weak var output: (any FilterOutput)? + + init(_ input: any Input, _ condition: AST.Condition?) { + self.input = input + self.condition = condition + input.setOutput(self) + } + + func setFilterOutput(_ output: any FilterOutput) { self.output = output } + func destroy() { input.destroy() } + func getSchema() -> SourceSchema { input.getSchema() } + + func push(_ change: Change, pusher: any InputBase) { output?.push(change, pusher: self) } + func reconcile(pusher: any InputBase) { output?.reconcile(pusher: self) } + + func fetch(_ req: FetchRequest) -> NodeStream { + NodeStream { [self] in + var childReq = req + childReq.filter = mergeFilters(req.filter, condition) + return AnyIterator(FilterIterator(input.fetch(childReq).makeIterator(), output)) + } + } +} + +/// `FilterStart.fetch`'s generator: `beginFilter`, then `endFilter` once the +/// stream ends or is abandoned (the JS `finally`). +final class FilterIterator: IteratorProtocol { + var input: LazyIterator? + weak var output: (any FilterOutput)? + + init(_ input: LazyIterator, _ output: (any FilterOutput)?) { + self.input = input + self.output = output + output?.beginFilter() + } + + func next() -> Node? { + while let node = input?.next() { + if output?.filter(node) ?? false { return node } + } + finish() + return nil + } + + func finish() { + if input != nil { + input = nil + output?.endFilter() + } + } + + deinit { finish() } +} + +func mergeFilters(_ reqFilter: AST.Condition?, _ own: AST.Condition?) -> AST.Condition? { + guard let own else { return reqFilter } + guard let reqFilter else { return own } + return .and([reqFilter, own]) +} + +/// Adapts the end of the `where` sub-graph back to an `Input`. +final class FilterEnd: Input, FilterOutput { + let start: FilterStart + let input: any FilterInput + weak var output: (any Output)? + + init(_ start: FilterStart, _ input: any FilterInput) { + self.start = start + self.input = input + input.setFilterOutput(self) + } + + func fetch(_ req: FetchRequest) -> NodeStream { start.fetch(req) } + func beginFilter() {} + func endFilter() {} + func filter(_ node: Node) -> Bool { true } + func setOutput(_ output: any Output) { self.output = output } + func destroy() { input.destroy() } + func getSchema() -> SourceSchema { input.getSchema() } + func push(_ change: Change, pusher: any InputBase) { output?.push(change, pusher: self) } + func reconcile(pusher: any InputBase) { output?.reconcile(pusher: self) } +} + +/// Filters on whether a relationship is non-empty (`exists.ts`). +final class Exists: FilterOperator { + let input: any FilterInput + let relationshipName: String + let not: Bool + let parentJoinKey: [String] + let noSizeReuse: Bool + var cache: [String: Bool] = [:] + weak var output: (any FilterOutput)? + /// Set while processing a push, when relationships can be inconsistent, + /// to stop reusing cached sizes across rows with the same join key. + var inPush = false + + init( + _ input: any FilterInput, _ relationshipName: String, _ parentJoinKey: [String], + _ type: AST.CorrelatedSubqueryOperator + ) { + self.input = input + self.relationshipName = relationshipName + self.not = type == .notExists + self.parentJoinKey = parentJoinKey + let schema = input.getSchema() + precondition( + schema.relationship(relationshipName) != nil, "Input schema missing \(relationshipName)") + noSizeReuse = parentJoinKey == schema.primaryKey + input.setFilterOutput(self) + } + + func setFilterOutput(_ output: any FilterOutput) { self.output = output } + + func beginFilter() { output?.beginFilter() } + + func endFilter() { + cache = [:] + output?.endFilter() + } + + func filter(_ node: Node) -> Bool { + var exists: Bool? + if !noSizeReuse && !inPush { + let key = cacheKey(node) + exists = cache[key] + if exists == nil { + exists = fetchSize(node) > 0 + cache[key] = exists + } + } + return passes(node, exists) && (output?.filter(node) ?? false) + } + + func destroy() { input.destroy() } + func getSchema() -> SourceSchema { input.getSchema() } + func reconcile(pusher: any InputBase) { output?.reconcile(pusher: self) } + + func push(_ change: Change, pusher: any InputBase) { + precondition(!inPush, "Unexpected re-entrancy") + inPush = true + defer { inPush = false } + switch change { + case .add, .edit, .remove: + pushWithFilter(change) + case .child(let node, let child): + if child.relationshipName != relationshipName || child.change.type == .edit + || child.change.type == .child + { + pushWithFilter(change) + return + } + switch child.change { + case .add: + let size = fetchSize(node) + if size == 1 { + if not { + output?.push(.remove(node.with(relationshipName) { .empty }), pusher: self) + } else { + output?.push(.add(node), pusher: self) + } + } else { + pushWithFilter(change, size > 0) + } + case .remove(let removed): + let size = fetchSize(node) + if size == 0 { + if not { + output?.push(.add(node), pusher: self) + } else { + output?.push( + .remove(node.with(relationshipName) { NodeStream([removed]) }), pusher: self) + } + } else { + pushWithFilter(change, size > 0) + } + default: + break + } + } + } + + func passes(_ node: Node, _ exists: Bool? = nil) -> Bool { + let e = exists ?? (fetchSize(node) > 0) + return not ? !e : e + } + + func cacheKey(_ node: Node) -> String { + JSONSerializer.stringify(.array(parentJoinKey.map { node.row[$0] ?? .null })) + } + + func pushWithFilter(_ change: Change, _ exists: Bool? = nil) { + if passes(change.node, exists) { output?.push(change, pusher: self) } + } + + func fetchSize(_ node: Node) -> Int { + guard let relationship = node.relationships[relationshipName] else { + preconditionFailure("Exists: relationship \"\(relationshipName)\" not found on node") + } + var size = 0 + for _ in relationship() { size += 1 } + return size + } +} + +/// Forks the `where` sub-graph for an OR (`fan-out.ts`). +final class FanOut: FilterOperator { + let input: any FilterInput + var outputs: [WeakFilterOutput] = [] + weak var fanIn: FanIn? + var destroyCount = 0 + + init(_ input: any FilterInput) { + self.input = input + input.setFilterOutput(self) + } + + func setFanIn(_ fanIn: FanIn) { self.fanIn = fanIn } + func setFilterOutput(_ output: any FilterOutput) { outputs.append(WeakFilterOutput(output)) } + + func destroy() { + precondition(destroyCount < outputs.count, "FanOut already destroyed once for each output") + destroyCount += 1 + if destroyCount == outputs.count { input.destroy() } + } + + func getSchema() -> SourceSchema { input.getSchema() } + func beginFilter() { for o in outputs { o.value?.beginFilter() } } + func endFilter() { for o in outputs { o.value?.endFilter() } } + + func filter(_ node: Node) -> Bool { + for o in outputs where o.value?.filter(node) ?? false { return true } + return false + } + + func push(_ change: Change, pusher: any InputBase) { + for o in outputs { o.value?.push(change, pusher: self) } + guard let fanIn else { preconditionFailure("fan-out must have a corresponding fan-in set!") } + fanIn.fanOutDonePushingToAllBranches(change.type) + } + + func reconcile(pusher: any InputBase) { + for o in outputs { o.value?.reconcile(pusher: self) } + fanIn?.fanOutDoneReconciling() + } +} + +/// Merges the branches of an OR, removing duplicates (`fan-in.ts`). +final class FanIn: FilterOperator { + let inputs: [any FilterInput] + let schema: SourceSchema + weak var output: (any FilterOutput)? + var accumulatedPushes: [Change] = [] + + init(_ fanOut: FanOut, _ inputs: [any FilterInput]) { + self.inputs = inputs + schema = fanOut.getSchema() + for input in inputs { + input.setFilterOutput(self) + precondition(schema === input.getSchema(), "Schema mismatch in fan-in") + } + } + + func setFilterOutput(_ output: any FilterOutput) { self.output = output } + func destroy() { for i in inputs { i.destroy() } } + func getSchema() -> SourceSchema { schema } + func beginFilter() { output?.beginFilter() } + func endFilter() { output?.endFilter() } + func filter(_ node: Node) -> Bool { output?.filter(node) ?? false } + + func push(_ change: Change, pusher: any InputBase) { + accumulatedPushes.append(change) + } + + func fanOutDonePushingToAllBranches(_ fanOutChangeType: ChangeType) { + if inputs.isEmpty { + precondition( + accumulatedPushes.isEmpty, + "If there are no inputs then fan-in should not receive any pushes.") + return + } + guard let output else { + accumulatedPushes.removeAll() + return + } + pushAccumulatedChanges( + &accumulatedPushes, output, self, fanOutChangeType, mergeRelationships: { a, _ in a }, + addEmptyRelationships: { $0 }) + } + + func reconcile(pusher: any InputBase) {} + + func fanOutDoneReconciling() { + if inputs.isEmpty { return } + output?.reconcile(pusher: self) + } +} + +/// `buildFilterPipeline`: wraps the `where` sub-graph `pipeline` builds +/// between a `FilterStart` and a `FilterEnd`. +func buildFilterPipeline( + _ input: any Input, + _ delegate: any BuilderDelegate, + _ pipeline: (any FilterInput) throws(PipelineError) -> any FilterInput, + _ condition: AST.Condition? +) throws(PipelineError) -> any Input { + let filterStart = FilterStart(input, condition) + delegate.addEdge(input, filterStart) + let middle = try pipeline(filterStart) + delegate.addEdge(filterStart, middle) + let filterEnd = FilterEnd(filterStart, middle) + delegate.addEdge(middle, filterEnd) + return filterEnd +} + +/// A weak reference to a filter output, for operators with several. +struct WeakFilterOutput { + weak var value: (any FilterOutput)? + init(_ value: any FilterOutput) { self.value = value } +} + +/// A weak reference to an output, for operators with several. +struct WeakOutput { + weak var value: (any Output)? + init(_ value: any Output) { self.value = value } +} diff --git a/swift/Sources/Zero/IVM/FlippedJoin.swift b/swift/Sources/Zero/IVM/FlippedJoin.swift new file mode 100644 index 0000000000..e8b17e5400 --- /dev/null +++ b/swift/Sources/Zero/IVM/FlippedJoin.swift @@ -0,0 +1,327 @@ +/// The most entries in one batched parent fetch (`flipped-join.ts`). +nonisolated(unsafe) var multiConstraintChunkSize = 256 + +/// An inner join that fetches children first, then their parents +/// (`flipped-join.ts`). Outputs parents, in parent order, that have at +/// least one related child. +final class FlippedJoin: Input { + let parent: any Input + let child: any Input + let parentKey: [String] + let childKey: [String] + let relationshipName: String + let schema: SourceSchema + let boundProvider: (any TakeBoundProvider)? + /// The parents this join has output. + let parentIndex: JoinIndex + let indexesParents: Bool + weak var output: (any Output)? + private var parentOutput: ClosureOutput! + private var childOutput: ClosureOutput! + + var inprogressChildChange: Change? + var inprogressChildChangePosition: Row? + var inprogressParentFetchBounds: [ParentFetchBound]? + + init( + parent: any Input, + child: any Input, + parentKey: [String], + childKey: [String], + relationshipName: String, + hidden: Bool, + system: AST.System, + parentPartitionKey: [String]? = nil, + boundProvider: (any TakeBoundProvider)? = nil, + parentIndex: JoinIndex? = nil + ) { + precondition(parent !== child, "Parent and child must be different operators") + precondition( + parentKey.count == childKey.count, "The parentKey and childKey keys must have same length") + self.parent = parent + self.child = child + self.parentKey = parentKey + self.childKey = childKey + self.relationshipName = relationshipName + self.boundProvider = boundProvider + if let parentIndex { + self.parentIndex = parentIndex + indexesParents = false + } else { + self.parentIndex = JoinIndex(parentKey, parent.getSchema().primaryKey, parentPartitionKey) + indexesParents = true + } + schema = parent.getSchema().adding( + relationshipName, child.getSchema().with(isHidden: hidden, system: system)) + parentOutput = ClosureOutput( + push: { [unowned self] in self.pushParent($0) }, + reconcile: { [unowned self] in self.output?.reconcile(pusher: self) }) + childOutput = ClosureOutput( + push: { [unowned self] in self.pushChild($0) }, + reconcile: { [unowned self] in self.output?.reconcile(pusher: self) }) + parent.setOutput(parentOutput) + child.setOutput(childOutput) + } + + func destroy() { + child.destroy() + parent.destroy() + } + + func setOutput(_ output: any Output) { self.output = output } + func getSchema() -> SourceSchema { schema } + + /// A child node, and whether it is the in-progress removed child spliced + /// back in (JS compares it by identity). + typealias ChildNode = (node: Node, isRemovedOverlay: Bool) + + func fetch(_ req: FetchRequest) -> NodeStream { + NodeStream { [self] in + // Constraints on the parent's join key become child constraints. + var childConstraint = Constraint() + var hasChildConstraint = false + if let constraint = req.constraint { + for e in constraint.entries { + if let index = parentKey.firstIndex(of: e.key) { + hasChildConstraint = true + childConstraint.set(childKey[index], e.value) + } + } + } + var childNodes: [ChildNode] = child.fetch( + hasChildConstraint ? FetchRequest(constraint: childConstraint) : FetchRequest() + ).map { ($0, false) } + + // Undo an in-progress remove here; it is re-applied below to the + // parents the push has reached. + if case .remove(let removedNode) = inprogressChildChange { + let compare = child.getSchema().compareRows + let insertPos = binarySearch(childNodes.count) { + compare(removedNode.row, childNodes[$0].node.row) + } + childNodes.insert((removedNode, true), at: insertPos) + } + return fetchBatched(req, childNodes) + } + } + + private func fetchBatched(_ req: FetchRequest, _ childNodes: [ChildNode]) -> AnyIterator { + let parentReqConstraint = req.constraint + var computedMulti: [Constraint] = [] + var childIndexesByKey: [JSKey: [Int]] = [:] + for i in 0.. Int = + req.reverse ? { compareRows($1.row, $0.row) } : { compareRows($0.row, $1.row) } + let incoming = req.multiConstraints ?? [] + let parentStream: NodeStream + if computedMulti.count <= multiConstraintChunkSize { + var r = req + r.multiConstraints = incoming + [computedMulti] + parentStream = parent.fetch(r) + } else { + var chunks: [NodeStream] = [] + var i = 0 + while i < computedMulti.count { + var r = req + r.multiConstraints = + incoming + [ + Array(computedMulti[i.. Node? { + var overlaid = related.map(\.node) + // The parent has yet to get the in-progress child change if it comes + // after the current position and a parent fetch of the push yields it. + let compareRows = parent.getSchema().compareRows + let isParentInPushQueue = + inprogressChildChangePosition != nil + && compareRows(minParentNode.row, inprogressChildChangePosition!) > 0 + && (inprogressParentFetchBounds.map { + isInParentFetch($0, minParentNode.row, compareRows) + } ?? true) + if let inprogress = inprogressChildChange, inprogressChildChangePosition != nil, + isJoinMatch(inprogress.node.row, childKey, minParentNode.row, parentKey) + { + if inprogress.type == .remove { + if !isParentInPushQueue { + // The removed child was spliced into the children above. + overlaid = related.filter { !$0.isRemovedOverlay }.map(\.node) + } + } else if isParentInPushQueue { + overlaid = Array(generateWithOverlay(NodeStream(overlaid), inprogress, child.getSchema())) + } + } + if overlaid.isEmpty { return nil } + indexParentRow(minParentNode.row) + let children = overlaid + return minParentNode.with(relationshipName) { NodeStream(children) } + } + + func pushChild(_ change: Change) { + switch change { + case .add, .remove: + pushChildChange(change) + case .edit(let node, let oldNode): + precondition( + rowEqualsForCompoundKey(oldNode.row, node.row, childKey), + "Child edit must not change relationship.") + pushChildChange(change, exists: true) + case .child: + pushChildChange(change, exists: true) + } + } + + func pushChildChange(_ change: Change, exists: Bool = false) { + inprogressChildChange = change + inprogressChildChangePosition = nil + defer { + inprogressChildChange = nil + inprogressChildChangePosition = nil + inprogressParentFetchBounds = nil + } + let childRow = change.node.row + guard let constraint = buildJoinConstraint(childRow, childKey, parentKey) else { return } + let changeType = change.type + let partitions = parentIndex.lookup(childRow, childKey) + // Without a resident parent, a remove or edit cannot affect the view. + if partitions == nil && changeType != .add && changeType != .child { return } + let fetchConstraints: [Constraint] + if changeType != .add && changeType != .child { + fetchConstraints = partitions!.map { p in p.map { constraint.merging($0) } ?? constraint } + } else { + fetchConstraints = [constraint] + } + if let boundProvider { + inprogressParentFetchBounds = readParentFetchBounds(boundProvider, fetchConstraints) + } + let parentNodeStream: NodeStream + if fetchConstraints.count == 1 { + parentNodeStream = parent.fetch(FetchRequest(constraint: fetchConstraints[0])) + } else { + let compareRows = schema.compareRows + parentNodeStream = mergeSortedStreams( + fetchConstraints.map { parent.fetch(FetchRequest(constraint: $0)) }, + { compareRows($0.row, $1.row) }) + } + for parentNode in parentNodeStream { + inprogressChildChange = change + inprogressChildChangePosition = parentNode.row + let childNodeStream: () -> NodeStream = { [self] in + guard let c = buildJoinConstraint(parentNode.row, parentKey, childKey) else { + return .empty + } + return child.fetch(FetchRequest(constraint: c)) + } + var parentExists = exists + if !parentExists { + let childCompare = child.getSchema().compareRows + for childNode in childNodeStream() where childCompare(childNode.row, childRow) != 0 { + parentExists = true + break + } + } + if parentExists { + output?.push( + .child( + parentNode.with(relationshipName, childNodeStream), + ChildChange(relationshipName: relationshipName, change: change)), + pusher: self) + } else { + let changed = change.node + let newNode = parentNode.with(relationshipName) { NodeStream([changed]) } + if change.type == .add { + indexParentRow(parentNode.row) + output?.push(.add(newNode), pusher: self) + } else { + unindexParentRow(parentNode.row) + output?.push(.remove(newNode), pusher: self) + } + } + } + } + + func indexParentRow(_ row: Row) { + if indexesParents { parentIndex.add(row) } + } + + func unindexParentRow(_ row: Row) { + if indexesParents { parentIndex.remove(row) } + } + + func pushParent(_ change: Change) { + let flip: (Node) -> Node = { [self] node in + node.with(relationshipName) { [self] in + guard let c = buildJoinConstraint(node.row, parentKey, childKey) else { return .empty } + return child.fetch(FetchRequest(constraint: c)) + } + } + // An inner join: no related child, no push. + guard let c = buildJoinConstraint(change.node.row, parentKey, childKey), + child.fetch(FetchRequest(constraint: c)).firstElement != nil + else { return } + switch change { + case .add(let node): + indexParentRow(node.row) + output?.push(.add(flip(node)), pusher: self) + case .remove(let node): + unindexParentRow(node.row) + output?.push(.remove(flip(node)), pusher: self) + case .child(let node, let childChange): + output?.push(.child(flip(node), childChange), pusher: self) + case .edit(let node, let oldNode): + precondition( + rowEqualsForCompoundKey(oldNode.row, node.row, parentKey), + "Parent edit must not change relationship.") + unindexParentRow(oldNode.row) + indexParentRow(node.row) + output?.push(.edit(flip(node), oldNode: flip(oldNode)), pusher: self) + } + } +} + +/// `binarySearch(high, compare)` (`shared/src/binary-search.ts`): the index +/// where `compare` is 0, or the first index past the elements it is +/// positive for. +func binarySearch(_ high: Int, _ compare: (Int) -> Int) -> Int { + var low = 0 + var high = high + while low < high { + let mid = low + ((high - low) >> 1) + let i = compare(mid) + if i == 0 { return mid } + if i > 0 { low = mid + 1 } else { high = mid } + } + return low +} diff --git a/swift/Sources/Zero/IVM/Join.swift b/swift/Sources/Zero/IVM/Join.swift new file mode 100644 index 0000000000..d0fb91e663 --- /dev/null +++ b/swift/Sources/Zero/IVM/Join.swift @@ -0,0 +1,351 @@ +/// Set when a join feeds an EXISTS or NOT EXISTS condition (`ExistsJoin` +/// in `join.ts`). Its parent index is kept by a `JoinIndexTap` at the end +/// of the pipeline. +struct ExistsJoin { + let op: AST.CorrelatedSubqueryOperator + let parentIndex: JoinIndex +} + +/// Joins a parent and a child input into hierarchical output: each parent +/// node gets a relationship streaming its children (`join.ts`). +final class Join: Input { + let parent: any Input + let child: any Input + let parentKey: [String] + let childKey: [String] + let relationshipName: String + let schema: SourceSchema + let parentIndex: JoinIndex + /// Whether this join maintains `parentIndex` itself; not for EXISTS joins. + let indexesParents: Bool + /// The child change type still pushed when no indexed parent has its key. + let qualifyingChildChange: ChangeType? + let boundProvider: (any TakeBoundProvider)? + weak var output: (any Output)? + private var parentOutput: ClosureOutput! + private var childOutput: ClosureOutput! + + var inprogressChildChange: Change? + var inprogressChildChangePosition: Row? + /// Primary keys of the parents the in-progress change reached, kept when + /// the parent input is unordered. + var inprogressReachedParents: Set? + var inprogressParentFetchBounds: [ParentFetchBound]? + + init( + parent: any Input, + child: any Input, + parentKey: [String], + childKey: [String], + relationshipName: String, + hidden: Bool, + system: AST.System, + parentPartitionKey: [String]? = nil, + boundProvider: (any TakeBoundProvider)? = nil, + exists: ExistsJoin? = nil + ) { + precondition(parent !== child, "Parent and child must be different operators") + precondition( + parentKey.count == childKey.count, "The parentKey and childKey keys must have same length") + precondition(exists == nil || parentPartitionKey == nil, "EXISTS joins are not partitioned") + self.parent = parent + self.child = child + self.parentKey = parentKey + self.childKey = childKey + self.relationshipName = relationshipName + if let exists { + parentIndex = exists.parentIndex + indexesParents = false + qualifyingChildChange = exists.op == .exists ? .add : .remove + } else { + parentIndex = JoinIndex(parentKey, parent.getSchema().primaryKey, parentPartitionKey) + indexesParents = true + qualifyingChildChange = nil + } + self.boundProvider = boundProvider + let parentSchema = parent.getSchema() + let childSchema = child.getSchema() + schema = parentSchema.adding( + relationshipName, childSchema.with(isHidden: hidden, system: system)) + + parentOutput = ClosureOutput( + push: { [unowned self] in self.pushParent($0) }, + reconcile: { [unowned self] in self.output?.reconcile(pusher: self) }) + childOutput = ClosureOutput( + push: { [unowned self] in self.pushChild($0) }, + reconcile: { [unowned self] in self.output?.reconcile(pusher: self) }) + parent.setOutput(parentOutput) + child.setOutput(childOutput) + } + + func destroy() { + parent.destroy() + child.destroy() + } + + func setOutput(_ output: any Output) { self.output = output } + func getSchema() -> SourceSchema { schema } + + func fetch(_ req: FetchRequest) -> NodeStream { + NodeStream { [self] in + var it = parent.fetch(req).makeIterator() + return AnyIterator { + guard let parentNode = it.next() else { return nil } + self.indexParentRow(parentNode.row) + return self.processParentNode(parentNode.row, parentNode.relationships) + } + } + } + + func pushParent(_ change: Change) { + switch change { + case .add(let node): + indexParentRow(node.row) + output?.push(.add(processParentNode(node.row, node.relationships)), pusher: self) + case .remove(let node): + unindexParentRow(node.row) + output?.push(.remove(processParentNode(node.row, node.relationships)), pusher: self) + case .child(let node, let childChange): + output?.push( + .child(processParentNode(node.row, node.relationships), childChange), pusher: self) + case .edit(let node, let oldNode): + precondition( + rowEqualsForCompoundKey(oldNode.row, node.row, parentKey), + "Parent edit must not change relationship.") + unindexParentRow(oldNode.row) + indexParentRow(node.row) + output?.push( + .edit( + processParentNode(node.row, node.relationships), + oldNode: processParentNode(oldNode.row, oldNode.relationships)), + pusher: self) + } + } + + func pushChild(_ change: Change) { + if case .edit(let node, let oldNode) = change { + precondition( + rowEqualsForCompoundKey(oldNode.row, node.row, childKey), + "Child edit must not change relationship.") + } + pushChildChange(change.node.row, change) + } + + func pushChildChange(_ childRow: Row, _ change: Change) { + inprogressChildChange = change + inprogressChildChangePosition = nil + inprogressReachedParents = parent.getSchema().sort == nil ? [] : nil + defer { + inprogressChildChange = nil + inprogressChildChangePosition = nil + inprogressReachedParents = nil + inprogressParentFetchBounds = nil + } + guard let constraint = buildJoinConstraint(childRow, childKey, parentKey) else { return } + let fetchConstraints: [Constraint] + if let partitions = parentIndex.lookup(childRow, childKey) { + fetchConstraints = partitions.map { p in p.map { constraint.merging($0) } ?? constraint } + } else if change.type == qualifyingChildChange { + fetchConstraints = [constraint] + } else { + return + } + if let boundProvider { + inprogressParentFetchBounds = readParentFetchBounds(boundProvider, fetchConstraints) + } + let parentNodeStream: NodeStream + if fetchConstraints.count == 1 { + parentNodeStream = parent.fetch(FetchRequest(constraint: fetchConstraints[0])) + } else { + let compareRows = schema.compareRows + parentNodeStream = mergeSortedStreams( + fetchConstraints.map { parent.fetch(FetchRequest(constraint: $0)) }, + { compareRows($0.row, $1.row) }) + } + for parentNode in parentNodeStream { + inprogressChildChangePosition = parentNode.row + inprogressReachedParents?.insert(canonicalKey(parentNode.row, schema.primaryKey)) + let childChange = Change.child( + processParentNode(parentNode.row, parentNode.relationships), + ChildChange(relationshipName: relationshipName, change: change)) + output?.push(childChange, pusher: self) + } + } + + /// Whether the in-progress child change has yet to reach `row` in the + /// parent stream. + func isAfterInprogressPosition(_ row: Row) -> Bool { + guard let position = inprogressChildChangePosition else { return false } + if let reached = inprogressReachedParents { + return !reached.contains(canonicalKey(row, schema.primaryKey)) + } + return schema.compareRows(row, position) > 0 + } + + func indexParentRow(_ row: Row) { + if indexesParents { parentIndex.add(row) } + } + + func unindexParentRow(_ row: Row) { + if indexesParents { parentIndex.remove(row) } + } + + func processParentNode(_ parentNodeRow: Row, _ parentNodeRelations: Relationships) -> Node { + let childStream: () -> NodeStream = { [self] in + let constraint = buildJoinConstraint(parentNodeRow, parentKey, childKey) + let stream = constraint.map { child.fetch(FetchRequest(constraint: $0)) } ?? .empty + // The parent has yet to get the in-progress child change if it comes + // after the current position and a parent fetch of the push yields it. + let inPushQueue = + isAfterInprogressPosition(parentNodeRow) + && (inprogressParentFetchBounds.map { + isInParentFetch($0, parentNodeRow, schema.compareRows) + } ?? true) + if let inprogress = inprogressChildChange, + isJoinMatch(parentNodeRow, parentKey, inprogress.node.row, childKey), inPushQueue + { + let childSchema = child.getSchema() + if childSchema.sort == nil { + return generateWithOverlayUnordered(stream, inprogress, childSchema) + } + return generateWithOverlay(stream, inprogress, childSchema) + } + return stream + } + var relationships = parentNodeRelations + relationships[relationshipName] = childStream + return Node(row: parentNodeRow, relationships: relationships) + } +} + +/// Provides `Take`'s bound to upstream operators (`take-gate.ts`). +protocol TakeBoundProvider: AnyObject { + func getBound(_ constraint: Constraint?) -> Row? +} + +/// The bound a `TakeGate` caps one parent fetch of a child push at. +struct ParentFetchBound { + let constraint: Constraint + let bound: Row? +} + +/// Reads the bounds for a child push's parent fetches before they start. +func readParentFetchBounds( + _ provider: any TakeBoundProvider, _ constraints: [Constraint] +) + -> [ParentFetchBound] +{ + constraints.map { ParentFetchBound(constraint: $0, bound: provider.getBound($0)) } +} + +/// Whether one of the parent fetches of a push yields `row`. +func isInParentFetch(_ bounds: [ParentFetchBound], _ row: Row, _ compareRows: Comparator) -> Bool { + for b in bounds where b.constraint.matches(row) { + guard let bound = b.bound else { return true } + return compareRows(row, bound) <= 0 + } + return false +} + +/// Caps upstream parent fetches at a downstream `Take`'s bound. +final class TakeGate: Operator, TakeBoundProvider { + let input: any Input + let comparator: Comparator + weak var boundProvider: (any TakeBoundProvider)? + weak var output: (any Output)? + var openDepth = 0 + + init(_ input: any Input) { + let schema = input.getSchema() + precondition(schema.sort != nil, "TakeGate requires sorted input") + self.input = input + comparator = schema.compareRows + input.setOutput(self) + } + + func open() { openDepth += 1 } + + func close() { + precondition(openDepth > 0, "TakeGate.close called without matching open") + openDepth -= 1 + } + + func setBoundProvider(_ provider: any TakeBoundProvider) { boundProvider = provider } + + func getBound(_ constraint: Constraint?) -> Row? { + if openDepth > 0 { return nil } + return boundProvider?.getBound(constraint) + } + + func getSchema() -> SourceSchema { input.getSchema() } + func setOutput(_ output: any Output) { self.output = output } + func destroy() { input.destroy() } + func push(_ change: Change, pusher: any InputBase) { output?.push(change, pusher: self) } + func reconcile(pusher: any InputBase) { output?.reconcile(pusher: self) } + + func fetch(_ req: FetchRequest) -> NodeStream { + NodeStream { [self] in + if openDepth > 0 || req.reverse { + return AnyIterator(input.fetch(req).makeIterator()) + } + guard let bound = boundProvider?.getBound(req.constraint) else { + return AnyIterator(input.fetch(req).makeIterator()) + } + var it: LazyIterator? = input.fetch(req).makeIterator() + let comparator = self.comparator + return AnyIterator { + guard let node = it?.next() else { return nil } + if comparator(node.row, bound) > 0 { + it = nil + return nil + } + return node + } + } + } +} + +/// Keeps the parent indexes of a pipeline's EXISTS joins equal to the rows +/// the pipeline emits (`join-index-tap.ts`). +final class JoinIndexTap: Operator { + let input: any Input + let indexes: [JoinIndex] + weak var output: (any Output)? + + init(_ input: any Input, _ indexes: [JoinIndex]) { + self.input = input + self.indexes = indexes + input.setOutput(self) + } + + func setOutput(_ output: any Output) { self.output = output } + func getSchema() -> SourceSchema { input.getSchema() } + func destroy() { input.destroy() } + + func fetch(_ req: FetchRequest) -> NodeStream { + let indexes = self.indexes + return input.fetch(req).mapLazy { node in + for index in indexes { index.add(node.row) } + return node + } + } + + func push(_ change: Change, pusher: any InputBase) { + switch change { + case .add(let node): + for index in indexes { index.add(node.row) } + case .remove(let node): + for index in indexes { index.remove(node.row) } + case .edit(let node, let oldNode): + for index in indexes { + index.remove(oldNode.row) + index.add(node.row) + } + case .child: + break + } + output?.push(change, pusher: self) + } + + func reconcile(pusher: any InputBase) { output?.reconcile(pusher: self) } +} diff --git a/swift/Sources/Zero/IVM/JoinUtils.swift b/swift/Sources/Zero/IVM/JoinUtils.swift new file mode 100644 index 0000000000..3aba8010ac --- /dev/null +++ b/swift/Sources/Zero/IVM/JoinUtils.swift @@ -0,0 +1,269 @@ +/// Helpers shared by `Join` and `FlippedJoin` (`join-utils.ts`). + +/// `generateWithOverlay`: applies the in-flight child change `overlay` to a +/// sorted child stream, so a parent that has yet to receive the change sees +/// the children as they were. +func generateWithOverlay( + _ stream: NodeStream, _ overlay: Change, _ schema: SourceSchema +) -> NodeStream { + NodeStream { + var it = stream.makeIterator() + var applied = false + var editOldApplied = false + var editNewApplied = false + var queue: [Node] = [] + var finished = false + return AnyIterator { + while true { + if !queue.isEmpty { return queue.removeFirst() } + if finished { return nil } + guard let node = it.next() else { + finished = true + if !applied { + if case .remove(let n) = overlay { + applied = true + return n + } else if case .edit(_, let oldNode) = overlay { + precondition(editNewApplied, "edit overlay: new node must be applied before old node") + editOldApplied = true + applied = true + return oldNode + } + } + precondition(applied, "overlayGenerator: overlay was never applied to any fetched node") + return nil + } + var yieldNode = true + if !applied { + switch overlay { + case .add(let n): + if schema.compareRows(n.row, node.row) == 0 { + applied = true + yieldNode = false + } + case .remove(let n): + if schema.compareRows(n.row, node.row) < 0 { + applied = true + queue.append(n) + } + case .edit(let n, let oldNode): + if !editOldApplied && schema.compareRows(oldNode.row, node.row) < 0 { + editOldApplied = true + if editNewApplied { applied = true } + queue.append(oldNode) + } + if !editNewApplied && schema.compareRows(n.row, node.row) == 0 { + editNewApplied = true + if editOldApplied { applied = true } + yieldNode = false + } + case .child(let n, let child): + if schema.compareRows(n.row, node.row) == 0 { + applied = true + queue.append(overlayChild(node, child, schema)) + yieldNode = false + } + } + } + if yieldNode { queue.append(node) } + } + } + } +} + +/// `node` with the child change applied to the relationship it is on. +private func overlayChild(_ node: Node, _ child: ChildChange, _ schema: SourceSchema) -> Node { + let name = child.relationshipName + let original = node.relationships[name]! + let childSchema = schema.relationship(name)! + return node.with(name) { generateWithOverlay(original(), child.change, childSchema) } +} + +/// `generateWithOverlayUnordered`: the overlay for an unordered child +/// stream. Removed rows go first; added rows are suppressed by primary key. +func generateWithOverlayUnordered( + _ stream: NodeStream, _ overlay: Change, _ schema: SourceSchema +) -> NodeStream { + NodeStream { + var it = stream.makeIterator() + var injected = false + var suppressed = false + return AnyIterator { + if !injected { + injected = true + if case .remove(let n) = overlay { return n } + if case .edit(_, let oldNode) = overlay { return oldNode } + } + while let node = it.next() { + if !suppressed { + switch overlay { + case .add(let n), .edit(let n, _): + if rowEqualsForCompoundKey(n.row, node.row, schema.primaryKey) { + suppressed = true + continue + } + case .child(let n, let child): + if rowEqualsForCompoundKey(n.row, node.row, schema.primaryKey) { + suppressed = true + return overlayChild(node, child, schema) + } + case .remove: + break + } + } + return node + } + precondition( + suppressed || overlay.type == .remove, + "overlayGenerator: overlay was never applied to any fetched node") + return nil + } + } +} + +func rowEqualsForCompoundKey(_ a: Row, _ b: Row, _ key: [String]) -> Bool { + for k in key where ValueCompare.compare(a[k], b[k]) != 0 { return false } + return true +} + +func isJoinMatch(_ parent: Row, _ parentKey: [String], _ child: Row, _ childKey: [String]) -> Bool { + for i in 0.. Constraint? +{ + var c = Constraint() + for i in 0.. JSKey { + if keys.count == 1 { return JSKey(canonicalValue(row[keys[0]])) } + var s = "" + for (i, k) in keys.enumerated() { + if i > 0 { s += "\u{0}" } + s += canonicalValue(row[k]) + } + return JSKey(s) +} + +func canonicalKey(_ constraint: Constraint, _ keys: [String]) -> JSKey { + var row: Row = [:] + for e in constraint.entries { row[e.key] = e.value } + return canonicalKey(row, keys) +} + +private func canonicalValue(_ v: Value?) -> String { + switch v { + case nil, .null?: "n" + case .string(let s)?: "s" + s + case .number(let n)?: "d" + JSNumber.format(n) + case .bool(let b)?: b ? "t" : "f" + case let v?: "j" + JSONSerializer.stringify(v) + } +} + +/// The partitions of indexed parents that share a join key, as fetch +/// constraints; `[nil]` for an unpartitioned index (`join-index.ts`). +typealias ParentPartitions = [Constraint?] + +/// An index from join key to the parent rows that have it. `Join` and +/// `FlippedJoin` skip the parent fetch of a child change when no indexed +/// parent can see it. +final class JoinIndex { + struct Partition { + var constraint: Constraint + var pks: Set + } + + let parentKey: [String] + let primaryKey: [String] + let joinKeyIsPK: Bool + let partitionKey: [String]? + var unpartitioned: [JSKey: Set] = [:] + /// Join key, then partition key. Partitions keep insertion order. + var partitioned: [JSKey: (order: [JSKey], partitions: [JSKey: Partition])] = [:] + private(set) var size = 0 + + init(_ parentKey: [String], _ primaryKey: [String], _ partitionKey: [String]? = nil) { + self.parentKey = parentKey + self.primaryKey = primaryKey + joinKeyIsPK = parentKey == primaryKey + self.partitionKey = partitionKey + } + + func add(_ row: Row) { + guard let joinKey = joinKeyOf(row, parentKey) else { return } + let pk = joinKeyIsPK ? joinKey : canonicalKey(row, primaryKey) + guard let partitionKey else { + if unpartitioned[joinKey, default: []].insert(pk).inserted { size += 1 } + return + } + let partKey = canonicalKey(row, partitionKey) + var entry = partitioned[joinKey] ?? ([], [:]) + if entry.partitions[partKey] == nil { + entry.order.append(partKey) + entry.partitions[partKey] = Partition(constraint: Constraint.from(row, partitionKey), pks: []) + } + if entry.partitions[partKey]!.pks.insert(pk).inserted { size += 1 } + partitioned[joinKey] = entry + } + + func remove(_ row: Row) { + guard let joinKey = joinKeyOf(row, parentKey) else { return } + let pk = joinKeyIsPK ? joinKey : canonicalKey(row, primaryKey) + guard let partitionKey else { + guard var pks = unpartitioned[joinKey] else { return } + if pks.remove(pk) != nil { + size -= 1 + unpartitioned[joinKey] = pks.isEmpty ? nil : pks + } + return + } + guard var entry = partitioned[joinKey] else { return } + let partKey = canonicalKey(row, partitionKey) + guard var partition = entry.partitions[partKey] else { return } + if partition.pks.remove(pk) != nil { + size -= 1 + if partition.pks.isEmpty { + entry.partitions[partKey] = nil + entry.order.removeAll { $0 == partKey } + partitioned[joinKey] = entry.partitions.isEmpty ? nil : entry + } else { + entry.partitions[partKey] = partition + partitioned[joinKey] = entry + } + } + } + + /// The partitions of the indexed parents whose join key matches + /// `childRow` over `childKey`, or `nil` if there are none. + func lookup(_ childRow: Row, _ childKey: [String]) -> ParentPartitions? { + guard let joinKey = joinKeyOf(childRow, childKey) else { return nil } + guard partitionKey != nil else { + return unpartitioned[joinKey] != nil ? [nil] : nil + } + guard let entry = partitioned[joinKey] else { return nil } + return entry.order.map { entry.partitions[$0]!.constraint } + } + + /// The canonical join key of `row`, or `nil` if part of it is null. + private func joinKeyOf(_ row: Row, _ key: [String]) -> JSKey? { + for k in key where row[k] == .null { return nil } + return canonicalKey(row, key) + } +} diff --git a/swift/Sources/Zero/IVM/MemorySource.swift b/swift/Sources/Zero/IVM/MemorySource.swift new file mode 100644 index 0000000000..d2c7e7ea42 --- /dev/null +++ b/swift/Sources/Zero/IVM/MemorySource.swift @@ -0,0 +1,705 @@ +/// A source of rows held in memory, kept in sorted order +/// (`packages/zql/src/ivm/memory-source.ts`). +public final class MemorySource: Source { + struct Overlay { + var epoch: Int + var change: SourceChange + } + + struct Overlays { + var add: Row? + var remove: Row? + } + + struct Index { + var comparator: BoundComparator + var data: BTreeSet + } + + final class Connection { + var input: MemorySourceInput! + weak var output: (any Output)? + let sort: AST.Ordering + let splitEditKeys: Set? + let compareRows: Comparator + let filters: (condition: AST.Condition, predicate: RowPredicate)? + var lastPushedEpoch = 0 + + init( + sort: AST.Ordering, splitEditKeys: Set?, compareRows: @escaping Comparator, + filters: (condition: AST.Condition, predicate: RowPredicate)? + ) { + self.sort = sort + self.splitEditKeys = splitEditKeys + self.compareRows = compareRows + self.filters = filters + } + } + + let tableName: String + let columns: [String: ColumnSchema] + let primaryKey: [String] + let primaryIndexSort: AST.Ordering + let primaryIndexKey: String + var indexes: [String: Index] = [:] + var indexOrder: [String] = [] + var connections: [Connection] = [] + var overlay: Overlay? + var pushEpoch = 0 + private let schema: TableSchema + + public convenience init(_ table: TableSchema) { + self.init(table: table, primaryIndexData: nil) + } + + init(table: TableSchema, primaryIndexData: BTreeSet?) { + schema = table + tableName = table.name + columns = table.columns + primaryKey = table.primaryKey + primaryIndexSort = table.primaryKey.map { AST.OrderPart($0, .asc) } + let comparator = BoundComparator(primaryIndexSort) + primaryIndexKey = Self.indexKey(primaryIndexSort) + indexes[primaryIndexKey] = Index( + comparator: comparator, data: primaryIndexData ?? BTreeSet(comparator.compareRows)) + indexOrder.append(primaryIndexKey) + } + + public var tableSchema: TableSchema { schema } + + /// A copy whose primary index shares nodes with this one until either + /// changes. + public func fork() -> MemorySource { + MemorySource(table: schema, primaryIndexData: primaryIndex.data) + } + + /// The rows, in primary key order. + public var data: BTreeSet { primaryIndex.data } + + static func indexKey(_ sort: AST.Ordering) -> String { + JSONSerializer.stringify( + .array(sort.map { [.string($0.field), .string($0.direction.rawValue)] })) + } + + var primaryIndex: Index { indexes[primaryIndexKey]! } + + public func connect( + sort: AST.Ordering?, filters: AST.Condition?, splitEditKeys: Set? + ) throws(PipelineError) -> any SourceInput { + let transformed = transformFilters(filters) + let unordered = sort == nil + let internalSort = sort ?? primaryIndexSort + var connFilters: (condition: AST.Condition, predicate: RowPredicate)? + if let condition = transformed.filters { + connFilters = (condition, try createPredicate(condition)) + } + let connection = Connection( + sort: internalSort, splitEditKeys: splitEditKeys, + compareRows: makeComparator(internalSort), filters: connFilters) + let schema = SourceSchema( + tableName: tableName, columns: columns, primaryKey: primaryKey, + compareRows: connection.compareRows, sort: unordered ? nil : internalSort) + if !unordered { + try assertOrderingIncludesPK(internalSort, primaryKey) + } + let input = MemorySourceInput( + source: self, connection: connection, schema: schema, + fullyAppliedFilters: !transformed.conditionsRemoved) + connection.input = input + connections.append(connection) + return input + } + + func disconnect(_ input: MemorySourceInput) { + guard let i = connections.firstIndex(where: { $0.input === input }) else { + preconditionFailure("Connection not found") + } + // Indexes are kept for the life of the source (see the JS comment). + connections[i].input = nil + connections.remove(at: i) + } + + func getOrCreateIndex(_ sort: AST.Ordering) -> Index { + let key = Self.indexKey(sort) + if let index = indexes[key] { return index } + let comparator = BoundComparator(sort) + let rows = Array(primaryIndex.data.values()).sorted { comparator.compareRows($0, $1) < 0 } + let index = Index(comparator: comparator, data: BTreeSet(sorted: rows, comparator.compareRows)) + indexes[key] = index + indexOrder.append(key) + return index + } + + /// For tests: the keys of the indexes built so far. + public var indexKeys: [String] { indexOrder } + + func fetch(_ req: FetchRequest, _ conn: Connection) -> NodeStream { + NodeStream { [self] in AnyIterator(self.fetchIterator(req, conn)) } + } + + private func fetchIterator(_ req: FetchRequest, _ conn: Connection) -> AnyIterator { + if let multis = req.multiConstraints, multis.contains(where: { !$0.isEmpty }) { + return AnyIterator(fetchMulti(req, conn).makeIterator()) + } + let requestedSort = conn.sort + let compareRows = conn.compareRows + let connectionComparator: Comparator = req.reverse ? { compareRows($1, $0) } : compareRows + + let reqFilter = req.filter + let connFilterCondition = conn.filters?.condition + let mergedFilterCondition: AST.Condition? = + if let connFilterCondition, let reqFilter { + .and([connFilterCondition, reqFilter]) + } else { + connFilterCondition ?? reqFilter + } + let mergedFilterPredicate = mergePredicates(conn.filters?.predicate, reqFilter) + + let pkConstraint = primaryKeyConstraintFromFilters(mergedFilterCondition, primaryKey) + // The primary key constraint is more limiting than the constraint. + let fetchOrPkConstraint = pkConstraint ?? req.constraint + + var indexSort: AST.Ordering = [] + if let fetchOrPkConstraint { + for key in fetchOrPkConstraint.keys { indexSort.append(AST.OrderPart(key, .asc)) } + } + // Constraining by the whole primary key yields one row, so the requested + // sort does not matter. + if primaryKey.count > 1 || fetchOrPkConstraint == nil + || !fetchOrPkConstraint!.matchesPrimaryKey(primaryKey) + { + indexSort.append(contentsOf: requestedSort) + } + + let index = getOrCreateIndex(indexSort) + let data = index.data + let compare = index.comparator.compareRows + let indexComparator: Comparator = req.reverse ? { compare($1, $0) } : compare + + let startAt = req.start?.row + + // Start a constrained scan at the first row with the constraint's + // values, whatever the direction of the following sort parts. + let probe: ((Row) -> Int)? + if let fetchOrPkConstraint { + var scanStart: [String: Bound] = [:] + for part in indexSort { + if fetchOrPkConstraint.has(part.field) { + scanStart[part.field] = .value(fetchOrPkConstraint[part.field]) + } else if req.reverse { + scanStart[part.field] = part.direction == .asc ? .max : .min + } else { + scanStart[part.field] = part.direction == .asc ? .min : .max + } + } + probe = index.comparator.probe(scanStart) + } else if let startAt { + probe = { compare($0, startAt) } + } else { + probe = nil + } + + let rows: AnyIterator = + req.reverse + ? AnyIterator(data.valuesReversed(from: probe)) : AnyIterator(data.values(from: probe)) + + // Fast path: no overlay, no start, and no filters. + let overlayActive = overlay.map { conn.lastPushedEpoch >= $0.epoch } ?? false + if !overlayActive && req.start == nil && conn.filters == nil && req.filter == nil { + let constraint = req.constraint + var done = false + return AnyIterator { + if done { return nil } + guard let row = rows.next() else { return nil } + if let constraint, !constraint.matches(row) { + done = true + return nil + } + return Node(row: row) + } + } + + // `req.constraint` and not the primary key constraint, which comes from + // the filters and acts as one. + let rowsIterable: AnyIterator = + if pkConstraint != nil { + { () -> AnyIterator in + var taken = false + return AnyIterator { + if taken { return nil } + taken = true + return rows.next() + } + }() + } else { + rows + } + let withOverlay = generateWithOverlay( + startAt: startAt, rows: rowsIterable, constraint: req.constraint, overlay: overlay, + lastPushedEpoch: conn.lastPushedEpoch, compare: indexComparator, + startAtCompare: connectionComparator, filterPredicate: mergedFilterPredicate) + let withStart = generateWithStart(withOverlay, req.start, connectionComparator) + let withConstraint = generateWithConstraint(withStart, req.constraint) + if let mergedFilterPredicate { + return AnyIterator { + while let node = withConstraint.next() { + if mergedFilterPredicate(node.row) { return node } + } + return nil + } + } + return withConstraint + } + + private func fetchMulti(_ req: FetchRequest, _ conn: Connection) -> NodeStream { + let multis = req.multiConstraints!.filter { !$0.isEmpty } + let primary = multis[0] + let rest = multis.dropFirst() + let base = req.constraint + let subStreams: [NodeStream] = primary.map { c in + var sub = req + sub.constraint = base.map { $0.merging(c) } ?? c + sub.multiConstraints = nil + return fetch(sub, conn) + } + let compareRows = conn.compareRows + let merged = mergeSortedStreams( + subStreams, + req.reverse ? { compareRows($1.row, $0.row) } : { compareRows($0.row, $1.row) }) + if rest.isEmpty { return merged } + return NodeStream { + var it = merged.makeIterator() + return AnyIterator { + while let node = it.next() { + if rest.allSatisfy({ mc in mc.contains { $0.matches(node.row) } }) { return node } + } + return nil + } + } + } + + /// Pushes an add for each of `rows`. When nothing is connected and the + /// source is empty, builds the primary index in one pass instead. + public func pushAdds(_ rows: [Row]) { + let index = primaryIndex + let canBulkLoad = connections.isEmpty && indexes.count == 1 && index.data.isEmpty + if !canBulkLoad { + for row in rows { push(.add(row)) } + return + } + let compare = index.comparator.compareRows + var sorted = rows + if !isAscending(rows, compare) { + sorted.sort { compare($0, $1) < 0 } + } + indexes[primaryIndexKey]!.data = BTreeSet(sorted: sorted, compare) + } + + public func push(_ change: SourceChange) { + genPushAndWriteWithSplitEdit(change) + } + + func genPushAndWriteWithSplitEdit(_ change: SourceChange) { + var shouldSplitEdit = false + if case .edit(let row, let oldRow) = change { + for conn in connections { + if let keys = conn.splitEditKeys { + for key in keys where !ValueCompare.equal(row[key], oldRow[key]) { + shouldSplitEdit = true + break + } + } + } + if shouldSplitEdit { + pushEpoch += 1 + genPushAndWrite(.remove(oldRow), epoch: pushEpoch) + pushEpoch += 1 + genPushAndWrite(.add(row), epoch: pushEpoch) + return + } + } + pushEpoch += 1 + genPushAndWrite(change, epoch: pushEpoch) + } + + private func genPushAndWrite(_ change: SourceChange, epoch: Int) { + genPush(change, epoch: epoch) + writeChange(change) + } + + private func genPush(_ change: SourceChange, epoch: Int) { + let data = primaryIndex.data + switch change { + case .add(let row): + precondition(!data.contains(row), "Row already exists \(Self.describe(change))") + case .remove(let row): + precondition(data.contains(row), "Row not found \(Self.describe(change))") + case .edit(_, let oldRow): + precondition(data.contains(oldRow), "Row not found \(Self.describe(change))") + } + + overlay = Overlay(epoch: epoch, change: change) + defer { overlay = nil } + // Index-based, like iterating the live JS array: a pipeline can + // disconnect while a push is in flight. + var i = 0 + while i < connections.count { + let conn = connections[i] + i += 1 + guard let output = conn.output else { continue } + conn.lastPushedEpoch = epoch + let outputChange: Change = + switch change { + case .edit(let row, let oldRow): .edit(Node(row: row), oldNode: Node(row: oldRow)) + case .add(let row): .add(Node(row: row)) + case .remove(let row): .remove(Node(row: row)) + } + filterPush(outputChange, output, conn.input, conn.filters?.predicate) + } + i = 0 + while i < connections.count { + let conn = connections[i] + i += 1 + if let output = conn.output { + output.reconcile(pusher: conn.input) + } + } + } + + private func writeChange(_ change: SourceChange) { + for key in indexOrder { + switch change { + case .add(let row): + indexes[key]!.data.add(row) + case .remove(let row): + let removed = indexes[key]!.data.delete(row) + precondition(removed, "MemorySource: remove must succeed") + case .edit(let row, let oldRow): + let removed = indexes[key]!.data.delete(oldRow) + precondition(removed, "MemorySource: edit remove must succeed") + indexes[key]!.data.add(row) + } + } + } + + static func describe(_ change: SourceChange) -> String { + switch change { + case .add(let r): "[0,\(JSONSerializer.stringify(.object(r))),null]" + case .remove(let r): "[1,\(JSONSerializer.stringify(.object(r))),null]" + case .edit(let r, let o): + "[2,\(JSONSerializer.stringify(.object(r))),\(JSONSerializer.stringify(.object(o)))]" + } + } +} + +/// A pipeline's connection to a `MemorySource`. +final class MemorySourceInput: SourceInput { + unowned let source: MemorySource + let connection: MemorySource.Connection + let schema: SourceSchema + let fullyAppliedFilters: Bool + + init( + source: MemorySource, connection: MemorySource.Connection, schema: SourceSchema, + fullyAppliedFilters: Bool + ) { + self.source = source + self.connection = connection + self.schema = schema + self.fullyAppliedFilters = fullyAppliedFilters + } + + func getSchema() -> SourceSchema { schema } + func fetch(_ req: FetchRequest) -> NodeStream { source.fetch(req, connection) } + func setOutput(_ output: any Output) { connection.output = output } + func destroy() { source.disconnect(self) } +} + +/// A bound in an index scan: a value, or below or above every value. +enum Bound { + case value(Value?) + case min + case max +} + +/// `makeBoundComparator(sort)`: orders rows by `sort`, and rows against +/// partial rows whose fields may be `min` or `max`. +struct BoundComparator { + let sort: AST.Ordering + let compareRows: Comparator + + init(_ sort: AST.Ordering) { + self.sort = sort + let parts = sort.map { ($0.field, $0.direction == .asc) } + compareRows = { a, b in + for (field, asc) in parts { + let c = ValueCompare.compare(a[field], b[field]) + if c != 0 { return asc ? c : -c } + } + return 0 + } + } + + /// Compares rows to `bound`: negative when the row is before it. + func probe(_ bound: [String: Bound]) -> (Row) -> Int { + let parts = sort.map { ($0.field, $0.direction == .asc, bound[$0.field] ?? .value(nil)) } + return { row in + for (field, asc, b) in parts { + let c = compareBounds(.value(row[field]), b) + if c != 0 { return asc ? c : -c } + } + return 0 + } + } +} + +func compareBounds(_ a: Bound, _ b: Bound) -> Int { + switch (a, b) { + case (.min, .min), (.max, .max): return 0 + case (.min, _): return -1 + case (.max, _): return 1 + case (_, .min): return 1 + case (_, .max): return -1 + case (.value(let x), .value(let y)): return ValueCompare.compare(x, y) + } +} + +func assertOrderingIncludesPK(_ ordering: AST.Ordering, _ pk: [String]) throws(PipelineError) { + let fields = ordering.map(\.field) + let missing = pk.filter { !fields.contains($0) } + if !missing.isEmpty { + throw PipelineError( + "Ordering must include all primary key fields. Missing: \(missing.joined(separator: ", ")).") + } +} + +func mergePredicates(_ conn: RowPredicate?, _ reqFilter: AST.Condition?) -> RowPredicate? { + guard let reqFilter else { return conn } + // FilterStart's conditions were checked when the pipeline was built. + let reqPredicate = (try? createPredicate(reqFilter)) ?? { _ in false } + guard let conn else { return reqPredicate } + return { conn($0) && reqPredicate($0) } +} + +func isAscending(_ rows: [Row], _ compare: Comparator) -> Bool { + var i = 1 + while i < rows.count { + if compare(rows[i - 1], rows[i]) > 0 { return false } + i += 1 + } + return true +} + +func generateWithConstraint(_ it: AnyIterator, _ constraint: Constraint?) -> AnyIterator +{ + guard let constraint else { return it } + var done = false + return AnyIterator { + if done { return nil } + guard let node = it.next() else { return nil } + if !constraint.matches(node.row) { + done = true + return nil + } + return node + } +} + +/// `generateWithStart`: skips rows before `start`. +func generateWithStart( + _ nodes: AnyIterator, _ start: Start?, _ compare: @escaping Comparator +) -> AnyIterator { + guard let start else { return nodes } + var started = false + return AnyIterator { + while let node = nodes.next() { + if !started { + let c = compare(node.row, start.row) + started = start.basis == .at ? c >= 0 : c > 0 + } + if started { return node } + } + return nil + } +} + +/// `generateWithOverlay`: splices the in-flight push into `rows`. +func generateWithOverlay( + startAt: Row?, + rows: AnyIterator, + constraint: Constraint?, + overlay: MemorySource.Overlay?, + lastPushedEpoch: Int, + compare: @escaping Comparator, + startAtCompare: Comparator, + filterPredicate: RowPredicate?, + multiConstraints: [[Constraint]]? = nil +) -> AnyIterator { + var toApply: MemorySource.Overlay? + if let overlay, lastPushedEpoch >= overlay.epoch { toApply = overlay } + let overlays = computeOverlays( + startAt: startAt, constraint: constraint, overlay: toApply, startAtCompare: startAtCompare, + filterPredicate: filterPredicate, multiConstraints: multiConstraints) + return generateWithOverlayInner(rows, overlays, compare) +} + +func overlaysOf(_ overlay: MemorySource.Overlay?) -> MemorySource.Overlays { + switch overlay?.change { + case .add(let row)?: MemorySource.Overlays(add: row, remove: nil) + case .remove(let row)?: MemorySource.Overlays(add: nil, remove: row) + case .edit(let row, let oldRow)?: MemorySource.Overlays(add: row, remove: oldRow) + case nil: MemorySource.Overlays() + } +} + +func computeOverlays( + startAt: Row?, + constraint: Constraint?, + overlay: MemorySource.Overlay?, + startAtCompare: Comparator, + filterPredicate: RowPredicate?, + multiConstraints: [[Constraint]]? +) -> MemorySource.Overlays { + var overlays = overlaysOf(overlay) + if let startAt { + func keep(_ row: Row?) -> Row? { + guard let row, startAtCompare(row, startAt) >= 0 else { return nil } + return row + } + overlays = MemorySource.Overlays(add: keep(overlays.add), remove: keep(overlays.remove)) + } + if let constraint { + overlays = MemorySource.Overlays( + add: overlays.add.flatMap { constraint.matches($0) ? $0 : nil }, + remove: overlays.remove.flatMap { constraint.matches($0) ? $0 : nil }) + } + overlays = applyMultiConstraintsToOverlays(overlays, multiConstraints) + if let filterPredicate { + overlays = MemorySource.Overlays( + add: overlays.add.flatMap { filterPredicate($0) ? $0 : nil }, + remove: overlays.remove.flatMap { filterPredicate($0) ? $0 : nil }) + } + return overlays +} + +func applyMultiConstraintsToOverlays( + _ overlays: MemorySource.Overlays, _ multiConstraints: [[Constraint]]? +) -> MemorySource.Overlays { + guard let multiConstraints else { return overlays } + var result = overlays + for mc in multiConstraints where !mc.isEmpty { + func matchesAny(_ row: Row?) -> Row? { + guard let row, mc.contains(where: { $0.matches(row) }) else { return nil } + return row + } + result = MemorySource.Overlays(add: matchesAny(result.add), remove: matchesAny(result.remove)) + } + return result +} + +func generateWithOverlayInner( + _ rows: AnyIterator, _ overlays: MemorySource.Overlays, _ compare: @escaping Comparator +) -> AnyIterator { + var addOverlayYielded = false + var removeOverlaySkipped = false + var pending: Row? + var finished = false + return AnyIterator { + if let p = pending { + pending = nil + return Node(row: p) + } + if finished { return nil } + while let row = rows.next() { + var emitAdd: Row? + if !addOverlayYielded, let add = overlays.add, compare(add, row) < 0 { + addOverlayYielded = true + emitAdd = add + } + if !removeOverlaySkipped, let remove = overlays.remove, compare(remove, row) == 0 { + removeOverlaySkipped = true + if let emitAdd { return Node(row: emitAdd) } + continue + } + if let emitAdd { + pending = row + return Node(row: emitAdd) + } + return Node(row: row) + } + finished = true + if !addOverlayYielded, let add = overlays.add { + addOverlayYielded = true + return Node(row: add) + } + return nil + } +} + +/// `mergeSortedStreams`: an N-way merge of streams sorted by `compare`. +func mergeSortedStreams( + _ streams: [NodeStream], _ compare: @escaping (Node, Node) -> Int +) + -> NodeStream +{ + NodeStream { + var iterators = streams.map { $0.makeIterator() } + var heap: [(node: Node, idx: Int)] = [] + var primed = false + + func siftUp(_ start: Int) { + var i = start + while i > 0 { + let p = (i - 1) >> 1 + if compare(heap[i].node, heap[p].node) >= 0 { return } + heap.swapAt(i, p) + i = p + } + } + func siftDown(_ start: Int) { + var i = start + let n = heap.count + while true { + let l = (i << 1) + 1 + let r = l + 1 + var smallest = i + if l < n && compare(heap[l].node, heap[smallest].node) < 0 { smallest = l } + if r < n && compare(heap[r].node, heap[smallest].node) < 0 { smallest = r } + if smallest == i { return } + heap.swapAt(i, smallest) + i = smallest + } + } + + var pendingRefill: Int? + return AnyIterator { + if !primed { + primed = true + for i in 0.. SourceSchema? { + for r in relationships where r.name == name { return r.schema } + return nil + } + + public var relationshipNames: [String] { relationships.map(\.name) } + + /// A copy with the given fields replaced. + func with( + relationships: [(name: String, schema: SourceSchema)]? = nil, + isHidden: Bool? = nil, + system: AST.System? = nil + ) -> SourceSchema { + SourceSchema( + tableName: tableName, columns: columns, primaryKey: primaryKey, + relationships: relationships ?? self.relationships, isHidden: isHidden ?? self.isHidden, + system: system ?? self.system, compareRows: compareRows, sort: sort) + } + + /// This schema with `schema` added (or replaced) as relationship `name`. + func adding(_ name: String, _ schema: SourceSchema) -> SourceSchema { + var rels = relationships + if let i = rels.firstIndex(where: { $0.name == name }) { + rels[i].schema = schema + } else { + rels.append((name, schema)) + } + return with(relationships: rels) + } +} + +/// Where a fetch starts (`Start` in `operator.ts`). +public struct Start { + public enum Basis: Sendable { case at, after } + public var row: Row + public var basis: Basis + + public init(row: Row, basis: Basis) { + self.row = row + self.basis = basis + } +} + +/// `FetchRequest` in `operator.ts`. +public struct FetchRequest { + public var constraint: Constraint? + /// Multi-row IN clauses, all ANDed together and with `constraint`. + public var multiConstraints: [[Constraint]]? + /// If supplied, `start.row` must have previously been output by fetch or + /// push. + public var start: Start? + /// Whether to fetch in reverse order of the schema's sort. + public var reverse: Bool + /// A predicate without subqueries for the source to apply. Only + /// `FilterStart` introduces one; pass-through operators keep it. + public var filter: AST.Condition? + + public init( + constraint: Constraint? = nil, + multiConstraints: [[Constraint]]? = nil, + start: Start? = nil, + reverse: Bool = false, + filter: AST.Condition? = nil + ) { + self.constraint = constraint + self.multiConstraints = multiConstraints + self.start = start + self.reverse = reverse + self.filter = filter + } +} + +/// `InputBase` in `operator.ts`. +public protocol InputBase: AnyObject { + func getSchema() -> SourceSchema + func destroy() +} + +/// An operator's input: something that can be fetched from and pushes to an +/// output. +public protocol Input: InputBase { + func setOutput(_ output: any Output) + func fetch(_ req: FetchRequest) -> NodeStream +} + +/// An operator's output (`Output` in `operator.ts`). +/// +/// Pushing is two-phase. `push` propagates a change; once every change of a +/// source push has propagated, `reconcile` lets bounded operators (`Take`) +/// refill and push the rows that refill them. +public protocol Output: AnyObject { + func push(_ change: Change, pusher: any InputBase) + /// Phase two. Sinks can ignore it; operators must forward it. + func reconcile(pusher: any InputBase) +} + +extension Output { + public func reconcile(pusher: any InputBase) {} +} + +/// An operator in a pipeline: an input to the next and an output of the +/// previous. +/// +/// Operators hold their inputs strongly and their outputs weakly, so the +/// view at the end of a pipeline owns it. A push that reaches a released +/// output is dropped. +public protocol Operator: Input, Output {} + +/// Routes an input's pushes to closures, as JS operators do with object +/// literals. The operator owns it: inputs hold their outputs weakly. +final class ClosureOutput: Output { + let onPush: (Change) -> Void + let onReconcile: () -> Void + + init(push: @escaping (Change) -> Void, reconcile: @escaping () -> Void) { + onPush = push + onReconcile = reconcile + } + + func push(_ change: Change, pusher: any InputBase) { onPush(change) } + func reconcile(pusher: any InputBase) { onReconcile() } +} + +/// The `where` sub-graph's input (`FilterInput` in `filter-operators.ts`). +public protocol FilterInput: InputBase { + func setFilterOutput(_ output: any FilterOutput) +} + +/// The `where` sub-graph's output. Filter operators have no `fetch`; a fetch +/// through the sub-graph asks `filter` whether each node passes. +public protocol FilterOutput: Output { + func beginFilter() + func filter(_ node: Node) -> Bool + func endFilter() +} + +public protocol FilterOperator: FilterInput, FilterOutput {} + +/// A source of rows for pipelines (`Source` in `source.ts`). +public protocol Source: AnyObject { + var tableSchema: TableSchema { get } + /// Connects a pipeline. `sort` is `nil` for an unordered connection. + func connect( + sort: AST.Ordering?, filters: AST.Condition?, splitEditKeys: Set? + ) throws(PipelineError) -> any SourceInput + func push(_ change: SourceChange) +} + +/// A source's connection to one pipeline. +public protocol SourceInput: Input { + /// Whether the source applies all of the connection's filters, so no + /// `Filter` operators are needed above it. + var fullyAppliedFilters: Bool { get } +} diff --git a/swift/Sources/Zero/IVM/Stream.swift b/swift/Sources/Zero/IVM/Stream.swift new file mode 100644 index 0000000000..6b8bd9dd21 --- /dev/null +++ b/swift/Sources/Zero/IVM/Stream.swift @@ -0,0 +1,89 @@ +/// A lazy stream of values (`packages/zql/src/ivm/stream.ts`). +/// +/// JS streams are generators, whose bodies start running on the first +/// `next()`. `IVMStream` matches that: `make` runs when the first element is +/// requested, not when the stream is created or iterated. Pipelines rely on +/// this, because a fetch reads operator and source state when it starts. +/// +/// The JS `'yield'` tokens, which only hand control back to a scheduler, +/// have no counterpart: the Swift engine hydrates synchronously. +public struct IVMStream: Sequence { + @usableFromInline let make: () -> AnyIterator + + /// A stream whose iterator `make` builds on the first `next()`. + @inlinable + public init(_ make: @escaping () -> AnyIterator) { + self.make = make + } + + /// A stream that yields `elements`. + @inlinable + public init(_ elements: [Element]) { + self.make = { AnyIterator(elements.makeIterator()) } + } + + @inlinable + public static var empty: IVMStream { + IVMStream { AnyIterator { nil } } + } + + @inlinable + public func makeIterator() -> LazyIterator { + LazyIterator(make) + } + + /// Maps each element; `f` runs as elements are pulled. + @inlinable + public func mapLazy(_ f: @escaping (Element) -> T) -> IVMStream { + let source = self + return IVMStream { + var it = source.makeIterator() + return AnyIterator { it.next().map(f) } + } + } +} + +/// Starts the underlying iterator on the first `next()`. +public struct LazyIterator: IteratorProtocol { + @usableFromInline var make: (() -> AnyIterator)? + @usableFromInline var iterator: AnyIterator? + + @inlinable + init(_ make: @escaping () -> AnyIterator) { + self.make = make + } + + @inlinable + public mutating func next() -> Element? { + if let make { + iterator = make() + self.make = nil + } + return iterator?.next() + } +} + +/// Builds an iterator from a closure over local state; the closure runs +/// when the first element is requested. +@inlinable +func lazyStream(_ body: @escaping () -> () -> Element?) -> IVMStream { + IVMStream { AnyIterator(body()) } +} + +extension Sequence { + /// The first element, pulling at most one. + @inlinable + var firstElement: Element? { + var it = makeIterator() + return it.next() + } + + /// Pulls every element. + @inlinable + func consume() { + var it = makeIterator() + while it.next() != nil {} + } +} + +public typealias NodeStream = IVMStream diff --git a/swift/Sources/Zero/IVM/Take.swift b/swift/Sources/Zero/IVM/Take.swift new file mode 100644 index 0000000000..88034ed29c --- /dev/null +++ b/swift/Sources/Zero/IVM/Take.swift @@ -0,0 +1,650 @@ +/// Implements `limit`: passes the first `limit` nodes of its input and keeps +/// a bound, the last accepted row, to decide on pushes (`take.ts`). Counts +/// globally or per value of a partition key. +final class Take: Operator, TakeBoundProvider { + struct State { + var size: Int + var bound: Row? + } + + struct DirtyPartition { + var constraint: Constraint? + /// The bound reported to an upstream `TakeGate` until reconcile, so a + /// removal does not unbound the gate during the push. + var gateBound: Row? + } + + let input: any Input + let limit: Int + let partitionKey: [String]? + var storage: [JSKey: State] = [:] + /// A row hidden from fetches while a split push is in flight. + var rowHiddenFromFetch: Row? + weak var takeGate: TakeGate? + /// Partitions with removals in phase one that reconcile must refill, in + /// insertion order. + var dirtyPartitions: [(key: JSKey, state: DirtyPartition)] = [] + weak var output: (any Output)? + + init(_ input: any Input, limit: Int, partitionKey: [String]? = nil) throws(PipelineError) { + precondition(limit >= 0, "Limit must be non-negative") + let schema = input.getSchema() + guard let sort = schema.sort else { throw PipelineError("Take requires sorted input") } + try assertOrderingIncludesPK(sort, schema.primaryKey) + self.input = input + self.limit = limit + self.partitionKey = partitionKey + input.setOutput(self) + } + + func setTakeGate(_ gate: TakeGate) { takeGate = gate } + func setOutput(_ output: any Output) { self.output = output } + func getSchema() -> SourceSchema { input.getSchema() } + func destroy() { input.destroy() } + + func dirty(_ key: JSKey) -> DirtyPartition? { + dirtyPartitions.first { $0.key == key }?.state + } + + func getBound(_ constraint: Constraint?) -> Row? { + if let partitionKey, !constraintContainsPartitionKey(constraint, partitionKey) { + return nil + } + let key = takeStateKey(constraint) + if let dirty = dirty(key) { + // Phase one: keep upstream gates at the bound before the removals. + return dirty.gateBound + } + guard let state = storage[key], state.size >= limit else { return nil } + return state.bound + } + + func fetch(_ req: FetchRequest) -> NodeStream { + NodeStream { [self] in + precondition( + partitionKey == nil + || (req.constraint != nil + && constraintContainsPartitionKey(req.constraint, partitionKey)), + "Partitioned take does not allow unpartitioned fetches") + let key = takeStateKey(req.constraint) + guard let state = storage[key] else { + if constraintMatchesPartitionKey(req.constraint, partitionKey) { + return initialFetch(req, key) + } + return AnyIterator { nil } + } + guard let bound = state.bound else { return AnyIterator { nil } } + var it: LazyIterator? = input.fetch(req).makeIterator() + var count = 0 + var yielded = false + let compareRows = getSchema().compareRows + return AnyIterator { + // Count the node yielded last on resume, as the JS generator does. + if yielded { + yielded = false + count += 1 + if count >= self.limit { it = nil } + } + while let node = it?.next() { + if compareRows(bound, node.row) < 0 { break } + if let hidden = self.rowHiddenFromFetch, compareRows(hidden, node.row) == 0 { continue } + yielded = true + return node + } + it = nil + return nil + } + } + } + + /// The first fetch of a partition: hydrates its state. The state is + /// written once the stream ends, as in the JS `finally`. + private func initialFetch(_ req: FetchRequest, _ key: JSKey) -> AnyIterator { + precondition(req.start == nil, "Start should be undefined") + precondition(!req.reverse, "Reverse should be false") + if limit == 0 { return AnyIterator { nil } } + var it: LazyIterator? = input.fetch(req).makeIterator() + var size = 0 + var bound: Row? + var pending: Node? + return AnyIterator { [self] in + guard it != nil else { return nil } + if let p = pending { + pending = nil + bound = p.row + size += 1 + if size == limit { + it = nil + storage[key] = State(size: size, bound: bound) + return nil + } + } + guard let node = it!.next() else { + it = nil + storage[key] = State(size: size, bound: bound) + return nil + } + pending = node + return node + } + } + + func stateAndConstraint(_ row: Row) -> (State?, JSKey, Constraint?) { + let key = takeStateKey(row) + let state = storage[key] + var constraint: Constraint? + if state != nil, let partitionKey { + constraint = Constraint.from(row, partitionKey) + } + return (state, key, constraint) + } + + func push(_ change: Change, pusher: any InputBase) { + if case .edit(let node, let oldNode) = change { + pushEditChange(change, node, oldNode) + return + } + let (maybeState, key, constraint) = stateAndConstraint(change.node.row) + guard let state = maybeState else { return } + let compareRows = getSchema().compareRows + switch change { + case .add(let node): + if state.size < limit { + if dirty(key) != nil { + // With an unresolved deficit, only admit rows before the bound, so + // reconcile can backfill earlier rows in order. + if state.bound == nil || compareRows(node.row, state.bound!) >= 0 { return } + } + let nextSize = state.size + 1 + if nextSize == limit { dirtyPartitions.removeAll { $0.key == key } } + setState( + key, nextSize, + state.bound == nil || compareRows(state.bound!, node.row) < 0 ? node.row : state.bound) + output?.push(change, pusher: self) + return + } + // size == limit + guard let bound = state.bound, compareRows(node.row, bound) < 0 else { return } + var beforeBoundNode: Node? + var boundNode: Node? + if limit == 1 { + boundNode = + input.fetch( + FetchRequest(constraint: constraint, start: Start(row: bound, basis: .at)) + ).firstElement + } else { + var it = input.fetch( + FetchRequest(constraint: constraint, start: Start(row: bound, basis: .at), reverse: true) + ).makeIterator() + boundNode = it.next() + beforeBoundNode = it.next() + } + guard let boundNode else { preconditionFailure("Take: boundNode must be found during fetch") } + // Remove before add to keep output size <= limit. + setState( + key, state.size, + beforeBoundNode == nil || compareRows(node.row, beforeBoundNode!.row) > 0 + ? node.row : beforeBoundNode!.row) + pushWithRowHiddenFromFetch(node.row, .remove(boundNode)) + output?.push(change, pusher: self) + case .remove(let node): + guard let bound = state.bound else { return } // after the bound + let compToBound = compareRows(node.row, bound) + if compToBound > 0 || (compToBound < 0 && limit == 1) { return } + let beforeBoundNode = input.fetch( + FetchRequest(constraint: constraint, start: Start(row: bound, basis: .after), reverse: true) + ).firstElement + let finalBound: Row? = + state.size - 1 == 0 ? nil : compToBound < 0 ? bound : beforeBoundNode?.row + if dirty(key) == nil { + // Snapshot the bound before the first removal for upstream gates. + dirtyPartitions.append((key, DirtyPartition(constraint: constraint, gateBound: bound))) + } + setState(key, state.size - 1, finalBound) + output?.push(change, pusher: self) + case .child(let node, _): + if let bound = state.bound, compareRows(node.row, bound) <= 0 { + output?.push(change, pusher: self) + } + case .edit: + break + } + } + + private func pushEditChange(_ change: Change, _ node: Node, _ oldNode: Node) { + if let partitionKey { + precondition( + rowEqualsForCompoundKey(oldNode.row, node.row, partitionKey), + "Unexpected change of partition key") + } + let (maybeState, key, constraint) = stateAndConstraint(oldNode.row) + guard let state = maybeState else { return } + guard let bound = state.bound else { preconditionFailure("Bound should be set") } + let compareRows = getSchema().compareRows + let oldCmp = compareRows(oldNode.row, bound) + let newCmp = compareRows(node.row, bound) + + func replaceBoundAndForwardChange() { + setState(key, state.size, node.row) + output?.push(change, pusher: self) + } + + if oldCmp == 0 { + // The bound row changed. + if newCmp == 0 { + output?.push(change, pusher: self) + return + } + if newCmp < 0 { + if limit == 1 { + replaceBoundAndForwardChange() + return + } + // The new row stays in, but might no longer be the bound. + guard + let beforeBoundNode = input.fetch( + FetchRequest( + constraint: constraint, start: Start(row: bound, basis: .after), reverse: true) + ).firstElement + else { preconditionFailure("Take: beforeBoundNode must be found during fetch") } + setState(key, state.size, beforeBoundNode.row) + output?.push(change, pusher: self) + return + } + precondition(newCmp > 0, "New comparison must be greater than 0") + // The first row at the old bound is the new bound. + takeGate?.open() + let newBoundNode = input.fetch( + FetchRequest(constraint: constraint, start: Start(row: bound, basis: .at)) + ).firstElement + takeGate?.close() + guard let newBoundNode else { + preconditionFailure("Take: newBoundNode must be found during fetch") + } + if compareRows(newBoundNode.row, node.row) == 0 { + replaceBoundAndForwardChange() + return + } + // The new row is out of bounds: remove the old row, add the new bound. + setState(key, state.size, newBoundNode.row) + pushWithRowHiddenFromFetch(newBoundNode.row, .remove(oldNode)) + output?.push(.add(newBoundNode), pusher: self) + return + } + + if oldCmp > 0 { + precondition(newCmp != 0, "Invalid state. Row has duplicate primary key") + if newCmp > 0 { return } // both outside + // Old was outside, new is inside: push out the old bound. + var it = input.fetch( + FetchRequest(constraint: constraint, start: Start(row: bound, basis: .at), reverse: true) + ).makeIterator() + guard let oldBoundNode = it.next() else { + preconditionFailure("Take: oldBoundNode must be found during fetch") + } + guard let newBoundNode = it.next() else { + preconditionFailure("Take: newBoundNode must be found during fetch") + } + setState(key, state.size, newBoundNode.row) + pushWithRowHiddenFromFetch(node.row, .remove(oldBoundNode)) + output?.push(.add(node), pusher: self) + return + } + + // oldCmp < 0 + precondition(newCmp != 0, "Invalid state. Row has duplicate primary key") + if newCmp < 0 { + output?.push(change, pusher: self) // both inside + return + } + // Old was inside, new is past the bound: the row after the bound or the + // new row is the new bound. + takeGate?.open() + let afterBoundNode = input.fetch( + FetchRequest(constraint: constraint, start: Start(row: bound, basis: .after)) + ).firstElement + takeGate?.close() + guard let afterBoundNode else { + preconditionFailure("Take: afterBoundNode must be found during fetch") + } + if compareRows(afterBoundNode.row, node.row) == 0 { + replaceBoundAndForwardChange() + return + } + output?.push(.remove(oldNode), pusher: self) + setState(key, state.size, afterBoundNode.row) + output?.push(.add(afterBoundNode), pusher: self) + } + + private func pushWithRowHiddenFromFetch(_ row: Row, _ change: Change) { + rowHiddenFromFetch = row + defer { rowHiddenFromFetch = nil } + output?.push(change, pusher: self) + } + + private func setState(_ key: JSKey, _ size: Int, _ bound: Row?) { + storage[key] = State(size: size, bound: bound) + } + + func reconcile(pusher: any InputBase) { + if !dirtyPartitions.isEmpty { + let dirty = dirtyPartitions + dirtyPartitions.removeAll() + for (key, d) in dirty { + guard let state = storage[key] else { + preconditionFailure("Take: dirty partition must exist in storage") + } + let deficit = limit - state.size + precondition(deficit > 0, "Take: dirty partition must have a deficit") + var toPush: [Node] = [] + takeGate?.open() + do { + var it = input.fetch( + FetchRequest( + constraint: d.constraint, start: state.bound.map { Start(row: $0, basis: .after) }) + ).makeIterator() + while let node = it.next() { + toPush.append(node) + if toPush.count == deficit { break } + } + } + takeGate?.close() + var currentSize = state.size + for node in toPush { + currentSize += 1 + setState(key, currentSize, node.row) + output?.push(.add(node), pusher: self) + } + } + } + output?.reconcile(pusher: self) + } + + func takeStateKey(_ row: Row) -> JSKey { + var values: [JSONValue] = [.string("take")] + if let partitionKey { + for k in partitionKey { values.append(row[k] ?? .null) } + } + return JSKey(JSONSerializer.stringify(.array(values))) + } + + func takeStateKey(_ constraint: Constraint?) -> JSKey { + var values: [JSONValue] = [.string("take")] + if let partitionKey, let constraint { + for k in partitionKey { values.append(constraint[k] ?? .null) } + } + return JSKey(JSONSerializer.stringify(.array(values))) + } +} + +func constraintMatchesPartitionKey(_ constraint: Constraint?, _ partitionKey: [String]?) -> Bool { + guard let constraint, let partitionKey else { return constraint == nil && partitionKey == nil } + if partitionKey.count != constraint.count { return false } + return partitionKey.allSatisfy { constraint.has($0) } +} + +func constraintContainsPartitionKey(_ constraint: Constraint?, _ partitionKey: [String]?) -> Bool { + guard let constraint, let partitionKey else { return false } + return partitionKey.allSatisfy { constraint.has($0) } +} + +/// Sets where the pipeline starts: no row before the bound is output +/// (`skip.ts`). +final class Skip: Operator { + let input: any Input + let bound: AST.Bound + let comparator: Comparator + weak var output: (any Output)? + + init(_ input: any Input, _ bound: AST.Bound) { + let schema = input.getSchema() + precondition(schema.sort != nil, "Skip requires sorted input") + self.input = input + self.bound = bound + comparator = schema.compareRows + input.setOutput(self) + } + + func getSchema() -> SourceSchema { input.getSchema() } + func setOutput(_ output: any Output) { self.output = output } + func destroy() { input.destroy() } + + func fetch(_ req: FetchRequest) -> NodeStream { + NodeStream { [self] in + guard case .start(let start) = getStart(req) else { return AnyIterator { nil } } + var r = req + r.start = start + var it: LazyIterator? = input.fetch(r).makeIterator() + if !req.reverse { return AnyIterator { it?.next() } } + return AnyIterator { + guard let node = it?.next() else { return nil } + if !self.shouldBePresent(node.row) { + it = nil + return nil + } + return node + } + } + } + + func shouldBePresent(_ row: Row) -> Bool { + let cmp = comparator(bound.row, row) + return cmp < 0 || (cmp == 0 && !bound.exclusive) + } + + func push(_ change: Change, pusher: any InputBase) { + if case .edit(let node, let oldNode) = change { + maybeSplitAndPushEditChange(node, oldNode, shouldBePresent, output, self) + return + } + if shouldBePresent(change.node.row) { output?.push(change, pusher: self) } + } + + func reconcile(pusher: any InputBase) { output?.reconcile(pusher: self) } + + enum FetchStart { + case empty + case start(Start?) + } + + private func getStart(_ req: FetchRequest) -> FetchStart { + let boundStart = Start(row: bound.row, basis: bound.exclusive ? .after : .at) + guard let reqStart = req.start else { + return req.reverse ? .start(nil) : .start(boundStart) + } + let cmp = comparator(bound.row, reqStart.row) + if !req.reverse { + // The skip bound is after the requested start. + if cmp > 0 { return .start(boundStart) } + if cmp == 0 { + if bound.exclusive || reqStart.basis == .after { + return .start(Start(row: bound.row, basis: .after)) + } + return .start(boundStart) + } + return .start(reqStart) + } + // Reverse: a bound after the start leaves nothing. + if cmp > 0 { return .empty } + if cmp == 0 { + if !bound.exclusive && reqStart.basis == .at { return .start(boundStart) } + return .empty + } + return .start(reqStart) + } +} + +/// A count-based limit for EXISTS subqueries that needs no order: it tracks +/// membership by primary key (`cap.ts`). +final class Cap: Operator { + struct State { + var size: Int + var pks: [JSKey] + } + + let input: any Input + let limit: Int + let partitionKey: [String]? + let primaryKey: [String] + var storage: [JSKey: State] = [:] + weak var output: (any Output)? + + init(_ input: any Input, limit: Int, partitionKey: [String]? = nil) { + precondition(limit >= 0, "Limit must be non-negative") + self.input = input + self.limit = limit + self.partitionKey = partitionKey + primaryKey = input.getSchema().primaryKey + input.setOutput(self) + } + + func setOutput(_ output: any Output) { self.output = output } + func getSchema() -> SourceSchema { input.getSchema() } + func destroy() { input.destroy() } + func reconcile(pusher: any InputBase) { output?.reconcile(pusher: self) } + + func fetch(_ req: FetchRequest) -> NodeStream { + NodeStream { [self] in + precondition(req.start == nil, "Cap does not support start") + precondition(!req.reverse, "Cap does not support reverse") + precondition( + partitionKey == nil + || (req.constraint != nil && constraintMatchesPartitionKey(req.constraint, partitionKey)), + "Cap fetch: constraint must match partition key when partitioned") + let key = capStateKey(req.constraint) + guard let state = storage[key] else { return initialFetch(req, key) } + if state.size == 0 { return AnyIterator { nil } } + // Point lookups by the tracked primary keys. + var pkIndex = 0 + var current: LazyIterator? + return AnyIterator { + while true { + if let node = current?.next() { return node } + guard pkIndex < state.pks.count else { return nil } + let pkConstraint = self.deserializePK(state.pks[pkIndex]) + pkIndex += 1 + // Keep the partition key so partitioned operators upstream keep + // their scope. + let constraint = req.constraint.map { $0.merging(pkConstraint) } ?? pkConstraint + current = self.input.fetch(FetchRequest(constraint: constraint)).makeIterator() + } + } + } + } + + private func initialFetch(_ req: FetchRequest, _ key: JSKey) -> AnyIterator { + if limit == 0 { return AnyIterator { nil } } + precondition( + constraintMatchesPartitionKey(req.constraint, partitionKey), + "Constraint should match partition key") + var it: LazyIterator? = input.fetch(req).makeIterator() + var size = 0 + var pks: [JSKey] = [] + var pending: Node? + return AnyIterator { [self] in + guard it != nil else { return nil } + if let p = pending { + pending = nil + pks.append(serializePK(p.row)) + size += 1 + if size == limit { + it = nil + storage[key] = State(size: size, pks: pks) + return nil + } + } + guard let node = it!.next() else { + it = nil + storage[key] = State(size: size, pks: pks) + return nil + } + pending = node + return node + } + } + + func push(_ change: Change, pusher: any InputBase) { + if case .edit(let node, let oldNode) = change { + if let partitionKey { + precondition( + rowEqualsForCompoundKey(oldNode.row, node.row, partitionKey), + "Unexpected change of partition key") + } + let key = capStateKey(oldNode.row) + guard let state = storage[key] else { return } + let oldPK = serializePK(oldNode.row) + if state.pks.contains(oldPK) { + let newPK = serializePK(node.row) + if oldPK != newPK { + storage[key] = State(size: state.size, pks: state.pks.map { $0 == oldPK ? newPK : $0 }) + } + output?.push(change, pusher: self) + } + return + } + let key = capStateKey(change.node.row) + guard let state = storage[key] else { return } + let pk = serializePK(change.node.row) + switch change { + case .add: + if state.size < limit { + storage[key] = State(size: state.size + 1, pks: state.pks + [pk]) + output?.push(change, pusher: self) + } + case .remove(let node): + guard let pkIndex = state.pks.firstIndex(of: pk) else { return } + var pks = state.pks + pks.remove(at: pkIndex) + let newSize = state.size - 1 + // Refill with the first row not in the set. + let pkSet = Set(pks) + let constraint = partitionKey.map { Constraint.from(node.row, $0) } + var replacement: Node? + for candidate in input.fetch(FetchRequest(constraint: constraint)) + where !pkSet.contains(serializePK(candidate.row)) { + replacement = candidate + break + } + if let replacement { + // Without the replacement while the remove is forwarded. + storage[key] = State(size: newSize, pks: pks) + output?.push(change, pusher: self) + pks.append(serializePK(replacement.row)) + storage[key] = State(size: newSize + 1, pks: pks) + output?.push(.add(replacement), pusher: self) + } else { + storage[key] = State(size: newSize, pks: pks) + output?.push(change, pusher: self) + } + case .child: + if state.pks.contains(pk) { output?.push(change, pusher: self) } + case .edit: + break + } + } + + func capStateKey(_ row: Row) -> JSKey { + var values: [JSONValue] = [.string("cap")] + if let partitionKey { for k in partitionKey { values.append(row[k] ?? .null) } } + return JSKey(JSONSerializer.stringify(.array(values))) + } + + func capStateKey(_ constraint: Constraint?) -> JSKey { + var values: [JSONValue] = [.string("cap")] + if let partitionKey, let constraint { + for k in partitionKey { values.append(constraint[k] ?? .null) } + } + return JSKey(JSONSerializer.stringify(.array(values))) + } + + func serializePK(_ row: Row) -> JSKey { + JSKey(JSONSerializer.stringify(.array(primaryKey.map { row[$0] ?? .null }))) + } + + func deserializePK(_ pk: JSKey) -> Constraint { + let values = (try? JSONParser.parse(pk.string))?.arrayValue ?? [] + var c = Constraint() + for (i, k) in primaryKey.enumerated() { c.set(k, i < values.count ? values[i] : nil) } + return c + } +} diff --git a/swift/Sources/Zero/IVM/UnionFan.swift b/swift/Sources/Zero/IVM/UnionFan.swift new file mode 100644 index 0000000000..471cdc89e4 --- /dev/null +++ b/swift/Sources/Zero/IVM/UnionFan.swift @@ -0,0 +1,430 @@ +/// Forks a pipeline for an OR with flipped subqueries (`union-fan-out.ts`). +final class UnionFanOut: Operator { + var destroyCount = 0 + weak var unionFanIn: UnionFanIn? + let input: any Input + var outputs: [WeakOutput] = [] + + init(_ input: any Input) { + self.input = input + input.setOutput(self) + } + + func setFanIn(_ fanIn: UnionFanIn) { + precondition(unionFanIn == nil, "FanIn already set for this FanOut") + unionFanIn = fanIn + } + + func push(_ change: Change, pusher: any InputBase) { + guard let fanIn = unionFanIn else { preconditionFailure("UnionFanOut has no fan-in") } + fanIn.fanOutStartedPushing() + for o in outputs { o.value?.push(change, pusher: self) } + fanIn.fanOutDonePushing(change.type) + } + + func reconcile(pusher: any InputBase) { + guard let fanIn = unionFanIn else { preconditionFailure("UnionFanOut has no fan-in") } + fanIn.fanOutStartedReconciling() + for o in outputs { o.value?.reconcile(pusher: self) } + fanIn.fanOutDoneReconciling() + } + + func setOutput(_ output: any Output) { outputs.append(WeakOutput(output)) } + func getSchema() -> SourceSchema { input.getSchema() } + func fetch(_ req: FetchRequest) -> NodeStream { input.fetch(req) } + + func destroy() { + precondition(destroyCount < outputs.count, "FanOut already destroyed once for each output") + destroyCount += 1 + if destroyCount == outputs.count { input.destroy() } + } +} + +/// Merges the branches of a `UnionFanOut` (`union-fan-in.ts`). +final class UnionFanIn: Operator { + let inputs: [any Input] + let schema: SourceSchema + var fanOutPushStarted = false + var fanOutReconcileStarted = false + weak var output: (any Output)? + var accumulatedPushes: [Change] = [] + /// Relationships the branches add (flipped joins' witnesses). + let branchRels: Set + + init(_ fanOut: UnionFanOut, _ inputs: [any Input]) { + self.inputs = inputs + let fanOutSchema = fanOut.getSchema() + precondition(fanOutSchema.sort != nil, "UnionFanIn requires sorted input") + var relationships = fanOutSchema.relationships + var fromBranches: Set = [] + for input in inputs { + let inputSchema = input.getSchema() + precondition( + fanOutSchema.tableName == inputSchema.tableName, + "Table name mismatch in union fan-in: \(fanOutSchema.tableName) !== \(inputSchema.tableName)" + ) + precondition( + fanOutSchema.primaryKey == inputSchema.primaryKey, "Primary key mismatch in union fan-in") + precondition(fanOutSchema.system == inputSchema.system, "System mismatch in union fan-in") + for (name, relSchema) in inputSchema.relationships { + if fanOutSchema.relationship(name) != nil { continue } + precondition( + !fromBranches.contains(name), + "Relationship \(name) exists in multiple upstream inputs to union fan-in") + relationships.append((name, relSchema)) + fromBranches.insert(name) + } + } + schema = fanOutSchema.with(relationships: relationships) + branchRels = fromBranches + fanOut.setFanIn(self) + for input in inputs { input.setOutput(self) } + } + + func destroy() { for i in inputs { i.destroy() } } + func getSchema() -> SourceSchema { schema } + func setOutput(_ output: any Output) { self.output = output } + + func fetch(_ req: FetchRequest) -> NodeStream { + let streams = inputs.map { $0.fetch(req) } + let compareRows = schema.compareRows + let compare: (Node, Node) -> Int = + req.reverse ? { compareRows($1.row, $0.row) } : { compareRows($0.row, $1.row) } + let relNames = schema.relationshipNames + return mergeFetches(streams, compare).mapLazy { node in + var relationships = node.relationships + mergeEmpty(&relationships, relNames) + return Node(row: node.row, relationships: relationships) + } + } + + func push(_ change: Change, pusher: any InputBase) { + if !fanOutPushStarted { + pushInternalChange(change, pusher) + return + } + accumulatedPushes.append(change) + } + + /// A change from inside the fan-out/fan-in sub-graph, which only a + /// flipped join's child produces. An add or remove is forwarded when no + /// other branch has the row. + private func pushInternalChange(_ change: Change, _ pusher: any InputBase) { + if change.type == .child { + output?.push(change, pusher: self) + return + } + precondition( + change.type == .add || change.type == .remove, + "UnionFanIn: expected add or remove change type, got \(change.type.rawValue)") + var hadMatch = false + for input in inputs { + if input === pusher { + hadMatch = true + continue + } + let constraint = Constraint.from(change.node.row, schema.primaryKey) + if input.fetch(FetchRequest(constraint: constraint)).firstElement != nil { + // Another branch keeps the row; it gained or lost this branch's + // witnesses. + guard let output else { return } + pushWitnessDeltas(change.node, change.type, branchRels.contains, output, self) + return + } + } + precondition(hadMatch, "Pusher was not one of the inputs to union-fan-in!") + output?.push(change, pusher: self) + } + + func fanOutStartedPushing() { + precondition( + !fanOutPushStarted, "UnionFanIn: fanOutStartedPushing called while already pushing") + fanOutPushStarted = true + } + + func fanOutDonePushing(_ fanOutChangeType: ChangeType) { + precondition( + fanOutPushStarted, "UnionFanIn: fanOutDonePushing called without fanOutStartedPushing") + fanOutPushStarted = false + if inputs.isEmpty || accumulatedPushes.isEmpty { return } + guard let output else { + accumulatedPushes.removeAll() + return + } + let schema = self.schema + pushAccumulatedChanges( + &accumulatedPushes, output, self, fanOutChangeType, + mergeRelationships: mergeRelationships, + addEmptyRelationships: { makeAddEmptyRelationships(schema, $0) }, + isBranchRel: branchRels.contains) + } + + func fanOutStartedReconciling() { + precondition( + !fanOutReconcileStarted, + "UnionFanIn: fanOutStartedReconciling called while already reconciling") + fanOutReconcileStarted = true + } + + func fanOutDoneReconciling() { + precondition( + fanOutReconcileStarted, + "UnionFanIn: fanOutDoneReconciling called without fanOutStartedReconciling") + fanOutReconcileStarted = false + if inputs.isEmpty { return } + output?.reconcile(pusher: self) + } + + func reconcile(pusher: any InputBase) { + if fanOutReconcileStarted { return } + output?.reconcile(pusher: self) + } +} + +/// `mergeFetches`: merges sorted branch streams, uniting the relationships +/// of a row that several branches yield (the first branch wins a conflict). +func mergeFetches( + _ fetches: [NodeStream], _ comparator: @escaping (Node, Node) -> Int +) + -> NodeStream +{ + NodeStream { + var iterators = fetches.map { $0.makeIterator() } + var current: [Node?] = [] + var initialized = false + var lastNodeYielded: Node? + return AnyIterator { + if !initialized { + initialized = true + for i in 0.. Change, + addEmptyRelationships: (Change) -> Change, + isBranchRel: ((String) -> Bool)? = nil +) { + if accumulatedPushes.isEmpty { return } + // Witness changes of a nested union pass through around this fan-in's + // change: removals before, additions after. + var witnessesLeft: [Change] = [] + var witnessesEntered: [Change] = [] + // One change per type, in the order the types first appeared. + var candidates: [(type: ChangeType, change: Change)] = [] + for change in accumulatedPushes { + if let isBranchRel, case .child(_, let child) = change, isBranchRel(child.relationshipName) { + if child.change.type == .remove { + witnessesLeft.append(change) + } else { + witnessesEntered.append(change) + } + continue + } + if fanOutChangeType == .child && change.type != .child { + precondition( + !candidates.contains { $0.type == change.type }, + "Fan-in:child expected at most one \(change.type.rawValue) when fan-out is of type child") + } + if let i = candidates.firstIndex(where: { $0.type == change.type }) { + candidates[i].change = mergeRelationships(candidates[i].change, change) + } else { + candidates.append((change.type, change)) + } + } + accumulatedPushes.removeAll() + precondition(!candidates.isEmpty, "Fan-in: witness changes without a change for their row") + for change in witnessesLeft { output.push(change, pusher: pusher) } + pushCollapsed( + candidates, output, pusher, fanOutChangeType, mergeRelationships, addEmptyRelationships, + isBranchRel) + for change in witnessesEntered { output.push(change, pusher: pusher) } +} + +private func pushCollapsed( + _ candidates: [(type: ChangeType, change: Change)], + _ output: any Output, + _ pusher: any InputBase, + _ fanOutChangeType: ChangeType, + _ mergeRelationships: (Change, Change) -> Change, + _ addEmptyRelationships: (Change) -> Change, + _ isBranchRel: ((String) -> Bool)? +) { + func get(_ type: ChangeType) -> Change? { candidates.first { $0.type == type }?.change } + let types = candidates.map(\.type) + switch fanOutChangeType { + case .remove: + precondition(types == [.remove], "Fan-in:remove expected all removes") + output.push(addEmptyRelationships(get(.remove)!), pusher: pusher) + case .add: + precondition(types == [.add], "Fan-in:add expected all adds") + output.push(addEmptyRelationships(get(.add)!), pusher: pusher) + case .edit: + precondition( + types.allSatisfy { $0 == .add || $0 == .remove || $0 == .edit }, + "Fan-in:edit expected all adds, removes, or edits") + let addChange = get(.add) + let removeChange = get(.remove) + var editChange = get(.edit) + // The row stays: branches it left take their witnesses, branches it + // entered bring theirs, as child changes around the edit. + if let isBranchRel, editChange != nil || (addChange != nil && removeChange != nil) { + if let removeChange { + pushWitnessDeltas(removeChange.node, .remove, isBranchRel, output, pusher) + } + func strip(_ n: Node) -> Node { + var r = Relationships() + for (k, v) in n.relationships where !isBranchRel(k) { r[k] = v } + return Node(row: n.row, relationships: r) + } + output.push( + addEmptyRelationships( + editChange ?? .edit(strip(addChange!.node), oldNode: strip(removeChange!.node))), + pusher: pusher) + if let addChange { + pushWitnessDeltas(addChange.node, .add, isBranchRel, output, pusher) + } + return + } + // An edit supersedes adds and removes. + if var edit = editChange { + if let addChange { edit = mergeRelationships(edit, addChange) } + if let removeChange { edit = mergeRelationships(edit, removeChange) } + editChange = edit + output.push(addEmptyRelationships(edit), pusher: pusher) + return + } + // A branch turned the edit into a remove and another into an add. + if let addChange, let removeChange { + output.push( + addEmptyRelationships(.edit(addChange.node, oldNode: removeChange.node)), pusher: pusher) + return + } + output.push(addEmptyRelationships((addChange ?? removeChange)!), pusher: pusher) + case .child: + precondition( + types.allSatisfy { $0 == .add || $0 == .remove || $0 == .child }, + "Fan-in:child expected all adds, removes, or children") + precondition( + types.count <= 2, "Fan-in:child expected at most 2 types on a child change from fan-out") + // A branch that kept the child change takes precedence. + if let childChange = get(.child) { + let left = get(.remove) + let entered = get(.add) + if let isBranchRel, let left { + pushWitnessDeltas(left.node, .remove, isBranchRel, output, pusher) + } + output.push(childChange, pusher: pusher) + if let isBranchRel, let entered { + pushWitnessDeltas(entered.node, .add, isBranchRel, output, pusher) + } + return + } + let addChange = get(.add) + let removeChange = get(.remove) + precondition( + addChange == nil || removeChange == nil, + "Fan-in:child expected either add or remove, not both") + output.push(addEmptyRelationships((addChange ?? removeChange)!), pusher: pusher) + } +} + +/// The row stays, but gained (`add`) or lost (`remove`) every witness in +/// its branch relationships: one child change per witness. +func pushWitnessDeltas( + _ node: Node, _ type: ChangeType, _ isBranchRel: (String) -> Bool, _ output: any Output, + _ pusher: any InputBase +) { + for (name, children) in node.relationships where isBranchRel(name) { + for child in children() { + output.push( + .child( + node, + ChildChange(relationshipName: name, change: type == .add ? .add(child) : .remove(child))), + pusher: pusher) + } + } +} + +/// `mergeRelationships`: puts `right`'s relationships into `left` where +/// `left` lacks them. +func mergeRelationships(_ left: Change, _ right: Change) -> Change { + func merged(_ l: Node, _ r: Node) -> Node { + Node(row: l.row, relationships: r.relationships.merging(l.relationships)) + } + if left.type == right.type { + switch (left, right) { + case (.add(let l), .add(let r)): return .add(merged(l, r)) + case (.remove(let l), .remove(let r)): return .remove(merged(l, r)) + case (.edit(let l, let lo), .edit(let r, let ro)): + return .edit(merged(l, r), oldNode: merged(lo, ro)) + case (.child(let l, let child), .child(let r, _)): + return .child(merged(l, r), child) + default: preconditionFailure() + } + } + guard case .edit(let l, let lo) = left else { + preconditionFailure( + "mergeRelationships: when types differ, left.type must be edit, got left.type=\(left.type.rawValue), right.type=\(right.type.rawValue)" + ) + } + switch right { + case .add(let r): return .edit(merged(l, r), oldNode: lo) + case .remove(let r): return .edit(l, oldNode: merged(lo, r)) + default: preconditionFailure("mergeRelationships: unexpected right change") + } +} + +/// `makeAddEmptyRelationships(schema)(change)`. +func makeAddEmptyRelationships(_ schema: SourceSchema, _ change: Change) -> Change { + if schema.relationships.isEmpty { return change } + let names = schema.relationshipNames + func fill(_ n: Node) -> Node { + var r = n.relationships + mergeEmpty(&r, names) + return Node(row: n.row, relationships: r) + } + switch change { + case .add(let n): return .add(fill(n)) + case .remove(let n): return .remove(fill(n)) + case .edit(let n, let o): return .edit(fill(n), oldNode: fill(o)) + case .child: return change // only the path to the change has relationships + } +} + +/// Adds an empty stream for each of `names` that `relationships` lacks. +func mergeEmpty(_ relationships: inout Relationships, _ names: [String]) { + for name in names where relationships[name] == nil { + relationships[name] = { .empty } + } +} diff --git a/swift/Sources/Zero/IVM/View.swift b/swift/Sources/Zero/IVM/View.swift new file mode 100644 index 0000000000..6b03843656 --- /dev/null +++ b/swift/Sources/Zero/IVM/View.swift @@ -0,0 +1,441 @@ +/// The shape of a query result: whether it is a single row, and the +/// formats of its relationships (`packages/zero-types/src/format.ts`). +public struct Format: Sendable, Hashable { + public var singular: Bool + public var relationships: [String: Format] + + public init(singular: Bool = false, relationships: [String: Format] = [:]) { + self.singular = singular + self.relationships = relationships + } +} + +/// A row in a materialized view, with the relationships the query asked +/// for (`Entry` in `view.ts`). +public struct ViewEntry: Sendable { + public var row: Row + public var relationships: [String: ViewValue] + /// How many edges of the relationship reach this row. + var refCount: Int + + init(row: Row, refCount: Int = 1) { + self.row = row + self.relationships = [:] + self.refCount = refCount + } + + /// The row with its relationships, as JS views present it. + public var json: JSONValue { + var o = row + for (name, value) in relationships { + switch value { + case .many(let entries): o[name] = .array(entries.map(\.json)) + case .one(let entry?): o[name] = entry.json + case .one(nil): o[name] = nil + } + } + return .object(o) + } +} + +/// A relationship in a view: several rows, or at most one. +public enum ViewValue: Sendable { + case many([ViewEntry]) + case one(ViewEntry?) + + /// The value as JS views present it; `nil` for a missing singular row. + public var json: JSONValue? { + switch self { + case .many(let entries): .array(entries.map(\.json)) + case .one(let entry): entry?.json + } + } + + public var entries: [ViewEntry] { + switch self { + case .many(let e): e + case .one(let e): e.map { [$0] } ?? [] + } + } +} + +/// Applies changes to a view tree (`view-apply-change.ts`). Swift's value +/// semantics give the JS copy-on-write behavior: unchanged subtrees keep +/// sharing storage with earlier snapshots. +enum ViewApply { + /// Applies `change` to `parent`'s `relationship`. Returns whether the + /// tree changed (JS returns the same entry when it did not). + @discardableResult + static func apply( + _ parent: inout ViewEntry, _ change: Change, _ schema: SourceSchema, _ relationship: String, + _ format: Format + ) -> Bool { + if schema.isHidden { + switch change { + case .add(let node), .remove(let node): + var changed = false + let isAdd = change.type == .add + for (name, stream) in node.relationships { + guard let childSchema = schema.relationship(name) else { + preconditionFailure("missing relationship schema \(name)") + } + for child in stream() { + changed = + apply(&parent, isAdd ? .add(child) : .remove(child), childSchema, name, format) + || changed + } + } + return changed + case .edit: + // A hidden row changed; a change to its relationships would have + // been split into a remove and an add. + return false + case .child(_, let child): + guard let childSchema = schema.relationship(child.relationshipName) else { + preconditionFailure("missing relationship schema \(child.relationshipName)") + } + return apply(&parent, child.change, childSchema, relationship, format) + } + } + + switch change { + case .add(let node): + if format.singular { + if case .one(var old?)? = parent.relationships[relationship] { + precondition( + schema.compareRows(old.row, node.row) == 0, + "Singular relationship '\(relationship)' should not have multiple rows. You may need to declare this relationship with the `many` helper instead of the `one` helper in your schema." + ) + old.refCount += 1 + parent.relationships[relationship] = .one(old) + } else { + var entry = ViewEntry(row: node.row) + initializeRelationships(&entry, node, schema, format.relationships) + parent.relationships[relationship] = .one(entry) + } + } else { + var list = takeList(&parent, relationship) + let pos = search(list, node.row, schema.compareRows) + if pos >= 0 { + list[pos].refCount += 1 + } else { + var entry = ViewEntry(row: node.row) + initializeRelationships(&entry, node, schema, format.relationships) + list.insert(entry, at: ~pos) + } + parent.relationships[relationship] = .many(list) + } + return true + + case .remove(let node): + if format.singular { + guard case .one(var old?)? = parent.relationships[relationship] else { + preconditionFailure("node does not exist") + } + if old.refCount == 1 { + parent.relationships[relationship] = .one(nil) + } else { + old.refCount -= 1 + parent.relationships[relationship] = .one(old) + } + } else { + var list = takeList(&parent, relationship) + let pos = search(list, node.row, schema.compareRows) + precondition(pos >= 0, "node does not exist") + if list[pos].refCount == 1 { + list.remove(at: pos) + } else { + list[pos].refCount -= 1 + } + parent.relationships[relationship] = .many(list) + } + return true + + case .child(let node, let child): + guard let childSchema = schema.relationship(child.relationshipName) else { + preconditionFailure("missing relationship schema \(child.relationshipName)") + } + guard let childFormat = format.relationships[child.relationshipName] else { + return false // not in the view + } + if format.singular { + guard case .one(var existing?)? = parent.relationships.removeValue(forKey: relationship) + else { preconditionFailure("node does not exist") } + let changed = apply( + &existing, child.change, childSchema, child.relationshipName, childFormat) + parent.relationships[relationship] = .one(existing) + return changed + } + var list = takeList(&parent, relationship) + let pos = search(list, node.row, schema.compareRows) + precondition(pos >= 0, "node does not exist") + let changed = apply( + &list[pos], child.change, childSchema, child.relationshipName, childFormat) + parent.relationships[relationship] = .many(list) + return changed + + case .edit(let node, let oldNode): + if format.singular { + guard case .one(var existing?)? = parent.relationships[relationship] else { + preconditionFailure("node does not exist") + } + applyEdit(&existing, node.row) + parent.relationships[relationship] = .one(existing) + return true + } + var list = takeList(&parent, relationship) + defer { parent.relationships[relationship] = .many(list) } + if schema.compareRows(oldNode.row, node.row) != 0 { + // The sort key changed, so the row may move. + let oldPos = search(list, oldNode.row, schema.compareRows) + precondition(oldPos >= 0, "old node does not exist") + let rawPos = search(list, node.row, schema.compareRows) + let found = rawPos >= 0 + let pos = found ? rawPos : ~rawPos + // With one reference, a row landing where it was is only edited. + if list[oldPos].refCount == 1 && (pos == oldPos || pos - 1 == oldPos) { + applyEdit(&list[oldPos], node.row) + return true + } + // Move the row. With several references an edit arrives for each: + // a copy stays behind until the last one moves. + let oldEntry = list[oldPos] + let newRefCount = oldEntry.refCount - 1 + var adjustedPos = pos + if newRefCount == 0 { + list.remove(at: oldPos) + adjustedPos = oldPos < pos ? pos - 1 : pos + } else { + list[oldPos].refCount = newRefCount + } + if found { + let rc = list[adjustedPos].refCount + applyEdit(&list[adjustedPos], node.row) + list[adjustedPos].refCount = rc + 1 + } else { + var moved = oldEntry + applyEdit(&moved, node.row) + moved.refCount = 1 + list.insert(moved, at: adjustedPos) + } + return true + } + let pos = search(list, oldNode.row, schema.compareRows) + precondition(pos >= 0, "node does not exist") + applyEdit(&list[pos], node.row) + return true + } + } + + /// `{...existing, ...row}`. + static func applyEdit(_ entry: inout ViewEntry, _ row: Row) { + entry.row.merge(row) { _, new in new } + } + + /// Removes and returns the list for `relationship`, so that it is mutated + /// in place rather than copied. + static func takeList(_ parent: inout ViewEntry, _ relationship: String) -> [ViewEntry] { + guard case .many(let list)? = parent.relationships.removeValue(forKey: relationship) else { + preconditionFailure("expected relationship array") + } + return list + } + + /// Fills in a new entry's relationships from its node. + static func initializeRelationships( + _ entry: inout ViewEntry, _ node: Node, _ schema: SourceSchema, _ childFormats: [String: Format] + ) { + for (name, stream) in node.relationships { + guard let childSchema = schema.relationship(name) else { + preconditionFailure("missing relationship schema \(name)") + } + guard let childFormat = childFormats[name] else { continue } + if childSchema.isHidden || childFormat.singular { + entry.relationships[name] = childFormat.singular ? .one(nil) : .many([]) + for child in stream() { + apply(&entry, .add(child), childSchema, name, childFormat) + } + } else { + var list: [ViewEntry] = [] + for child in stream() { + let pos = search(list, child.row, childSchema.compareRows) + if pos >= 0 { + list[pos].refCount += 1 + } else { + var e = ViewEntry(row: child.row) + initializeRelationships(&e, child, childSchema, childFormat.relationships) + list.insert(e, at: ~pos) + } + } + entry.relationships[name] = .many(list) + } + } + } + + /// The index of `target`, or `~insertionPoint`. Probes the end first: + /// hydration appends in order. + static func search(_ view: [ViewEntry], _ target: Row, _ comparator: Comparator) -> Int { + var high = view.count - 1 + if high < 0 { return ~0 } + let last = comparator(view[high].row, target) + if last < 0 { return ~(high + 1) } + if last == 0 { return high } + var low = 0 + high -= 1 + while low <= high { + let mid = (low + high) >> 1 + let c = comparator(view[mid].row, target) + if c < 0 { + low = mid + 1 + } else if c > 0 { + high = mid - 1 + } else { + return mid + } + } + return ~low + } +} + +/// The result type of a view (`ResultType` in `typed-view.ts`). +public enum ResultType: String, Sendable, Hashable { + case unknown, cached, complete, error +} + +/// A materialized view of a pipeline's output (`array-view.ts`). +/// +/// Listeners are notified on `flush`, which the engine calls when a +/// transaction commits. +public final class ArrayView: Output { + public typealias Listener = (ViewValue, ResultType) -> Void + + let input: any Input + let format: Format + private var schema: SourceSchema? + private var root: ViewEntry + private var listeners: [(id: Int, listener: Listener)] = [] + private var nextListenerID = 0 + private var dirty = false + private var committedRoot: ViewEntry? + private var held = false + private var heldResultType: ResultType = .unknown + public private(set) var resultType: ResultType = .unknown + public var onDestroy: (() -> Void)? + + /// Hydrates from `input`. `complete` marks the result complete from the + /// start. + public init(_ input: any Input, format: Format, complete: Bool = false) { + self.input = input + self.format = format + root = ViewEntry(row: [:]) + root.relationships[""] = format.singular ? .one(nil) : .many([]) + if complete { resultType = .complete } + input.setOutput(self) + hydrate() + } + + /// The current rows. + public var data: ViewValue { + (committedRoot ?? root).relationships[""]! + } + + private func getSchema() -> SourceSchema { + if let schema { return schema } + let s = input.getSchema() + schema = s + return s + } + + /// Adds a listener, calls it with the current data, and returns a + /// function that removes it. + @discardableResult + public func addListener(_ listener: @escaping Listener) -> () -> Void { + let id = nextListenerID + nextListenerID += 1 + listeners.append((id, listener)) + let (data, type) = listenerArgs() + listener(data, type) + return { [weak self] in self?.listeners.removeAll { $0.id == id } } + } + + private func listenerArgs() -> (ViewValue, ResultType) { + if committedRoot != nil { return (data, heldResultType) } + return (data, resultType) + } + + private func fireListeners() { + let (data, type) = listenerArgs() + for (_, listener) in listeners { listener(data, type) } + } + + public func destroy() { onDestroy?() } + + private func hydrate() { + dirty = true + let schema = getSchema() + for node in input.fetch(FetchRequest()) { + ViewApply.apply(&root, .add(node), schema, "", format) + } + flush() + } + + public func push(_ change: Change, pusher: any InputBase) { + if ViewApply.apply(&root, change, getSchema(), "", format) { + dirty = true + } + } + + /// Keeps exposing the current snapshot until `releaseData` or the next + /// `flush`. + public func holdData() { + committedRoot = root + held = true + heldResultType = resultType + } + + public func releaseData() { committedRoot = nil } + + /// Notifies listeners if the view changed since the last flush. + public func flush() { + committedRoot = nil + held = false + if !dirty { return } + dirty = false + fireListeners() + } + + public func setResultType(_ type: ResultType) { + resultType = type + if held { + dirty = true + } else if !dirty { + fireListeners() + } + } + + /// The server-confirmed result of an earlier sync is in the store. + public func markCached() { + if resultType == .unknown { setResultType(.cached) } + } + + public func unmarkCached() { + if resultType == .cached { setResultType(.unknown) } + } +} + +extension Format: JSONCodable { + public init(json: JSONValue) throws(DecodingError) { + let o = try JSONObjectReader(json) + let relationships = try o.optional("relationships") { (v) throws(DecodingError) in + try JSONDecode.record(v) { (f) throws(DecodingError) in try Format(json: f) } + } + self.init( + singular: try o.optionalBool("singular") ?? false, relationships: relationships ?? [:]) + } + + public var json: JSONValue { + var rels: [String: JSONValue] = [:] + for (k, v) in relationships { rels[k] = v.json } + return ["singular": .bool(singular), "relationships": .object(rels)] + } +} diff --git a/swift/Sources/Zero/Query/AnyQuery.swift b/swift/Sources/Zero/Query/AnyQuery.swift new file mode 100644 index 0000000000..e25a3fd475 --- /dev/null +++ b/swift/Sources/Zero/Query/AnyQuery.swift @@ -0,0 +1,403 @@ +/// The prefix of the aliases the builder gives `exists` subqueries +/// (`SUBQ_PREFIX` in `packages/zero-protocol/src/ast.ts`). +public let subqueryAliasPrefix = "zsubq_" + +/// The name and arguments of the named query a query was built by. +public struct CustomQueryID: Sendable, Hashable { + public var name: String + public var args: [JSONValue] + + public init(name: String, args: [JSONValue]) { + self.name = name + self.args = args + } +} + +/// A value taken from the context a query runs in, such as the auth data +/// (`staticParam` in `packages/zql/src/query/static-param.ts`). +public struct StaticParameter: Sendable, Hashable { + public var anchor: AST.Anchor + public var field: AST.StaticField + + public init(_ anchor: AST.Anchor, _ field: String) { + self.anchor = anchor + self.field = .single(field) + } + + public init(_ anchor: AST.Anchor, path: [String]) { + self.anchor = anchor + self.field = .path(path) + } + + var position: AST.ValuePosition { .static(anchor: anchor, field: field) } +} + +/// The untyped query builder (`QueryImpl` in +/// `packages/zql/src/query/query-impl.ts`). Fields and relationships are +/// named by string; the typed builder wraps this one. +/// +/// The AST is always normalized: each step builds its part of the AST in +/// normalized form. Misuse that JS reports by throwing, such as an unknown +/// relationship or a `limit` inside a junction relationship, is a +/// programming error here and stops the program. +public struct AnyQuery: Sendable { + public let schema: ZeroSchema + public let table: String + public let ast: AST + public let format: Format + public let system: AST.System + public let customQueryID: CustomQueryID? + /// The two-hop relationship this query is the destination of, if any. + let currentJunction: String? + + public init(_ schema: ZeroSchema, _ table: String, system: AST.System = .client) { + self.init( + schema: schema, table: table, ast: AST(table: table), format: Format(), system: system, + customQueryID: nil, currentJunction: nil) + } + + init( + schema: ZeroSchema, table: String, ast: AST, format: Format, system: AST.System, + customQueryID: CustomQueryID?, currentJunction: String? + ) { + self.schema = schema + self.table = table + self.ast = ast + self.format = format + self.system = system + self.customQueryID = customQueryID + self.currentJunction = currentJunction + } + + private func with(ast: AST? = nil, format: Format? = nil) -> AnyQuery { + AnyQuery( + schema: schema, table: table, ast: ast ?? self.ast, format: format ?? self.format, + system: system, customQueryID: customQueryID, currentJunction: currentJunction) + } + + private func base(_ table: String, alias: String, format: Format, junction: String?) -> AnyQuery { + AnyQuery( + schema: schema, table: table, ast: AST(table: table, alias: alias), format: format, + system: system, customQueryID: customQueryID, currentJunction: junction) + } + + /// The query's hash as the client and server know it. + public var hash: String { + QueryHash.hashOfQueryInternals( + ast: ast, format: format.json, system: system, currentJunction: currentJunction, + customQueryName: customQueryID?.name, customQueryArgs: customQueryID?.args) + } + + public func nameAndArgs(_ name: String, _ args: [JSONValue]) -> AnyQuery { + AnyQuery( + schema: schema, table: table, ast: ast, format: format, system: system, + customQueryID: CustomQueryID(name: name, args: args), currentJunction: currentJunction) + } + + /// At most one row; the view holds the row or nothing rather than a list. + public func one() -> AnyQuery { + var ast = self.ast + ast.limit = 1 + var format = self.format + format.singular = true + return with(ast: ast, format: format) + } + + public func `where`(_ field: String, _ op: AST.SimpleOperator, _ value: JSONValue) -> AnyQuery { + `where`(cmpCondition(field, op, .literal(value))) + } + + public func `where`(_ field: String, _ value: JSONValue) -> AnyQuery { + `where`(field, .eq, value) + } + + public func `where`( + _ field: String, _ op: AST.SimpleOperator, _ param: StaticParameter + ) + -> AnyQuery + { + `where`(cmpCondition(field, op, param.position)) + } + + public func `where`(_ factory: (ExpressionBuilder) -> AST.Condition) -> AnyQuery { + `where`(factory(ExpressionBuilder(self))) + } + + public func `where`(_ condition: AST.Condition) -> AnyQuery { + let merged = ast.where.map { andConditions([$0, condition]) } ?? condition + var ast = self.ast + ast.where = AST.normalizeCondition(simplifyCondition(merged)) + return with(ast: ast) + } + + public func whereExists( + _ relationship: String, flip: Bool? = nil, scalar: Bool? = nil, + _ sub: ((AnyQuery) -> AnyQuery)? = nil + ) -> AnyQuery { + `where` { $0.exists(relationship, flip: flip, scalar: scalar, sub) } + } + + public func start(_ row: Row, inclusive: Bool = false) -> AnyQuery { + var ast = self.ast + ast.start = AST.Bound(row: row, exclusive: !inclusive) + return with(ast: ast) + } + + public func limit(_ limit: Int) -> AnyQuery { + precondition(limit >= 0, "Limit must be non-negative") + precondition(Int32(exactly: limit) != nil, "Limit must be an integer") + if let currentJunction { + preconditionFailure( + "Limit is not supported in junction relationships yet. Junction relationship being limited: \(currentJunction)" + ) + } + var ast = self.ast + ast.limit = Double(limit) + return with(ast: ast) + } + + public func orderBy(_ field: String, _ direction: AST.Direction) -> AnyQuery { + if let currentJunction { + preconditionFailure( + "Order by is not supported in junction relationships yet. Junction relationship being ordered: \(currentJunction)" + ) + } + var ast = self.ast + ast.orderBy = (ast.orderBy ?? []) + [AST.OrderPart(field, direction)] + return with(ast: ast) + } + + public func related(_ relationship: String, _ sub: ((AnyQuery) -> AnyQuery)? = nil) -> AnyQuery { + precondition( + !relationship.hasPrefix(subqueryAliasPrefix), + "Relationship names may not start with \"\(subqueryAliasPrefix)\". That is a reserved prefix." + ) + let sub = sub ?? { $0 } + let hops = relationshipHops(relationship) + var ast = self.ast + var format = self.format + switch hops.count { + case 1: + let hop = hops[0] + let q = sub( + base( + hop.destSchema, alias: relationship, + format: Format(singular: hop.cardinality == .one), junction: nil)) + ast.related = insertRelated( + ast.related, + AST.CorrelatedSubquery( + correlation: correlation(hop), subquery: q.ast, system: system)) + format.relationships[relationship] = q.format + case 2: + let (first, second) = (hops[0], hops[1]) + let q = sub( + base( + second.destSchema, alias: relationship, + format: Format(singular: second.cardinality == .one), junction: relationship)) + let junction = AST( + table: first.destSchema, alias: relationship, + related: [ + AST.CorrelatedSubquery( + correlation: correlation(second), subquery: q.ast, system: system) + ]) + ast.related = insertRelated( + ast.related, + AST.CorrelatedSubquery( + correlation: correlation(first), subquery: junction, system: system, hidden: true)) + format.relationships[relationship] = q.format + default: + preconditionFailure("Invalid relationship \(relationship)") + } + return with(ast: ast, format: format) + } + + /// `#exists`: the condition `exists(relationship)` adds. + func existsCondition( + _ relationship: String, flip: Bool?, scalar: Bool?, _ sub: ((AnyQuery) -> AnyQuery)? + ) -> AST.Condition { + let sub = sub ?? { $0 } + let hops = relationshipHops(relationship) + switch hops.count { + case 1: + let hop = hops[0] + let q = sub( + base( + hop.destSchema, alias: subqueryAliasPrefix + relationship, format: Format(), + junction: nil)) + return .correlatedSubquery( + related: AST.CorrelatedSubquery( + correlation: correlation(hop), subquery: q.ast, system: system), + op: .exists, flip: flip, scalar: scalar) + case 2: + let (first, second) = (hops[0], hops[1]) + let q = sub( + base( + second.destSchema, alias: "\(subqueryAliasPrefix)zhidden_\(relationship)", + format: Format(), junction: relationship)) + let inner = AST.Condition.correlatedSubquery( + related: AST.CorrelatedSubquery( + correlation: correlation(second), subquery: q.ast, system: system), + op: .exists, flip: flip, scalar: scalar) + let junction = AST( + table: first.destSchema, alias: subqueryAliasPrefix + relationship, + where: AST.normalizeCondition(inner)) + // The junction hop carries `flip` but not `scalar`, as in JS. + return .correlatedSubquery( + related: AST.CorrelatedSubquery( + correlation: correlation(first), subquery: junction, system: system), + op: .exists, flip: flip, scalar: nil) + default: + preconditionFailure("Invalid relationship \(relationship)") + } + } + + private func relationshipHops(_ relationship: String) -> Relationship { + guard let hops = schema.relationship(table, relationship) else { + preconditionFailure("Invalid relationship \(table).\(relationship)") + } + for hop in hops { + precondition( + !hop.sourceField.isEmpty && hop.sourceField.count == hop.destField.count, + "Invalid relationship \(table).\(relationship)") + } + return hops + } + + private func correlation(_ hop: RelationshipHop) -> AST.Correlation { + AST.Correlation(parentField: hop.sourceField, childField: hop.destField) + } +} + +/// `insertRelated`: adds `subquery` to a normalized `related`, after the +/// entries it compares equal to. +func insertRelated( + _ related: [AST.CorrelatedSubquery]?, _ subquery: AST.CorrelatedSubquery +) + -> [AST.CorrelatedSubquery] +{ + guard var related else { return [subquery] } + var i = related.count + while i > 0 && AST.cmpRelated(related[i - 1], subquery) > 0 { i -= 1 } + related.insert(subquery, at: i) + return related +} + +/// Builds the conditions of a `where` (`ExpressionBuilder` in +/// `packages/zql/src/query/expression.ts`). +public struct ExpressionBuilder: Sendable { + let query: AnyQuery + + init(_ query: AnyQuery) { self.query = query } + + public func cmp(_ field: String, _ op: AST.SimpleOperator, _ value: JSONValue) -> AST.Condition { + cmpCondition(field, op, .literal(value)) + } + + public func cmp(_ field: String, _ value: JSONValue) -> AST.Condition { + cmpCondition(field, .eq, .literal(value)) + } + + public func cmp( + _ field: String, _ op: AST.SimpleOperator, _ param: StaticParameter + ) + -> AST.Condition + { + cmpCondition(field, op, param.position) + } + + /// Compares two literals or parameters, neither of them a column. + public func cmpLit( + _ left: JSONValue, _ op: AST.SimpleOperator, _ right: JSONValue + ) + -> AST.Condition + { + .simple(op: op, left: .literal(left), right: .literal(right)) + } + + public func cmpLit( + _ left: StaticParameter, _ op: AST.SimpleOperator, _ right: JSONValue + ) + -> AST.Condition + { + .simple(op: op, left: left.position, right: .literal(right)) + } + + public func and(_ conditions: AST.Condition?...) -> AST.Condition { + andConditions(conditions.compactMap { $0 }) + } + + public func and(_ conditions: [AST.Condition]) -> AST.Condition { andConditions(conditions) } + + public func or(_ conditions: AST.Condition?...) -> AST.Condition { + orConditions(conditions.compactMap { $0 }) + } + + public func or(_ conditions: [AST.Condition]) -> AST.Condition { orConditions(conditions) } + + public func not(_ condition: AST.Condition) -> AST.Condition { negateCondition(condition) } + + public func exists( + _ relationship: String, flip: Bool? = nil, scalar: Bool? = nil, + _ sub: ((AnyQuery) -> AnyQuery)? = nil + ) -> AST.Condition { + query.existsCondition(relationship, flip: flip, scalar: scalar, sub) + } +} + +func cmpCondition( + _ field: String, _ op: AST.SimpleOperator, _ right: AST.ValuePosition +) + -> AST.Condition +{ + .simple(op: op, left: .column(field), right: right) +} + +/// `and(...)`: drops the always-true conditions; one left is itself. +func andConditions(_ conditions: [AST.Condition]) -> AST.Condition { + let expressions = conditions.filter { !$0.isAlwaysTrue } + if expressions.count == 1 { return expressions[0] } + if expressions.contains(where: \.isAlwaysFalse) { return .falseCondition } + return .and(expressions) +} + +/// `or(...)`: drops the always-false conditions; one left is itself. +func orConditions(_ conditions: [AST.Condition]) -> AST.Condition { + let expressions = conditions.filter { !$0.isAlwaysFalse } + if expressions.count == 1 { return expressions[0] } + if expressions.contains(where: \.isAlwaysTrue) { return .trueCondition } + return .or(expressions) +} + +/// `not`: pushes the negation down to the leaves (De Morgan). +func negateCondition(_ condition: AST.Condition) -> AST.Condition { + switch condition { + case .and(let c): .or(c.map(negateCondition)) + case .or(let c): .and(c.map(negateCondition)) + case .correlatedSubquery(let related, let op, let flip, let scalar): + .correlatedSubquery( + related: related, op: op == .exists ? .notExists : .exists, flip: flip, scalar: scalar) + case .simple(let op, let left, let right): + .simple(op: op.negated, left: left, right: right) + } +} + +extension AST.SimpleOperator { + var negated: AST.SimpleOperator { + switch self { + case .eq: .ne + case .ne: .eq + case .lt: .ge + case .gt: .le + case .ge: .lt + case .le: .gt + case .in: .notIn + case .notIn: .in + case .like: .notLike + case .notLike: .like + case .ilike: .notIlike + case .notIlike: .ilike + case .is: .isNot + case .isNot: .is + } + } +} diff --git a/swift/Sources/Zero/Query/Predicate.swift b/swift/Sources/Zero/Query/Predicate.swift new file mode 100644 index 0000000000..9d9794ba38 --- /dev/null +++ b/swift/Sources/Zero/Query/Predicate.swift @@ -0,0 +1,218 @@ +/// Row predicates from conditions (`packages/zql/src/builder/filter.ts`). +public typealias RowPredicate = (Row) -> Bool + +/// An error building a pipeline, where JS throws. +public struct PipelineError: Error, CustomStringConvertible { + public let description: String + public init(_ description: String) { self.description = description } +} + +/// `createPredicate(condition)` for a condition without subqueries. +func createPredicate(_ condition: AST.Condition) throws(PipelineError) -> RowPredicate { + switch condition { + case .and(let conditions): + var predicates: [RowPredicate] = [] + for c in conditions { predicates.append(try createPredicate(c)) } + return { row in + for p in predicates where !p(row) { return false } + return true + } + case .or(let conditions): + var predicates: [RowPredicate] = [] + for c in conditions { predicates.append(try createPredicate(c)) } + return { row in + for p in predicates where p(row) { return true } + return false + } + case .correlatedSubquery: + throw PipelineError("createPredicate: unexpected correlated subquery") + case .simple(let op, let left, let right): + guard case .literal(let rhs) = right else { + if case .static = right { + throw PipelineError("static values should be resolved before creating predicates") + } + throw PipelineError("createPredicate: right side must be a literal") + } + if case .static = left { + throw PipelineError("static values should be resolved before creating predicates") + } + switch op { + case .is, .isNot: + let isNot = op == .isNot + let impl: (Value?) -> Bool = { lhs in + ValueCompare.identical(lhs, rhs) != isNot + } + if case .literal(let l) = left { + let result = impl(l) + return { _ in result } + } + guard case .column(let name) = left else { preconditionFailure() } + return { row in impl(row[name]) } + default: + break + } + if rhs == .null { return { _ in false } } + let impl = try createPredicateImpl(rhs, op) + if case .literal(let l) = left { + if l == .null { return { _ in false } } + let result = impl(l) + return { _ in result } + } + guard case .column(let name) = left else { preconditionFailure() } + return { row in + guard let lhs = row[name], lhs != .null else { return false } + return impl(lhs) + } + } +} + +/// `createPredicateImpl`: `lhs` and `rhs` are not null. +func createPredicateImpl( + _ rhs: Value, _ op: AST.SimpleOperator +) throws(PipelineError) -> (Value) -> Bool { + switch op { + case .eq: return { ValueCompare.identical($0, rhs) } + case .ne: return { !ValueCompare.identical($0, rhs) } + // compareValues orders strings by UTF-8, matching ORDER BY and SQLite. + case .lt: return { ValueCompare.compare($0, rhs) < 0 } + case .le: return { ValueCompare.compare($0, rhs) <= 0 } + case .gt: return { ValueCompare.compare($0, rhs) > 0 } + case .ge: return { ValueCompare.compare($0, rhs) >= 0 } + case .like: return try likePredicate(rhs, caseInsensitive: false) + case .notLike: + let p = try likePredicate(rhs, caseInsensitive: false) + return { !p($0) } + case .ilike: return try likePredicate(rhs, caseInsensitive: true) + case .notIlike: + let p = try likePredicate(rhs, caseInsensitive: true) + return { !p($0) } + case .in, .notIn: + guard case .array(let values) = rhs else { + throw PipelineError("Expected rhs to be an array for \(op.rawValue) operator") + } + let isIn = op == .in + return { lhs in sameValueZeroContains(values, lhs) == isIn } + case .is, .isNot: + preconditionFailure("handled by createPredicate") + } +} + +/// `new Set(values).has(lhs)`: SameValueZero, arrays and objects by +/// reference (so never). +func sameValueZeroContains(_ values: [Value], _ lhs: Value) -> Bool { + switch lhs { + case .array, .object: return false + case .number(let n) where n.isNaN: + return values.contains { if case .number(let m) = $0 { m.isNaN } else { false } } + default: + return values.contains { ValueCompare.identical($0, lhs) } + } +} + +/// `getLikePredicate(pattern, flags)`. JS converts the pattern with +/// `String()` and throws on a non-string left side; Swift treats that as no +/// match. +func likePredicate(_ pattern: Value, caseInsensitive: Bool) throws(PipelineError) -> (Value) -> Bool +{ + let source: String + switch pattern { + case .string(let s): source = s + case .number(let n): source = JSNumber.format(n) + case .bool(let b): source = b ? "true" : "false" + case .null: source = "null" + case .array, .object: source = JSString.from(pattern) + } + let like: LikePattern + do { + like = try LikePattern(source, caseInsensitive: caseInsensitive) + } catch { + throw PipelineError(error.description) + } + return { lhs in + guard case .string(let s) = lhs else { return false } + return like.matches(s) + } +} + +/// JS `String(value)` for JSON values. +enum JSString { + static func from(_ v: Value) -> String { + switch v { + case .null: "null" + case .bool(let b): b ? "true" : "false" + case .number(let n): JSNumber.format(n) + case .string(let s): s + case .array(let a): a.map { e in e == .null ? "" : from(e) }.joined(separator: ",") + case .object: "[object Object]" + } + } +} + +/// `transformFilters`: a condition without subqueries that matches a +/// superset of the rows `filters` matches, and whether anything was removed. +func transformFilters( + _ filters: AST.Condition? +) -> (filters: AST.Condition?, conditionsRemoved: Bool) { + guard let filters else { return (nil, false) } + switch filters { + case .simple: + return (filters, false) + case .correlatedSubquery: + return (nil, true) + case .and(let conditions), .or(let conditions): + let isOr = if case .or = filters { true } else { false } + var transformed: [AST.Condition] = [] + var removed = false + for c in conditions { + let t = transformFilters(c) + // If any branch of an OR ends up empty, the whole OR goes. + if t.filters == nil && isOr { return (nil, true) } + removed = removed || t.conditionsRemoved + if let f = t.filters { transformed.append(f) } + } + return (simplifyCondition(isOr ? .or(transformed) : .and(transformed)), removed) + } +} + +/// `simplifyCondition` (`packages/zql/src/query/expression.ts`). +public func simplifyCondition(_ c: AST.Condition) -> AST.Condition { + switch c { + case .simple, .correlatedSubquery: + return c + case .and(let conditions), .or(let conditions): + let isAnd = if case .and = c { true } else { false } + if conditions.count == 1 { return simplifyCondition(conditions[0]) } + let flattened = flatten(isAnd, conditions.map(simplifyCondition)) + if isAnd && flattened.contains(where: \.isAlwaysFalse) { return .falseCondition } + if !isAnd && flattened.contains(where: \.isAlwaysTrue) { return .trueCondition } + return isAnd ? .and(flattened) : .or(flattened) + } +} + +/// `flatten(type, conditions)`: inlines children of the same type. +func flatten(_ isAnd: Bool, _ conditions: [AST.Condition]) -> [AST.Condition] { + var out: [AST.Condition] = [] + for c in conditions { + switch c { + case .and(let inner) where isAnd: out.append(contentsOf: inner) + case .or(let inner) where !isAnd: out.append(contentsOf: inner) + default: out.append(c) + } + } + return out +} + +extension AST.Condition { + /// `TRUE`: an empty AND. + public static var trueCondition: AST.Condition { .and([]) } + /// `FALSE`: an empty OR. + public static var falseCondition: AST.Condition { .or([]) } + + var isAlwaysTrue: Bool { + if case .and(let c) = self { c.isEmpty } else { false } + } + + var isAlwaysFalse: Bool { + if case .or(let c) = self { c.isEmpty } else { false } + } +} diff --git a/swift/Sources/Zero/Schema/Schema.swift b/swift/Sources/Zero/Schema/Schema.swift new file mode 100644 index 0000000000..b40f3c3ed9 --- /dev/null +++ b/swift/Sources/Zero/Schema/Schema.swift @@ -0,0 +1,224 @@ +/// The runtime description of a Zero schema (`packages/zero-types/src/schema.ts`). +/// +/// `zero-swift codegen` emits one from the app's `schema.ts`; tests decode +/// them from the conformance fixtures. +public struct ZeroSchema: Sendable, Hashable { + public var tables: [String: TableSchema] + /// Relationships by table, then by name. + public var relationships: [String: [String: Relationship]] + /// Table names in declaration order. + public var tableNames: [String] + + public init(tables: [TableSchema], relationships: [String: [String: Relationship]] = [:]) { + var byName: [String: TableSchema] = [:] + for table in tables { byName[table.name] = table } + self.tables = byName + self.tableNames = tables.map(\.name) + self.relationships = relationships + } + + public func table(_ name: String) -> TableSchema? { tables[name] } + + public func relationship(_ table: String, _ name: String) -> Relationship? { + relationships[table]?[name] + } +} + +/// The type of a column value (`ValueType` in `schema-value.ts`). +public enum ValueType: String, Sendable, Hashable, CaseIterable { + case string, number, boolean, null, json +} + +/// A column (`SchemaValue`). +public struct ColumnSchema: Sendable, Hashable { + public var type: ValueType + public var optional: Bool + public var serverName: String? + + public init(_ type: ValueType, optional: Bool = false, serverName: String? = nil) { + self.type = type + self.optional = optional + self.serverName = serverName + } +} + +/// A table (`TableSchema` in `schema.ts`). +public struct TableSchema: Sendable, Hashable { + public var name: String + public var serverName: String? + /// Columns by name. + public var columns: [String: ColumnSchema] + /// Column names in declaration order. + public var columnNames: [String] + public var primaryKey: [String] + public var uniqueKeys: [[String]]? + + public init( + name: String, + serverName: String? = nil, + columns: [(String, ColumnSchema)], + primaryKey: [String], + uniqueKeys: [[String]]? = nil + ) { + self.name = name + self.serverName = serverName + var byName: [String: ColumnSchema] = [:] + for (column, schema) in columns { byName[column] = schema } + self.columns = byName + self.columnNames = columns.map(\.0) + self.primaryKey = primaryKey + self.uniqueKeys = uniqueKeys + } +} + +public enum Cardinality: String, Sendable, Hashable { + case one, many +} + +/// One hop of a relationship (`Connection` in `schema.ts`). +public struct RelationshipHop: Sendable, Hashable { + public var sourceField: [String] + public var destField: [String] + public var destSchema: String + public var cardinality: Cardinality + + public init( + sourceField: [String], destField: [String], destSchema: String, cardinality: Cardinality + ) { + self.sourceField = sourceField + self.destField = destField + self.destSchema = destSchema + self.cardinality = cardinality + } +} + +/// A relationship: one hop, or two through a junction table. +public typealias Relationship = [RelationshipHop] + +extension ZeroSchema: JSONCodable { + public init(json: JSONValue) throws(DecodingError) { + let o = try JSONObjectReader(json) + let tablesJSON = try o.required("tables", JSONDecode.object) + var tables: [TableSchema] = [] + for key in tablesJSON.keys.sorted(by: StringCompare.utf16Less) { + tables.append(try TableSchema(json: tablesJSON[key]!).named(key)) + } + var relationships: [String: [String: Relationship]] = [:] + if let rels = try o.optional("relationships", JSONDecode.object) { + for (table, value) in rels { + var byName: [String: Relationship] = [:] + for (name, hops) in try JSONDecode.object(value) { + byName[name] = try JSONDecode.array(hops) { (h) throws(DecodingError) in + try RelationshipHop(json: h) + } + } + relationships[table] = byName + } + } + self.init(tables: tables, relationships: relationships) + } + + public var json: JSONValue { + var tables: [String: JSONValue] = [:] + for (name, table) in self.tables { tables[name] = table.json } + var rels: [String: JSONValue] = [:] + for (table, byName) in relationships { + var o: [String: JSONValue] = [:] + for (name, hops) in byName { o[name] = .array(hops.map(\.json)) } + rels[table] = .object(o) + } + return ["tables": .object(tables), "relationships": .object(rels)] + } +} + +extension TableSchema: JSONCodable { + fileprivate func named(_ key: String) -> TableSchema { + var copy = self + if copy.name.isEmpty { copy.name = key } + return copy + } + + /// Decodes a table. JSON objects lose their key order in `JSONValue`, so + /// columns are ordered by an optional `columnOrder` array, else by name. + public init(json: JSONValue) throws(DecodingError) { + let o = try JSONObjectReader(json) + let name = try o.optionalString("name") ?? "" + let serverName = try o.optionalString("serverName") + let columnsJSON = try o.required("columns", JSONDecode.object) + let order = + try o.optional("columnOrder") { (v) throws(DecodingError) in + try JSONDecode.array(v, JSONDecode.string) + } ?? columnsJSON.keys.sorted(by: StringCompare.utf16Less) + var columns: [(String, ColumnSchema)] = [] + for column in order { + guard let value = columnsJSON[column] else { continue } + let c = try JSONObjectReader(value) + let typeName = try c.string("type") + guard let type = ValueType(rawValue: typeName) else { + throw DecodingError("Unknown type \(typeName)", path: ["columns", column, "type"]) + } + columns.append( + ( + column, + ColumnSchema( + type, optional: try c.optional("optional", JSONDecode.bool) ?? false, + serverName: try c.optionalString("serverName")) + )) + } + let primaryKey = try o.required("primaryKey") { (v) throws(DecodingError) in + try JSONDecode.array(v, JSONDecode.string) + } + let uniqueKeys = try o.optional("uniqueKeys") { (v) throws(DecodingError) in + try JSONDecode.array(v) { (k) throws(DecodingError) in + try JSONDecode.array(k, JSONDecode.string) + } + } + self.init( + name: name, serverName: serverName, columns: columns, primaryKey: primaryKey, + uniqueKeys: uniqueKeys) + } + + public var json: JSONValue { + var columns: [String: JSONValue] = [:] + for (name, column) in self.columns { + var c: [String: JSONValue] = ["type": .string(column.type.rawValue)] + if column.optional { c["optional"] = true } + if let serverName = column.serverName { c["serverName"] = .string(serverName) } + columns[name] = .object(c) + } + var o: [String: JSONValue] = [ + "name": .string(name), + "columns": .object(columns), + "columnOrder": columnNames.json, + "primaryKey": primaryKey.json, + ] + if let serverName { o["serverName"] = .string(serverName) } + if let uniqueKeys { o["uniqueKeys"] = .array(uniqueKeys.map(\.json)) } + return .object(o) + } +} + +extension RelationshipHop: JSONCodable { + public init(json: JSONValue) throws(DecodingError) { + let o = try JSONObjectReader(json) + let cardinality = try o.string("cardinality") + self.init( + sourceField: try o.required("sourceField") { (v) throws(DecodingError) in + try JSONDecode.array(v, JSONDecode.string) + }, + destField: try o.required("destField") { (v) throws(DecodingError) in + try JSONDecode.array(v, JSONDecode.string) + }, + destSchema: try o.string("destSchema"), + cardinality: cardinality == "one" ? .one : .many) + } + + public var json: JSONValue { + [ + "sourceField": sourceField.json, + "destField": destField.json, + "destSchema": .string(destSchema), + "cardinality": .string(cardinality.rawValue), + ] + } +} diff --git a/swift/Sources/Zero/Util/BTree.swift b/swift/Sources/Zero/Util/BTree.swift new file mode 100644 index 0000000000..a79429757b --- /dev/null +++ b/swift/Sources/Zero/Util/BTree.swift @@ -0,0 +1,316 @@ +/// A persistent B+ tree set (`packages/shared/src/btree-set.ts`). +/// +/// Nodes are shared between copies and copied on write, so copying a tree +/// is O(1) (`MemorySource.fork`) and an iterator keeps reading the tree as +/// it was when the iterator was created. +public struct BTreeSet { + public typealias Compare = (Element, Element) -> Int + + @usableFromInline final class Node { + /// Leaf: the elements. Internal: the highest element of each child. + @usableFromInline var keys: [Element] + /// Empty for a leaf. + @usableFromInline var children: [Node] + + @usableFromInline init(keys: [Element], children: [Node] = []) { + self.keys = keys + self.children = children + } + + @usableFromInline var isLeaf: Bool { children.isEmpty } + + func copy() -> Node { Node(keys: keys, children: children) } + } + + static var maxNodeSize: Int { 64 } + + @usableFromInline var root: Node + public private(set) var count: Int + public let compare: Compare + + public init(_ compare: @escaping Compare) { + self.compare = compare + root = Node(keys: []) + count = 0 + } + + /// Builds a tree from elements already sorted by `compare`, in O(n). + public init(sorted elements: S, _ compare: @escaping Compare) + where S.Element == Element { + self.compare = compare + let max = Self.maxNodeSize + var level: [Node] = [] + var keys: [Element] = [] + keys.reserveCapacity(max) + var n = 0 + for e in elements { + keys.append(e) + n += 1 + if keys.count == max { + level.append(Node(keys: keys)) + keys.removeAll(keepingCapacity: true) + } + } + if !keys.isEmpty || level.isEmpty { level.append(Node(keys: keys)) } + while level.count > 1 { + var next: [Node] = [] + var i = 0 + while i < level.count { + let children = Array(level[i.. Element? { + var node = root + while !node.isLeaf { + let i = lowerBound(node.keys) { compare($0, element) } + if i == node.keys.count { return nil } + node = node.children[i] + } + let i = lowerBound(node.keys) { compare($0, element) } + if i < node.keys.count && compare(node.keys[i], element) == 0 { return node.keys[i] } + return nil + } + + public func contains(_ element: Element) -> Bool { get(element) != nil } + + /// Adds `element`, replacing an equal one. Returns whether it was new. + @discardableResult + public mutating func add(_ element: Element) -> Bool { + if !isKnownUniquelyReferenced(&root) { root = root.copy() } + let (inserted, split) = Self.insert(element, into: root, compare) + if let split { + root = Node( + keys: [root.keys[root.keys.count - 1], split.keys[split.keys.count - 1]], + children: [root, split]) + } + if inserted { count += 1 } + return inserted + } + + /// Removes the element equal to `element`. Returns whether there was one. + @discardableResult + public mutating func delete(_ element: Element) -> Bool { + if !isKnownUniquelyReferenced(&root) { root = root.copy() } + let removed = Self.remove(element, from: root, compare) + while !root.isLeaf && root.children.count == 1 { root = root.children[0] } + if !root.isLeaf && root.children.isEmpty { root = Node(keys: []) } + if removed { count -= 1 } + return removed + } + + /// The elements, ascending. + public func values() -> ForwardIterator { values(from: nil) } + + /// The elements not less than `probe`, ascending. `probe(e)` compares `e` + /// to the bound: negative when `e` is before it. + public func values(from probe: ((Element) -> Int)?) -> ForwardIterator { + ForwardIterator(root: root, probe: probe) + } + + /// The elements not greater than `probe`, descending. + public func valuesReversed(from probe: ((Element) -> Int)?) -> ReverseIterator { + ReverseIterator(root: root, probe: probe) + } + + // MARK: Mutation + + /// Inserts into `node`, which the caller made unique. Returns whether the + /// element was new and the new right sibling if `node` split. + static func insert(_ e: Element, into node: Node, _ compare: Compare) -> (Bool, Node?) { + if node.isLeaf { + let i = lowerBound(node.keys) { compare($0, e) } + if i < node.keys.count && compare(node.keys[i], e) == 0 { + node.keys[i] = e + return (false, nil) + } + node.keys.insert(e, at: i) + return (true, splitIfNeeded(node)) + } + var i = lowerBound(node.keys) { compare($0, e) } + if i == node.keys.count { i -= 1 } + if !isKnownUniquelyReferenced(&node.children[i]) { node.children[i] = node.children[i].copy() } + let child = node.children[i] + let (inserted, split) = insert(e, into: child, compare) + node.keys[i] = child.keys[child.keys.count - 1] + if let split { + node.children.insert(split, at: i + 1) + node.keys.insert(split.keys[split.keys.count - 1], at: i + 1) + } + return (inserted, splitIfNeeded(node)) + } + + static func splitIfNeeded(_ node: Node) -> Node? { + if node.keys.count <= maxNodeSize { return nil } + let half = node.keys.count / 2 + let right = Node( + keys: Array(node.keys[half...]), children: node.isLeaf ? [] : Array(node.children[half...])) + node.keys.removeSubrange(half...) + if !node.isLeaf { node.children.removeSubrange(half...) } + return right + } + + /// Removes from `node`, which the caller made unique. + static func remove(_ e: Element, from node: Node, _ compare: Compare) -> Bool { + let i = lowerBound(node.keys) { compare($0, e) } + if i == node.keys.count { return false } + if node.isLeaf { + if compare(node.keys[i], e) != 0 { return false } + node.keys.remove(at: i) + return true + } + if !isKnownUniquelyReferenced(&node.children[i]) { node.children[i] = node.children[i].copy() } + let child = node.children[i] + if !remove(e, from: child, compare) { return false } + if child.keys.isEmpty { + node.children.remove(at: i) + node.keys.remove(at: i) + return true + } + node.keys[i] = child.keys[child.keys.count - 1] + if child.keys.count < maxNodeSize / 4 { + // Merge with a neighbor when the two fit in one node. + let j = i + 1 < node.children.count ? i + 1 : i - 1 + if j >= 0 && node.children[j].keys.count + child.keys.count <= maxNodeSize { + let (l, r) = j > i ? (i, j) : (j, i) + if !isKnownUniquelyReferenced(&node.children[l]) { + node.children[l] = node.children[l].copy() + } + let left = node.children[l] + let right = node.children[r] + left.keys.append(contentsOf: right.keys) + left.children.append(contentsOf: right.children) + node.children.remove(at: r) + node.keys.remove(at: r) + node.keys[l] = left.keys[left.keys.count - 1] + } + } + return true + } + + // MARK: Iteration + + public struct ForwardIterator: IteratorProtocol, Sequence { + var path: [(Node, Int)] = [] + var leaf: Node? + var index = 0 + + init(root: Node, probe: ((Element) -> Int)?) { + var node = root + while !node.isLeaf { + let i = probe.map { p in lowerBound(node.keys) { p($0) } } ?? 0 + if i == node.keys.count { return } + path.append((node, i)) + node = node.children[i] + } + leaf = node + index = probe.map { p in lowerBound(node.keys) { p($0) } } ?? 0 + } + + public mutating func next() -> Element? { + while let l = leaf { + if index < l.keys.count { + index += 1 + return l.keys[index - 1] + } + leaf = nil + while let (node, ci) = path.popLast() { + if ci + 1 < node.children.count { + path.append((node, ci + 1)) + var n = node.children[ci + 1] + while !n.isLeaf { + path.append((n, 0)) + n = n.children[0] + } + leaf = n + index = 0 + break + } + } + } + return nil + } + } + + public struct ReverseIterator: IteratorProtocol, Sequence { + var path: [(Node, Int)] = [] + var leaf: Node? + var index = 0 + + init(root: Node, probe: ((Element) -> Int)?) { + var node = root + while !node.isLeaf { + var i = node.keys.count - 1 + if let p = probe { + i = Swift.min(lowerBound(node.keys) { p($0) }, node.keys.count - 1) + } + path.append((node, i)) + node = node.children[i] + } + leaf = node + if let p = probe { + // The last element not greater than the bound. + index = upperBound(node.keys) { p($0) } - 1 + } else { + index = node.keys.count - 1 + } + } + + public mutating func next() -> Element? { + while let l = leaf { + if index >= 0 { + index -= 1 + return l.keys[index + 1] + } + leaf = nil + while let (node, ci) = path.popLast() { + if ci > 0 { + path.append((node, ci - 1)) + var n = node.children[ci - 1] + while !n.isLeaf { + path.append((n, n.children.count - 1)) + n = n.children[n.children.count - 1] + } + leaf = n + index = n.keys.count - 1 + break + } + } + } + return nil + } + } +} + +/// The first index whose element compares `>= 0`. +@inlinable +func lowerBound(_ keys: [E], _ cmp: (E) -> Int) -> Int { + var low = 0 + var high = keys.count + while low < high { + let mid = (low + high) >> 1 + if cmp(keys[mid]) < 0 { low = mid + 1 } else { high = mid } + } + return low +} + +/// The first index whose element compares `> 0`. +@inlinable +func upperBound(_ keys: [E], _ cmp: (E) -> Int) -> Int { + var low = 0 + var high = keys.count + while low < high { + let mid = (low + high) >> 1 + if cmp(keys[mid]) <= 0 { low = mid + 1 } else { high = mid } + } + return low +} diff --git a/swift/Sources/Zero/Util/StringCompare.swift b/swift/Sources/Zero/Util/StringCompare.swift index 3f6a377553..511d3b3009 100644 --- a/swift/Sources/Zero/Util/StringCompare.swift +++ b/swift/Sources/Zero/Util/StringCompare.swift @@ -54,3 +54,23 @@ public enum StringCompare { a.utf8.elementsEqual(b.utf8) } } + +/// A string key compared by code units, like a JS `Map` key. Swift's own +/// `String` equality treats canonically equivalent strings as equal. +public struct JSKey: Hashable, Sendable, CustomStringConvertible { + public let string: String + + @inlinable + public init(_ string: String) { self.string = string } + + @inlinable + public static func == (a: JSKey, b: JSKey) -> Bool { StringCompare.equal(a.string, b.string) } + + @inlinable + public func hash(into hasher: inout Hasher) { + var s = string + s.withUTF8 { hasher.combine(bytes: UnsafeRawBufferPointer($0)) } + } + + public var description: String { string } +} diff --git a/swift/Tests/ConformanceTests/IVMConformanceTests.swift b/swift/Tests/ConformanceTests/IVMConformanceTests.swift new file mode 100644 index 0000000000..005edd26c2 --- /dev/null +++ b/swift/Tests/ConformanceTests/IVMConformanceTests.swift @@ -0,0 +1,200 @@ +import Testing + +@testable import Zero + +/// Runs the IVM vectors: hydrate a view over the dataset, apply each push, +/// and compare the view and the changes it received with JS. +@Suite(.enabled(if: Vectors.available)) +struct IVMConformanceTests { + final class Delegate: BuilderDelegate { + let sources: [String: MemorySource] + init(_ sources: [String: MemorySource]) { self.sources = sources } + func getSource(_ tableName: String) -> (any Source)? { sources[tableName] } + } + + /// Records what the pipeline pushes to the view. + final class Recorder: Operator { + let input: any Input + weak var output: (any Output)? + var changes: [JSONValue] = [] + + init(_ input: any Input) { + self.input = input + input.setOutput(self) + } + + func setOutput(_ output: any Output) { self.output = output } + func getSchema() -> SourceSchema { input.getSchema() } + func destroy() { input.destroy() } + func fetch(_ req: FetchRequest) -> NodeStream { input.fetch(req) } + + func push(_ change: Change, pusher: any InputBase) { + changes.append(Self.record(change)) + output?.push(change, pusher: self) + } + + func reconcile(pusher: any InputBase) { output?.reconcile(pusher: self) } + + static func record(_ change: Change) -> JSONValue { + switch change { + case .add(let n): ["type": "add", "row": .object(n.row)] + case .remove(let n): ["type": "remove", "row": .object(n.row)] + case .edit(let n, let o): ["type": "edit", "row": .object(n.row), "oldRow": .object(o.row)] + case .child(let n, let c): + [ + "type": "child", "row": .object(n.row), + "child": ["relationshipName": .string(c.relationshipName), "change": record(c.change)], + ] + } + } + } + + static func sourceChange(_ push: JSONValue) -> (String, SourceChange) { + let table = push["table"]!.stringValue! + let row = push["row"]!.objectValue! + switch push["type"]!.stringValue! { + case "add": return (table, .add(row)) + case "remove": return (table, .remove(row)) + default: return (table, .edit(row, oldRow: push["oldRow"]!.objectValue!)) + } + } + + /// Replays a harness `Program` (`src/ivm/harness.ts`) with the Swift + /// builder. + static func build(_ schema: ZeroSchema, _ program: JSONValue) -> AnyQuery { + apply(AnyQuery(schema, program["table"]!.stringValue!), program["ops"]!) + } + + static func apply(_ q: AnyQuery, _ ops: JSONValue) -> AnyQuery { + ops.arrayValue!.reduce(q) { q, op in apply(q, op: op) } + } + + static func sub(_ ops: JSONValue?) -> ((AnyQuery) -> AnyQuery)? { + guard let ops, ops != .null else { return nil } + return { apply($0, ops) } + } + + static func op(_ s: JSONValue?) -> AST.SimpleOperator { + AST.SimpleOperator(rawValue: s!.stringValue!)! + } + + static func apply(_ q: AnyQuery, op: JSONValue) -> AnyQuery { + let flip = op["flip"]?.boolValue + let scalar = op["scalar"]?.boolValue + switch op["op"]!.stringValue! { + case "where": + let field = op["field"]!.stringValue! + let value = op["value"] ?? .null + return op["cmp"] == nil ? q.where(field, value) : q.where(field, Self.op(op["cmp"]), value) + case "whereExpr": + return q.where { expr($0, op["expr"]!) } + case "whereExists": + return q.whereExists(op["rel"]!.stringValue!, flip: flip, scalar: scalar, sub(op["sub"])) + case "related": + return q.related(op["rel"]!.stringValue!, sub(op["sub"])) + case "orderBy": + return q.orderBy(op["field"]!.stringValue!, AST.Direction(rawValue: op["dir"]!.stringValue!)!) + case "limit": + return q.limit(Int(op["n"]!.numberValue!)) + case "start": + return q.start(op["row"]!.objectValue!, inclusive: op["inclusive"]?.boolValue ?? false) + case "one": + return q.one() + case let other: + fatalError("unknown op \(other)") + } + } + + static func expr(_ eb: ExpressionBuilder, _ e: JSONValue) -> AST.Condition { + if let c = e["cmp"]?.arrayValue { + return eb.cmp(c[0].stringValue!, op(c[1]), c[2]) + } + if let c = e["and"]?.arrayValue { return eb.and(c.map { expr(eb, $0) }) } + if let c = e["or"]?.arrayValue { return eb.or(c.map { expr(eb, $0) }) } + if let c = e["not"] { return eb.not(expr(eb, c)) } + return eb.exists( + e["exists"]!.stringValue!, flip: e["flip"]?.boolValue, scalar: e["scalar"]?.boolValue, + sub(e["sub"])) + } + + /// Runs one case; returns a description of the first mismatch. + static func run(_ c: JSONValue, schema: ZeroSchema, datasets: [JSONValue]) throws -> String? { + let dataset = datasets[Int(c["dataset"]!.numberValue!)] + var sources: [String: MemorySource] = [:] + for name in schema.tableNames { + let source = MemorySource(schema.tables[name]!) + for row in dataset["data"]?[name]?.arrayValue ?? [] { source.push(.add(row.objectValue!)) } + sources[name] = source + } + let ast = try AST(json: c["ast"]!) + let format = try Format(json: c["format"]!) + let built = build(schema, c["program"]!) + if built.ast != ast { + return "builder ast\n swift: \(built.ast.json)\n js: \(c["ast"]!)" + } + if built.format != format { + return "builder format\n swift: \(built.format.json)\n js: \(c["format"]!)" + } + let result = c["result"]! + let delegate = Delegate(sources) + let recorder: Recorder + let view: ArrayView + do { + let input = try buildPipeline(ast, delegate, queryID: "q") + recorder = Recorder(input) + view = ArrayView(recorder, format: format, complete: true) + } catch { + if let expected = result["error"]?.stringValue { + return error.description == expected ? nil : "error \(error) != \(expected)" + } + return "unexpected error \(error)" + } + if let expected = result["error"] { + return "expected error \(expected)" + } + func viewJSON() -> JSONValue { view.data.json ?? .null } + if viewJSON() != result["initial"]! { + return "initial view\n swift: \(viewJSON())\n js: \(result["initial"]!)" + } + let steps = result["steps"]!.arrayValue! + for (i, push) in dataset["pushes"]!.arrayValue!.enumerated() { + recorder.changes.removeAll() + let (table, change) = sourceChange(push) + sources[table]!.push(change) + view.flush() + let step = steps[i] + if JSONValue.array(recorder.changes) != step["changes"]! { + return + "push \(i) \(push) changes\n swift: \(JSONValue.array(recorder.changes))\n js: \(step["changes"]!)" + } + if viewJSON() != step["data"]! { + return "push \(i) \(push) view\n swift: \(viewJSON())\n js: \(step["data"]!)" + } + } + return nil + } + + static func runAll(_ file: JSONValue) throws { + let shared = file["shared"]! + let schema = try ZeroSchema(json: shared["schema"]!) + let datasets = shared["datasets"]!.arrayValue! + var failures = 0 + for c in file["cases"]!.arrayValue! { + if let mismatch = try run(c, schema: schema, datasets: datasets) { + failures += 1 + if failures <= 5 { + Issue.record("\(c["name"]!.stringValue!): \(c["ast"]!)\n\(mismatch)") + } + } + } + #expect(failures == 0, "\(failures) cases failed") + } + + @Test func curated() throws { + try Self.runAll(try Vectors.file("ivm")) + } + + @Test(.enabled(if: Vectors.generatedAvailable)) func fuzz() throws { + try Self.runAll(try Vectors.generatedFile("ivm-fuzz")) + } +} diff --git a/swift/Tests/ConformanceTests/Vectors.swift b/swift/Tests/ConformanceTests/Vectors.swift index d972eeefab..58b720ea42 100644 --- a/swift/Tests/ConformanceTests/Vectors.swift +++ b/swift/Tests/ConformanceTests/Vectors.swift @@ -40,6 +40,22 @@ enum Vectors { return file["cases"]?.arrayValue ?? [] } + /// A whole vector file, including its `shared` fixtures. + static func file(_ name: String) throws -> JSONValue { + let file = try JSONParser.parse(Array(try data(name))) + guard file["formatVersion"] == .number(Double(supportedFormatVersion)) else { + throw VectorError.unsupportedFormat( + name: name, version: Int(file["formatVersion"]?.numberValue ?? -1)) + } + return file + } + + static func generatedFile(_ name: String) throws -> JSONValue { + guard let generatedDirectory else { throw VectorError.missingDirectory } + let data = try Data(contentsOf: generatedDirectory.appendingPathComponent("\(name).json")) + return try JSONParser.parse(Array(data)) + } + struct File: Decodable { let formatVersion: Int let kind: String diff --git a/swift/Tests/ZeroTests/IVMTests.swift b/swift/Tests/ZeroTests/IVMTests.swift new file mode 100644 index 0000000000..d7fd15b033 --- /dev/null +++ b/swift/Tests/ZeroTests/IVMTests.swift @@ -0,0 +1,79 @@ +import Testing + +@testable import Zero + +/// A smoke test of the query pipeline for builds without the conformance +/// vectors; `IVMConformanceTests` checks the engine against JS. +@Suite struct IVMTests { + static let schema = ZeroSchema( + tables: [ + TableSchema( + name: "issue", + columns: [("id", ColumnSchema(.string)), ("title", ColumnSchema(.string))], + primaryKey: ["id"]), + TableSchema( + name: "comment", + columns: [ + ("id", ColumnSchema(.string)), ("issueID", ColumnSchema(.string)), + ("body", ColumnSchema(.string)), + ], + primaryKey: ["id"]), + ], + relationships: [ + "issue": [ + "comments": [ + RelationshipHop( + sourceField: ["id"], destField: ["issueID"], destSchema: "comment", cardinality: .many) + ] + ] + ]) + + final class Delegate: BuilderDelegate { + let sources: [String: MemorySource] + init(_ sources: [String: MemorySource]) { self.sources = sources } + func getSource(_ tableName: String) -> (any Source)? { sources[tableName] } + } + + @Test func materializesAndUpdates() throws { + let issues = MemorySource(Self.schema.tables["issue"]!) + let comments = MemorySource(Self.schema.tables["comment"]!) + for (id, title) in [("i1", "b"), ("i2", "a"), ("i3", "c")] { + issues.push(.add(["id": .string(id), "title": .string(title)])) + } + comments.push(.add(["id": "c1", "issueID": "i2", "body": "x"])) + + let query = AnyQuery(Self.schema, "issue") + .whereExists("comments") + .related("comments") { $0.orderBy("id", .desc) } + .orderBy("title", .asc) + .limit(2) + let delegate = Delegate(["issue": issues, "comment": comments]) + let view = ArrayView( + try buildPipeline(query.ast, delegate, queryID: "q"), format: query.format, complete: true) + var notified = 0 + _ = view.addListener { _, _ in notified += 1 } + + func titles() -> [String] { view.data.entries.map { $0.row["title"]!.stringValue! } } + #expect(titles() == ["a"]) + + // A comment on i1 makes it pass the EXISTS; it sorts after i2. + comments.push(.add(["id": "c2", "issueID": "i1", "body": "y"])) + view.flush() + #expect(titles() == ["a", "b"]) + + // i3 gets a comment, but the limit of 2 keeps it out. + comments.push(.add(["id": "c3", "issueID": "i3", "body": "z"])) + comments.push(.add(["id": "c4", "issueID": "i2", "body": "w"])) + view.flush() + #expect(titles() == ["a", "b"]) + let i2Comments = view.data.entries[0].relationships["comments"]!.entries + #expect(i2Comments.map { $0.row["id"]!.stringValue! } == ["c4", "c1"]) + + // Removing i2's comments drops it and lets i3 in. + comments.push(.remove(["id": "c1", "issueID": "i2", "body": "x"])) + comments.push(.remove(["id": "c4", "issueID": "i2", "body": "w"])) + view.flush() + #expect(titles() == ["b", "c"]) + #expect(notified > 0) + } +} From 5224aa8481b10efe6812201069db33a420d4aeb1 Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 19 Oct 2026 05:37:14 +0000 Subject: [PATCH 006/104] 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 --- .../src/protocol/messages.test.ts | 2303 ++++++ .../vectors/protocol-client-schema.json | 1527 ++++ .../vectors/protocol-downstream.json | 6622 +++++++++++++++++ .../vectors/protocol-poke-chunks.json | 2134 ++++++ .../vectors/protocol-sec-protocols.json | 2906 ++++++++ .../vectors/protocol-upstream.json | 4954 ++++++++++++ .../Sources/Zero/Protocol/ClientSchema.swift | 65 + swift/Sources/Zero/Protocol/Connect.swift | 263 + swift/Sources/Zero/Protocol/Downstream.swift | 92 + swift/Sources/Zero/Protocol/ErrorBody.swift | 223 + swift/Sources/Zero/Protocol/Inspect.swift | 232 + swift/Sources/Zero/Protocol/Messages.swift | 188 + swift/Sources/Zero/Protocol/Mutation.swift | 271 + swift/Sources/Zero/Protocol/Poke.swift | 196 + swift/Sources/Zero/Protocol/Primitives.swift | 45 + .../Zero/Protocol/ProtocolDecoding.swift | 129 + .../Sources/Zero/Protocol/ProtocolEnums.swift | 63 + swift/Sources/Zero/Protocol/Push.swift | 143 + .../Sources/Zero/Protocol/QueriesPatch.swift | 109 + swift/Sources/Zero/Protocol/RowsPatch.swift | 67 + swift/Sources/Zero/Protocol/StrictAST.swift | 115 + swift/Sources/Zero/Protocol/Upstream.swift | 94 + .../Sources/Zero/Protocol/ValitaSchema.swift | 94 + .../ProtocolConformanceTests.swift | 118 + swift/Tests/ZeroTests/ProtocolTests.swift | 147 + 25 files changed, 23100 insertions(+) create mode 100644 packages/zero-conformance/src/protocol/messages.test.ts create mode 100644 packages/zero-conformance/vectors/protocol-client-schema.json create mode 100644 packages/zero-conformance/vectors/protocol-downstream.json create mode 100644 packages/zero-conformance/vectors/protocol-poke-chunks.json create mode 100644 packages/zero-conformance/vectors/protocol-sec-protocols.json create mode 100644 packages/zero-conformance/vectors/protocol-upstream.json create mode 100644 swift/Sources/Zero/Protocol/ClientSchema.swift create mode 100644 swift/Sources/Zero/Protocol/Connect.swift create mode 100644 swift/Sources/Zero/Protocol/Downstream.swift create mode 100644 swift/Sources/Zero/Protocol/ErrorBody.swift create mode 100644 swift/Sources/Zero/Protocol/Inspect.swift create mode 100644 swift/Sources/Zero/Protocol/Messages.swift create mode 100644 swift/Sources/Zero/Protocol/Mutation.swift create mode 100644 swift/Sources/Zero/Protocol/Poke.swift create mode 100644 swift/Sources/Zero/Protocol/Primitives.swift create mode 100644 swift/Sources/Zero/Protocol/ProtocolDecoding.swift create mode 100644 swift/Sources/Zero/Protocol/ProtocolEnums.swift create mode 100644 swift/Sources/Zero/Protocol/Push.swift create mode 100644 swift/Sources/Zero/Protocol/QueriesPatch.swift create mode 100644 swift/Sources/Zero/Protocol/RowsPatch.swift create mode 100644 swift/Sources/Zero/Protocol/StrictAST.swift create mode 100644 swift/Sources/Zero/Protocol/Upstream.swift create mode 100644 swift/Sources/Zero/Protocol/ValitaSchema.swift create mode 100644 swift/Tests/ConformanceTests/ProtocolConformanceTests.swift create mode 100644 swift/Tests/ZeroTests/ProtocolTests.swift diff --git a/packages/zero-conformance/src/protocol/messages.test.ts b/packages/zero-conformance/src/protocol/messages.test.ts new file mode 100644 index 0000000000..0fa01a69cb --- /dev/null +++ b/packages/zero-conformance/src/protocol/messages.test.ts @@ -0,0 +1,2303 @@ +/** + * Vectors for the zero-protocol wire messages + * (`packages/zero-protocol/src/up.ts`, `down.ts`, `poke.ts`, `connect.ts`, + * and `client-schema.ts`). + * + * The JS client parses downstream messages in valita's `passthrough` mode + * (`#onMessage` in `packages/zero-client/src/client/zero.ts`). The Swift + * client decodes them into types that drop unknown keys, as `strip` mode + * does. So each case records whether `passthrough` accepts the message and, + * if it does, the `strip` output, which the Swift types must encode back to. + * + * Mutation results are the exception. `mutationResultSchema`'s OK branch has + * no discriminator and accepts every object, so `strip` turns every error + * into `{}`. The client reads the `passthrough` output, and tells errors + * apart with `'error' in result` (`mutation-tracker.ts`). That output is the + * result as sent, unless it has `data`: then valita returns just `{data}`, as + * its object matcher copies only known keys once a field's matcher returns a + * value, which `jsonSchema` always does. So the expected output has each + * result as the client reads it: `{error, message?, details?}` or `{data?}`. + */ +import {expect, test} from 'vitest'; +import * as v from '../../../shared/src/valita.ts'; +import { + clientSchemaSchema, + normalizeClientSchema, +} from '../../../zero-protocol/src/client-schema.ts'; +import { + decodeSecProtocols, + encodeSecProtocols, + initConnectionMessageSchema, + type InitConnectionMessage, +} from '../../../zero-protocol/src/connect.ts'; +import {downstreamSchema} from '../../../zero-protocol/src/down.ts'; +import * as ErrorKind from '../../../zero-protocol/src/error-kind-enum.ts'; +import { + POKE_CHUNK_MESSAGE_TYPE, + pokePartsSchema, +} from '../../../zero-protocol/src/poke.ts'; +import {upstreamSchema} from '../../../zero-protocol/src/up.ts'; +import {interestingStrings, Random, randomString} from '../random.ts'; +import {emitGeneratedVectors, emitVectors} from '../vectors.ts'; + +type JSONValue = null | boolean | number | string | JSONValue[] | JSONObject; +type JSONObject = {[key: string]: JSONValue}; +type Obj = Record; + +// Random values. + +const numbers = [ + 0, + -0, + 1, + -1, + 2, + 7, + 0.5, + -2.25, + 2 ** 31, + 2 ** 53, + -(2 ** 53), + 1e21, + 1e-7, + 5e-324, + Number.MAX_VALUE, +]; + +function str(r: Random): string { + return r.bool(0.3) ? r.pick(interestingStrings) : randomString(r); +} + +function num(r: Random): number { + return r.bool(0.7) ? r.pick(numbers) : r.int(100000); +} + +function list(r: Random, max: number, f: () => T): T[] { + const n = r.int(max + 1); + const out: T[] = []; + for (let i = 0; i < n; i++) { + out.push(f()); + } + return out; +} + +function record(r: Random, max: number, f: () => T): {[key: string]: T} { + const out: {[key: string]: T} = {}; + const n = r.int(max + 1); + for (let i = 0; i < n; i++) { + // NFC, so that no two keys are canonically equivalent: Swift's + // `[String: JSONValue]` would take them for one key. + out[str(r).normalize('NFC')] = f(); + } + return out; +} + +function json(r: Random, depth = 0): JSONValue { + switch (r.int(depth >= 2 ? 4 : 6)) { + case 0: + return null; + case 1: + return r.bool(); + case 2: + return num(r); + case 3: + return str(r); + case 4: + return list(r, 3, () => json(r, depth + 1)); + default: + return record(r, 3, () => json(r, depth + 1)); + } +} + +/** `{[key]: value()}` with probability `p`, to spread into an object. */ +function opt( + r: Random, + key: string, + value: () => JSONValue, + p = 0.5, +): JSONObject { + return r.bool(p) ? {[key]: value()} : {}; +} + +/** Keys no schema has, which `strip` drops. */ +const extraKeys = ['zExtra', 'newField', '', '\u00e9', '\u{1f600}']; +let extraProbability = 0.15; + +function extras(r: Random): JSONObject { + return r.bool(extraProbability) ? {[r.pick(extraKeys)]: json(r, 1)} : {}; +} + +// Shared message parts. + +const tables = ['issue', 'comment', 'user', '\u00e9t\u00e9']; +const columns = ['id', 'title', 'n', '\u{1f600}', '']; + +function row(r: Random): JSONObject { + const out: JSONObject = {}; + for (const c of columns) { + if (r.bool()) { + out[c] = json(r, 1); + } + } + return out; +} + +function primaryKeyValue(r: Random): JSONValue { + return r.pick(['a', '', 1, -0, 1.5, true, false, '\u{1f600}']); +} + +function primaryKeyValues(r: Random): JSONObject { + return {id: primaryKeyValue(r), ...opt(r, 'n', () => primaryKeyValue(r))}; +} + +function mutationID(r: Random): JSONObject { + return {id: r.int(1000), clientID: str(r), ...extras(r)}; +} + +const conditions: JSONObject[] = [ + { + type: 'simple', + op: '=', + left: {type: 'column', name: 'id'}, + right: {type: 'literal', value: '1'}, + }, + { + type: 'and', + conditions: [ + { + type: 'correlatedSubquery', + op: 'EXISTS', + related: { + correlation: {parentField: ['ownerID'], childField: ['id']}, + subquery: {table: 'user'}, + }, + }, + { + type: 'simple', + op: 'IN', + left: {type: 'column', name: 'n'}, + right: {type: 'literal', value: [1, 'a', true]}, + }, + ], + }, + { + type: 'simple', + op: '=', + left: {type: 'column', name: 'ownerID'}, + right: {type: 'static', anchor: 'authData', field: ['user', 'sub']}, + }, +]; + +const asts: JSONObject[] = [ + {table: 'issue'}, + {table: 'issue', where: conditions[0], orderBy: [['id', 'asc']], limit: 10}, + { + table: 'issue', + alias: 'i', + schema: 'public', + related: [ + { + correlation: {parentField: ['id'], childField: ['issueID']}, + subquery: { + table: 'comment', + alias: 'comments', + orderBy: [['id', 'desc']], + }, + system: 'permissions', + hidden: true, + }, + ], + start: {row: {id: 'a', n: null}, exclusive: true}, + }, + {table: 'issue', where: conditions[1]}, + {table: 'issue', where: conditions[2]}, +]; + +function deleteClientsBody(r: Random): JSONObject { + return { + ...opt(r, 'clientIDs', () => list(r, 2, () => str(r))), + ...opt(r, 'clientGroupIDs', () => list(r, 2, () => str(r))), + ...extras(r), + }; +} + +// Downstream messages. + +function queriesPatchOp(r: Random): JSONObject { + switch (r.int(4)) { + case 0: + case 1: + return { + op: 'put', + hash: str(r), + ...opt(r, 'ttl', () => num(r)), + ...extras(r), + }; + case 2: + return {op: 'del', hash: str(r), ...extras(r)}; + default: + return {op: 'clear', ...extras(r)}; + } +} + +function rowPatchOp(r: Random): JSONObject { + const tableName = r.pick(tables); + switch (r.int(5)) { + case 0: + case 1: + return {op: 'put', tableName, value: row(r), ...extras(r)}; + case 2: + return { + op: 'update', + tableName, + id: primaryKeyValues(r), + // jsonObjectSchema checks `typeof v === 'object'`, so arrays pass. + ...opt(r, 'merge', () => + r.bool(0.85) ? row(r) : list(r, 2, () => json(r, 1)), + ), + ...opt(r, 'constrain', () => list(r, 3, () => r.pick(columns))), + ...extras(r), + }; + case 3: + return {op: 'del', tableName, id: primaryKeyValues(r), ...extras(r)}; + default: + return {op: 'clear', ...extras(r)}; + } +} + +function mutationResult(r: Random): JSONObject { + switch (r.int(7)) { + case 0: + return {...extras(r)}; + case 1: + case 2: + return {data: json(r), ...extras(r)}; + case 3: + return { + error: 'app', + ...opt(r, 'message', () => str(r)), + ...opt(r, 'details', () => json(r)), + ...extras(r), + }; + case 4: + return { + error: r.pick(['oooMutation', 'alreadyProcessed']), + ...opt(r, 'details', () => json(r)), + ...extras(r), + }; + default: + // The OK branch accepts any object, so any `error` passes too. + return { + error: r.bool() ? json(r) : 'unknownError', + ...opt(r, 'message', () => json(r)), + ...opt(r, 'data', () => json(r)), + }; + } +} + +function mutationResponse(r: Random): JSONObject { + return {id: mutationID(r), result: mutationResult(r), ...extras(r)}; +} + +function mutationPatchOp(r: Random): JSONObject { + return r.bool(0.7) + ? {op: 'put', mutation: mutationResponse(r), ...extras(r)} + : {op: 'del', id: mutationID(r), ...extras(r)}; +} + +function pokePartBody(r: Random): JSONObject { + return { + pokeID: str(r), + ...opt(r, 'lastMutationIDChanges', () => record(r, 3, () => r.int(1000))), + ...opt( + r, + 'desiredQueriesPatches', + () => record(r, 2, () => list(r, 3, () => queriesPatchOp(r))), + 0.3, + ), + ...opt( + r, + 'gotQueriesPatch', + () => list(r, 3, () => queriesPatchOp(r)), + 0.3, + ), + ...opt(r, 'rowsPatch', () => list(r, 5, () => rowPatchOp(r)), 0.6), + ...opt( + r, + 'mutationsPatch', + () => list(r, 3, () => mutationPatchOp(r)), + 0.4, + ), + ...extras(r), + }; +} + +const backoffKinds: string[] = [ + ErrorKind.Rebalance, + ErrorKind.Rehome, + ErrorKind.ServerOverloaded, +]; +const basicKinds: string[] = Object.values(ErrorKind).filter( + k => + !backoffKinds.includes(k) && + k !== ErrorKind.PushFailed && + k !== ErrorKind.TransformFailed, +); +const pushServerReasons = [ + 'database', + 'parse', + 'oooMutation', + 'unsupportedPushVersion', + 'internal', +]; +const transformServerReasons = ['database', 'parse', 'internal']; +const zeroCacheReasons = ['timeout', 'parse', 'internal']; + +function failureCause(r: Random, serverReasons: string[]): JSONObject { + switch (r.int(3)) { + case 0: + return {origin: 'server', reason: r.pick(serverReasons)}; + case 1: + return { + origin: 'zeroCache', + reason: 'http', + status: r.pick([400, 401, 500, 503]), + ...opt(r, 'bodyPreview', () => str(r)), + }; + default: + return {origin: 'zeroCache', reason: r.pick(zeroCacheReasons)}; + } +} + +function errorBody(r: Random): JSONObject { + const message = str(r); + switch (r.int(4)) { + case 0: + return { + kind: r.pick(basicKinds), + message, + ...opt(r, 'origin', () => r.pick(['server', 'zeroCache'])), + ...extras(r), + }; + case 1: + return { + kind: r.pick(backoffKinds), + message, + ...opt(r, 'minBackoffMs', () => num(r)), + ...opt(r, 'maxBackoffMs', () => num(r)), + ...opt(r, 'reconnectParams', () => record(r, 2, () => str(r))), + ...opt(r, 'origin', () => 'zeroCache'), + ...extras(r), + }; + case 2: + return { + kind: ErrorKind.PushFailed, + message, + mutationIDs: list(r, 2, () => mutationID(r)), + ...opt(r, 'details', () => json(r)), + ...failureCause(r, pushServerReasons), + ...extras(r), + }; + default: + return { + kind: ErrorKind.TransformFailed, + message, + queryIDs: list(r, 2, () => str(r)), + ...opt(r, 'details', () => json(r)), + ...failureCause(r, transformServerReasons), + ...extras(r), + }; + } +} + +function pushResponseBody(r: Random): JSONObject { + const mutationIDs = () => + opt(r, 'mutationIDs', () => list(r, 2, () => mutationID(r))); + switch (r.int(6)) { + case 0: + case 1: + return {mutations: list(r, 3, () => mutationResponse(r)), ...extras(r)}; + case 2: + return {error: 'unsupportedPushVersion', ...mutationIDs(), ...extras(r)}; + case 3: + return { + error: 'unsupportedSchemaVersion', + ...mutationIDs(), + ...extras(r), + }; + case 4: + return { + error: 'http', + status: r.pick([400, 500]), + details: str(r), + ...mutationIDs(), + ...extras(r), + }; + default: + return { + error: 'zeroPusher', + details: str(r), + ...mutationIDs(), + ...extras(r), + }; + } +} + +function tdigest(r: Random): number[] { + return [num(r), ...list(r, 3, () => num(r))]; +} + +function costEstimate(r: Random): JSONObject { + return { + startupCost: num(r), + scanEst: num(r), + cost: num(r), + returnedRows: num(r), + selectivity: num(r), + ...opt(r, 'limit', () => num(r)), + ...extras(r), + }; +} + +function constraints(r: Random): JSONObject { + return record(r, 2, () => + r.bool(0.2) ? null : record(r, 2, () => json(r, 1)), + ); +} + +function planDebugEvent(r: Random): JSONObject { + const attemptNumber = r.int(5); + const joinStates = () => + list(r, 2, () => ({ + join: str(r), + type: r.pick(['semi', 'flipped', 'unflippable', 'newType']), + })); + const branchPattern = () => list(r, 3, () => r.int(2)); + switch (r.int(9)) { + case 0: + return {type: 'attempt-start', attemptNumber, totalAttempts: r.int(5)}; + case 1: + return { + type: 'connection-costs', + attemptNumber, + costs: list(r, 2, () => ({ + connection: str(r), + cost: num(r), + costEstimate: costEstimate(r), + pinned: r.bool(), + constraints: constraints(r), + constraintCosts: record(r, 2, () => costEstimate(r)), + })), + }; + case 2: + return { + type: 'connection-selected', + attemptNumber, + connection: str(r), + cost: num(r), + isRoot: r.bool(), + }; + case 3: + return { + type: 'constraints-propagated', + attemptNumber, + connectionConstraints: list(r, 2, () => ({ + connection: str(r), + constraints: constraints(r), + constraintCosts: record(r, 1, () => costEstimate(r)), + })), + }; + case 4: + return { + type: 'plan-complete', + attemptNumber, + totalCost: num(r), + flipPattern: r.int(8), + joinStates: joinStates(), + }; + case 5: + return {type: 'plan-failed', attemptNumber, reason: str(r)}; + case 6: + return { + type: 'best-plan-selected', + bestAttemptNumber: attemptNumber, + totalCost: num(r), + flipPattern: r.int(8), + joinStates: joinStates(), + }; + case 7: + return { + type: 'node-cost', + ...opt(r, 'attemptNumber', () => attemptNumber), + nodeType: r.pick([ + 'connection', + 'join', + 'fan-in', + 'terminus', + 'newNode', + ]), + node: str(r), + branchPattern: branchPattern(), + downstreamChildSelectivity: num(r), + costEstimate: costEstimate(r), + ...opt(r, 'filters', () => r.pick(conditions)), + ...opt(r, 'ordering', () => [['id', r.pick(['asc', 'desc'])]]), + ...opt(r, 'joinType', () => r.pick(['semi', 'flipped'])), + ...extras(r), + }; + default: + return { + type: 'node-constraint', + ...opt(r, 'attemptNumber', () => attemptNumber), + nodeType: 'join', + node: str(r), + branchPattern: branchPattern(), + ...opt(r, 'constraint', () => + r.bool(0.3) ? null : record(r, 2, () => json(r, 1)), + ), + from: str(r), + }; + } +} + +function analyzeQueryResult(r: Random): JSONObject { + const rowsBySource = () => + record(r, 2, () => record(r, 2, () => list(r, 2, () => row(r)))); + const countsBySource = () => + record(r, 2, () => record(r, 2, () => r.int(100))); + return { + warnings: list(r, 2, () => str(r)), + ...opt( + r, + 'syncedRows', + () => record(r, 2, () => list(r, 2, () => row(r))), + 0.3, + ), + syncedRowCount: r.int(100), + start: num(r), + end: num(r), + ...opt(r, 'elapsed', () => num(r), 0.3), + ...opt(r, 'afterPermissions', () => str(r), 0.3), + ...opt(r, 'vendedRowCounts', countsBySource, 0.3), + ...opt(r, 'vendedRows', rowsBySource, 0.2), + ...opt( + r, + 'sqlitePlans', + () => record(r, 2, () => list(r, 2, () => str(r))), + 0.3, + ), + ...opt(r, 'readRows', rowsBySource, 0.2), + ...opt(r, 'readRowCountsByQuery', countsBySource, 0.2), + ...opt(r, 'readRowCount', () => r.int(100), 0.3), + ...opt(r, 'dbScansByQuery', countsBySource, 0.2), + ...opt(r, 'joinPlans', () => list(r, 3, () => planDebugEvent(r)), 0.4), + ...extras(r), + }; +} + +function inspectQueryRow(r: Random): JSONObject { + return { + clientID: str(r), + queryID: str(r), + ast: r.bool() ? r.pick(asts) : null, + name: r.bool() ? str(r) : null, + args: r.bool() ? list(r, 2, () => json(r, 1)) : null, + got: r.bool(), + deleted: r.bool(), + ttl: num(r), + inactivatedAt: r.bool() ? num(r) : null, + rowCount: r.int(100), + ...opt(r, 'metrics', () => + r.bool(0.3) + ? null + : { + ...opt(r, 'query-hydration-server-ms', () => num(r)), + 'query-update-server': tdigest(r), + }, + ), + ...extras(r), + }; +} + +function inspectDownBody(r: Random): JSONObject { + const id = str(r); + switch (r.int(6)) { + case 0: + return { + id, + op: 'queries', + value: list(r, 2, () => inspectQueryRow(r)), + ...extras(r), + }; + case 1: + return { + id, + op: 'metrics', + value: { + 'query-materialization-server': tdigest(r), + 'query-update-server': tdigest(r), + }, + }; + case 2: + return {id, op: 'version', value: str(r)}; + case 3: + return {id, op: 'authenticated', value: r.bool()}; + case 4: + return { + id, + op: 'analyze-query', + value: analyzeQueryResult(r), + ...extras(r), + }; + default: + return {id, op: 'error', value: str(r)}; + } +} + +function erroredQuery(r: Random): JSONObject { + return r.bool() + ? { + error: 'app', + id: str(r), + name: str(r), + ...opt(r, 'message', () => str(r)), + ...opt(r, 'details', () => json(r)), + ...extras(r), + } + : { + error: 'parse', + id: str(r), + name: str(r), + message: str(r), + ...opt(r, 'details', () => json(r)), + ...extras(r), + }; +} + +function downstreamMessage(r: Random): JSONValue { + switch (r.int(14)) { + case 0: + return [ + 'connected', + {wsid: str(r), ...opt(r, 'timestamp', () => num(r)), ...extras(r)}, + ]; + case 1: + return ['error', errorBody(r)]; + case 2: + return ['pong', {...extras(r)}]; + case 3: + return [ + 'pokeStart', + { + pokeID: str(r), + baseCookie: r.bool(0.2) ? null : str(r), + ...opt(r, 'schemaVersions', () => ({ + minSupportedVersion: r.int(3), + maxSupportedVersion: r.int(9), + ...extras(r), + })), + ...opt(r, 'timestamp', () => num(r)), + ...extras(r), + }, + ]; + case 4: + case 5: + case 6: + return ['pokePart', pokePartBody(r)]; + case 7: + return [ + 'pokeEnd', + { + pokeID: str(r), + cookie: str(r), + ...opt(r, 'cancel', () => r.bool()), + ...extras(r), + }, + ]; + case 8: + return [ + 'pull', + { + cookie: str(r), + requestID: str(r), + lastMutationIDChanges: record(r, 3, () => r.int(1000)), + ...extras(r), + }, + ]; + case 9: + return ['deleteClients', deleteClientsBody(r)]; + case 10: + case 11: + return ['pushResponse', pushResponseBody(r)]; + case 12: + return ['inspect', inspectDownBody(r)]; + default: + return ['transformError', list(r, 3, () => erroredQuery(r))]; + } +} + +// Upstream messages. + +function upQueriesPatchOp(r: Random): JSONObject { + switch (r.int(5)) { + case 0: + case 1: + case 2: + return { + op: 'put', + hash: str(r), + ...opt(r, 'ttl', () => num(r)), + ...opt(r, 'ast', () => r.pick(asts), 0.6), + ...opt(r, 'name', () => str(r), 0.3), + ...opt(r, 'args', () => list(r, 2, () => json(r, 1)), 0.3), + ...extras(r), + }; + case 3: + return {op: 'del', hash: str(r), ...extras(r)}; + default: + return {op: 'clear', ...extras(r)}; + } +} + +const valueTypes = ['string', 'number', 'boolean', 'null', 'json']; +// Names whose UTF-16 order differs from their code point order. None is an +// array index, which JS objects order first, and no two are canonically +// equivalent, which Swift dictionaries would take for one key. +const schemaNames = [ + 'issue', + 'Issue', + 'comment', + 'a', + 'ab', + 'b', + 'e', + '_', + '', + '\u00e9', + '\ue000', + '\u{1f600}', + '\u{1d49c}', +]; + +function pickSome(r: Random, names: readonly string[], max: number): string[] { + const out: string[] = []; + for (let i = names.length === 0 ? 0 : r.int(max + 1); i > 0; i--) { + const name = r.pick(names); + if (!out.includes(name)) { + out.push(name); + } + } + return out; +} + +function clientSchema(r: Random): JSONObject { + const schemaTables: JSONObject = {}; + for (const name of pickSome(r, schemaNames, 4)) { + const columnNames = pickSome(r, schemaNames, 4); + const schemaColumns: JSONObject = {}; + for (const c of columnNames) { + schemaColumns[c] = {type: r.pick(valueTypes), ...extras(r)}; + } + schemaTables[name] = { + columns: schemaColumns, + primaryKey: pickSome(r, columnNames, 3), + ...extras(r), + }; + } + return {tables: schemaTables, ...extras(r)}; +} + +function initConnectionBody(r: Random): JSONObject { + return { + desiredQueriesPatch: list(r, 3, () => upQueriesPatchOp(r)), + ...opt(r, 'clientSchema', () => clientSchema(r), 0.3), + ...opt(r, 'deleted', () => deleteClientsBody(r), 0.2), + ...opt(r, 'userPushURL', () => str(r), 0.3), + ...opt(r, 'userPushHeaders', () => record(r, 2, () => str(r)), 0.3), + ...opt(r, 'userQueryURL', () => str(r), 0.3), + ...opt(r, 'userQueryHeaders', () => record(r, 2, () => str(r)), 0.3), + ...opt(r, 'activeClients', () => list(r, 3, () => str(r)), 0.3), + ...opt(r, 'traceparent', () => str(r), 0.2), + ...extras(r), + }; +} + +function crudOp(r: Random): JSONObject { + const op = r.pick(['insert', 'upsert', 'update', 'delete']); + return { + op, + tableName: r.pick(tables), + primaryKey: r.bool(0.8) ? ['id'] : ['id', 'n'], + value: op === 'delete' ? primaryKeyValues(r) : row(r), + ...extras(r), + }; +} + +function mutation(r: Random): JSONObject { + const base = {id: r.int(1000), clientID: str(r), timestamp: num(r)}; + return r.bool(0.3) + ? { + type: 'crud', + ...base, + name: '_zero_crud', + args: [{ops: list(r, 3, () => crudOp(r)), ...extras(r)}], + ...extras(r), + } + : { + type: 'custom', + ...base, + name: str(r), + args: list(r, 3, () => json(r, 1)), + ...extras(r), + }; +} + +function inspectUpBody(r: Random): JSONObject { + const id = str(r); + switch (r.int(5)) { + case 0: + return { + id, + op: 'queries', + ...opt(r, 'clientID', () => str(r)), + ...extras(r), + }; + case 1: + return {id, op: 'metrics', ...extras(r)}; + case 2: + return {id, op: 'version', ...extras(r)}; + case 3: + return {id, op: 'authenticate', value: str(r), ...extras(r)}; + default: + return { + id, + op: 'analyze-query', + ...opt(r, 'value', () => r.pick(asts), 0.3), + ...opt(r, 'options', () => ({ + ...opt(r, 'vendedRows', () => r.bool()), + ...opt(r, 'syncedRows', () => r.bool()), + ...opt(r, 'joinPlans', () => r.bool()), + ...extras(r), + })), + ...opt(r, 'ast', () => r.pick(asts), 0.5), + ...opt(r, 'name', () => str(r), 0.3), + ...opt(r, 'args', () => list(r, 2, () => json(r, 1)), 0.3), + ...extras(r), + }; + } +} + +function upstreamMessage(r: Random): JSONValue { + switch (r.int(13)) { + case 0: + case 1: + return ['initConnection', initConnectionBody(r)]; + case 2: + return ['ping', {...extras(r)}]; + case 3: + return ['deleteClients', deleteClientsBody(r)]; + case 4: + return [ + 'changeDesiredQueries', + { + desiredQueriesPatch: list(r, 3, () => upQueriesPatchOp(r)), + ...opt(r, 'traceparent', () => str(r)), + ...extras(r), + }, + ]; + case 5: + return [ + 'pull', + { + clientGroupID: str(r), + cookie: r.bool(0.3) ? null : str(r), + requestID: str(r), + ...extras(r), + }, + ]; + case 6: + return ['updateAuth', {auth: str(r), ...extras(r)}]; + case 7: + case 8: + return [ + 'push', + { + clientGroupID: str(r), + mutations: list(r, 3, () => mutation(r)), + pushVersion: 1, + ...opt(r, 'schemaVersion', () => r.int(5)), + timestamp: num(r), + requestID: str(r), + ...opt(r, 'auth', () => str(r), 0.2), + ...opt(r, 'traceparent', () => str(r), 0.2), + ...extras(r), + }, + ]; + case 9: + return ['closeConnection', list(r, 2, () => json(r, 1))]; + case 10: + return ['inspect', inspectUpBody(r)]; + case 11: + return ['ackMutationResponses', mutationID(r)]; + default: + // The union is open: a tag this build doesn't know parses as is. + return [ + r.pick(['newMessage', '', 'Ping']), + ...list(r, 2, () => json(r, 1)), + ]; + } +} + +// Invalid messages: random edits of valid ones. valita decides which still +// parse. + +type Path = (string | number)[]; + +function paths(value: JSONValue, path: Path, out: Path[]): Path[] { + out.push(path); + if (Array.isArray(value)) { + value.forEach((e, i) => paths(e, [...path, i], out)); + } else if (value !== null && typeof value === 'object') { + for (const [k, e] of Object.entries(value)) { + paths(e, [...path, k], out); + } + } + return out; +} + +const replacements: JSONValue[] = [ + null, + true, + 0, + -1.5, + '', + 'bogus', + [], + [1], + {}, + {zExtra: 1}, +]; +const literals = [ + 'put', + 'del', + 'clear', + 'update', + 'insert', + 'delete', + 'app', + 'parse', + 'http', + 'timeout', + 'server', + 'zeroCache', + 'client', + 'PushFailed', + 'TransformFailed', + 'Rebalance', + 'Internal', + 'crud', + 'custom', + '_zero_crud', + 'pokePart', + 'pull', + 'queries', + 'error', + 'simple', + 'and', + 'column', + 'asc', +]; +const editKeys = [ + 'error', + 'op', + 'type', + 'kind', + 'origin', + 'reason', + 'data', + 'message', + 'mutations', + 'status', + 'zExtra', +]; + +function replacement(r: Random): JSONValue { + return structuredClone(r.pick(replacements)); +} + +function mutate(r: Random, message: JSONValue): JSONValue { + // Boxed, so the root can be replaced too. + const box: JSONValue[] = [structuredClone(message)]; + const path = r.pick(paths(box[0], [0], [])); + let parent = box as JSONValue; + for (const k of path.slice(0, -1)) { + parent = (parent as Record)[k]; + } + const container = parent as Record; + // Every path starts with the box index. + const key = path.at(-1) as string | number; + const node = container[key]; + switch (r.int(4)) { + case 0: + container[key] = replacement(r); + break; + case 1: + if (path.length === 1) { + container[key] = replacement(r); + } else if (Array.isArray(container)) { + container.splice(key as number, 1); + } else { + delete container[key]; + } + break; + case 2: + if (Array.isArray(node)) { + node.push( + node.length > 0 && r.bool() + ? structuredClone(node[0]) + : replacement(r), + ); + } else if (node !== null && typeof node === 'object') { + node[r.pick(editKeys)] = r.bool() ? r.pick(literals) : replacement(r); + } else { + container[key] = replacement(r); + } + break; + default: + container[key] = + typeof node === 'number' ? String(node) : r.pick(literals); + break; + } + return box[0]; +} + +// Expected results. + +/** A result as the JS client reads it: `'error' in result`. */ +function readResult(result: Obj): JSONObject { + const keys = 'error' in result ? ['error', 'message', 'details'] : ['data']; + const out: JSONObject = {}; + for (const k of keys) { + if (k in result) { + out[k] = result[k] as JSONValue; + } + } + return out; +} + +function readPokePart(strip: Obj, passthrough: Obj): Obj { + const patch = strip['mutationsPatch'] as Obj[] | undefined; + if (patch === undefined) { + return strip; + } + const raw = passthrough['mutationsPatch'] as Obj[]; + return { + ...strip, + mutationsPatch: patch.map((op, i) => + op['op'] === 'put' + ? { + ...op, + mutation: { + ...(op['mutation'] as Obj), + result: readResult((raw[i]['mutation'] as Obj)['result'] as Obj), + }, + } + : op, + ), + }; +} + +function readDownstream(strip: unknown, passthrough: unknown): JSONValue { + const [tag, body] = strip as [string, Obj]; + const [, raw] = passthrough as [string, Obj]; + if (tag === 'pokePart') { + return [tag, readPokePart(body, raw)] as JSONValue; + } + if (tag === 'pushResponse' && 'mutations' in body) { + const rawMutations = raw['mutations'] as Obj[]; + return [ + tag, + { + mutations: (body['mutations'] as Obj[]).map((m, i) => ({ + ...m, + result: readResult(rawMutations[i]['result'] as Obj), + })), + }, + ] as JSONValue; + } + return strip as JSONValue; +} + +type MessageCase = {message: JSONValue; ok: boolean; output?: JSONValue}; + +function messageCase( + message: JSONValue, + schema: v.Type, + read: (strip: unknown, passthrough: unknown) => JSONValue, +): MessageCase { + const passthrough = v.test(message, schema, 'passthrough'); + const strip = v.test(message, schema, 'strip'); + // The modes differ only in what they return. + expect(strip.ok).toBe(passthrough.ok); + if (!passthrough.ok || !strip.ok) { + return {message, ok: false}; + } + return {message, ok: true, output: read(strip.value, passthrough.value)}; +} + +function downstreamCase(message: JSONValue): MessageCase { + return messageCase(message, downstreamSchema, readDownstream); +} + +function upstreamCase(message: JSONValue): MessageCase { + return messageCase(message, upstreamSchema, strip => strip as JSONValue); +} + +function corpus( + r: Random, + hand: JSONValue[], + random: (r: Random) => JSONValue, + toCase: (message: JSONValue) => MessageCase, + validCount: number, + mutatedCount: number, +): MessageCase[] { + const cases = hand.map(toCase); + const valid: JSONValue[] = []; + for (let i = 0; i < validCount; i++) { + const message = random(r); + const c = toCase(message); + expect(c.ok, JSON.stringify(message)).toBe(true); + cases.push(c); + valid.push(message); + } + for (let i = 0; i < mutatedCount; i++) { + let message = mutate(r, r.pick(valid)); + if (r.bool(0.3)) { + message = mutate(r, message); + } + cases.push(toCase(message)); + } + return cases; +} + +// Hand-written downstream messages, for each variant and its edges. + +const mid = (id: number): JSONObject => ({id, clientID: 'c'}); + +const handDownstream: JSONValue[] = [ + ['connected', {wsid: 'ws1'}], + ['connected', {wsid: 'ws1', timestamp: 1700000000000}], + // An optional field may be absent, but not null. + ['connected', {wsid: 'ws1', timestamp: null}], + ['connected', {wsid: 1}], + ['connected', {}], + ['connected', {wsid: 'ws1'}, 'extra'], + ['connected'], + [], + 'connected', + null, + {}, + 5, + ['unknownMessage', {}], + [1, {}], + ['pong', {}], + ['pong', {zExtra: 1}], + ['pong', []], + ['pong', null], + ...basicKinds.map(kind => ['error', {kind, message: 'm'}]), + ['error', {kind: 'InvalidMessage', message: 'm', origin: 'server'}], + ['error', {kind: 'InvalidMessage', message: 'm', origin: 'zeroCache'}], + ['error', {kind: 'InvalidMessage', message: 'm', origin: 'client'}], + ['error', {kind: 'InvalidMessage'}], + ['error', {message: 'm'}], + ['error', {kind: 'NoSuchKind', message: 'm'}], + ...backoffKinds.map(kind => [ + 'error', + { + kind, + message: 'm', + minBackoffMs: 100, + maxBackoffMs: 1000, + reconnectParams: {'a': 'b', '': ''}, + origin: 'zeroCache', + }, + ]), + ['error', {kind: 'Rebalance', message: 'm', origin: 'server'}], + ['error', {kind: 'Rehome', message: 'm', reconnectParams: {a: 1}}], + ...[...pushServerReasons, 'http', 'timeout'].map(reason => [ + 'error', + { + kind: 'PushFailed', + message: 'm', + mutationIDs: [mid(1)], + origin: 'server', + reason, + }, + ]), + ...[...zeroCacheReasons, 'database', 'oooMutation'].map(reason => [ + 'error', + { + kind: 'PushFailed', + message: 'm', + mutationIDs: [], + origin: 'zeroCache', + reason, + }, + ]), + [ + 'error', + { + kind: 'PushFailed', + message: 'm', + mutationIDs: [mid(1), mid(2)], + origin: 'zeroCache', + reason: 'http', + status: 500, + bodyPreview: '', + details: {a: [1, null]}, + }, + ], + [ + 'error', + { + kind: 'PushFailed', + message: 'm', + mutationIDs: [], + origin: 'zeroCache', + reason: 'http', + }, + ], + [ + 'error', + {kind: 'PushFailed', message: 'm', origin: 'server', reason: 'parse'}, + ], + [ + 'error', + { + kind: 'PushFailed', + message: 'm', + mutationIDs: [], + origin: 'client', + reason: 'parse', + }, + ], + ...[...transformServerReasons, 'oooMutation', 'http'].map(reason => [ + 'error', + { + kind: 'TransformFailed', + message: 'm', + queryIDs: ['q'], + origin: 'server', + reason, + }, + ]), + ...[...zeroCacheReasons, 'database'].map(reason => [ + 'error', + { + kind: 'TransformFailed', + message: 'm', + queryIDs: [], + origin: 'zeroCache', + reason, + }, + ]), + [ + 'error', + { + kind: 'TransformFailed', + message: 'm', + queryIDs: ['q1', 'q2'], + origin: 'zeroCache', + reason: 'http', + status: 404, + details: 'd', + }, + ], + [ + 'error', + { + kind: 'TransformFailed', + message: 'm', + mutationIDs: [], + origin: 'server', + reason: 'parse', + }, + ], + ['pokeStart', {pokeID: 'p', baseCookie: null}], + [ + 'pokeStart', + { + pokeID: 'p', + baseCookie: '1', + schemaVersions: {minSupportedVersion: 1, maxSupportedVersion: 2}, + timestamp: 5, + }, + ], + // `baseCookie` is nullable, not optional. + ['pokeStart', {pokeID: 'p'}], + ['pokeStart', {pokeID: 'p', baseCookie: 1}], + [ + 'pokeStart', + {pokeID: 'p', baseCookie: '1', schemaVersions: {minSupportedVersion: 1}}, + ], + ['pokePart', {pokeID: 'p'}], + ['pokePart', {}], + [ + 'pokePart', + { + pokeID: 'p', + rowsPatch: [ + {op: 'put', tableName: 't', value: {id: 1, x: null, j: {a: [1]}}}, + { + op: 'update', + tableName: 't', + id: {id: 1}, + merge: {x: 2}, + constrain: ['id', 'x'], + }, + {op: 'update', tableName: 't', id: {id: 'a', b: true}, merge: [1, 2]}, + {op: 'update', tableName: 't', id: {id: 'a'}}, + {op: 'del', tableName: 't', id: {id: 1.5}}, + {op: 'del', tableName: 't', id: {}}, + {op: 'clear'}, + ], + }, + ], + [ + 'pokePart', + { + pokeID: 'p', + rowsPatch: [{op: 'update', tableName: 't', id: {id: 1}, merge: 'x'}], + }, + ], + [ + 'pokePart', + { + pokeID: 'p', + rowsPatch: [{op: 'update', tableName: 't', id: {id: 1}, merge: null}], + }, + ], + [ + 'pokePart', + {pokeID: 'p', rowsPatch: [{op: 'del', tableName: 't', id: {id: null}}]}, + ], + ['pokePart', {pokeID: 'p', rowsPatch: [{op: 'del', tableName: 't', id: []}]}], + [ + 'pokePart', + {pokeID: 'p', rowsPatch: [{op: 'put', tableName: 't', value: []}]}, + ], + ['pokePart', {pokeID: 'p', rowsPatch: [{op: 'put', tableName: 't'}]}], + [ + 'pokePart', + {pokeID: 'p', rowsPatch: [{op: 'upsert', tableName: 't', value: {}}]}, + ], + ['pokePart', {pokeID: 'p', rowsPatch: {}}], + [ + 'pokePart', + { + pokeID: 'p', + lastMutationIDChanges: {c1: 5, c2: 0}, + desiredQueriesPatches: { + c1: [ + {op: 'put', hash: 'h', ttl: 1000}, + {op: 'del', hash: 'h2'}, + {op: 'clear'}, + ], + c2: [], + }, + gotQueriesPatch: [{op: 'put', hash: 'h'}], + }, + ], + ['pokePart', {pokeID: 'p', lastMutationIDChanges: {c1: '5'}}], + [ + 'pokePart', + {pokeID: 'p', gotQueriesPatch: [{op: 'put', hash: 'h', ttl: null}]}, + ], + ['pokePart', {pokeID: 'p', gotQueriesPatch: [{op: 'add', hash: 'h'}]}], + [ + 'pokePart', + { + pokeID: 'p', + mutationsPatch: [ + {op: 'put', mutation: {id: mid(1), result: {}}}, + {op: 'put', mutation: {id: mid(2), result: {data: {a: 1}}}}, + {op: 'put', mutation: {id: mid(3), result: {data: null}}}, + { + op: 'put', + mutation: { + id: mid(4), + result: {error: 'app', message: 'boom', details: [1]}, + }, + }, + {op: 'put', mutation: {id: mid(5), result: {error: 'oooMutation'}}}, + { + op: 'put', + mutation: { + id: mid(6), + result: {error: 'alreadyProcessed', details: 'x'}, + }, + }, + // Any object is a result, so any `error` is an error. + {op: 'put', mutation: {id: mid(7), result: {error: 5, message: 6}}}, + {op: 'put', mutation: {id: mid(8), result: {error: null}}}, + // With `data`, valita's passthrough output is just `{data}`: OK. + { + op: 'put', + mutation: {id: mid(9), result: {data: 1, error: 'app', zExtra: true}}, + }, + {op: 'put', mutation: {id: mid(10), result: {zExtra: true}}}, + {op: 'del', id: mid(11)}, + ], + }, + ], + [ + 'pokePart', + { + pokeID: 'p', + mutationsPatch: [{op: 'put', mutation: {id: mid(1), result: null}}], + }, + ], + [ + 'pokePart', + { + pokeID: 'p', + mutationsPatch: [{op: 'put', mutation: {id: mid(1), result: []}}], + }, + ], + [ + 'pokePart', + {pokeID: 'p', mutationsPatch: [{op: 'put', mutation: {id: mid(1)}}]}, + ], + ['pokePart', {pokeID: 'p', mutationsPatch: [{op: 'del', id: {id: 1}}]}], + ['pokeEnd', {pokeID: 'p', cookie: '2'}], + ['pokeEnd', {pokeID: 'p', cookie: '', cancel: true}], + ['pokeEnd', {pokeID: 'p', cookie: null}], + ['pokeEnd', {pokeID: 'p', cookie: '2', cancel: 'yes'}], + ['pull', {cookie: '1', requestID: 'r', lastMutationIDChanges: {}}], + ['pull', {cookie: '1', requestID: 'r', lastMutationIDChanges: {c: 3}}], + ['pull', {cookie: null, requestID: 'r', lastMutationIDChanges: {}}], + ['pull', {cookie: '1', requestID: 'r'}], + ['deleteClients', {}], + ['deleteClients', {clientIDs: ['a'], clientGroupIDs: ['b', '']}], + ['deleteClients', {clientIDs: null}], + ['deleteClients', {clientIDs: [1]}], + ['pushResponse', {mutations: []}], + [ + 'pushResponse', + { + mutations: [ + {id: mid(1), result: {error: 'app'}}, + {id: mid(2), result: {data: 'x', zExtra: 1}}, + ], + }, + ], + ['pushResponse', {error: 'unsupportedPushVersion'}], + ['pushResponse', {error: 'unsupportedSchemaVersion', mutationIDs: [mid(1)]}], + ['pushResponse', {error: 'http', status: 500, details: 'x'}], + ['pushResponse', {error: 'http', status: 500}], + ['pushResponse', {error: 'zeroPusher', details: 'x', mutationIDs: []}], + ['pushResponse', {error: 'zeroPusher', details: 'x', mutationIDs: null}], + ['pushResponse', {error: 'other'}], + // Not OK, so it must be an error. + ['pushResponse', {mutations: 'x', error: 'zeroPusher', details: 'd'}], + ['pushResponse', {mutations: [{id: mid(1)}]}], + ['pushResponse', {}], + ['inspect', {id: '1', op: 'version', value: '1.0'}], + ['inspect', {id: '1', op: 'authenticated', value: true}], + ['inspect', {id: '1', op: 'error', value: 'nope', zExtra: 1}], + [ + 'inspect', + { + id: '1', + op: 'metrics', + value: { + 'query-materialization-server': [1], + 'query-update-server': [1, 2, 3], + }, + }, + ], + [ + 'inspect', + { + id: '1', + op: 'metrics', + value: {'query-materialization-server': [], 'query-update-server': [1]}, + }, + ], + [ + 'inspect', + { + id: '1', + op: 'queries', + value: [ + { + clientID: 'c', + queryID: 'q', + ast: asts[1], + name: null, + args: null, + got: true, + deleted: false, + ttl: 1000, + inactivatedAt: null, + rowCount: 3, + metrics: null, + }, + { + clientID: 'c', + queryID: 'q2', + ast: null, + name: 'issues', + args: [1, {a: 'b'}], + got: false, + deleted: true, + ttl: 0, + inactivatedAt: 5, + rowCount: 0, + metrics: {'query-update-server': [1.5]}, + zExtra: 1, + }, + ], + }, + ], + [ + 'inspect', + { + id: '1', + op: 'queries', + value: [ + { + clientID: 'c', + queryID: 'q', + ast: {table: 1}, + name: null, + args: null, + got: true, + deleted: false, + ttl: 1000, + inactivatedAt: null, + rowCount: 3, + }, + ], + }, + ], + [ + 'inspect', + { + id: '1', + op: 'analyze-query', + value: {warnings: [], syncedRowCount: 0, start: 0, end: 1}, + }, + ], + [ + 'inspect', + { + id: '1', + op: 'analyze-query', + value: { + warnings: ['w'], + syncedRowCount: 0, + start: 0, + end: 1, + joinPlans: [{type: 'plan-failed', attemptNumber: 1}], + }, + }, + ], + ['inspect', {id: '1', op: 'unknown', value: 1}], + ['inspect', {op: 'version', value: '1'}], + ['transformError', []], + [ + 'transformError', + [ + {error: 'app', id: 'q', name: 'n'}, + {error: 'parse', id: 'q', name: 'n', message: 'm', details: {}}, + ], + ], + ['transformError', [{error: 'parse', id: 'q', name: 'n'}]], + ['transformError', [{error: 'other', id: 'q', name: 'n', message: 'm'}]], + ['transformError', {}], +]; + +// Hand-written upstream messages. + +/** An AST whose where clause nests `n` conjunctions. */ +function nestedConditions(n: number): JSONObject { + let where: JSONObject = conditions[0]; + for (let i = 0; i < n; i++) { + where = {type: 'and', conditions: [where]}; + } + return {table: 'issue', where}; +} + +/** An AST with `n` levels of related subqueries. */ +function nestedRelated(n: number): JSONObject { + let ast: JSONObject = {table: 'issue'}; + for (let i = 0; i < n; i++) { + ast = { + table: 'issue', + related: [ + {correlation: {parentField: ['id'], childField: ['id']}, subquery: ast}, + ], + }; + } + return ast; +} + +const put = (ast: JSONValue): JSONValue => [ + 'changeDesiredQueries', + {desiredQueriesPatch: [{op: 'put', hash: 'h', ast}]}, +]; + +const crud = (fields: JSONObject): JSONValue => [ + 'push', + { + clientGroupID: 'g', + mutations: [ + { + type: 'crud', + id: 1, + clientID: 'c', + name: '_zero_crud', + args: [ + { + ops: [ + { + op: 'insert', + tableName: 'issue', + primaryKey: ['id'], + value: {id: 'a'}, + }, + ], + }, + ], + timestamp: 1, + ...fields, + }, + ], + pushVersion: 1, + timestamp: 1, + requestID: 'r', + }, +]; + +// At most MAX_AST_DEPTH (50) levels. Emitted as text: pretty-printed, the +// indentation alone would take megabytes. +const deepUpstream: JSONValue[] = [ + put(nestedConditions(49)), + put(nestedConditions(50)), + put(nestedConditions(51)), + put(nestedRelated(50)), + put(nestedRelated(51)), +]; + +type TextCase = {messageText: string; ok: boolean; outputText?: string}; + +function asText(c: MessageCase): TextCase { + return { + messageText: JSON.stringify(c.message), + ok: c.ok, + ...(c.output === undefined ? {} : {outputText: JSON.stringify(c.output)}), + }; +} + +const handUpstream: JSONValue[] = [ + ['ping', {}], + ['ping', {zExtra: 1}], + ['ping'], + ['ping', {}, 1], + ['ping', []], + // A tag this build doesn't know, of any length. + ['newMessage'], + ['newMessage', 1, 2, 3], + [''], + // The tag must be a string. + [1, {}], + [null], + [true, {}], + [['ping'], {}], + [], + 'ping', + {}, + ['initConnection', {desiredQueriesPatch: []}], + [ + 'initConnection', + { + desiredQueriesPatch: [ + {op: 'put', hash: 'h1', ttl: 300000, ast: asts[2]}, + {op: 'put', hash: 'h2', name: 'issues', args: [1, 'a', {b: null}]}, + {op: 'del', hash: 'h3'}, + {op: 'clear'}, + ], + clientSchema: { + tables: { + issue: { + columns: {id: {type: 'string'}, n: {type: 'number'}}, + primaryKey: ['id'], + }, + }, + }, + deleted: {clientIDs: ['a'], clientGroupIDs: []}, + userPushURL: 'https://example.com/push', + userPushHeaders: {authorization: 'Bearer x'}, + userQueryURL: 'https://example.com/query', + userQueryHeaders: {}, + activeClients: ['c1', 'c2'], + traceparent: '00-abc-def-01', + }, + ], + [ + 'initConnection', + { + desiredQueriesPatch: [], + clientSchema: { + tables: {issue: {columns: {id: {type: 'date'}}, primaryKey: ['id']}}, + }, + }, + ], + [ + 'initConnection', + {desiredQueriesPatch: [], clientSchema: {tables: {issue: {columns: {}}}}}, + ], + ['initConnection', {desiredQueriesPatch: [], userPushHeaders: {a: 1}}], + ['initConnection', {desiredQueriesPatch: [], activeClients: 'c1'}], + ['initConnection', {}], + // ASTs from clients are parsed strictly, even inside a lenient parse. + put({table: 'issue', zExtra: 1}), + put({ + table: 'issue', + where: { + type: 'simple', + op: '=', + left: {type: 'column', name: 'a', zExtra: 1}, + right: {type: 'literal', value: 1}, + }, + }), + put({table: 'issue', where: {type: 'and', conditions: [], zExtra: 1}}), + put({ + table: 'issue', + related: [ + { + correlation: {parentField: ['id'], childField: ['id'], zExtra: 1}, + subquery: {table: 'comment'}, + }, + ], + }), + put({ + table: 'issue', + related: [ + { + correlation: {parentField: ['id'], childField: ['id']}, + subquery: {table: 'comment', zExtra: 1}, + }, + ], + }), + put({table: 'issue', start: {row: {id: 1}, exclusive: false, zExtra: 1}}), + // A row is a record, so any key is a column. + put({table: 'issue', start: {row: {zExtra: 1}, exclusive: false}}), + put({table: 'issue', orderBy: [['id', 'asc', 'extra']]}), + put({table: 1}), + [ + 'changeDesiredQueries', + {desiredQueriesPatch: [{op: 'put', hash: 'h', ast: null}]}, + ], + ['changeDesiredQueries', {desiredQueriesPatch: [{op: 'put'}]}], + ['pull', {clientGroupID: 'g', cookie: null, requestID: 'r'}], + ['pull', {clientGroupID: 'g', cookie: '5', requestID: 'r'}], + ['pull', {clientGroupID: 'g', requestID: 'r'}], + ['updateAuth', {auth: 'x'}], + ['updateAuth', {}], + crud({}), + crud({name: 'other'}), + crud({args: []}), + crud({args: [{ops: []}, {ops: []}]}), + crud({args: [{}]}), + crud({type: 'custom'}), + [ + 'push', + { + clientGroupID: 'g', + mutations: [ + { + type: 'crud', + id: 1, + clientID: 'c', + name: '_zero_crud', + args: [ + { + ops: [ + { + op: 'upsert', + tableName: 't', + primaryKey: ['a', 'b'], + value: {a: 1, b: 2, c: null}, + }, + { + op: 'update', + tableName: 't', + primaryKey: ['a'], + value: {a: 1}, + }, + { + op: 'delete', + tableName: 't', + primaryKey: ['a'], + value: {a: 1}, + }, + ], + }, + ], + timestamp: 1, + }, + { + type: 'custom', + id: 2, + clientID: 'c', + name: 'issue.create', + args: [{id: 'a'}], + timestamp: 2, + }, + ], + pushVersion: 1, + schemaVersion: 3, + timestamp: 3, + requestID: 'r', + auth: 'token', + traceparent: 't', + }, + ], + [ + 'push', + { + clientGroupID: 'g', + mutations: [ + { + type: 'crud', + id: 1, + clientID: 'c', + name: '_zero_crud', + args: [ + {ops: [{op: 'insert', tableName: 't', primaryKey: [], value: {}}]}, + ], + timestamp: 1, + }, + ], + pushVersion: 1, + timestamp: 1, + requestID: 'r', + }, + ], + [ + 'push', + { + clientGroupID: 'g', + mutations: [ + { + type: 'crud', + id: 1, + clientID: 'c', + name: '_zero_crud', + args: [ + { + ops: [ + { + op: 'delete', + tableName: 't', + primaryKey: ['a'], + value: {a: null}, + }, + ], + }, + ], + timestamp: 1, + }, + ], + pushVersion: 1, + timestamp: 1, + requestID: 'r', + }, + ], + [ + 'push', + { + clientGroupID: 'g', + mutations: [ + { + type: 'custom', + id: 1, + clientID: 'c', + name: '_zero_crud', + args: [], + timestamp: 1, + }, + ], + pushVersion: 1, + timestamp: 1, + requestID: 'r', + }, + ], + ['closeConnection', []], + ['closeConnection', [1, 'a', null, {b: []}]], + ['closeConnection', {}], + ['ackMutationResponses', {id: 1, clientID: 'c'}], + ['ackMutationResponses', {id: '1', clientID: 'c'}], + ['inspect', {id: 'i', op: 'queries'}], + ['inspect', {id: 'i', op: 'queries', clientID: 'c'}], + ['inspect', {id: 'i', op: 'authenticate', value: 'secret'}], + ['inspect', {id: 'i', op: 'authenticate'}], + ['inspect', {id: 'i', op: 'newOp'}], + [ + 'inspect', + { + id: 'i', + op: 'analyze-query', + value: asts[1], + ast: asts[3], + options: {vendedRows: true, syncedRows: false, joinPlans: true}, + name: 'q', + args: [], + }, + ], + ['inspect', {id: 'i', op: 'analyze-query', ast: {table: 'issue', zExtra: 1}}], + ['inspect', {id: 'i', op: 'analyze-query', options: {vendedRows: 1}}], +]; + +test('protocol-downstream', () => { + const r = new Random(0xd0a1); + emitVectors( + 'protocol-downstream', + 'protocol-downstream', + 'Downstream messages (`downstreamSchema`). `ok` is whether valita accepts the message in `passthrough` mode; `output` is its `strip` output, with each mutation result as the client reads it (`{error, message?, details?}` if it has an `error` key, else `{data?}`).', + corpus(r, handDownstream, downstreamMessage, downstreamCase, 50, 100), + ); + emitGeneratedVectors( + 'protocol-downstream-fuzz', + 'protocol-downstream', + 'Random and randomly edited downstream messages; see vectors/protocol-downstream.json.', + corpus(r, [], downstreamMessage, downstreamCase, 2000, 6000), + ); +}); + +test('protocol-upstream', () => { + const r = new Random(0x0b5); + emitVectors( + 'protocol-upstream', + 'protocol-upstream', + 'Upstream messages (`upstreamSchema`, an open tagged tuple union). `ok` is whether valita accepts the message in `passthrough` mode; `output` is its `strip` output. Deeply nested messages are given as JSON text, in `messageText` and `outputText`.', + [ + ...deepUpstream.map(m => asText(upstreamCase(m))), + ...corpus(r, handUpstream, upstreamMessage, upstreamCase, 50, 100), + ], + ); + emitGeneratedVectors( + 'protocol-upstream-fuzz', + 'protocol-upstream', + 'Random and randomly edited upstream messages; see vectors/protocol-upstream.json.', + corpus(r, [], upstreamMessage, upstreamCase, 2000, 6000), + ); +}); + +// Binary poke chunks. + +type PokeChunksCase = {messages: string[]; ok: boolean; parts?: JSONValue}; + +const utf8 = new TextEncoder(); + +function hex(bytes: Uint8Array): string { + return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''); +} + +function bytes(...parts: (string | number[])[]): Uint8Array { + const out: number[] = []; + for (const p of parts) { + out.push(...(typeof p === 'string' ? utf8.encode(p) : p)); + } + return new Uint8Array(out); +} + +/** Chunk messages for `payload`, each tagged with the chunk message type. */ +function chunked(payload: Uint8Array, cuts: number[]): Uint8Array[] { + const bounds = [0, ...cuts, payload.length]; + const out: Uint8Array[] = []; + for (let i = 1; i < bounds.length; i++) { + out.push( + bytes( + [POKE_CHUNK_MESSAGE_TYPE], + [...payload.subarray(bounds[i - 1], bounds[i])], + ), + ); + } + return out; +} + +function randomCuts(r: Random, length: number): number[] { + const cuts = list(r, 6, () => r.int(length + 1)); + return cuts.sort((a, b) => a - b); +} + +/** + * The client's handling of a poke's binary messages: `#onMessage` in + * `zero.ts` checks the type byte, then `decodePokeChunks` in + * `zero-poke-handler.ts` decodes them. + */ +function pokeChunksCase(messages: Uint8Array[]): PokeChunksCase { + const c: PokeChunksCase = {messages: messages.map(hex), ok: false}; + const chunks: Uint8Array[] = []; + for (const m of messages) { + if (m[0] !== POKE_CHUNK_MESSAGE_TYPE) { + return c; + } + chunks.push(m.subarray(1)); + } + let parsed: unknown; + try { + const decoder = new TextDecoder('utf-8', {fatal: true}); + let text = ''; + for (const chunk of chunks) { + text += decoder.decode(chunk, {stream: true}); + } + text += decoder.decode(); + parsed = JSON.parse(text); + } catch { + return c; + } + const passthrough = v.test(parsed, pokePartsSchema, 'passthrough'); + const strip = v.test(parsed, pokePartsSchema, 'strip'); + expect(strip.ok).toBe(passthrough.ok); + if (!passthrough.ok || !strip.ok) { + return c; + } + const raw = passthrough.value as Obj[]; + return { + ...c, + ok: true, + parts: (strip.value as Obj[]).map((p, i) => + readPokePart(p, raw[i]), + ) as JSONValue, + }; +} + +function randomPokeChunks(r: Random): PokeChunksCase { + const parts = r.bool(0.8) + ? list(r, 3, () => pokePartBody(r)) + : list(r, 2, () => mutate(r, pokePartBody(r))); + const payload = utf8.encode(JSON.stringify(parts)); + return pokeChunksCase(chunked(payload, randomCuts(r, payload.length))); +} + +const BOM = [0xef, 0xbb, 0xbf]; + +const handPokeChunks: Uint8Array[][] = [ + chunked(bytes('[]'), []), + chunked(bytes('[]'), [1]), + chunked(bytes(' [ ] '), [0, 0, 5]), + // An empty payload is not JSON. + chunked(bytes(''), []), + chunked(bytes('[{"pokeID":"中"}]'), [14, 15]), + chunked(bytes('[{"pokeID":"\u{1f600}","zExtra":1}]'), [13, 14, 15]), + chunked( + bytes('[{"pokeID":"p"},{"pokeID":"q","rowsPatch":[{"op":"clear"}]}]'), + [5, 30], + ), + // TextDecoder drops a leading BOM, even split across chunks, but only one. + chunked(bytes(BOM, '[]'), []), + chunked(bytes(BOM, '[]'), [1, 2]), + chunked(bytes(BOM, BOM, '[]'), []), + chunked(bytes('[', BOM, ']'), []), + // Invalid UTF-8 is fatal. + chunked(bytes('["', [0xff], '"]'), []), + chunked(bytes('[{"pokeID":"', [0xc0, 0xaf], '"}]'), []), + chunked(bytes('[{"pokeID":"', [0xed, 0xa0, 0x80], '"}]'), []), + chunked(bytes('[{"pokeID":"', [0xf4, 0x90, 0x80, 0x80], '"}]'), []), + chunked(bytes('[{"pokeID":"', [0xe4, 0xb8]), [13]), + chunked(bytes('[{"pokeID":"', [0xe4, 0xb8], '"}]'), [13]), + // Not JSON, or not PokePartBody[]. + chunked(bytes('[{"pokeID":"p"}'), []), + chunked(bytes('{}'), []), + chunked(bytes('[{}]'), []), + chunked(bytes('[{"pokeID":1}]'), []), + chunked(bytes('[null]'), []), + // A binary message of another type, or none. + [bytes([1], '[]')], + [bytes('[]')], + [bytes([0], '['), bytes([2], ']')], + [bytes([])], + // Mutation results are read as the client does. + chunked( + bytes( + JSON.stringify([ + { + pokeID: 'p', + mutationsPatch: [ + { + op: 'put', + mutation: { + id: mid(1), + result: {error: 'app', message: 'm', zExtra: 1}, + }, + }, + {op: 'put', mutation: {id: mid(2), result: {data: [1], zExtra: 1}}}, + ], + }, + ]), + ), + [40, 41], + ), +]; + +test('protocol-poke-chunks', () => { + const r = new Random(0xc4c); + const cases = handPokeChunks.map(pokeChunksCase); + for (let i = 0; i < 40; i++) { + cases.push(randomPokeChunks(r)); + } + emitVectors( + 'protocol-poke-chunks', + 'protocol-poke-chunks', + 'The binary poke chunks of one poke, each message in hex (a 0x00 type byte, then the next bytes of the UTF-8 JSON of a `PokePartBody[]`). `ok` is whether the client accepts them; `parts` is the `strip` output of `pokePartsSchema`, with mutation results as the client reads them (see protocol-downstream.json).', + cases, + ); + const fuzz: PokeChunksCase[] = []; + for (let i = 0; i < 1500; i++) { + fuzz.push(randomPokeChunks(r)); + } + emitGeneratedVectors( + 'protocol-poke-chunks-fuzz', + 'protocol-poke-chunks', + 'Random poke chunks; see vectors/protocol-poke-chunks.json.', + fuzz, + ); +}); + +// The Sec-WebSocket-Protocol header. + +type SecProtocolsCase = { + encoded: string; + ok: boolean; + decoded?: JSONValue; + // Set for cases from `encodeSecProtocols`: the Swift encoding of the same + // values must decode to `decoded`. + initConnectionMessage?: JSONValue; + authToken?: string; +}; + +function canonical(value: unknown): string { + return JSON.stringify(value, (_, v: unknown) => + v !== null && typeof v === 'object' && !Array.isArray(v) + ? Object.fromEntries( + Object.entries(v as Obj).sort(([a], [b]) => + a < b ? -1 : a > b ? 1 : 0, + ), + ) + : v, + ); +} + +function decodeCase(encoded: string): SecProtocolsCase { + try { + return { + encoded, + ok: true, + decoded: decodeSecProtocols(encoded) as JSONValue, + }; + } catch { + return {encoded, ok: false}; + } +} + +function encodeCase( + message: JSONValue | undefined, + authToken: string | undefined, +): SecProtocolsCase { + if (message !== undefined) { + // The Swift types keep every field of a valid message without extras. + const strip = v.parse(message, initConnectionMessageSchema, 'strip'); + expect(canonical(strip)).toBe(canonical(message)); + } + const c = decodeCase( + encodeSecProtocols(message as InitConnectionMessage | undefined, authToken), + ); + expect(c.ok).toBe(true); + return { + ...c, + ...(message === undefined ? {} : {initConnectionMessage: message}), + ...(authToken === undefined ? {} : {authToken}), + }; +} + +const base64 = (b: Uint8Array) => btoa(String.fromCharCode(...b)); + +test('protocol-sec-protocols', () => { + const r = new Random(0x5ec); + const cases: SecProtocolsCase[] = [ + encodeCase(undefined, undefined), + encodeCase(undefined, ''), + encodeCase(undefined, 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.sig'), + encodeCase(['initConnection', {desiredQueriesPatch: []}], undefined), + // `encodeURIComponent` escapes `+`, `/`, and `=`; atob is forgiving. + decodeCase('e30'), + decodeCase('e30%3d'), + decodeCase('e3%200%3D'), + decodeCase(' e30= '), + decodeCase('77u/e30='), + decodeCase('e30=='), + decodeCase('e3=0'), + decodeCase('e30%'), + decodeCase('e30%3'), + decodeCase('%E0%A4%A'), + decodeCase('%C3%A9'), + decodeCase('!!!!'), + decodeCase('a'), + decodeCase(''), + // TextDecoder drops a BOM and replaces invalid UTF-8. + decodeCase(encodeURIComponent(base64(bytes(BOM, '{"authToken":"a"}')))), + decodeCase( + encodeURIComponent( + base64(bytes('{"authToken":"', [0xff, 0xe4, 0xb8], '"}')), + ), + ), + decodeCase(encodeURIComponent(base64(bytes('5')))), + decodeCase(encodeURIComponent(base64(bytes('not json')))), + ]; + const saved = extraProbability; + extraProbability = 0; + for (let i = 0; i < 40; i++) { + cases.push( + encodeCase( + r.bool(0.9) ? ['initConnection', initConnectionBody(r)] : undefined, + r.bool(0.8) ? str(r) : undefined, + ), + ); + } + extraProbability = saved; + emitVectors( + 'protocol-sec-protocols', + 'protocol-sec-protocols', + 'The Sec-WebSocket-Protocol value (`encodeSecProtocols` and `decodeSecProtocols`). `decoded` is what `decodeSecProtocols(encoded)` returns, if it does not throw. Cases with `initConnectionMessage` or `authToken` were encoded from them.', + cases, + ); +}); + +// normalizeClientSchema. + +test('protocol-client-schema', () => { + const r = new Random(0xc5); + const saved = extraProbability; + extraProbability = 0; + const cases: {schema: JSONValue; normalized: string}[] = []; + for (let i = 0; i < 60; i++) { + const schema = clientSchema(r); + cases.push({ + schema, + normalized: JSON.stringify( + normalizeClientSchema(v.parse(schema, clientSchemaSchema)), + ), + }); + } + extraProbability = saved; + emitVectors( + 'protocol-client-schema', + 'protocol-client-schema', + 'normalizeClientSchema: tables, columns, and primary keys sorted by UTF-16 code units. `normalized` is `JSON.stringify` of the result.', + cases, + ); +}); diff --git a/packages/zero-conformance/vectors/protocol-client-schema.json b/packages/zero-conformance/vectors/protocol-client-schema.json new file mode 100644 index 0000000000..7c91b72646 --- /dev/null +++ b/packages/zero-conformance/vectors/protocol-client-schema.json @@ -0,0 +1,1527 @@ +{ + "formatVersion": 1, + "kind": "protocol-client-schema", + "description": "normalizeClientSchema: tables, columns, and primary keys sorted by UTF-16 code units. `normalized` is `JSON.stringify` of the result.", + "cases": [ + { + "schema": { + "tables": { + "a": { + "columns": { + "𝒜": { + "type": "string" + }, + "comment": { + "type": "string" + }, + "e": { + "type": "number" + } + }, + "primaryKey": [ + "e", + "𝒜" + ] + } + } + }, + "normalized": "{\"tables\":{\"a\":{\"columns\":{\"comment\":{\"type\":\"string\"},\"e\":{\"type\":\"number\"},\"𝒜\":{\"type\":\"string\"}},\"primaryKey\":[\"e\",\"𝒜\"]}}}" + }, + { + "schema": { + "tables": { + "a": { + "columns": {}, + "primaryKey": [] + }, + "😀": { + "columns": { + "e": { + "type": "null" + }, + "é": { + "type": "boolean" + } + }, + "primaryKey": [ + "e", + "é" + ] + }, + "": { + "columns": { + "ab": { + "type": "boolean" + }, + "": { + "type": "number" + }, + "a": { + "type": "number" + }, + "issue": { + "type": "boolean" + } + }, + "primaryKey": [ + "a", + "" + ] + }, + "Issue": { + "columns": { + "é": { + "type": "boolean" + }, + "e": { + "type": "json" + }, + "b": { + "type": "null" + }, + "issue": { + "type": "json" + } + }, + "primaryKey": [ + "b", + "é" + ] + } + } + }, + "normalized": "{\"tables\":{\"\":{\"columns\":{\"a\":{\"type\":\"number\"},\"ab\":{\"type\":\"boolean\"},\"issue\":{\"type\":\"boolean\"},\"\":{\"type\":\"number\"}},\"primaryKey\":[\"a\",\"\"]},\"Issue\":{\"columns\":{\"b\":{\"type\":\"null\"},\"e\":{\"type\":\"json\"},\"issue\":{\"type\":\"json\"},\"é\":{\"type\":\"boolean\"}},\"primaryKey\":[\"b\",\"é\"]},\"a\":{\"columns\":{},\"primaryKey\":[]},\"😀\":{\"columns\":{\"e\":{\"type\":\"null\"},\"é\":{\"type\":\"boolean\"}},\"primaryKey\":[\"e\",\"é\"]}}}" + }, + { + "schema": { + "tables": { + "issue": { + "columns": {}, + "primaryKey": [] + }, + "_": { + "columns": { + "_": { + "type": "boolean" + }, + "a": { + "type": "string" + } + }, + "primaryKey": [ + "_", + "a" + ] + } + } + }, + "normalized": "{\"tables\":{\"_\":{\"columns\":{\"_\":{\"type\":\"boolean\"},\"a\":{\"type\":\"string\"}},\"primaryKey\":[\"_\",\"a\"]},\"issue\":{\"columns\":{},\"primaryKey\":[]}}}" + }, + { + "schema": { + "tables": {} + }, + "normalized": "{\"tables\":{}}" + }, + { + "schema": { + "tables": {} + }, + "normalized": "{\"tables\":{}}" + }, + { + "schema": { + "tables": { + "𝒜": { + "columns": { + "": { + "type": "number" + }, + "é": { + "type": "boolean" + } + }, + "primaryKey": [] + }, + "e": { + "columns": { + "😀": { + "type": "boolean" + }, + "a": { + "type": "string" + }, + "ab": { + "type": "null" + } + }, + "primaryKey": [ + "a", + "ab", + "😀" + ] + } + } + }, + "normalized": "{\"tables\":{\"e\":{\"columns\":{\"a\":{\"type\":\"string\"},\"ab\":{\"type\":\"null\"},\"😀\":{\"type\":\"boolean\"}},\"primaryKey\":[\"a\",\"ab\",\"😀\"]},\"𝒜\":{\"columns\":{\"é\":{\"type\":\"boolean\"},\"\":{\"type\":\"number\"}},\"primaryKey\":[]}}}" + }, + { + "schema": { + "tables": { + "a": { + "columns": { + "𝒜": { + "type": "number" + }, + "_": { + "type": "string" + }, + "comment": { + "type": "null" + }, + "é": { + "type": "json" + } + }, + "primaryKey": [ + "𝒜" + ] + }, + "e": { + "columns": { + "_": { + "type": "boolean" + }, + "": { + "type": "null" + }, + "😀": { + "type": "string" + } + }, + "primaryKey": [] + } + } + }, + "normalized": "{\"tables\":{\"a\":{\"columns\":{\"_\":{\"type\":\"string\"},\"comment\":{\"type\":\"null\"},\"é\":{\"type\":\"json\"},\"𝒜\":{\"type\":\"number\"}},\"primaryKey\":[\"𝒜\"]},\"e\":{\"columns\":{\"\":{\"type\":\"null\"},\"_\":{\"type\":\"boolean\"},\"😀\":{\"type\":\"string\"}},\"primaryKey\":[]}}}" + }, + { + "schema": { + "tables": {} + }, + "normalized": "{\"tables\":{}}" + }, + { + "schema": { + "tables": { + "Issue": { + "columns": { + "issue": { + "type": "null" + }, + "_": { + "type": "boolean" + } + }, + "primaryKey": [ + "issue" + ] + }, + "𝒜": { + "columns": { + "b": { + "type": "null" + } + }, + "primaryKey": [ + "b" + ] + }, + "": { + "columns": { + "issue": { + "type": "number" + }, + "a": { + "type": "number" + }, + "😀": { + "type": "boolean" + } + }, + "primaryKey": [] + } + } + }, + "normalized": "{\"tables\":{\"\":{\"columns\":{\"a\":{\"type\":\"number\"},\"issue\":{\"type\":\"number\"},\"😀\":{\"type\":\"boolean\"}},\"primaryKey\":[]},\"Issue\":{\"columns\":{\"_\":{\"type\":\"boolean\"},\"issue\":{\"type\":\"null\"}},\"primaryKey\":[\"issue\"]},\"𝒜\":{\"columns\":{\"b\":{\"type\":\"null\"}},\"primaryKey\":[\"b\"]}}}" + }, + { + "schema": { + "tables": { + "𝒜": { + "columns": { + "_": { + "type": "number" + }, + "😀": { + "type": "string" + } + }, + "primaryKey": [ + "_" + ] + }, + "ab": { + "columns": { + "é": { + "type": "boolean" + }, + "😀": { + "type": "null" + }, + "_": { + "type": "string" + } + }, + "primaryKey": [ + "_", + "😀" + ] + } + } + }, + "normalized": "{\"tables\":{\"ab\":{\"columns\":{\"_\":{\"type\":\"string\"},\"é\":{\"type\":\"boolean\"},\"😀\":{\"type\":\"null\"}},\"primaryKey\":[\"_\",\"😀\"]},\"𝒜\":{\"columns\":{\"_\":{\"type\":\"number\"},\"😀\":{\"type\":\"string\"}},\"primaryKey\":[\"_\"]}}}" + }, + { + "schema": { + "tables": { + "issue": { + "columns": { + "a": { + "type": "null" + }, + "comment": { + "type": "boolean" + } + }, + "primaryKey": [ + "a", + "comment" + ] + }, + "é": { + "columns": { + "e": { + "type": "number" + }, + "ab": { + "type": "boolean" + } + }, + "primaryKey": [] + }, + "b": { + "columns": { + "": { + "type": "json" + } + }, + "primaryKey": [ + "" + ] + } + } + }, + "normalized": "{\"tables\":{\"b\":{\"columns\":{\"\":{\"type\":\"json\"}},\"primaryKey\":[\"\"]},\"issue\":{\"columns\":{\"a\":{\"type\":\"null\"},\"comment\":{\"type\":\"boolean\"}},\"primaryKey\":[\"a\",\"comment\"]},\"é\":{\"columns\":{\"ab\":{\"type\":\"boolean\"},\"e\":{\"type\":\"number\"}},\"primaryKey\":[]}}}" + }, + { + "schema": { + "tables": {} + }, + "normalized": "{\"tables\":{}}" + }, + { + "schema": { + "tables": {} + }, + "normalized": "{\"tables\":{}}" + }, + { + "schema": { + "tables": { + "issue": { + "columns": { + "Issue": { + "type": "null" + }, + "comment": { + "type": "number" + }, + "_": { + "type": "null" + } + }, + "primaryKey": [ + "_" + ] + }, + "": { + "columns": { + "😀": { + "type": "null" + }, + "𝒜": { + "type": "json" + } + }, + "primaryKey": [] + }, + "ab": { + "columns": { + "𝒜": { + "type": "null" + } + }, + "primaryKey": [] + } + } + }, + "normalized": "{\"tables\":{\"\":{\"columns\":{\"𝒜\":{\"type\":\"json\"},\"😀\":{\"type\":\"null\"}},\"primaryKey\":[]},\"ab\":{\"columns\":{\"𝒜\":{\"type\":\"null\"}},\"primaryKey\":[]},\"issue\":{\"columns\":{\"Issue\":{\"type\":\"null\"},\"_\":{\"type\":\"null\"},\"comment\":{\"type\":\"number\"}},\"primaryKey\":[\"_\"]}}}" + }, + { + "schema": { + "tables": { + "ab": { + "columns": { + "𝒜": { + "type": "json" + }, + "b": { + "type": "json" + } + }, + "primaryKey": [ + "𝒜" + ] + }, + "e": { + "columns": { + "𝒜": { + "type": "json" + }, + "_": { + "type": "json" + }, + "é": { + "type": "string" + }, + "Issue": { + "type": "number" + } + }, + "primaryKey": [ + "_", + "é", + "𝒜" + ] + }, + "comment": { + "columns": { + "😀": { + "type": "json" + } + }, + "primaryKey": [ + "😀" + ] + } + } + }, + "normalized": "{\"tables\":{\"ab\":{\"columns\":{\"b\":{\"type\":\"json\"},\"𝒜\":{\"type\":\"json\"}},\"primaryKey\":[\"𝒜\"]},\"comment\":{\"columns\":{\"😀\":{\"type\":\"json\"}},\"primaryKey\":[\"😀\"]},\"e\":{\"columns\":{\"Issue\":{\"type\":\"number\"},\"_\":{\"type\":\"json\"},\"é\":{\"type\":\"string\"},\"𝒜\":{\"type\":\"json\"}},\"primaryKey\":[\"_\",\"é\",\"𝒜\"]}}}" + }, + { + "schema": { + "tables": { + "e": { + "columns": { + "_": { + "type": "json" + } + }, + "primaryKey": [] + } + } + }, + "normalized": "{\"tables\":{\"e\":{\"columns\":{\"_\":{\"type\":\"json\"}},\"primaryKey\":[]}}}" + }, + { + "schema": { + "tables": { + "comment": { + "columns": { + "": { + "type": "number" + }, + "b": { + "type": "boolean" + }, + "😀": { + "type": "null" + } + }, + "primaryKey": [ + "b" + ] + }, + "a": { + "columns": { + "issue": { + "type": "string" + }, + "": { + "type": "json" + } + }, + "primaryKey": [ + "issue", + "" + ] + }, + "é": { + "columns": { + "_": { + "type": "boolean" + }, + "😀": { + "type": "number" + }, + "Issue": { + "type": "null" + } + }, + "primaryKey": [] + } + } + }, + "normalized": "{\"tables\":{\"a\":{\"columns\":{\"issue\":{\"type\":\"string\"},\"\":{\"type\":\"json\"}},\"primaryKey\":[\"issue\",\"\"]},\"comment\":{\"columns\":{\"b\":{\"type\":\"boolean\"},\"😀\":{\"type\":\"null\"},\"\":{\"type\":\"number\"}},\"primaryKey\":[\"b\"]},\"é\":{\"columns\":{\"Issue\":{\"type\":\"null\"},\"_\":{\"type\":\"boolean\"},\"😀\":{\"type\":\"number\"}},\"primaryKey\":[]}}}" + }, + { + "schema": { + "tables": {} + }, + "normalized": "{\"tables\":{}}" + }, + { + "schema": { + "tables": { + "é": { + "columns": { + "": { + "type": "boolean" + }, + "b": { + "type": "json" + }, + "ab": { + "type": "number" + }, + "issue": { + "type": "string" + } + }, + "primaryKey": [ + "ab" + ] + }, + "Issue": { + "columns": {}, + "primaryKey": [] + }, + "ab": { + "columns": { + "": { + "type": "boolean" + }, + "b": { + "type": "null" + }, + "_": { + "type": "string" + }, + "é": { + "type": "null" + } + }, + "primaryKey": [] + } + } + }, + "normalized": "{\"tables\":{\"Issue\":{\"columns\":{},\"primaryKey\":[]},\"ab\":{\"columns\":{\"_\":{\"type\":\"string\"},\"b\":{\"type\":\"null\"},\"é\":{\"type\":\"null\"},\"\":{\"type\":\"boolean\"}},\"primaryKey\":[]},\"é\":{\"columns\":{\"ab\":{\"type\":\"number\"},\"b\":{\"type\":\"json\"},\"issue\":{\"type\":\"string\"},\"\":{\"type\":\"boolean\"}},\"primaryKey\":[\"ab\"]}}}" + }, + { + "schema": { + "tables": {} + }, + "normalized": "{\"tables\":{}}" + }, + { + "schema": { + "tables": {} + }, + "normalized": "{\"tables\":{}}" + }, + { + "schema": { + "tables": { + "𝒜": { + "columns": { + "": { + "type": "null" + }, + "𝒜": { + "type": "number" + } + }, + "primaryKey": [ + "" + ] + }, + "": { + "columns": { + "b": { + "type": "string" + }, + "issue": { + "type": "string" + }, + "é": { + "type": "string" + } + }, + "primaryKey": [ + "issue" + ] + }, + "issue": { + "columns": {}, + "primaryKey": [] + } + } + }, + "normalized": "{\"tables\":{\"\":{\"columns\":{\"b\":{\"type\":\"string\"},\"issue\":{\"type\":\"string\"},\"é\":{\"type\":\"string\"}},\"primaryKey\":[\"issue\"]},\"issue\":{\"columns\":{},\"primaryKey\":[]},\"𝒜\":{\"columns\":{\"𝒜\":{\"type\":\"number\"},\"\":{\"type\":\"null\"}},\"primaryKey\":[\"\"]}}}" + }, + { + "schema": { + "tables": { + "_": { + "columns": { + "Issue": { + "type": "json" + }, + "é": { + "type": "json" + }, + "issue": { + "type": "boolean" + } + }, + "primaryKey": [ + "issue", + "é" + ] + }, + "comment": { + "columns": { + "b": { + "type": "json" + }, + "a": { + "type": "string" + } + }, + "primaryKey": [ + "a", + "b" + ] + } + } + }, + "normalized": "{\"tables\":{\"_\":{\"columns\":{\"Issue\":{\"type\":\"json\"},\"issue\":{\"type\":\"boolean\"},\"é\":{\"type\":\"json\"}},\"primaryKey\":[\"issue\",\"é\"]},\"comment\":{\"columns\":{\"a\":{\"type\":\"string\"},\"b\":{\"type\":\"json\"}},\"primaryKey\":[\"a\",\"b\"]}}}" + }, + { + "schema": { + "tables": {} + }, + "normalized": "{\"tables\":{}}" + }, + { + "schema": { + "tables": { + "é": { + "columns": { + "𝒜": { + "type": "boolean" + }, + "": { + "type": "number" + } + }, + "primaryKey": [] + }, + "ab": { + "columns": { + "": { + "type": "number" + } + }, + "primaryKey": [ + "" + ] + }, + "": { + "columns": { + "𝒜": { + "type": "number" + }, + "😀": { + "type": "string" + } + }, + "primaryKey": [ + "𝒜" + ] + }, + "a": { + "columns": {}, + "primaryKey": [] + } + } + }, + "normalized": "{\"tables\":{\"\":{\"columns\":{\"𝒜\":{\"type\":\"number\"},\"😀\":{\"type\":\"string\"}},\"primaryKey\":[\"𝒜\"]},\"a\":{\"columns\":{},\"primaryKey\":[]},\"ab\":{\"columns\":{\"\":{\"type\":\"number\"}},\"primaryKey\":[\"\"]},\"é\":{\"columns\":{\"\":{\"type\":\"number\"},\"𝒜\":{\"type\":\"boolean\"}},\"primaryKey\":[]}}}" + }, + { + "schema": { + "tables": { + "": { + "columns": { + "a": { + "type": "boolean" + }, + "😀": { + "type": "string" + } + }, + "primaryKey": [] + }, + "b": { + "columns": { + "comment": { + "type": "number" + }, + "𝒜": { + "type": "null" + }, + "é": { + "type": "number" + } + }, + "primaryKey": [ + "𝒜" + ] + }, + "Issue": { + "columns": { + "ab": { + "type": "number" + } + }, + "primaryKey": [ + "ab" + ] + } + } + }, + "normalized": "{\"tables\":{\"Issue\":{\"columns\":{\"ab\":{\"type\":\"number\"}},\"primaryKey\":[\"ab\"]},\"b\":{\"columns\":{\"comment\":{\"type\":\"number\"},\"é\":{\"type\":\"number\"},\"𝒜\":{\"type\":\"null\"}},\"primaryKey\":[\"𝒜\"]},\"\":{\"columns\":{\"a\":{\"type\":\"boolean\"},\"😀\":{\"type\":\"string\"}},\"primaryKey\":[]}}}" + }, + { + "schema": { + "tables": {} + }, + "normalized": "{\"tables\":{}}" + }, + { + "schema": { + "tables": { + "a": { + "columns": { + "ab": { + "type": "boolean" + } + }, + "primaryKey": [ + "ab" + ] + }, + "comment": { + "columns": { + "": { + "type": "null" + }, + "😀": { + "type": "boolean" + }, + "é": { + "type": "json" + } + }, + "primaryKey": [ + "é" + ] + } + } + }, + "normalized": "{\"tables\":{\"a\":{\"columns\":{\"ab\":{\"type\":\"boolean\"}},\"primaryKey\":[\"ab\"]},\"comment\":{\"columns\":{\"é\":{\"type\":\"json\"},\"😀\":{\"type\":\"boolean\"},\"\":{\"type\":\"null\"}},\"primaryKey\":[\"é\"]}}}" + }, + { + "schema": { + "tables": {} + }, + "normalized": "{\"tables\":{}}" + }, + { + "schema": { + "tables": { + "a": { + "columns": { + "": { + "type": "number" + }, + "b": { + "type": "number" + }, + "comment": { + "type": "string" + } + }, + "primaryKey": [] + } + } + }, + "normalized": "{\"tables\":{\"a\":{\"columns\":{\"b\":{\"type\":\"number\"},\"comment\":{\"type\":\"string\"},\"\":{\"type\":\"number\"}},\"primaryKey\":[]}}}" + }, + { + "schema": { + "tables": { + "ab": { + "columns": { + "Issue": { + "type": "number" + } + }, + "primaryKey": [ + "Issue" + ] + } + } + }, + "normalized": "{\"tables\":{\"ab\":{\"columns\":{\"Issue\":{\"type\":\"number\"}},\"primaryKey\":[\"Issue\"]}}}" + }, + { + "schema": { + "tables": { + "b": { + "columns": { + "comment": { + "type": "number" + }, + "": { + "type": "boolean" + } + }, + "primaryKey": [ + "comment", + "" + ] + }, + "ab": { + "columns": { + "a": { + "type": "json" + }, + "𝒜": { + "type": "number" + }, + "é": { + "type": "boolean" + } + }, + "primaryKey": [ + "a", + "é" + ] + } + } + }, + "normalized": "{\"tables\":{\"ab\":{\"columns\":{\"a\":{\"type\":\"json\"},\"é\":{\"type\":\"boolean\"},\"𝒜\":{\"type\":\"number\"}},\"primaryKey\":[\"a\",\"é\"]},\"b\":{\"columns\":{\"comment\":{\"type\":\"number\"},\"\":{\"type\":\"boolean\"}},\"primaryKey\":[\"comment\",\"\"]}}}" + }, + { + "schema": { + "tables": { + "ab": { + "columns": { + "_": { + "type": "boolean" + } + }, + "primaryKey": [ + "_" + ] + }, + "𝒜": { + "columns": { + "ab": { + "type": "string" + }, + "b": { + "type": "string" + }, + "": { + "type": "number" + } + }, + "primaryKey": [ + "b" + ] + }, + "a": { + "columns": { + "": { + "type": "null" + }, + "e": { + "type": "number" + } + }, + "primaryKey": [ + "e", + "" + ] + } + } + }, + "normalized": "{\"tables\":{\"a\":{\"columns\":{\"e\":{\"type\":\"number\"},\"\":{\"type\":\"null\"}},\"primaryKey\":[\"e\",\"\"]},\"ab\":{\"columns\":{\"_\":{\"type\":\"boolean\"}},\"primaryKey\":[\"_\"]},\"𝒜\":{\"columns\":{\"ab\":{\"type\":\"string\"},\"b\":{\"type\":\"string\"},\"\":{\"type\":\"number\"}},\"primaryKey\":[\"b\"]}}}" + }, + { + "schema": { + "tables": { + "b": { + "columns": { + "b": { + "type": "boolean" + }, + "_": { + "type": "string" + }, + "𝒜": { + "type": "string" + }, + "issue": { + "type": "number" + } + }, + "primaryKey": [ + "issue" + ] + }, + "Issue": { + "columns": { + "": { + "type": "json" + } + }, + "primaryKey": [ + "" + ] + }, + "issue": { + "columns": { + "𝒜": { + "type": "number" + }, + "ab": { + "type": "null" + }, + "_": { + "type": "string" + }, + "😀": { + "type": "null" + } + }, + "primaryKey": [ + "_", + "ab" + ] + } + } + }, + "normalized": "{\"tables\":{\"Issue\":{\"columns\":{\"\":{\"type\":\"json\"}},\"primaryKey\":[\"\"]},\"b\":{\"columns\":{\"_\":{\"type\":\"string\"},\"b\":{\"type\":\"boolean\"},\"issue\":{\"type\":\"number\"},\"𝒜\":{\"type\":\"string\"}},\"primaryKey\":[\"issue\"]},\"issue\":{\"columns\":{\"_\":{\"type\":\"string\"},\"ab\":{\"type\":\"null\"},\"𝒜\":{\"type\":\"number\"},\"😀\":{\"type\":\"null\"}},\"primaryKey\":[\"_\",\"ab\"]}}}" + }, + { + "schema": { + "tables": { + "Issue": { + "columns": { + "Issue": { + "type": "json" + }, + "𝒜": { + "type": "boolean" + } + }, + "primaryKey": [] + }, + "_": { + "columns": {}, + "primaryKey": [] + } + } + }, + "normalized": "{\"tables\":{\"Issue\":{\"columns\":{\"Issue\":{\"type\":\"json\"},\"𝒜\":{\"type\":\"boolean\"}},\"primaryKey\":[]},\"_\":{\"columns\":{},\"primaryKey\":[]}}}" + }, + { + "schema": { + "tables": {} + }, + "normalized": "{\"tables\":{}}" + }, + { + "schema": { + "tables": { + "a": { + "columns": {}, + "primaryKey": [] + }, + "𝒜": { + "columns": { + "Issue": { + "type": "json" + } + }, + "primaryKey": [] + }, + "issue": { + "columns": { + "a": { + "type": "null" + }, + "Issue": { + "type": "number" + }, + "": { + "type": "json" + } + }, + "primaryKey": [] + }, + "ab": { + "columns": { + "a": { + "type": "number" + } + }, + "primaryKey": [ + "a" + ] + } + } + }, + "normalized": "{\"tables\":{\"a\":{\"columns\":{},\"primaryKey\":[]},\"ab\":{\"columns\":{\"a\":{\"type\":\"number\"}},\"primaryKey\":[\"a\"]},\"issue\":{\"columns\":{\"Issue\":{\"type\":\"number\"},\"a\":{\"type\":\"null\"},\"\":{\"type\":\"json\"}},\"primaryKey\":[]},\"𝒜\":{\"columns\":{\"Issue\":{\"type\":\"json\"}},\"primaryKey\":[]}}}" + }, + { + "schema": { + "tables": { + "": { + "columns": { + "b": { + "type": "number" + }, + "Issue": { + "type": "null" + }, + "e": { + "type": "string" + } + }, + "primaryKey": [] + }, + "comment": { + "columns": {}, + "primaryKey": [] + }, + "Issue": { + "columns": { + "é": { + "type": "null" + }, + "": { + "type": "boolean" + }, + "😀": { + "type": "boolean" + }, + "𝒜": { + "type": "null" + } + }, + "primaryKey": [ + "é" + ] + } + } + }, + "normalized": "{\"tables\":{\"\":{\"columns\":{\"Issue\":{\"type\":\"null\"},\"b\":{\"type\":\"number\"},\"e\":{\"type\":\"string\"}},\"primaryKey\":[]},\"Issue\":{\"columns\":{\"é\":{\"type\":\"null\"},\"𝒜\":{\"type\":\"null\"},\"😀\":{\"type\":\"boolean\"},\"\":{\"type\":\"boolean\"}},\"primaryKey\":[\"é\"]},\"comment\":{\"columns\":{},\"primaryKey\":[]}}}" + }, + { + "schema": { + "tables": {} + }, + "normalized": "{\"tables\":{}}" + }, + { + "schema": { + "tables": { + "_": { + "columns": {}, + "primaryKey": [] + }, + "b": { + "columns": { + "😀": { + "type": "null" + } + }, + "primaryKey": [] + } + } + }, + "normalized": "{\"tables\":{\"_\":{\"columns\":{},\"primaryKey\":[]},\"b\":{\"columns\":{\"😀\":{\"type\":\"null\"}},\"primaryKey\":[]}}}" + }, + { + "schema": { + "tables": {} + }, + "normalized": "{\"tables\":{}}" + }, + { + "schema": { + "tables": { + "": { + "columns": {}, + "primaryKey": [] + }, + "Issue": { + "columns": { + "_": { + "type": "string" + } + }, + "primaryKey": [ + "_" + ] + }, + "": { + "columns": { + "é": { + "type": "null" + } + }, + "primaryKey": [ + "é" + ] + } + } + }, + "normalized": "{\"tables\":{\"\":{\"columns\":{},\"primaryKey\":[]},\"Issue\":{\"columns\":{\"_\":{\"type\":\"string\"}},\"primaryKey\":[\"_\"]},\"\":{\"columns\":{\"é\":{\"type\":\"null\"}},\"primaryKey\":[\"é\"]}}}" + }, + { + "schema": { + "tables": { + "a": { + "columns": { + "é": { + "type": "json" + }, + "": { + "type": "json" + } + }, + "primaryKey": [ + "é" + ] + } + } + }, + "normalized": "{\"tables\":{\"a\":{\"columns\":{\"é\":{\"type\":\"json\"},\"\":{\"type\":\"json\"}},\"primaryKey\":[\"é\"]}}}" + }, + { + "schema": { + "tables": {} + }, + "normalized": "{\"tables\":{}}" + }, + { + "schema": { + "tables": { + "𝒜": { + "columns": { + "é": { + "type": "json" + }, + "𝒜": { + "type": "number" + }, + "comment": { + "type": "boolean" + }, + "Issue": { + "type": "boolean" + } + }, + "primaryKey": [ + "Issue", + "comment" + ] + }, + "_": { + "columns": { + "_": { + "type": "number" + }, + "😀": { + "type": "json" + }, + "": { + "type": "null" + }, + "": { + "type": "string" + } + }, + "primaryKey": [ + "😀" + ] + }, + "e": { + "columns": {}, + "primaryKey": [] + }, + "ab": { + "columns": {}, + "primaryKey": [] + } + } + }, + "normalized": "{\"tables\":{\"_\":{\"columns\":{\"\":{\"type\":\"null\"},\"_\":{\"type\":\"number\"},\"😀\":{\"type\":\"json\"},\"\":{\"type\":\"string\"}},\"primaryKey\":[\"😀\"]},\"ab\":{\"columns\":{},\"primaryKey\":[]},\"e\":{\"columns\":{},\"primaryKey\":[]},\"𝒜\":{\"columns\":{\"Issue\":{\"type\":\"boolean\"},\"comment\":{\"type\":\"boolean\"},\"é\":{\"type\":\"json\"},\"𝒜\":{\"type\":\"number\"}},\"primaryKey\":[\"Issue\",\"comment\"]}}}" + }, + { + "schema": { + "tables": {} + }, + "normalized": "{\"tables\":{}}" + }, + { + "schema": { + "tables": { + "Issue": { + "columns": { + "issue": { + "type": "boolean" + }, + "😀": { + "type": "number" + }, + "b": { + "type": "number" + }, + "": { + "type": "json" + } + }, + "primaryKey": [ + "" + ] + }, + "ab": { + "columns": { + "": { + "type": "boolean" + }, + "😀": { + "type": "number" + }, + "issue": { + "type": "null" + } + }, + "primaryKey": [ + "😀" + ] + } + } + }, + "normalized": "{\"tables\":{\"Issue\":{\"columns\":{\"\":{\"type\":\"json\"},\"b\":{\"type\":\"number\"},\"issue\":{\"type\":\"boolean\"},\"😀\":{\"type\":\"number\"}},\"primaryKey\":[\"\"]},\"ab\":{\"columns\":{\"\":{\"type\":\"boolean\"},\"issue\":{\"type\":\"null\"},\"😀\":{\"type\":\"number\"}},\"primaryKey\":[\"😀\"]}}}" + }, + { + "schema": { + "tables": {} + }, + "normalized": "{\"tables\":{}}" + }, + { + "schema": { + "tables": {} + }, + "normalized": "{\"tables\":{}}" + }, + { + "schema": { + "tables": { + "comment": { + "columns": { + "ab": { + "type": "boolean" + }, + "": { + "type": "null" + }, + "": { + "type": "number" + }, + "_": { + "type": "json" + } + }, + "primaryKey": [] + }, + "𝒜": { + "columns": { + "": { + "type": "string" + }, + "e": { + "type": "number" + }, + "comment": { + "type": "number" + } + }, + "primaryKey": [ + "comment" + ] + } + } + }, + "normalized": "{\"tables\":{\"comment\":{\"columns\":{\"\":{\"type\":\"null\"},\"_\":{\"type\":\"json\"},\"ab\":{\"type\":\"boolean\"},\"\":{\"type\":\"number\"}},\"primaryKey\":[]},\"𝒜\":{\"columns\":{\"comment\":{\"type\":\"number\"},\"e\":{\"type\":\"number\"},\"\":{\"type\":\"string\"}},\"primaryKey\":[\"comment\"]}}}" + }, + { + "schema": { + "tables": { + "issue": { + "columns": { + "𝒜": { + "type": "string" + } + }, + "primaryKey": [] + } + } + }, + "normalized": "{\"tables\":{\"issue\":{\"columns\":{\"𝒜\":{\"type\":\"string\"}},\"primaryKey\":[]}}}" + }, + { + "schema": { + "tables": { + "ab": { + "columns": { + "𝒜": { + "type": "string" + }, + "": { + "type": "null" + }, + "ab": { + "type": "null" + } + }, + "primaryKey": [] + }, + "𝒜": { + "columns": { + "issue": { + "type": "json" + }, + "b": { + "type": "string" + } + }, + "primaryKey": [ + "b", + "issue" + ] + } + } + }, + "normalized": "{\"tables\":{\"ab\":{\"columns\":{\"ab\":{\"type\":\"null\"},\"𝒜\":{\"type\":\"string\"},\"\":{\"type\":\"null\"}},\"primaryKey\":[]},\"𝒜\":{\"columns\":{\"b\":{\"type\":\"string\"},\"issue\":{\"type\":\"json\"}},\"primaryKey\":[\"b\",\"issue\"]}}}" + }, + { + "schema": { + "tables": {} + }, + "normalized": "{\"tables\":{}}" + }, + { + "schema": { + "tables": {} + }, + "normalized": "{\"tables\":{}}" + }, + { + "schema": { + "tables": {} + }, + "normalized": "{\"tables\":{}}" + }, + { + "schema": { + "tables": { + "e": { + "columns": { + "a": { + "type": "boolean" + }, + "😀": { + "type": "boolean" + }, + "issue": { + "type": "number" + }, + "é": { + "type": "string" + } + }, + "primaryKey": [ + "a", + "issue", + "😀" + ] + }, + "_": { + "columns": { + "😀": { + "type": "boolean" + }, + "_": { + "type": "null" + } + }, + "primaryKey": [ + "😀" + ] + } + } + }, + "normalized": "{\"tables\":{\"_\":{\"columns\":{\"_\":{\"type\":\"null\"},\"😀\":{\"type\":\"boolean\"}},\"primaryKey\":[\"😀\"]},\"e\":{\"columns\":{\"a\":{\"type\":\"boolean\"},\"issue\":{\"type\":\"number\"},\"é\":{\"type\":\"string\"},\"😀\":{\"type\":\"boolean\"}},\"primaryKey\":[\"a\",\"issue\",\"😀\"]}}}" + }, + { + "schema": { + "tables": {} + }, + "normalized": "{\"tables\":{}}" + }, + { + "schema": { + "tables": { + "issue": { + "columns": { + "comment": { + "type": "string" + }, + "b": { + "type": "string" + }, + "😀": { + "type": "boolean" + }, + "Issue": { + "type": "null" + } + }, + "primaryKey": [ + "Issue", + "comment" + ] + } + } + }, + "normalized": "{\"tables\":{\"issue\":{\"columns\":{\"Issue\":{\"type\":\"null\"},\"b\":{\"type\":\"string\"},\"comment\":{\"type\":\"string\"},\"😀\":{\"type\":\"boolean\"}},\"primaryKey\":[\"Issue\",\"comment\"]}}}" + }, + { + "schema": { + "tables": { + "_": { + "columns": { + "Issue": { + "type": "null" + }, + "a": { + "type": "number" + }, + "_": { + "type": "boolean" + } + }, + "primaryKey": [] + } + } + }, + "normalized": "{\"tables\":{\"_\":{\"columns\":{\"Issue\":{\"type\":\"null\"},\"_\":{\"type\":\"boolean\"},\"a\":{\"type\":\"number\"}},\"primaryKey\":[]}}}" + }, + { + "schema": { + "tables": { + "Issue": { + "columns": {}, + "primaryKey": [] + }, + "𝒜": { + "columns": { + "é": { + "type": "string" + }, + "": { + "type": "null" + }, + "Issue": { + "type": "boolean" + } + }, + "primaryKey": [] + }, + "comment": { + "columns": { + "comment": { + "type": "null" + }, + "a": { + "type": "boolean" + } + }, + "primaryKey": [] + } + } + }, + "normalized": "{\"tables\":{\"Issue\":{\"columns\":{},\"primaryKey\":[]},\"comment\":{\"columns\":{\"a\":{\"type\":\"boolean\"},\"comment\":{\"type\":\"null\"}},\"primaryKey\":[]},\"𝒜\":{\"columns\":{\"\":{\"type\":\"null\"},\"Issue\":{\"type\":\"boolean\"},\"é\":{\"type\":\"string\"}},\"primaryKey\":[]}}}" + } + ] +} diff --git a/packages/zero-conformance/vectors/protocol-downstream.json b/packages/zero-conformance/vectors/protocol-downstream.json new file mode 100644 index 0000000000..54ab8ccfd8 --- /dev/null +++ b/packages/zero-conformance/vectors/protocol-downstream.json @@ -0,0 +1,6622 @@ +{ + "formatVersion": 1, + "kind": "protocol-downstream", + "description": "Downstream messages (`downstreamSchema`). `ok` is whether valita accepts the message in `passthrough` mode; `output` is its `strip` output, with each mutation result as the client reads it (`{error, message?, details?}` if it has an `error` key, else `{data?}`).", + "cases": [ + { + "message": [ + "connected", + { + "wsid": "ws1" + } + ], + "ok": true, + "output": [ + "connected", + { + "wsid": "ws1" + } + ] + }, + { + "message": [ + "connected", + { + "wsid": "ws1", + "timestamp": 1700000000000 + } + ], + "ok": true, + "output": [ + "connected", + { + "wsid": "ws1", + "timestamp": 1700000000000 + } + ] + }, + { + "message": [ + "connected", + { + "wsid": "ws1", + "timestamp": null + } + ], + "ok": false + }, + { + "message": [ + "connected", + { + "wsid": 1 + } + ], + "ok": false + }, + { + "message": [ + "connected", + {} + ], + "ok": false + }, + { + "message": [ + "connected", + { + "wsid": "ws1" + }, + "extra" + ], + "ok": false + }, + { + "message": [ + "connected" + ], + "ok": false + }, + { + "message": [], + "ok": false + }, + { + "message": "connected", + "ok": false + }, + { + "message": null, + "ok": false + }, + { + "message": {}, + "ok": false + }, + { + "message": 5, + "ok": false + }, + { + "message": [ + "unknownMessage", + {} + ], + "ok": false + }, + { + "message": [ + 1, + {} + ], + "ok": false + }, + { + "message": [ + "pong", + {} + ], + "ok": true, + "output": [ + "pong", + {} + ] + }, + { + "message": [ + "pong", + { + "zExtra": 1 + } + ], + "ok": true, + "output": [ + "pong", + {} + ] + }, + { + "message": [ + "pong", + [] + ], + "ok": false + }, + { + "message": [ + "pong", + null + ], + "ok": false + }, + { + "message": [ + "error", + { + "kind": "AuthInvalidated", + "message": "m" + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "AuthInvalidated", + "message": "m" + } + ] + }, + { + "message": [ + "error", + { + "kind": "ClientNotFound", + "message": "m" + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "ClientNotFound", + "message": "m" + } + ] + }, + { + "message": [ + "error", + { + "kind": "InvalidConnectionRequest", + "message": "m" + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "InvalidConnectionRequest", + "message": "m" + } + ] + }, + { + "message": [ + "error", + { + "kind": "InvalidConnectionRequestBaseCookie", + "message": "m" + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "InvalidConnectionRequestBaseCookie", + "message": "m" + } + ] + }, + { + "message": [ + "error", + { + "kind": "InvalidConnectionRequestLastMutationID", + "message": "m" + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "InvalidConnectionRequestLastMutationID", + "message": "m" + } + ] + }, + { + "message": [ + "error", + { + "kind": "InvalidConnectionRequestClientDeleted", + "message": "m" + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "InvalidConnectionRequestClientDeleted", + "message": "m" + } + ] + }, + { + "message": [ + "error", + { + "kind": "InvalidMessage", + "message": "m" + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "InvalidMessage", + "message": "m" + } + ] + }, + { + "message": [ + "error", + { + "kind": "InvalidPush", + "message": "m" + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "InvalidPush", + "message": "m" + } + ] + }, + { + "message": [ + "error", + { + "kind": "MutationFailed", + "message": "m" + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "MutationFailed", + "message": "m" + } + ] + }, + { + "message": [ + "error", + { + "kind": "MutationRateLimited", + "message": "m" + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "MutationRateLimited", + "message": "m" + } + ] + }, + { + "message": [ + "error", + { + "kind": "Unauthorized", + "message": "m" + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "Unauthorized", + "message": "m" + } + ] + }, + { + "message": [ + "error", + { + "kind": "VersionNotSupported", + "message": "m" + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "VersionNotSupported", + "message": "m" + } + ] + }, + { + "message": [ + "error", + { + "kind": "SchemaVersionNotSupported", + "message": "m" + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "SchemaVersionNotSupported", + "message": "m" + } + ] + }, + { + "message": [ + "error", + { + "kind": "Internal", + "message": "m" + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "Internal", + "message": "m" + } + ] + }, + { + "message": [ + "error", + { + "kind": "InvalidMessage", + "message": "m", + "origin": "server" + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "InvalidMessage", + "message": "m", + "origin": "server" + } + ] + }, + { + "message": [ + "error", + { + "kind": "InvalidMessage", + "message": "m", + "origin": "zeroCache" + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "InvalidMessage", + "message": "m", + "origin": "zeroCache" + } + ] + }, + { + "message": [ + "error", + { + "kind": "InvalidMessage", + "message": "m", + "origin": "client" + } + ], + "ok": false + }, + { + "message": [ + "error", + { + "kind": "InvalidMessage" + } + ], + "ok": false + }, + { + "message": [ + "error", + { + "message": "m" + } + ], + "ok": false + }, + { + "message": [ + "error", + { + "kind": "NoSuchKind", + "message": "m" + } + ], + "ok": false + }, + { + "message": [ + "error", + { + "kind": "Rebalance", + "message": "m", + "minBackoffMs": 100, + "maxBackoffMs": 1000, + "reconnectParams": { + "a": "b", + "": "" + }, + "origin": "zeroCache" + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "Rebalance", + "message": "m", + "minBackoffMs": 100, + "maxBackoffMs": 1000, + "reconnectParams": { + "a": "b", + "": "" + }, + "origin": "zeroCache" + } + ] + }, + { + "message": [ + "error", + { + "kind": "Rehome", + "message": "m", + "minBackoffMs": 100, + "maxBackoffMs": 1000, + "reconnectParams": { + "a": "b", + "": "" + }, + "origin": "zeroCache" + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "Rehome", + "message": "m", + "minBackoffMs": 100, + "maxBackoffMs": 1000, + "reconnectParams": { + "a": "b", + "": "" + }, + "origin": "zeroCache" + } + ] + }, + { + "message": [ + "error", + { + "kind": "ServerOverloaded", + "message": "m", + "minBackoffMs": 100, + "maxBackoffMs": 1000, + "reconnectParams": { + "a": "b", + "": "" + }, + "origin": "zeroCache" + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "ServerOverloaded", + "message": "m", + "minBackoffMs": 100, + "maxBackoffMs": 1000, + "reconnectParams": { + "a": "b", + "": "" + }, + "origin": "zeroCache" + } + ] + }, + { + "message": [ + "error", + { + "kind": "Rebalance", + "message": "m", + "origin": "server" + } + ], + "ok": false + }, + { + "message": [ + "error", + { + "kind": "Rehome", + "message": "m", + "reconnectParams": { + "a": 1 + } + } + ], + "ok": false + }, + { + "message": [ + "error", + { + "kind": "PushFailed", + "message": "m", + "mutationIDs": [ + { + "id": 1, + "clientID": "c" + } + ], + "origin": "server", + "reason": "database" + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "PushFailed", + "message": "m", + "mutationIDs": [ + { + "id": 1, + "clientID": "c" + } + ], + "origin": "server", + "reason": "database" + } + ] + }, + { + "message": [ + "error", + { + "kind": "PushFailed", + "message": "m", + "mutationIDs": [ + { + "id": 1, + "clientID": "c" + } + ], + "origin": "server", + "reason": "parse" + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "PushFailed", + "message": "m", + "mutationIDs": [ + { + "id": 1, + "clientID": "c" + } + ], + "origin": "server", + "reason": "parse" + } + ] + }, + { + "message": [ + "error", + { + "kind": "PushFailed", + "message": "m", + "mutationIDs": [ + { + "id": 1, + "clientID": "c" + } + ], + "origin": "server", + "reason": "oooMutation" + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "PushFailed", + "message": "m", + "mutationIDs": [ + { + "id": 1, + "clientID": "c" + } + ], + "origin": "server", + "reason": "oooMutation" + } + ] + }, + { + "message": [ + "error", + { + "kind": "PushFailed", + "message": "m", + "mutationIDs": [ + { + "id": 1, + "clientID": "c" + } + ], + "origin": "server", + "reason": "unsupportedPushVersion" + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "PushFailed", + "message": "m", + "mutationIDs": [ + { + "id": 1, + "clientID": "c" + } + ], + "origin": "server", + "reason": "unsupportedPushVersion" + } + ] + }, + { + "message": [ + "error", + { + "kind": "PushFailed", + "message": "m", + "mutationIDs": [ + { + "id": 1, + "clientID": "c" + } + ], + "origin": "server", + "reason": "internal" + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "PushFailed", + "message": "m", + "mutationIDs": [ + { + "id": 1, + "clientID": "c" + } + ], + "origin": "server", + "reason": "internal" + } + ] + }, + { + "message": [ + "error", + { + "kind": "PushFailed", + "message": "m", + "mutationIDs": [ + { + "id": 1, + "clientID": "c" + } + ], + "origin": "server", + "reason": "http" + } + ], + "ok": false + }, + { + "message": [ + "error", + { + "kind": "PushFailed", + "message": "m", + "mutationIDs": [ + { + "id": 1, + "clientID": "c" + } + ], + "origin": "server", + "reason": "timeout" + } + ], + "ok": false + }, + { + "message": [ + "error", + { + "kind": "PushFailed", + "message": "m", + "mutationIDs": [], + "origin": "zeroCache", + "reason": "timeout" + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "PushFailed", + "message": "m", + "mutationIDs": [], + "origin": "zeroCache", + "reason": "timeout" + } + ] + }, + { + "message": [ + "error", + { + "kind": "PushFailed", + "message": "m", + "mutationIDs": [], + "origin": "zeroCache", + "reason": "parse" + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "PushFailed", + "message": "m", + "mutationIDs": [], + "origin": "zeroCache", + "reason": "parse" + } + ] + }, + { + "message": [ + "error", + { + "kind": "PushFailed", + "message": "m", + "mutationIDs": [], + "origin": "zeroCache", + "reason": "internal" + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "PushFailed", + "message": "m", + "mutationIDs": [], + "origin": "zeroCache", + "reason": "internal" + } + ] + }, + { + "message": [ + "error", + { + "kind": "PushFailed", + "message": "m", + "mutationIDs": [], + "origin": "zeroCache", + "reason": "database" + } + ], + "ok": false + }, + { + "message": [ + "error", + { + "kind": "PushFailed", + "message": "m", + "mutationIDs": [], + "origin": "zeroCache", + "reason": "oooMutation" + } + ], + "ok": false + }, + { + "message": [ + "error", + { + "kind": "PushFailed", + "message": "m", + "mutationIDs": [ + { + "id": 1, + "clientID": "c" + }, + { + "id": 2, + "clientID": "c" + } + ], + "origin": "zeroCache", + "reason": "http", + "status": 500, + "bodyPreview": "", + "details": { + "a": [ + 1, + null + ] + } + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "PushFailed", + "mutationIDs": [ + { + "id": 1, + "clientID": "c" + }, + { + "id": 2, + "clientID": "c" + } + ], + "message": "m", + "origin": "zeroCache", + "reason": "http", + "status": 500, + "bodyPreview": "", + "details": { + "a": [ + 1, + null + ] + } + } + ] + }, + { + "message": [ + "error", + { + "kind": "PushFailed", + "message": "m", + "mutationIDs": [], + "origin": "zeroCache", + "reason": "http" + } + ], + "ok": false + }, + { + "message": [ + "error", + { + "kind": "PushFailed", + "message": "m", + "origin": "server", + "reason": "parse" + } + ], + "ok": false + }, + { + "message": [ + "error", + { + "kind": "PushFailed", + "message": "m", + "mutationIDs": [], + "origin": "client", + "reason": "parse" + } + ], + "ok": false + }, + { + "message": [ + "error", + { + "kind": "TransformFailed", + "message": "m", + "queryIDs": [ + "q" + ], + "origin": "server", + "reason": "database" + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "TransformFailed", + "message": "m", + "queryIDs": [ + "q" + ], + "origin": "server", + "reason": "database" + } + ] + }, + { + "message": [ + "error", + { + "kind": "TransformFailed", + "message": "m", + "queryIDs": [ + "q" + ], + "origin": "server", + "reason": "parse" + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "TransformFailed", + "message": "m", + "queryIDs": [ + "q" + ], + "origin": "server", + "reason": "parse" + } + ] + }, + { + "message": [ + "error", + { + "kind": "TransformFailed", + "message": "m", + "queryIDs": [ + "q" + ], + "origin": "server", + "reason": "internal" + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "TransformFailed", + "message": "m", + "queryIDs": [ + "q" + ], + "origin": "server", + "reason": "internal" + } + ] + }, + { + "message": [ + "error", + { + "kind": "TransformFailed", + "message": "m", + "queryIDs": [ + "q" + ], + "origin": "server", + "reason": "oooMutation" + } + ], + "ok": false + }, + { + "message": [ + "error", + { + "kind": "TransformFailed", + "message": "m", + "queryIDs": [ + "q" + ], + "origin": "server", + "reason": "http" + } + ], + "ok": false + }, + { + "message": [ + "error", + { + "kind": "TransformFailed", + "message": "m", + "queryIDs": [], + "origin": "zeroCache", + "reason": "timeout" + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "TransformFailed", + "message": "m", + "queryIDs": [], + "origin": "zeroCache", + "reason": "timeout" + } + ] + }, + { + "message": [ + "error", + { + "kind": "TransformFailed", + "message": "m", + "queryIDs": [], + "origin": "zeroCache", + "reason": "parse" + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "TransformFailed", + "message": "m", + "queryIDs": [], + "origin": "zeroCache", + "reason": "parse" + } + ] + }, + { + "message": [ + "error", + { + "kind": "TransformFailed", + "message": "m", + "queryIDs": [], + "origin": "zeroCache", + "reason": "internal" + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "TransformFailed", + "message": "m", + "queryIDs": [], + "origin": "zeroCache", + "reason": "internal" + } + ] + }, + { + "message": [ + "error", + { + "kind": "TransformFailed", + "message": "m", + "queryIDs": [], + "origin": "zeroCache", + "reason": "database" + } + ], + "ok": false + }, + { + "message": [ + "error", + { + "kind": "TransformFailed", + "message": "m", + "queryIDs": [ + "q1", + "q2" + ], + "origin": "zeroCache", + "reason": "http", + "status": 404, + "details": "d" + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "TransformFailed", + "queryIDs": [ + "q1", + "q2" + ], + "message": "m", + "origin": "zeroCache", + "reason": "http", + "status": 404, + "details": "d" + } + ] + }, + { + "message": [ + "error", + { + "kind": "TransformFailed", + "message": "m", + "mutationIDs": [], + "origin": "server", + "reason": "parse" + } + ], + "ok": false + }, + { + "message": [ + "pokeStart", + { + "pokeID": "p", + "baseCookie": null + } + ], + "ok": true, + "output": [ + "pokeStart", + { + "pokeID": "p", + "baseCookie": null + } + ] + }, + { + "message": [ + "pokeStart", + { + "pokeID": "p", + "baseCookie": "1", + "schemaVersions": { + "minSupportedVersion": 1, + "maxSupportedVersion": 2 + }, + "timestamp": 5 + } + ], + "ok": true, + "output": [ + "pokeStart", + { + "pokeID": "p", + "baseCookie": "1", + "schemaVersions": { + "minSupportedVersion": 1, + "maxSupportedVersion": 2 + }, + "timestamp": 5 + } + ] + }, + { + "message": [ + "pokeStart", + { + "pokeID": "p" + } + ], + "ok": false + }, + { + "message": [ + "pokeStart", + { + "pokeID": "p", + "baseCookie": 1 + } + ], + "ok": false + }, + { + "message": [ + "pokeStart", + { + "pokeID": "p", + "baseCookie": "1", + "schemaVersions": { + "minSupportedVersion": 1 + } + } + ], + "ok": false + }, + { + "message": [ + "pokePart", + { + "pokeID": "p" + } + ], + "ok": true, + "output": [ + "pokePart", + { + "pokeID": "p" + } + ] + }, + { + "message": [ + "pokePart", + {} + ], + "ok": false + }, + { + "message": [ + "pokePart", + { + "pokeID": "p", + "rowsPatch": [ + { + "op": "put", + "tableName": "t", + "value": { + "id": 1, + "x": null, + "j": { + "a": [ + 1 + ] + } + } + }, + { + "op": "update", + "tableName": "t", + "id": { + "id": 1 + }, + "merge": { + "x": 2 + }, + "constrain": [ + "id", + "x" + ] + }, + { + "op": "update", + "tableName": "t", + "id": { + "id": "a", + "b": true + }, + "merge": [ + 1, + 2 + ] + }, + { + "op": "update", + "tableName": "t", + "id": { + "id": "a" + } + }, + { + "op": "del", + "tableName": "t", + "id": { + "id": 1.5 + } + }, + { + "op": "del", + "tableName": "t", + "id": {} + }, + { + "op": "clear" + } + ] + } + ], + "ok": true, + "output": [ + "pokePart", + { + "pokeID": "p", + "rowsPatch": [ + { + "op": "put", + "tableName": "t", + "value": { + "id": 1, + "x": null, + "j": { + "a": [ + 1 + ] + } + } + }, + { + "op": "update", + "tableName": "t", + "id": { + "id": 1 + }, + "merge": { + "x": 2 + }, + "constrain": [ + "id", + "x" + ] + }, + { + "op": "update", + "tableName": "t", + "id": { + "id": "a", + "b": true + }, + "merge": [ + 1, + 2 + ] + }, + { + "op": "update", + "tableName": "t", + "id": { + "id": "a" + } + }, + { + "op": "del", + "tableName": "t", + "id": { + "id": 1.5 + } + }, + { + "op": "del", + "tableName": "t", + "id": {} + }, + { + "op": "clear" + } + ] + } + ] + }, + { + "message": [ + "pokePart", + { + "pokeID": "p", + "rowsPatch": [ + { + "op": "update", + "tableName": "t", + "id": { + "id": 1 + }, + "merge": "x" + } + ] + } + ], + "ok": false + }, + { + "message": [ + "pokePart", + { + "pokeID": "p", + "rowsPatch": [ + { + "op": "update", + "tableName": "t", + "id": { + "id": 1 + }, + "merge": null + } + ] + } + ], + "ok": false + }, + { + "message": [ + "pokePart", + { + "pokeID": "p", + "rowsPatch": [ + { + "op": "del", + "tableName": "t", + "id": { + "id": null + } + } + ] + } + ], + "ok": false + }, + { + "message": [ + "pokePart", + { + "pokeID": "p", + "rowsPatch": [ + { + "op": "del", + "tableName": "t", + "id": [] + } + ] + } + ], + "ok": false + }, + { + "message": [ + "pokePart", + { + "pokeID": "p", + "rowsPatch": [ + { + "op": "put", + "tableName": "t", + "value": [] + } + ] + } + ], + "ok": false + }, + { + "message": [ + "pokePart", + { + "pokeID": "p", + "rowsPatch": [ + { + "op": "put", + "tableName": "t" + } + ] + } + ], + "ok": false + }, + { + "message": [ + "pokePart", + { + "pokeID": "p", + "rowsPatch": [ + { + "op": "upsert", + "tableName": "t", + "value": {} + } + ] + } + ], + "ok": false + }, + { + "message": [ + "pokePart", + { + "pokeID": "p", + "rowsPatch": {} + } + ], + "ok": false + }, + { + "message": [ + "pokePart", + { + "pokeID": "p", + "lastMutationIDChanges": { + "c1": 5, + "c2": 0 + }, + "desiredQueriesPatches": { + "c1": [ + { + "op": "put", + "hash": "h", + "ttl": 1000 + }, + { + "op": "del", + "hash": "h2" + }, + { + "op": "clear" + } + ], + "c2": [] + }, + "gotQueriesPatch": [ + { + "op": "put", + "hash": "h" + } + ] + } + ], + "ok": true, + "output": [ + "pokePart", + { + "pokeID": "p", + "lastMutationIDChanges": { + "c1": 5, + "c2": 0 + }, + "desiredQueriesPatches": { + "c1": [ + { + "op": "put", + "hash": "h", + "ttl": 1000 + }, + { + "op": "del", + "hash": "h2" + }, + { + "op": "clear" + } + ], + "c2": [] + }, + "gotQueriesPatch": [ + { + "op": "put", + "hash": "h" + } + ] + } + ] + }, + { + "message": [ + "pokePart", + { + "pokeID": "p", + "lastMutationIDChanges": { + "c1": "5" + } + } + ], + "ok": false + }, + { + "message": [ + "pokePart", + { + "pokeID": "p", + "gotQueriesPatch": [ + { + "op": "put", + "hash": "h", + "ttl": null + } + ] + } + ], + "ok": false + }, + { + "message": [ + "pokePart", + { + "pokeID": "p", + "gotQueriesPatch": [ + { + "op": "add", + "hash": "h" + } + ] + } + ], + "ok": false + }, + { + "message": [ + "pokePart", + { + "pokeID": "p", + "mutationsPatch": [ + { + "op": "put", + "mutation": { + "id": { + "id": 1, + "clientID": "c" + }, + "result": {} + } + }, + { + "op": "put", + "mutation": { + "id": { + "id": 2, + "clientID": "c" + }, + "result": { + "data": { + "a": 1 + } + } + } + }, + { + "op": "put", + "mutation": { + "id": { + "id": 3, + "clientID": "c" + }, + "result": { + "data": null + } + } + }, + { + "op": "put", + "mutation": { + "id": { + "id": 4, + "clientID": "c" + }, + "result": { + "error": "app", + "message": "boom", + "details": [ + 1 + ] + } + } + }, + { + "op": "put", + "mutation": { + "id": { + "id": 5, + "clientID": "c" + }, + "result": { + "error": "oooMutation" + } + } + }, + { + "op": "put", + "mutation": { + "id": { + "id": 6, + "clientID": "c" + }, + "result": { + "error": "alreadyProcessed", + "details": "x" + } + } + }, + { + "op": "put", + "mutation": { + "id": { + "id": 7, + "clientID": "c" + }, + "result": { + "error": 5, + "message": 6 + } + } + }, + { + "op": "put", + "mutation": { + "id": { + "id": 8, + "clientID": "c" + }, + "result": { + "error": null + } + } + }, + { + "op": "put", + "mutation": { + "id": { + "id": 9, + "clientID": "c" + }, + "result": { + "data": 1, + "error": "app", + "zExtra": true + } + } + }, + { + "op": "put", + "mutation": { + "id": { + "id": 10, + "clientID": "c" + }, + "result": { + "zExtra": true + } + } + }, + { + "op": "del", + "id": { + "id": 11, + "clientID": "c" + } + } + ] + } + ], + "ok": true, + "output": [ + "pokePart", + { + "pokeID": "p", + "mutationsPatch": [ + { + "op": "put", + "mutation": { + "id": { + "id": 1, + "clientID": "c" + }, + "result": {} + } + }, + { + "op": "put", + "mutation": { + "id": { + "id": 2, + "clientID": "c" + }, + "result": { + "data": { + "a": 1 + } + } + } + }, + { + "op": "put", + "mutation": { + "id": { + "id": 3, + "clientID": "c" + }, + "result": { + "data": null + } + } + }, + { + "op": "put", + "mutation": { + "id": { + "id": 4, + "clientID": "c" + }, + "result": { + "error": "app", + "message": "boom", + "details": [ + 1 + ] + } + } + }, + { + "op": "put", + "mutation": { + "id": { + "id": 5, + "clientID": "c" + }, + "result": { + "error": "oooMutation" + } + } + }, + { + "op": "put", + "mutation": { + "id": { + "id": 6, + "clientID": "c" + }, + "result": { + "error": "alreadyProcessed", + "details": "x" + } + } + }, + { + "op": "put", + "mutation": { + "id": { + "id": 7, + "clientID": "c" + }, + "result": { + "error": 5, + "message": 6 + } + } + }, + { + "op": "put", + "mutation": { + "id": { + "id": 8, + "clientID": "c" + }, + "result": { + "error": null + } + } + }, + { + "op": "put", + "mutation": { + "id": { + "id": 9, + "clientID": "c" + }, + "result": { + "data": 1 + } + } + }, + { + "op": "put", + "mutation": { + "id": { + "id": 10, + "clientID": "c" + }, + "result": {} + } + }, + { + "op": "del", + "id": { + "id": 11, + "clientID": "c" + } + } + ] + } + ] + }, + { + "message": [ + "pokePart", + { + "pokeID": "p", + "mutationsPatch": [ + { + "op": "put", + "mutation": { + "id": { + "id": 1, + "clientID": "c" + }, + "result": null + } + } + ] + } + ], + "ok": false + }, + { + "message": [ + "pokePart", + { + "pokeID": "p", + "mutationsPatch": [ + { + "op": "put", + "mutation": { + "id": { + "id": 1, + "clientID": "c" + }, + "result": [] + } + } + ] + } + ], + "ok": false + }, + { + "message": [ + "pokePart", + { + "pokeID": "p", + "mutationsPatch": [ + { + "op": "put", + "mutation": { + "id": { + "id": 1, + "clientID": "c" + } + } + } + ] + } + ], + "ok": false + }, + { + "message": [ + "pokePart", + { + "pokeID": "p", + "mutationsPatch": [ + { + "op": "del", + "id": { + "id": 1 + } + } + ] + } + ], + "ok": false + }, + { + "message": [ + "pokeEnd", + { + "pokeID": "p", + "cookie": "2" + } + ], + "ok": true, + "output": [ + "pokeEnd", + { + "pokeID": "p", + "cookie": "2" + } + ] + }, + { + "message": [ + "pokeEnd", + { + "pokeID": "p", + "cookie": "", + "cancel": true + } + ], + "ok": true, + "output": [ + "pokeEnd", + { + "pokeID": "p", + "cookie": "", + "cancel": true + } + ] + }, + { + "message": [ + "pokeEnd", + { + "pokeID": "p", + "cookie": null + } + ], + "ok": false + }, + { + "message": [ + "pokeEnd", + { + "pokeID": "p", + "cookie": "2", + "cancel": "yes" + } + ], + "ok": false + }, + { + "message": [ + "pull", + { + "cookie": "1", + "requestID": "r", + "lastMutationIDChanges": {} + } + ], + "ok": true, + "output": [ + "pull", + { + "cookie": "1", + "requestID": "r", + "lastMutationIDChanges": {} + } + ] + }, + { + "message": [ + "pull", + { + "cookie": "1", + "requestID": "r", + "lastMutationIDChanges": { + "c": 3 + } + } + ], + "ok": true, + "output": [ + "pull", + { + "cookie": "1", + "requestID": "r", + "lastMutationIDChanges": { + "c": 3 + } + } + ] + }, + { + "message": [ + "pull", + { + "cookie": null, + "requestID": "r", + "lastMutationIDChanges": {} + } + ], + "ok": false + }, + { + "message": [ + "pull", + { + "cookie": "1", + "requestID": "r" + } + ], + "ok": false + }, + { + "message": [ + "deleteClients", + {} + ], + "ok": true, + "output": [ + "deleteClients", + {} + ] + }, + { + "message": [ + "deleteClients", + { + "clientIDs": [ + "a" + ], + "clientGroupIDs": [ + "b", + "" + ] + } + ], + "ok": true, + "output": [ + "deleteClients", + { + "clientIDs": [ + "a" + ], + "clientGroupIDs": [ + "b", + "" + ] + } + ] + }, + { + "message": [ + "deleteClients", + { + "clientIDs": null + } + ], + "ok": false + }, + { + "message": [ + "deleteClients", + { + "clientIDs": [ + 1 + ] + } + ], + "ok": false + }, + { + "message": [ + "pushResponse", + { + "mutations": [] + } + ], + "ok": true, + "output": [ + "pushResponse", + { + "mutations": [] + } + ] + }, + { + "message": [ + "pushResponse", + { + "mutations": [ + { + "id": { + "id": 1, + "clientID": "c" + }, + "result": { + "error": "app" + } + }, + { + "id": { + "id": 2, + "clientID": "c" + }, + "result": { + "data": "x", + "zExtra": 1 + } + } + ] + } + ], + "ok": true, + "output": [ + "pushResponse", + { + "mutations": [ + { + "id": { + "id": 1, + "clientID": "c" + }, + "result": { + "error": "app" + } + }, + { + "id": { + "id": 2, + "clientID": "c" + }, + "result": { + "data": "x" + } + } + ] + } + ] + }, + { + "message": [ + "pushResponse", + { + "error": "unsupportedPushVersion" + } + ], + "ok": true, + "output": [ + "pushResponse", + { + "error": "unsupportedPushVersion" + } + ] + }, + { + "message": [ + "pushResponse", + { + "error": "unsupportedSchemaVersion", + "mutationIDs": [ + { + "id": 1, + "clientID": "c" + } + ] + } + ], + "ok": true, + "output": [ + "pushResponse", + { + "error": "unsupportedSchemaVersion", + "mutationIDs": [ + { + "id": 1, + "clientID": "c" + } + ] + } + ] + }, + { + "message": [ + "pushResponse", + { + "error": "http", + "status": 500, + "details": "x" + } + ], + "ok": true, + "output": [ + "pushResponse", + { + "error": "http", + "status": 500, + "details": "x" + } + ] + }, + { + "message": [ + "pushResponse", + { + "error": "http", + "status": 500 + } + ], + "ok": false + }, + { + "message": [ + "pushResponse", + { + "error": "zeroPusher", + "details": "x", + "mutationIDs": [] + } + ], + "ok": true, + "output": [ + "pushResponse", + { + "error": "zeroPusher", + "details": "x", + "mutationIDs": [] + } + ] + }, + { + "message": [ + "pushResponse", + { + "error": "zeroPusher", + "details": "x", + "mutationIDs": null + } + ], + "ok": false + }, + { + "message": [ + "pushResponse", + { + "error": "other" + } + ], + "ok": false + }, + { + "message": [ + "pushResponse", + { + "mutations": "x", + "error": "zeroPusher", + "details": "d" + } + ], + "ok": true, + "output": [ + "pushResponse", + { + "error": "zeroPusher", + "details": "d" + } + ] + }, + { + "message": [ + "pushResponse", + { + "mutations": [ + { + "id": { + "id": 1, + "clientID": "c" + } + } + ] + } + ], + "ok": false + }, + { + "message": [ + "pushResponse", + {} + ], + "ok": false + }, + { + "message": [ + "inspect", + { + "id": "1", + "op": "version", + "value": "1.0" + } + ], + "ok": true, + "output": [ + "inspect", + { + "id": "1", + "op": "version", + "value": "1.0" + } + ] + }, + { + "message": [ + "inspect", + { + "id": "1", + "op": "authenticated", + "value": true + } + ], + "ok": true, + "output": [ + "inspect", + { + "id": "1", + "op": "authenticated", + "value": true + } + ] + }, + { + "message": [ + "inspect", + { + "id": "1", + "op": "error", + "value": "nope", + "zExtra": 1 + } + ], + "ok": true, + "output": [ + "inspect", + { + "id": "1", + "op": "error", + "value": "nope" + } + ] + }, + { + "message": [ + "inspect", + { + "id": "1", + "op": "metrics", + "value": { + "query-materialization-server": [ + 1 + ], + "query-update-server": [ + 1, + 2, + 3 + ] + } + } + ], + "ok": true, + "output": [ + "inspect", + { + "id": "1", + "op": "metrics", + "value": { + "query-materialization-server": [ + 1 + ], + "query-update-server": [ + 1, + 2, + 3 + ] + } + } + ] + }, + { + "message": [ + "inspect", + { + "id": "1", + "op": "metrics", + "value": { + "query-materialization-server": [], + "query-update-server": [ + 1 + ] + } + } + ], + "ok": false + }, + { + "message": [ + "inspect", + { + "id": "1", + "op": "queries", + "value": [ + { + "clientID": "c", + "queryID": "q", + "ast": { + "table": "issue", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "id" + }, + "right": { + "type": "literal", + "value": "1" + } + }, + "orderBy": [ + [ + "id", + "asc" + ] + ], + "limit": 10 + }, + "name": null, + "args": null, + "got": true, + "deleted": false, + "ttl": 1000, + "inactivatedAt": null, + "rowCount": 3, + "metrics": null + }, + { + "clientID": "c", + "queryID": "q2", + "ast": null, + "name": "issues", + "args": [ + 1, + { + "a": "b" + } + ], + "got": false, + "deleted": true, + "ttl": 0, + "inactivatedAt": 5, + "rowCount": 0, + "metrics": { + "query-update-server": [ + 1.5 + ] + }, + "zExtra": 1 + } + ] + } + ], + "ok": true, + "output": [ + "inspect", + { + "id": "1", + "op": "queries", + "value": [ + { + "clientID": "c", + "queryID": "q", + "ast": { + "table": "issue", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "id" + }, + "right": { + "type": "literal", + "value": "1" + } + }, + "orderBy": [ + [ + "id", + "asc" + ] + ], + "limit": 10 + }, + "name": null, + "args": null, + "got": true, + "deleted": false, + "ttl": 1000, + "inactivatedAt": null, + "rowCount": 3, + "metrics": null + }, + { + "clientID": "c", + "queryID": "q2", + "ast": null, + "name": "issues", + "args": [ + 1, + { + "a": "b" + } + ], + "got": false, + "deleted": true, + "ttl": 0, + "inactivatedAt": 5, + "rowCount": 0, + "metrics": { + "query-update-server": [ + 1.5 + ] + } + } + ] + } + ] + }, + { + "message": [ + "inspect", + { + "id": "1", + "op": "queries", + "value": [ + { + "clientID": "c", + "queryID": "q", + "ast": { + "table": 1 + }, + "name": null, + "args": null, + "got": true, + "deleted": false, + "ttl": 1000, + "inactivatedAt": null, + "rowCount": 3 + } + ] + } + ], + "ok": false + }, + { + "message": [ + "inspect", + { + "id": "1", + "op": "analyze-query", + "value": { + "warnings": [], + "syncedRowCount": 0, + "start": 0, + "end": 1 + } + } + ], + "ok": true, + "output": [ + "inspect", + { + "id": "1", + "op": "analyze-query", + "value": { + "warnings": [], + "syncedRowCount": 0, + "start": 0, + "end": 1 + } + } + ] + }, + { + "message": [ + "inspect", + { + "id": "1", + "op": "analyze-query", + "value": { + "warnings": [ + "w" + ], + "syncedRowCount": 0, + "start": 0, + "end": 1, + "joinPlans": [ + { + "type": "plan-failed", + "attemptNumber": 1 + } + ] + } + } + ], + "ok": false + }, + { + "message": [ + "inspect", + { + "id": "1", + "op": "unknown", + "value": 1 + } + ], + "ok": false + }, + { + "message": [ + "inspect", + { + "op": "version", + "value": "1" + } + ], + "ok": false + }, + { + "message": [ + "transformError", + [] + ], + "ok": true, + "output": [ + "transformError", + [] + ] + }, + { + "message": [ + "transformError", + [ + { + "error": "app", + "id": "q", + "name": "n" + }, + { + "error": "parse", + "id": "q", + "name": "n", + "message": "m", + "details": {} + } + ] + ], + "ok": true, + "output": [ + "transformError", + [ + { + "error": "app", + "id": "q", + "name": "n" + }, + { + "error": "parse", + "id": "q", + "name": "n", + "message": "m", + "details": {} + } + ] + ] + }, + { + "message": [ + "transformError", + [ + { + "error": "parse", + "id": "q", + "name": "n" + } + ] + ], + "ok": false + }, + { + "message": [ + "transformError", + [ + { + "error": "other", + "id": "q", + "name": "n", + "message": "m" + } + ] + ], + "ok": false + }, + { + "message": [ + "transformError", + {} + ], + "ok": false + }, + { + "message": [ + "pokePart", + { + "pokeID": "", + "lastMutationIDChanges": { + "\u0000": 432 + }, + "rowsPatch": [ + { + "op": "update", + "tableName": "user", + "id": { + "id": false, + "n": "a" + }, + "constrain": [ + "😀", + "title", + "" + ], + "é": {} + }, + { + "op": "del", + "tableName": "été", + "id": { + "id": false + } + } + ], + "mutationsPatch": [] + } + ], + "ok": true, + "output": [ + "pokePart", + { + "lastMutationIDChanges": { + "\u0000": 432 + }, + "pokeID": "", + "rowsPatch": [ + { + "op": "update", + "tableName": "user", + "id": { + "id": false, + "n": "a" + }, + "constrain": [ + "😀", + "title", + "" + ] + }, + { + "op": "del", + "tableName": "été", + "id": { + "id": false + } + } + ], + "mutationsPatch": [] + } + ] + }, + { + "message": [ + "pokePart", + { + "pokeID": "A", + "lastMutationIDChanges": { + "A中中ß中": 398 + }, + "gotQueriesPatch": [ + { + "op": "del", + "hash": "" + }, + { + "op": "del", + "hash": "b", + "zExtra": 0.5 + } + ], + "rowsPatch": [ + { + "op": "put", + "tableName": "user", + "value": { + "id": 57273, + "title": false, + "n": "", + "😀": 2, + "": false + } + }, + { + "op": "update", + "tableName": "comment", + "id": { + "id": "a" + }, + "constrain": [ + "title" + ], + "": "İ" + }, + { + "op": "put", + "tableName": "comment", + "value": { + "n": { + "": null + }, + "😀": [], + "": "\na b%é " + } + } + ] + } + ], + "ok": true, + "output": [ + "pokePart", + { + "lastMutationIDChanges": { + "A中中ß中": 398 + }, + "pokeID": "A", + "gotQueriesPatch": [ + { + "op": "del", + "hash": "" + }, + { + "op": "del", + "hash": "b" + } + ], + "rowsPatch": [ + { + "op": "put", + "tableName": "user", + "value": { + "id": 57273, + "title": false, + "n": "", + "😀": 2, + "": false + } + }, + { + "op": "update", + "tableName": "comment", + "id": { + "id": "a" + }, + "constrain": [ + "title" + ] + }, + { + "op": "put", + "tableName": "comment", + "value": { + "n": { + "": null + }, + "😀": [], + "": "\na b%é " + } + } + ] + } + ] + }, + { + "message": [ + "transformError", + [ + { + "error": "app", + "id": "􏿿", + "name": "%\"B_", + "message": "\"é中ß😀", + "details": [] + }, + { + "error": "app", + "id": " ", + "name": "ßb中A", + "message": " _é\n\"\"", + "details": true + }, + { + "error": "parse", + "id": "𝒜éb\"\nb", + "name": "é😀_İa𝒜", + "message": "İ" + } + ] + ], + "ok": true, + "output": [ + "transformError", + [ + { + "error": "app", + "id": "􏿿", + "name": "%\"B_", + "message": "\"é中ß😀", + "details": [] + }, + { + "error": "app", + "id": " ", + "name": "ßb中A", + "message": " _é\n\"\"", + "details": true + }, + { + "error": "parse", + "id": "𝒜éb\"\nb", + "name": "é😀_İa𝒜", + "message": "İ" + } + ] + ] + }, + { + "message": [ + "error", + { + "kind": "InvalidPush", + "message": "bİ%😀" + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "InvalidPush", + "message": "bİ%😀" + } + ] + }, + { + "message": [ + "pong", + {} + ], + "ok": true, + "output": [ + "pong", + {} + ] + }, + { + "message": [ + "pokePart", + { + "pokeID": "é中_béé\"", + "rowsPatch": [ + { + "op": "update", + "tableName": "issue", + "id": { + "id": "a", + "n": 1 + } + } + ], + "mutationsPatch": [], + "": [ + null, + "\n", + 0 + ] + } + ], + "ok": true, + "output": [ + "pokePart", + { + "rowsPatch": [ + { + "op": "update", + "tableName": "issue", + "id": { + "id": "a", + "n": 1 + } + } + ], + "mutationsPatch": [], + "pokeID": "é中_béé\"" + } + ] + }, + { + "message": [ + "pushResponse", + { + "mutations": [] + } + ], + "ok": true, + "output": [ + "pushResponse", + { + "mutations": [] + } + ] + }, + { + "message": [ + "pushResponse", + { + "error": "unsupportedPushVersion" + } + ], + "ok": true, + "output": [ + "pushResponse", + { + "error": "unsupportedPushVersion" + } + ] + }, + { + "message": [ + "deleteClients", + { + "clientIDs": [] + } + ], + "ok": true, + "output": [ + "deleteClients", + { + "clientIDs": [] + } + ] + }, + { + "message": [ + "pull", + { + "cookie": "z", + "requestID": "𝒜 中😀ABİ", + "lastMutationIDChanges": {}, + "😀": true + } + ], + "ok": true, + "output": [ + "pull", + { + "cookie": "z", + "requestID": "𝒜 中😀ABİ", + "lastMutationIDChanges": {} + } + ] + }, + { + "message": [ + "connected", + { + "wsid": "İ" + } + ], + "ok": true, + "output": [ + "connected", + { + "wsid": "İ" + } + ] + }, + { + "message": [ + "pokePart", + { + "pokeID": "é", + "desiredQueriesPatches": { + "9": [ + { + "op": "clear" + }, + { + "op": "del", + "hash": "%" + } + ] + }, + "rowsPatch": [ + { + "op": "put", + "tableName": "comment", + "value": { + "n": "𝒜😀%aß", + "": true + } + } + ] + } + ], + "ok": true, + "output": [ + "pokePart", + { + "desiredQueriesPatches": { + "9": [ + { + "op": "clear" + }, + { + "op": "del", + "hash": "%" + } + ] + }, + "pokeID": "é", + "rowsPatch": [ + { + "op": "put", + "tableName": "comment", + "value": { + "n": "𝒜😀%aß", + "": true + } + } + ] + } + ] + }, + { + "message": [ + "pull", + { + "cookie": "a", + "requestID": "", + "lastMutationIDChanges": {} + } + ], + "ok": true, + "output": [ + "pull", + { + "cookie": "a", + "requestID": "", + "lastMutationIDChanges": {} + } + ] + }, + { + "message": [ + "deleteClients", + {} + ], + "ok": true, + "output": [ + "deleteClients", + {} + ] + }, + { + "message": [ + "pokePart", + { + "pokeID": "" + } + ], + "ok": true, + "output": [ + "pokePart", + { + "pokeID": "" + } + ] + }, + { + "message": [ + "pokePart", + { + "pokeID": "\\\"ß中é", + "lastMutationIDChanges": { + "􏿿": 452, + "￿": 565, + "😀%ßßA\n": 976 + }, + "desiredQueriesPatches": { + "AB%_ßaß": [] + }, + "gotQueriesPatch": [ + { + "op": "put", + "hash": "Bİ", + "ttl": -9007199254740992 + }, + { + "op": "put", + "hash": "😀_%\\a\\a", + "zExtra": { + "9": "\\AİBé", + "中𝒜_aß😀aé": false, + "b": "A\"A\\Bßß" + } + } + ], + "rowsPatch": [] + } + ], + "ok": true, + "output": [ + "pokePart", + { + "lastMutationIDChanges": { + "􏿿": 452, + "￿": 565, + "😀%ßßA\n": 976 + }, + "desiredQueriesPatches": { + "AB%_ßaß": [] + }, + "pokeID": "\\\"ß中é", + "gotQueriesPatch": [ + { + "op": "put", + "hash": "Bİ", + "ttl": -9007199254740992 + }, + { + "op": "put", + "hash": "😀_%\\a\\a" + } + ], + "rowsPatch": [] + } + ] + }, + { + "message": [ + "inspect", + { + "id": "😀", + "op": "error", + "value": "𝒜_中" + } + ], + "ok": true, + "output": [ + "inspect", + { + "id": "😀", + "op": "error", + "value": "𝒜_中" + } + ] + }, + { + "message": [ + "pushResponse", + { + "mutations": [ + { + "id": { + "id": 859, + "clientID": "Bé%\\" + }, + "result": { + "error": "alreadyProcessed" + } + } + ], + "newField": {} + } + ], + "ok": true, + "output": [ + "pushResponse", + { + "mutations": [ + { + "id": { + "id": 859, + "clientID": "Bé%\\" + }, + "result": { + "error": "alreadyProcessed" + } + } + ] + } + ] + }, + { + "message": [ + "pong", + {} + ], + "ok": true, + "output": [ + "pong", + {} + ] + }, + { + "message": [ + "connected", + { + "wsid": "\\\\é" + } + ], + "ok": true, + "output": [ + "connected", + { + "wsid": "\\\\é" + } + ] + }, + { + "message": [ + "deleteClients", + { + "clientGroupIDs": [ + "İb" + ] + } + ], + "ok": true, + "output": [ + "deleteClients", + { + "clientGroupIDs": [ + "İb" + ] + } + ] + }, + { + "message": [ + "connected", + { + "wsid": "B\n\n\\\\" + } + ], + "ok": true, + "output": [ + "connected", + { + "wsid": "B\n\n\\\\" + } + ] + }, + { + "message": [ + "deleteClients", + {} + ], + "ok": true, + "output": [ + "deleteClients", + {} + ] + }, + { + "message": [ + "error", + { + "kind": "TransformFailed", + "message": "中", + "queryIDs": [], + "details": {}, + "origin": "server", + "reason": "parse", + "😀": false + } + ], + "ok": true, + "output": [ + "error", + { + "kind": "TransformFailed", + "queryIDs": [], + "message": "中", + "details": {}, + "origin": "server", + "reason": "parse" + } + ] + }, + { + "message": [ + "pokePart", + { + "pokeID": "z", + "rowsPatch": [ + { + "op": "put", + "tableName": "issue", + "value": { + "title": [], + "n": 2, + "😀": { + "9": null, + "_ß\n\\B中b": false + } + } + } + ], + "mutationsPatch": [] + } + ], + "ok": true, + "output": [ + "pokePart", + { + "pokeID": "z", + "rowsPatch": [ + { + "op": "put", + "tableName": "issue", + "value": { + "title": [], + "n": 2, + "😀": { + "9": null, + "_ß\n\\B中b": false + } + } + } + ], + "mutationsPatch": [] + } + ] + }, + { + "message": [ + "connected", + { + "wsid": "" + } + ], + "ok": true, + "output": [ + "connected", + { + "wsid": "" + } + ] + }, + { + "message": [ + "pokeEnd", + { + "pokeID": "ßaİ", + "cookie": "B\nß中A\n" + } + ], + "ok": true, + "output": [ + "pokeEnd", + { + "pokeID": "ßaİ", + "cookie": "B\nß中A\n" + } + ] + }, + { + "message": [ + "pull", + { + "cookie": "", + "requestID": "￿", + "lastMutationIDChanges": { + "中%😀中é\"é𝒜": 219 + }, + "é": false + } + ], + "ok": true, + "output": [ + "pull", + { + "cookie": "", + "requestID": "￿", + "lastMutationIDChanges": { + "中%😀中é\"é𝒜": 219 + } + } + ] + }, + { + "message": [ + "pushResponse", + { + "error": "zeroPusher", + "details": "𝒜A", + "mutationIDs": [ + { + "id": 100, + "clientID": "" + }, + { + "id": 320, + "clientID": "\"" + } + ], + "newField": [ + false, + false, + "A\"\"中b\n" + ] + } + ], + "ok": true, + "output": [ + "pushResponse", + { + "error": "zeroPusher", + "details": "𝒜A", + "mutationIDs": [ + { + "id": 100, + "clientID": "" + }, + { + "id": 320, + "clientID": "\"" + } + ] + } + ] + }, + { + "message": [ + "pokeStart", + { + "pokeID": "b", + "baseCookie": "𝒜" + } + ], + "ok": true, + "output": [ + "pokeStart", + { + "pokeID": "b", + "baseCookie": "𝒜" + } + ] + }, + { + "message": [ + "pushResponse", + { + "error": "unsupportedSchemaVersion", + "mutationIDs": [], + "zExtra": false + } + ], + "ok": true, + "output": [ + "pushResponse", + { + "error": "unsupportedSchemaVersion", + "mutationIDs": [] + } + ] + }, + { + "message": [ + "pushResponse", + { + "error": "http", + "status": 500, + "details": "�" + } + ], + "ok": true, + "output": [ + "pushResponse", + { + "error": "http", + "status": 500, + "details": "�" + } + ] + }, + { + "message": [ + "pokePart", + { + "pokeID": "_% é\naA", + "lastMutationIDChanges": {}, + "desiredQueriesPatches": { + "A𝒜😀ß\"_ß": [ + { + "op": "put", + "hash": "中 a ", + "ttl": 34 + }, + { + "op": "del", + "hash": "a" + }, + { + "op": "put", + "hash": "\"", + "ttl": 72531 + } + ] + }, + "rowsPatch": [ + { + "op": "put", + "tableName": "issue", + "value": { + "title": null, + "n": null, + "😀": { + "ß": "\u001f", + "中文": null, + "中\na": 91315 + }, + "": "ß" + } + }, + { + "op": "put", + "tableName": "user", + "value": { + "id": { + "%\"a ": true, + "ab": true + }, + "": { + "😀a\nb%": -2.25 + } + } + }, + { + "op": "put", + "tableName": "issue", + "value": { + "id": { + "\"": null, + "": "%é😀éİ", + " ": "a" + }, + "title": { + "ébİ_İ中A": "\\ß" + }, + "": 0.5 + } + }, + { + "op": "update", + "tableName": "été", + "id": { + "id": false, + "n": 0 + }, + "constrain": [], + "é": false + }, + { + "op": "del", + "tableName": "user", + "id": { + "id": false, + "n": 0 + } + } + ], + "zExtra": true + } + ], + "ok": true, + "output": [ + "pokePart", + { + "lastMutationIDChanges": {}, + "desiredQueriesPatches": { + "A𝒜😀ß\"_ß": [ + { + "op": "put", + "hash": "中 a ", + "ttl": 34 + }, + { + "op": "del", + "hash": "a" + }, + { + "op": "put", + "hash": "\"", + "ttl": 72531 + } + ] + }, + "pokeID": "_% é\naA", + "rowsPatch": [ + { + "op": "put", + "tableName": "issue", + "value": { + "title": null, + "n": null, + "😀": { + "ß": "\u001f", + "中文": null, + "中\na": 91315 + }, + "": "ß" + } + }, + { + "op": "put", + "tableName": "user", + "value": { + "id": { + "%\"a ": true, + "ab": true + }, + "": { + "😀a\nb%": -2.25 + } + } + }, + { + "op": "put", + "tableName": "issue", + "value": { + "id": { + "\"": null, + "": "%é😀éİ", + " ": "a" + }, + "title": { + "ébİ_İ中A": "\\ß" + }, + "": 0.5 + } + }, + { + "op": "update", + "tableName": "été", + "id": { + "id": false, + "n": 0 + }, + "constrain": [] + }, + { + "op": "del", + "tableName": "user", + "id": { + "id": false, + "n": 0 + } + } + ] + } + ] + }, + { + "message": [ + "pushResponse", + { + "error": "unsupportedPushVersion" + } + ], + "ok": true, + "output": [ + "pushResponse", + { + "error": "unsupportedPushVersion" + } + ] + }, + { + "message": [ + "inspect", + { + "id": "\n𝒜\n%b中a", + "op": "error", + "value": "中𝒜中😀a" + } + ], + "ok": true, + "output": [ + "inspect", + { + "id": "\n𝒜\n%b中a", + "op": "error", + "value": "中𝒜中😀a" + } + ] + }, + { + "message": [ + "inspect", + { + "id": "Ω", + "op": "queries", + "value": [ + { + "clientID": "", + "queryID": "B", + "ast": { + "table": "issue" + }, + "name": "é", + "args": null, + "got": false, + "deleted": true, + "ttl": 0, + "inactivatedAt": 9007199254740992, + "rowCount": 22 + }, + { + "clientID": "ß", + "queryID": "ı", + "ast": { + "table": "issue" + }, + "name": "中\"\\\"A \\", + "args": [ + 68520, + [ + null + ] + ], + "got": true, + "deleted": true, + "ttl": 0.5, + "inactivatedAt": -1, + "rowCount": 5 + } + ] + } + ], + "ok": true, + "output": [ + "inspect", + { + "id": "Ω", + "op": "queries", + "value": [ + { + "clientID": "", + "queryID": "B", + "ast": { + "table": "issue" + }, + "name": "é", + "args": null, + "got": false, + "deleted": true, + "ttl": 0, + "inactivatedAt": 9007199254740992, + "rowCount": 22 + }, + { + "clientID": "ß", + "queryID": "ı", + "ast": { + "table": "issue" + }, + "name": "中\"\\\"A \\", + "args": [ + 68520, + [ + null + ] + ], + "got": true, + "deleted": true, + "ttl": 0.5, + "inactivatedAt": -1, + "rowCount": 5 + } + ] + } + ] + }, + { + "message": [ + "pushResponse", + { + "error": "http", + "status": 500, + "details": "\u001f", + "mutationIDs": [ + { + "id": 13, + "clientID": "\nß\"\\" + } + ] + } + ], + "ok": true, + "output": [ + "pushResponse", + { + "error": "http", + "status": 500, + "details": "\u001f", + "mutationIDs": [ + { + "id": 13, + "clientID": "\nß\"\\" + } + ] + } + ] + }, + { + "message": [ + "deleteClients", + {} + ], + "ok": true, + "output": [ + "deleteClients", + {} + ] + }, + { + "message": [ + "pushResponse", + { + "mutations": [ + { + "id": { + "id": 762, + "clientID": "ßA\n%中𝒜\né" + }, + "result": { + "data": "A" + } + } + ] + } + ], + "ok": true, + "output": [ + "pushResponse", + { + "mutations": [ + { + "id": { + "id": 762, + "clientID": "ßA\n%中𝒜\né" + }, + "result": { + "data": "A" + } + } + ] + } + ] + }, + { + "message": [ + "pokePart", + { + "pokeID": "𝒜ab\\中𝒜😀\\", + "mutationsPatch": [ + { + "op": "del", + "id": { + "id": 842, + "clientID": "\\" + } + }, + { + "op": "put", + "mutation": { + "id": { + "id": 399, + "clientID": "0" + }, + "result": { + "data": false + }, + "😀": 0 + }, + "😀": { + "A": 5e-324 + } + }, + { + "op": "put", + "mutation": { + "id": { + "id": 732, + "clientID": "bb __", + "newField": "" + }, + "result": { + "data": "é%" + }, + "zExtra": true + } + } + ], + "😀": 1.7976931348623157e+308 + } + ], + "ok": true, + "output": [ + "pokePart", + { + "pokeID": "𝒜ab\\中𝒜😀\\", + "mutationsPatch": [ + { + "op": "del", + "id": { + "id": 842, + "clientID": "\\" + } + }, + { + "op": "put", + "mutation": { + "id": { + "id": 399, + "clientID": "0" + }, + "result": { + "data": false + } + } + }, + { + "op": "put", + "mutation": { + "id": { + "id": 732, + "clientID": "bb __" + }, + "result": { + "data": "é%" + } + } + } + ] + } + ] + }, + { + "message": [ + "pushResponse", + { + "error": "zeroPusher", + "details": "ß", + "mutationIDs": [ + { + "id": 111, + "clientID": "😀" + }, + { + "id": 73, + "clientID": "ßBA\"B\"" + } + ] + } + ], + "ok": true, + "output": [ + "pushResponse", + { + "error": "zeroPusher", + "details": "ß", + "mutationIDs": [ + { + "id": 111, + "clientID": "😀" + }, + { + "id": 73, + "clientID": "ßBA\"B\"" + } + ] + } + ] + }, + { + "message": [ + "pokePart", + { + "pokeID": "_ \"\n中", + "lastMutationIDChanges": {}, + "gotQueriesPatch": [ + { + "op": "del", + "hash": "ßé\\ é " + }, + { + "op": "del", + "hash": "é中a😀" + } + ], + "rowsPatch": [ + { + "op": "clear" + }, + { + "op": "put", + "tableName": "comment", + "value": { + "id": {}, + "title": [ + 63801, + false + ], + "n": "\"", + "😀": { + "\nb\\éß\nß": null, + "😀a\nA😀%": 2, + "\n": "\"" + } + } + }, + { + "op": "del", + "tableName": "issue", + "id": { + "id": "a" + } + } + ], + "mutationsPatch": [ + { + "op": "del", + "id": { + "id": 726, + "clientID": " İ\"İ𝒜a\\" + }, + "é": { + "􏿿": null, + "\"": 0 + } + }, + { + "op": "del", + "id": { + "id": 778, + "clientID": "ab中" + } + } + ] + } + ], + "ok": true, + "output": [ + "pokePart", + { + "lastMutationIDChanges": {}, + "gotQueriesPatch": [ + { + "op": "del", + "hash": "ßé\\ é " + }, + { + "op": "del", + "hash": "é中a😀" + } + ], + "pokeID": "_ \"\n中", + "rowsPatch": [ + { + "op": "clear" + }, + { + "op": "put", + "tableName": "comment", + "value": { + "id": {}, + "title": [ + 63801, + false + ], + "n": "\"", + "😀": { + "\nb\\éß\nß": null, + "😀a\nA😀%": 2, + "\n": "\"" + } + } + }, + { + "op": "del", + "tableName": "issue", + "id": { + "id": "a" + } + } + ], + "mutationsPatch": [ + { + "op": "del", + "id": { + "id": 726, + "clientID": " İ\"İ𝒜a\\" + } + }, + { + "op": "del", + "id": { + "id": 778, + "clientID": "ab中" + } + } + ] + } + ] + }, + { + "message": [ + "pong", + {} + ], + "ok": true, + "output": [ + "pong", + {} + ] + }, + { + "message": [ + "pushResponse", + { + "error": "zeroPusher", + "details": "\nb\"\"\\" + } + ], + "ok": true, + "output": [ + "pushResponse", + { + "error": "zeroPusher", + "details": "\nb\"\"\\" + } + ] + }, + { + "message": [ + "pokeEnd", + { + "pokeID": "é中", + "cookie": "İ_", + "cancel": false + } + ], + "ok": true, + "output": [ + "pokeEnd", + { + "pokeID": "é中", + "cookie": "İ_", + "cancel": false + } + ] + }, + { + "message": [ + "transformError", + [] + ], + "ok": true, + "output": [ + "transformError", + [] + ] + }, + { + "message": [ + "deleteClients", + { + "clientIDs": [ + " A", + "😀b" + ] + } + ], + "ok": true, + "output": [ + "deleteClients", + { + "clientIDs": [ + " A", + "😀b" + ] + } + ] + }, + { + "message": [ + "connected", + { + "wsid": "", + "timestamp": -9007199254740992 + } + ], + "ok": true, + "output": [ + "connected", + { + "wsid": "", + "timestamp": -9007199254740992 + } + ] + }, + { + "message": [ + "pokePart", + { + "pokeID": "😀", + "lastMutationIDChanges": { + "Ω": 231, + "中😀Aab": 764 + }, + "rowsPatch": [ + { + "op": "put", + "tableName": "été", + "value": { + "id": { + "中\\\n": "éB\\𝒜_A", + " 𝒜_\n": true, + "ßAß": -1 + }, + "": { + "\\İ中é中\\\\\n": 1e+21, + "Ω": null + } + } + }, + { + "op": "put", + "tableName": "comment", + "value": { + "": "￿" + } + }, + { + "op": "put", + "tableName": "été", + "value": { + "title": null, + "n": -9007199254740992, + "😀": true + } + }, + { + "op": "clear" + }, + { + "op": "del", + "tableName": "user", + "id": { + "id": "" + } + } + ] + } + ], + "ok": true, + "output": [ + "pokePart", + { + "lastMutationIDChanges": { + "Ω": 231, + "中😀Aab": 764 + }, + "pokeID": "😀", + "rowsPatch": [ + { + "op": "put", + "tableName": "été", + "value": { + "id": { + "中\\\n": "éB\\𝒜_A", + " 𝒜_\n": true, + "ßAß": -1 + }, + "": { + "\\İ中é中\\\\\n": 1e+21, + "Ω": null + } + } + }, + { + "op": "put", + "tableName": "comment", + "value": { + "": "￿" + } + }, + { + "op": "put", + "tableName": "été", + "value": { + "title": null, + "n": -9007199254740992, + "😀": true + } + }, + { + "op": "clear" + }, + { + "op": "del", + "tableName": "user", + "id": { + "id": "" + } + } + ] + } + ] + }, + { + "message": [ + "pokeEnd", + { + "pokeID": "a中ß", + "cookie": " 𝒜_ß" + } + ], + "ok": true, + "output": [ + "pokeEnd", + { + "pokeID": "a中ß", + "cookie": " 𝒜_ß" + } + ] + }, + { + "message": [ + "pokePart", + { + "pokeID": "é", + "desiredQueriesPatches": { + "9": [ + { + "op": "clear" + }, + { + "hash": "%" + } + ] + }, + "rowsPatch": [ + { + "op": [ + 1 + ], + "tableName": "comment", + "value": { + "n": "𝒜😀%aß", + "": true + } + } + ] + } + ], + "ok": false + }, + { + "message": [ + { + "zExtra": 1 + } + ], + "ok": false + }, + { + "message": [ + 0 + ], + "ok": false + }, + { + "message": [ + "pokeEnd", + true + ], + "ok": false + }, + { + "message": [ + "pushResponse", + {} + ], + "ok": false + }, + { + "message": [ + "transformError", + [ + { + "error": "app", + "name": "%\"B_", + "message": "\"é中ß😀", + "details": [] + }, + { + "error": "app", + "id": " ", + "name": "ßb中A", + "message": " _é\n\"\"", + "details": true + }, + { + "error": "parse", + "id": "𝒜éb\"\nb", + "name": "é😀_İa𝒜", + "message": "İ" + } + ] + ], + "ok": false + }, + { + "message": [ + "pokeEnd", + { + "pokeID": "custom", + "cookie": " 𝒜_ß" + } + ], + "ok": true, + "output": [ + "pokeEnd", + { + "pokeID": "custom", + "cookie": " 𝒜_ß" + } + ] + }, + { + "message": { + "zExtra": 1 + }, + "ok": false + }, + { + "message": [ + { + "clientIDs": [] + } + ], + "ok": false + }, + { + "message": [ + 1 + ], + "ok": false + }, + { + "message": [ + "pokePart", + { + "pokeID": "𝒜ab\\中𝒜😀\\", + "mutationsPatch": [ + { + "op": "del", + "id": { + "id": 842, + "clientID": "\\" + } + }, + { + "op": "put", + "mutation": { + "id": { + "id": 399, + "clientID": "0" + }, + "result": { + "data": false + }, + "😀": 0 + }, + "😀": { + "A": 5e-324 + } + }, + "simple" + ], + "😀": 1.7976931348623157e+308 + } + ], + "ok": false + }, + { + "message": [ + "pokePart", + { + "pokeID": "é", + "desiredQueriesPatches": { + "9": "and" + }, + "rowsPatch": [ + { + "op": "put", + "tableName": "comment", + "value": { + "n": "𝒜😀%aß", + "": true + } + } + ] + } + ], + "ok": false + }, + { + "message": "bogus", + "ok": false + }, + { + "message": -1.5, + "ok": false + }, + { + "message": [ + "deleteClients" + ], + "ok": false + }, + { + "message": [ + "pushResponse", + { + "error": "zeroPusher", + "mutationIDs": [ + { + "id": 100, + "clientID": "" + }, + { + "clientID": "\"" + } + ], + "newField": [ + false, + false, + "A\"\"中b\n" + ] + } + ], + "ok": false + }, + { + "message": [ + "pong", + { + "zExtra": "pull" + } + ], + "ok": true, + "output": [ + "pong", + {} + ] + }, + { + "message": [ + "pull", + { + "cookie": "", + "requestID": "", + "lastMutationIDChanges": {} + } + ], + "ok": true, + "output": [ + "pull", + { + "cookie": "", + "requestID": "", + "lastMutationIDChanges": {} + } + ] + }, + { + "message": [ + "connected", + { + "wsid": "\\\\é", + "kind": "" + } + ], + "ok": true, + "output": [ + "connected", + { + "wsid": "\\\\é" + } + ] + }, + { + "message": [ + "pushResponse", + { + "error": "" + } + ], + "ok": false + }, + { + "message": "bogus", + "ok": false + }, + { + "message": [ + "pokePart", + { + "pokeID": "A", + "lastMutationIDChanges": { + "A中中ß中": 398 + }, + "gotQueriesPatch": [ + { + "op": "del", + "hash": "" + }, + { + "op": "del", + "hash": "b", + "zExtra": 0.5 + }, + { + "op": "del", + "hash": "" + } + ], + "rowsPatch": [ + { + "op": "put", + "tableName": "user", + "value": { + "id": 57273, + "title": false, + "n": "", + "😀": 2, + "": false + } + }, + { + "op": "update", + "tableName": "comment", + "id": { + "id": "a" + }, + "constrain": [ + "title" + ], + "": "İ" + }, + { + "op": "put", + "tableName": "comment", + "value": { + "n": { + "": null + }, + "😀": [], + "": "\na b%é " + } + } + ] + } + ], + "ok": true, + "output": [ + "pokePart", + { + "lastMutationIDChanges": { + "A中中ß中": 398 + }, + "pokeID": "A", + "gotQueriesPatch": [ + { + "op": "del", + "hash": "" + }, + { + "op": "del", + "hash": "b" + }, + { + "op": "del", + "hash": "" + } + ], + "rowsPatch": [ + { + "op": "put", + "tableName": "user", + "value": { + "id": 57273, + "title": false, + "n": "", + "😀": 2, + "": false + } + }, + { + "op": "update", + "tableName": "comment", + "id": { + "id": "a" + }, + "constrain": [ + "title" + ] + }, + { + "op": "put", + "tableName": "comment", + "value": { + "n": { + "": null + }, + "😀": [], + "": "\na b%é " + } + } + ] + } + ] + }, + { + "message": [ + { + "clientIDs": [ + " A", + "😀b" + ] + } + ], + "ok": false + }, + { + "message": [ + "pushResponse" + ], + "ok": false + }, + { + "message": [ + "pokePart", + { + "pokeID": "A", + "lastMutationIDChanges": { + "A中中ß中": 398 + }, + "gotQueriesPatch": [ + { + "op": "del", + "hash": "" + }, + { + "op": "del", + "hash": "b", + "zExtra": 0.5 + } + ], + "rowsPatch": [ + { + "op": "put", + "tableName": "user", + "value": { + "id": 57273, + "title": false, + "n": "", + "😀": 2, + "": false + } + }, + { + "op": "update", + "tableName": "comment", + "id": { + "id": "a" + }, + "constrain": {}, + "": "İ" + }, + { + "op": "put", + "tableName": "comment", + "value": { + "n": { + "": null + }, + "😀": [], + "": "\na b%é " + } + } + ] + } + ], + "ok": false + }, + { + "message": [ + "pokePart", + { + "pokeID": "A", + "lastMutationIDChanges": { + "A中中ß中": 398 + }, + "gotQueriesPatch": [ + { + "op": "del", + "hash": "" + }, + { + "op": "del", + "hash": "b", + "zExtra": 0.5 + } + ], + "rowsPatch": [ + { + "op": "put", + "tableName": "user", + "value": { + "id": 57273, + "title": false, + "n": "", + "😀": 2, + "": false + } + }, + { + "op": "update", + "tableName": "comment", + "id": { + "id": "a" + }, + "constrain": "timeout", + "": "İ" + }, + { + "op": "put", + "tableName": "comment", + "value": { + "n": { + "": null + }, + "😀": [], + "": "\na b%é " + } + } + ] + } + ], + "ok": false + }, + { + "message": [ + "pokePart", + { + "pokeID": "", + "lastMutationIDChanges": { + "\u0000": 432 + }, + "rowsPatch": [ + { + "op": "update", + "tableName": "user", + "id": { + "id": false, + "n": "a" + }, + "constrain": [ + "😀", + "title", + "" + ], + "é": {} + }, + { + "op": "del", + "id": { + "id": false + } + } + ], + "mutationsPatch": [] + } + ], + "ok": false + }, + { + "message": [ + "pushResponse", + { + "mutations": [ + { + "id": { + "id": 859, + "clientID": "Bé%\\" + }, + "result": { + "error": "alreadyProcessed" + } + } + ], + "newField": {}, + "type": "app", + "kind": "server" + } + ], + "ok": true, + "output": [ + "pushResponse", + { + "mutations": [ + { + "id": { + "id": 859, + "clientID": "Bé%\\" + }, + "result": { + "error": "alreadyProcessed" + } + } + ] + } + ] + }, + { + "message": [ + "pushResponse", + { + "mutations": [ + { + "id": "_zero_crud", + "result": "bogus" + } + ] + } + ], + "ok": false + }, + { + "message": [ + "deleteClients", + { + "clientIDs": [ + " A", + [] + ] + } + ], + "ok": false + }, + { + "message": [ + "pong", + {}, + -1.5 + ], + "ok": false + }, + { + "message": [ + "pushResponse", + { + "error": "http", + "status": [], + "details": "�" + } + ], + "ok": false + }, + { + "message": [ + "pull", + { + "cookie": "", + "requestID": "￿", + "lastMutationIDChanges": { + "中%😀中é\"é𝒜": "219" + }, + "é": false + } + ], + "ok": false + }, + { + "message": [ + "pokePart", + { + "pokeID": "é", + "desiredQueriesPatches": { + "9": [ + { + "op": "clear" + }, + { + "op": "del", + "hash": "%" + } + ] + }, + "rowsPatch": [ + { + "op": "put", + "tableName": "", + "value": { + "n": "𝒜😀%aß", + "": true + } + } + ] + } + ], + "ok": true, + "output": [ + "pokePart", + { + "desiredQueriesPatches": { + "9": [ + { + "op": "clear" + }, + { + "op": "del", + "hash": "%" + } + ] + }, + "pokeID": "é", + "rowsPatch": [ + { + "op": "put", + "tableName": "", + "value": { + "n": "𝒜😀%aß", + "": true + } + } + ] + } + ] + }, + { + "message": [ + "pushResponse", + { + "error": "unsupportedSchemaVersion", + "mutationIDs": [], + "zExtra": -1.5 + } + ], + "ok": true, + "output": [ + "pushResponse", + { + "error": "unsupportedSchemaVersion", + "mutationIDs": [] + } + ] + }, + { + "message": [ + "deleteClients", + { + "clientIDs": [ + "bogus", + "😀b" + ] + } + ], + "ok": true, + "output": [ + "deleteClients", + { + "clientIDs": [ + "bogus", + "😀b" + ] + } + ] + }, + { + "message": [ + "custom", + { + "wsid": 0 + } + ], + "ok": false + }, + { + "message": "", + "ok": false + }, + { + "message": [ + "pokePart", + { + "pokeID": "é中_béé\"", + "rowsPatch": [ + { + "op": "update", + "tableName": "issue", + "id": { + "id": "a", + "n": 1 + } + } + ], + "mutationsPatch": "parse", + "": [ + null, + "", + 0 + ] + } + ], + "ok": false + }, + { + "message": [ + "pokePart", + { + "pokeID": "_% é\naA", + "lastMutationIDChanges": {}, + "desiredQueriesPatches": { + "A𝒜😀ß\"_ß": [ + { + "op": "put", + "hash": "中 a ", + "ttl": 34 + }, + { + "op": "del", + "hash": "a" + }, + { + "hash": "\"", + "ttl": 72531 + } + ] + }, + "rowsPatch": [ + { + "op": "put", + "tableName": "issue", + "value": { + "title": { + "zExtra": 1 + }, + "n": null, + "😀": { + "ß": "\u001f", + "中文": null, + "中\na": 91315 + }, + "": "ß" + } + }, + { + "op": "put", + "tableName": "user", + "value": { + "id": { + "%\"a ": true, + "ab": true + }, + "": { + "😀a\nb%": -2.25 + } + } + }, + { + "op": "put", + "tableName": "issue", + "value": { + "id": { + "\"": null, + "": "%é😀éİ", + " ": "a" + }, + "title": { + "ébİ_İ中A": "\\ß" + }, + "": 0.5 + } + }, + { + "op": "update", + "tableName": "été", + "id": { + "id": false, + "n": 0 + }, + "constrain": [], + "é": false + }, + { + "op": "del", + "tableName": "user", + "id": { + "id": false, + "n": 0 + } + } + ], + "zExtra": true + } + ], + "ok": false + }, + { + "message": [ + "pushResponse", + { + "mutations": [] + }, + null + ], + "ok": false + }, + { + "message": [ + "error", + { + "message": "bİ%😀", + "mutations": "queries" + } + ], + "ok": false + }, + { + "message": "_zero_crud", + "ok": false + }, + { + "message": [ + "pong", + "update" + ], + "ok": false + }, + { + "message": [ + { + "zExtra": 1 + }, + { + "cookie": "a", + "requestID": "", + "lastMutationIDChanges": {} + }, + "pull" + ], + "ok": false + }, + { + "message": [ + "pokePart", + { + "pokeID": "_% é\naA", + "lastMutationIDChanges": {}, + "desiredQueriesPatches": { + "A𝒜😀ß\"_ß": [ + { + "op": "put", + "hash": "中 a ", + "ttl": 34 + }, + { + "op": "del", + "hash": "a" + }, + { + "op": "put", + "hash": "\"", + "ttl": 72531 + } + ] + }, + "rowsPatch": [ + { + "op": "put", + "tableName": "issue", + "value": { + "title": null, + "n": null, + "😀": { + "ß": "\u001f", + "中文": null, + "中\na": 91315 + }, + "": "ß" + } + }, + { + "op": "put", + "tableName": "user", + "value": { + "id": { + "%\"a ": true, + "ab": true + }, + "": { + "😀a\nb%": -2.25 + } + } + }, + { + "op": "put", + "tableName": "issue", + "value": { + "id": { + "\"": null, + "": "%é😀éİ", + " ": "a" + }, + "title": { + "ébİ_İ中A": "\\ß" + }, + "": 0.5 + } + }, + { + "op": "update", + "tableName": "été", + "id": { + "id": false, + "n": 0 + }, + "constrain": [], + "é": false + }, + { + "op": "del", + "tableName": "user", + "id": { + "id": false, + "n": { + "zExtra": 1 + } + } + } + ], + "zExtra": [] + } + ], + "ok": false + }, + { + "message": [ + "pokePart", + { + "pokeID": "\\\"ß中é", + "lastMutationIDChanges": { + "􏿿": 452, + "￿": 565, + "😀%ßßA\n": 976 + }, + "desiredQueriesPatches": { + "AB%_ßaß": [] + }, + "gotQueriesPatch": [ + { + "op": "put", + "hash": "Bİ", + "ttl": -9007199254740992 + }, + { + "op": "put", + "hash": "😀_%\\a\\a", + "zExtra": { + "中𝒜_aß😀aé": false, + "b": "A\"A\\Bßß" + } + } + ], + "rowsPatch": [] + } + ], + "ok": true, + "output": [ + "pokePart", + { + "lastMutationIDChanges": { + "􏿿": 452, + "￿": 565, + "😀%ßßA\n": 976 + }, + "desiredQueriesPatches": { + "AB%_ßaß": [] + }, + "pokeID": "\\\"ß中é", + "gotQueriesPatch": [ + { + "op": "put", + "hash": "Bİ", + "ttl": -9007199254740992 + }, + { + "op": "put", + "hash": "😀_%\\a\\a" + } + ], + "rowsPatch": [] + } + ] + }, + { + "message": 0, + "ok": false + }, + { + "message": "timeout", + "ok": false + }, + { + "message": "custom", + "ok": false + }, + { + "message": [ + "connected", + "del" + ], + "ok": false + }, + { + "message": [ + "deleteClients", + "" + ], + "ok": false + }, + { + "message": [ + "connected", + 0 + ], + "ok": false + }, + { + "message": [ + "pokePart", + { + "pokeID": "_% é\naA", + "lastMutationIDChanges": {}, + "desiredQueriesPatches": { + "A𝒜😀ß\"_ß": [ + { + "op": "put", + "hash": "中 a ", + "ttl": 34 + }, + { + "op": "del", + "hash": "a" + }, + { + "op": "put", + "hash": "\"", + "ttl": 72531 + } + ] + }, + "rowsPatch": [ + { + "op": "put", + "tableName": "issue", + "value": { + "title": null, + "n": null, + "😀": { + "ß": "\u001f", + "中文": null, + "中\na": 91315 + }, + "": "ß" + } + }, + { + "op": "put", + "tableName": "user", + "value": { + "id": { + "%\"a ": true, + "ab": true + }, + "": { + "😀a\nb%": -2.25 + } + } + }, + { + "op": "put", + "tableName": -1.5, + "value": { + "id": { + "\"": null, + "": "%é😀éİ", + " ": "a" + }, + "title": { + "ébİ_İ中A": "\\ß" + }, + "": 0.5 + } + }, + { + "op": "update", + "tableName": "été", + "id": { + "id": false, + "n": 0 + }, + "constrain": [], + "é": false + }, + { + "op": "del", + "tableName": "user", + "id": { + "id": false, + "n": 0 + } + } + ], + "zExtra": true + } + ], + "ok": false + }, + { + "message": {}, + "ok": false + }, + { + "message": { + "zExtra": 1 + }, + "ok": false + }, + { + "message": true, + "ok": false + }, + { + "message": [ + "transformError", + [ + { + "error": "app", + "id": "􏿿", + "name": [], + "message": "\"é中ß😀", + "details": [] + }, + { + "error": "app", + "id": " ", + "name": "ßb中A", + "message": " _é\n\"\"", + "details": true + }, + { + "error": "parse", + "id": "𝒜éb\"\nb", + "name": "é😀_İa𝒜", + "message": "İ" + } + ] + ], + "ok": false + }, + { + "message": [ + { + "id": "😀", + "op": "error", + "value": "𝒜_中" + } + ], + "ok": false + }, + { + "message": [ + "pokePart", + -1.5 + ], + "ok": false + }, + { + "message": [ + "inspect", + { + "id": "Ω", + "op": "queries", + "value": [ + { + "clientID": "", + "queryID": "B", + "ast": { + "table": "issue" + }, + "name": "é", + "args": null, + "got": false, + "deleted": true, + "ttl": 0, + "inactivatedAt": 9007199254740992 + }, + { + "clientID": "ß", + "queryID": "ı", + "ast": { + "table": "issue" + }, + "name": "中\"\\\"A \\", + "args": [ + 68520, + [ + null + ] + ], + "got": true, + "deleted": true, + "ttl": 0.5, + "inactivatedAt": -1, + "rowCount": 5 + } + ] + } + ], + "ok": false + }, + { + "message": [ + "zeroCache", + { + "mutations": [] + } + ], + "ok": false + }, + { + "message": [ + "pushResponse", + { + "mutations": [ + null + ], + "error": "PushFailed" + } + ], + "ok": false + }, + { + "message": [ + "pull", + { + "cookie": 0, + "requestID": "𝒜 中😀ABİ", + "lastMutationIDChanges": {}, + "😀": true + }, + "" + ], + "ok": false + }, + { + "message": [ + "pushResponse", + {} + ], + "ok": false + }, + { + "message": [ + "connected", + {} + ], + "ok": false + }, + { + "message": "and", + "ok": false + }, + { + "message": [ + "pokePart", + -1.5 + ], + "ok": false + }, + { + "message": [], + "ok": false + }, + { + "message": "", + "ok": false + }, + { + "message": "crud", + "ok": false + }, + { + "message": [ + "pokeEnd", + { + "pokeID": "é中", + "cookie": true, + "cancel": false + } + ], + "ok": false + }, + { + "message": [ + "pokePart", + { + "pokeID": "é中_béé\"", + "rowsPatch": [ + { + "op": "update", + "tableName": "issue", + "id": { + "id": "a", + "n": 1 + } + } + ], + "mutationsPatch": [], + "": [ + "", + "\n", + 0 + ] + } + ], + "ok": true, + "output": [ + "pokePart", + { + "rowsPatch": [ + { + "op": "update", + "tableName": "issue", + "id": { + "id": "a", + "n": 1 + } + } + ], + "mutationsPatch": [], + "pokeID": "é中_béé\"" + } + ] + }, + { + "message": [ + "pokePart", + { + "pokeID": "Internal", + "desiredQueriesPatches": { + "9": [ + { + "op": "clear" + }, + { + "op": "del", + "hash": "%" + } + ] + }, + "rowsPatch": [ + { + "op": "put", + "tableName": "comment", + "value": { + "n": "𝒜😀%aß", + "": true + } + } + ] + } + ], + "ok": true, + "output": [ + "pokePart", + { + "desiredQueriesPatches": { + "9": [ + { + "op": "clear" + }, + { + "op": "del", + "hash": "%" + } + ] + }, + "pokeID": "Internal", + "rowsPatch": [ + { + "op": "put", + "tableName": "comment", + "value": { + "n": "𝒜😀%aß", + "": true + } + } + ] + } + ] + }, + { + "message": [ + "pushResponse", + { + "error": "http", + "status": 500, + "details": { + "zExtra": 1 + } + } + ], + "ok": false + }, + { + "message": [ + "inspect", + { + "id": "Ω", + "op": "queries", + "value": [ + { + "clientID": "", + "queryID": "B", + "ast": { + "table": "issue" + }, + "name": -1.5, + "args": null, + "got": false, + "deleted": true, + "ttl": 0, + "inactivatedAt": 9007199254740992, + "rowCount": 22 + }, + { + "clientID": "ß", + "queryID": "ı", + "ast": { + "table": "issue" + }, + "name": "中\"\\\"A \\", + "args": [ + 68520, + [ + null + ] + ], + "got": { + "zExtra": 1 + }, + "deleted": true, + "ttl": 0.5, + "inactivatedAt": -1, + "rowCount": 5 + } + ] + } + ], + "ok": false + }, + { + "message": [ + "pushResponse", + { + "error": "http", + "status": 500, + "details": "\u001f", + "mutationIDs": [] + } + ], + "ok": true, + "output": [ + "pushResponse", + { + "error": "http", + "status": 500, + "details": "\u001f", + "mutationIDs": [] + } + ] + }, + { + "message": [ + "pokePart", + { + "pokeID": "z", + "rowsPatch": [ + { + "op": "put", + "tableName": "issue", + "value": { + "title": [], + "n": 2, + "😀": { + "9": null, + "_ß\n\\B中b": false + } + } + } + ], + "mutationsPatch": [] + }, + null + ], + "ok": false + }, + { + "message": true, + "ok": false + }, + { + "message": null, + "ok": false + }, + { + "message": [ + { + "pokeID": "" + } + ], + "ok": false + }, + { + "message": [ + "pokePart", + { + "pokeID": "A", + "lastMutationIDChanges": { + "A中中ß中": -1.5 + }, + "gotQueriesPatch": [ + { + "op": "del", + "hash": "b", + "zExtra": 0.5 + } + ], + "rowsPatch": [ + { + "op": "put", + "tableName": "user", + "value": { + "id": 57273, + "title": false, + "n": "", + "😀": 2, + "": false + } + }, + { + "op": "update", + "tableName": "comment", + "id": { + "id": "a" + }, + "constrain": [ + "title" + ], + "": "İ" + }, + { + "op": "put", + "tableName": "comment", + "value": { + "n": { + "": null + }, + "😀": [], + "": "\na b%é " + } + } + ] + } + ], + "ok": true, + "output": [ + "pokePart", + { + "lastMutationIDChanges": { + "A中中ß中": -1.5 + }, + "pokeID": "A", + "gotQueriesPatch": [ + { + "op": "del", + "hash": "b" + } + ], + "rowsPatch": [ + { + "op": "put", + "tableName": "user", + "value": { + "id": 57273, + "title": false, + "n": "", + "😀": 2, + "": false + } + }, + { + "op": "update", + "tableName": "comment", + "id": { + "id": "a" + }, + "constrain": [ + "title" + ] + }, + { + "op": "put", + "tableName": "comment", + "value": { + "n": { + "": null + }, + "😀": [], + "": "\na b%é " + } + } + ] + } + ] + }, + { + "message": [ + "crud", + { + "zExtra": "_zero_crud" + } + ], + "ok": false + }, + { + "message": [ + "pushResponse", + { + "mutations": [ + { + "id": { + "id": 859, + "clientID": "Bé%\\" + }, + "result": { + "error": "alreadyProcessed" + } + } + ], + "newField": { + "error": { + "zExtra": 1 + } + } + } + ], + "ok": true, + "output": [ + "pushResponse", + { + "mutations": [ + { + "id": { + "id": 859, + "clientID": "Bé%\\" + }, + "result": { + "error": "alreadyProcessed" + } + } + ] + } + ] + }, + { + "message": [ + 1 + ], + "ok": false + }, + { + "message": [ + "column", + { + "clientIDs": [] + } + ], + "ok": false + }, + { + "message": "zeroCache", + "ok": false + }, + { + "message": "column", + "ok": false + }, + { + "message": [ + "pokePart", + { + "pokeID": "_ \"\n中", + "lastMutationIDChanges": {}, + "gotQueriesPatch": [ + { + "op": "del", + "hash": "ßé\\ é " + }, + { + "op": "del", + "hash": "é中a😀" + } + ], + "rowsPatch": [ + { + "op": "clear" + }, + { + "op": "put", + "tableName": "comment", + "value": { + "id": {}, + "title": [ + 63801, + false + ], + "n": "\"", + "😀": { + "\nb\\éß\nß": null, + "😀a\nA😀%": 2, + "\n": "\"" + } + } + }, + { + "op": "del", + "tableName": "issue", + "id": { + "id": "a" + } + } + ], + "mutationsPatch": "timeout" + } + ], + "ok": false + }, + { + "message": "0", + "ok": false + }, + { + "message": [ + "pushResponse", + { + "error": "zeroPusher", + "details": "ß", + "mutationIDs": [ + { + "id": 111, + "clientID": "😀" + }, + { + "id": 73, + "clientID": "ßBA\"B\"" + } + ] + }, + "pushResponse" + ], + "ok": false + }, + { + "message": "0", + "ok": false + }, + { + "message": [ + "put", + { + "clientGroupIDs": [ + "İb" + ] + } + ], + "ok": false + }, + { + "message": [ + "pushResponse", + { + "error": "unsupportedSchemaVersion", + "mutationIDs": [], + "zExtra": false, + "mutations": "custom" + } + ], + "ok": true, + "output": [ + "pushResponse", + { + "error": "unsupportedSchemaVersion", + "mutationIDs": [] + } + ] + }, + { + "message": [ + "pokePart", + { + "pokeID": "A", + "lastMutationIDChanges": { + "A中中ß中": 398 + }, + "gotQueriesPatch": [ + { + "op": "del", + "hash": "" + }, + { + "op": "del", + "hash": "b", + "zExtra": 0.5 + } + ], + "rowsPatch": [ + { + "op": "put", + "tableName": "user", + "value": { + "id": 57273, + "title": false, + "n": "", + "😀": 2, + "": false + } + }, + { + "tableName": "comment", + "id": { + "id": "a" + }, + "constrain": [ + "title" + ], + "": "İ" + }, + { + "op": "put", + "tableName": "comment", + "value": { + "n": { + "": null + }, + "😀": [], + "": "\na b%é " + } + } + ] + } + ], + "ok": false + }, + { + "message": [ + "error", + { + "kind": "InvalidPush" + } + ], + "ok": false + }, + { + "message": [ + "bogus", + {} + ], + "ok": false + }, + { + "message": [ + "pokeEnd" + ], + "ok": false + }, + { + "message": [ + "Rebalance", + { + "error": "http", + "status": 500, + "details": "�" + } + ], + "ok": false + }, + { + "message": [ + "crud" + ], + "ok": false + } + ] +} diff --git a/packages/zero-conformance/vectors/protocol-poke-chunks.json b/packages/zero-conformance/vectors/protocol-poke-chunks.json new file mode 100644 index 0000000000..f36e79722b --- /dev/null +++ b/packages/zero-conformance/vectors/protocol-poke-chunks.json @@ -0,0 +1,2134 @@ +{ + "formatVersion": 1, + "kind": "protocol-poke-chunks", + "description": "The binary poke chunks of one poke, each message in hex (a 0x00 type byte, then the next bytes of the UTF-8 JSON of a `PokePartBody[]`). `ok` is whether the client accepts them; `parts` is the `strip` output of `pokePartsSchema`, with mutation results as the client reads them (see protocol-downstream.json).", + "cases": [ + { + "messages": [ + "005b5d" + ], + "ok": true, + "parts": [] + }, + { + "messages": [ + "005b", + "005d" + ], + "ok": true, + "parts": [] + }, + { + "messages": [ + "00", + "00", + "00205b205d20", + "00" + ], + "ok": true, + "parts": [] + }, + { + "messages": [ + "00" + ], + "ok": false + }, + { + "messages": [ + "005b7b22706f6b654944223a22e4b8", + "00ad", + "00227d5d" + ], + "ok": true, + "parts": [ + { + "pokeID": "中" + } + ] + }, + { + "messages": [ + "005b7b22706f6b654944223a22f0", + "009f", + "0098", + "0080222c227a4578747261223a317d5d" + ], + "ok": true, + "parts": [ + { + "pokeID": "😀" + } + ] + }, + { + "messages": [ + "005b7b22706f", + "006b654944223a2270227d2c7b22706f6b654944223a2271222c", + "0022726f77735061746368223a5b7b226f70223a22636c656172227d5d7d5d" + ], + "ok": true, + "parts": [ + { + "pokeID": "p" + }, + { + "pokeID": "q", + "rowsPatch": [ + { + "op": "clear" + } + ] + } + ] + }, + { + "messages": [ + "00efbbbf5b5d" + ], + "ok": true, + "parts": [] + }, + { + "messages": [ + "00ef", + "00bb", + "00bf5b5d" + ], + "ok": true, + "parts": [] + }, + { + "messages": [ + "00efbbbfefbbbf5b5d" + ], + "ok": false + }, + { + "messages": [ + "005befbbbf5d" + ], + "ok": false + }, + { + "messages": [ + "005b22ff225d" + ], + "ok": false + }, + { + "messages": [ + "005b7b22706f6b654944223a22c0af227d5d" + ], + "ok": false + }, + { + "messages": [ + "005b7b22706f6b654944223a22eda080227d5d" + ], + "ok": false + }, + { + "messages": [ + "005b7b22706f6b654944223a22f4908080227d5d" + ], + "ok": false + }, + { + "messages": [ + "005b7b22706f6b654944223a22e4", + "00b8" + ], + "ok": false + }, + { + "messages": [ + "005b7b22706f6b654944223a22e4", + "00b8227d5d" + ], + "ok": false + }, + { + "messages": [ + "005b7b22706f6b654944223a2270227d" + ], + "ok": false + }, + { + "messages": [ + "007b7d" + ], + "ok": false + }, + { + "messages": [ + "005b7b7d5d" + ], + "ok": false + }, + { + "messages": [ + "005b7b22706f6b654944223a317d5d" + ], + "ok": false + }, + { + "messages": [ + "005b6e756c6c5d" + ], + "ok": false + }, + { + "messages": [ + "015b5d" + ], + "ok": false + }, + { + "messages": [ + "5b5d" + ], + "ok": false + }, + { + "messages": [ + "005b", + "025d" + ], + "ok": false + }, + { + "messages": [ + "" + ], + "ok": false + }, + { + "messages": [ + "005b7b22706f6b654944223a2270222c226d75746174696f6e735061746368223a5b7b226f70223a22", + "0070", + "007574222c226d75746174696f6e223a7b226964223a7b226964223a312c22636c69656e744944223a2263227d2c22726573756c74223a7b226572726f72223a22617070222c226d657373616765223a226d222c227a4578747261223a317d7d7d2c7b226f70223a22707574222c226d75746174696f6e223a7b226964223a7b226964223a322c22636c69656e744944223a2263227d2c22726573756c74223a7b2264617461223a5b315d2c227a4578747261223a317d7d7d5d7d5d" + ], + "ok": true, + "parts": [ + { + "pokeID": "p", + "mutationsPatch": [ + { + "op": "put", + "mutation": { + "id": { + "id": 1, + "clientID": "c" + }, + "result": { + "error": "app", + "message": "m" + } + } + }, + { + "op": "put", + "mutation": { + "id": { + "id": 2, + "clientID": "c" + }, + "result": { + "data": [ + 1 + ] + } + } + } + ] + } + ] + }, + { + "messages": [ + "005b7b22706f6b654944223a225c6e222c226d75746174696f6e735061746368223a5b7b226f70223a22707574222c226d75746174696f6e223a7b226964223a7b226964223a3935372c22636c69656e744944223a2242f09d929c227d2c22726573756c74223a7b7d", + "007d2c2274797065223a7b227a4578747261223a317d7d5d7d5d" + ], + "ok": true, + "parts": [ + { + "pokeID": "\n", + "mutationsPatch": [ + { + "op": "put", + "mutation": { + "id": { + "id": 957, + "clientID": "B𝒜" + }, + "result": {} + } + } + ] + } + ] + }, + { + "messages": [ + "00", + "00", + "005b", + "00", + "005d", + "00" + ], + "ok": true, + "parts": [] + }, + { + "messages": [ + "005b7b22706f6b654944223a22c4b05f222c226c6173744d75746174696f6e49444368616e676573223a7b225c22c3a9223a3131317d2c22726f77735061746368223a5b7b226f70223a2264656c22", + "002c227461626c654e616d65223a22697373756522", + "002c226964223a7b226964223a22227d7d2c7b226f70223a22707574222c227461626c654e616d65223a22c3a974c3a9222c2276616c7565223a7b22223a5b2d393030373139393235343734303939322c2d393030373139393235343734303939322c66616c73655d7d2c227a4578747261223a22615c225c22c3a9c3a9227d5d7d2c7b22706f6b654944223a2261e4b8adc39f5c22c39f222c22676f74517565726965735061746368223a5b7b226f70223a2264656c222c2268617368223a2239227d2c7b226f70223a2264656c222c2268617368223a225c7530303030227d", + "005d7d5d" + ], + "ok": true, + "parts": [ + { + "lastMutationIDChanges": { + "\"é": 111 + }, + "pokeID": "İ_", + "rowsPatch": [ + { + "op": "del", + "tableName": "issue", + "id": { + "id": "" + } + }, + { + "op": "put", + "tableName": "été", + "value": { + "": [ + -9007199254740992, + -9007199254740992, + false + ] + } + } + ] + }, + { + "pokeID": "a中ß\"ß", + "gotQueriesPatch": [ + { + "op": "del", + "hash": "9" + }, + { + "op": "del", + "hash": "\u0000" + } + ] + } + ] + }, + { + "messages": [ + "00", + "005b", + "00", + "00", + "00", + "005d" + ], + "ok": true, + "parts": [] + }, + { + "messages": [ + "005b7b22706f6b654944223a2241e4b8adee8080e4b8ad61ee8080222c22726f77735061746368223a5b7b226f70223a2264656c222c227461626c654e616d65223a2275736572222c226964223a7b226964223a312e352c226e223a307d7d2c22222c7b226f70223a22707574222c227461626c654e616d65223a22c3a974c3a9222c2276616c7565223a7b226964223a302c227469746c65223a22c3a96220222c226e223a33353338332c22f09f9880223a31652d372c22223a747275657d7d2c7b226f70223a22757064617465222c227461626c654e616d65223a22c3a974c3a9222c226964223a7b226964223a66616c73657d7d5d2c226d75746174696f6e735061746368223a5b7b226f70223a2264656c222c226964223a7b226964223a3932362c22636c69656e744944223a22ee8080f09f988025205c6e5f5c6ec3a9227d7d2c7b226f70223a22707574222c226d75746174696f6e223a7b226964223a7b226964223a3336352c22636c69656e744944223a22e4b8ad2042f09d929c227d2c22726573756c74223a7b7d7d7d5d7d5d" + ], + "ok": false + }, + { + "messages": [ + "005b", + "005d" + ], + "ok": true, + "parts": [] + }, + { + "messages": [ + "005b7b22706f6b654944223a22615c6e222c22726f77735061746368223a5b7b226f70223a22707574222c227461626c654e616d65223a226973737565222c2276616c7565223a7b226964223a39373131322c22f09f9880223a7b7d7d7d2c7b226f70223a22707574222c227461626c654e616d65223a22c3a974c3a9222c2276616c7565223a7b227469746c65223a323134373438333634382c226e223a6e756c6c7d7d2c7b226f70223a22707574222c227461626c654e616d65223a2275736572222c2276616c7565223a7b227469746c65223a2262227d2c22f09f9880223a5b5d7d2c7b226f70223a2264656c222c227461626c654e616d65223a22c3a974c3a9222c226964223a7b226964223a312c226e223a307d2c22c3a9223a22c39f5c5c20f09d929cf09d929ce4b8ad5c5c20227d2c7b226f70223a22636c656172227d5d2c226d75746174696f6e735061746368223a5b5d7d5d" + ], + "ok": true, + "parts": [ + { + "pokeID": "a\n", + "rowsPatch": [ + { + "op": "put", + "tableName": "issue", + "value": { + "id": 97112, + "😀": {} + } + }, + { + "op": "put", + "tableName": "été", + "value": { + "title": 2147483648, + "n": null + } + }, + { + "op": "put", + "tableName": "user", + "value": { + "title": "b" + } + }, + { + "op": "del", + "tableName": "été", + "id": { + "id": 1, + "n": 0 + } + }, + { + "op": "clear" + } + ], + "mutationsPatch": [] + } + ] + }, + { + "messages": [ + "005b7b22706f6b654944223a22222c22676f74517565726965735061", + "007463", + "0068223a5b5d2c226d75746174696f6e735061746368223a5b7b226f70223a2270757422", + "002c226d75746174696f6e223a7b226964223a7b226964223a3734", + "00362c22636c69656e744944223a22c3a9227d2c22726573756c", + "0074223a7b2264617461223a5b5d2c22223a22415f25ee80805c6e227d7d7d2c7b226f70223a22707574222c226d75746174696f6e223a7b", + "00226964223a7b226964223a3630362c22636c69656e744944223a22f09d929c227d2c22726573756c74223a7b2264617461223a6e756c6c7d7d7d5d7d5d" + ], + "ok": true, + "parts": [ + { + "gotQueriesPatch": [], + "pokeID": "", + "mutationsPatch": [ + { + "op": "put", + "mutation": { + "id": { + "id": 746, + "clientID": "é" + }, + "result": { + "data": [] + } + } + }, + { + "op": "put", + "mutation": { + "id": { + "id": 606, + "clientID": "𝒜" + }, + "result": { + "data": null + } + } + } + ] + } + ] + }, + { + "messages": [ + "005b7b22706f6b654944223a2225e4b8ad25", + "00f09f988041f09f9880222c226c6173744d75746174696f6e49444368616e676573223a7b22c39fc39f5c6ec39fc39f223a3130357d2c22646573697265645175657269657350617463686573223a7b22f09f9880c39f25f09f9880ee8080f09f988041223a5b7b226f70223a22707574222c2268617368223a225c5cc39f222c2274746c223a33353636357d2c7b226f70223a22707574222c2268617368223a22c3a9425c6ef09f98804225222c2274746c223a2d322e32357d2c7b226f70223a22636c656172222c22f09f9880223a31652d377d5d7d7d2c7b22706f6b654944223a22f09f9880f09d929c5c2242c4b06120ee8080222c226d", + "0075746174696f6e735061746368223a5b7b226f70223a2264656c222c226964223a7b226964223a3132382c22636c69656e744944223a22ee8080e4b8ad227d7d2c7b226f70223a22707574222c226d75746174696f6e223a7b226964223a7b226964223a3938312c22636c69656e744944223a2225c39fc4b06120c39f62", + "0062222c22223a6e756c6c7d2c227265", + "0073756c74223a7b7d7d2c22c3a9223a6e756c6c7d2c7b226f70223a22707574222c226d75746174696f6e223a7b226964223a7b226964223a3232392c22636c69656e744944223a22c4b1227d2c22726573756c74223a7b226572726f72223a22617070222c226d657373616765223a225fc39f615c6ec39f222c2264657461696c73223a6e756c6c7d2c22f09f9880223a5b2d393030373139393235343734303939325d7d7d5d7d2c7b22706f6b654944223a225a227d5d" + ], + "ok": true, + "parts": [ + { + "lastMutationIDChanges": { + "ßß\nßß": 105 + }, + "pokeID": "%中%😀A😀", + "desiredQueriesPatches": { + "😀ß%😀😀A": [ + { + "op": "put", + "hash": "\\ß", + "ttl": 35665 + }, + { + "op": "put", + "hash": "éB\n😀B%", + "ttl": -2.25 + }, + { + "op": "clear" + } + ] + } + }, + { + "pokeID": "😀𝒜\"Bİa ", + "mutationsPatch": [ + { + "op": "del", + "id": { + "id": 128, + "clientID": "中" + } + }, + { + "op": "put", + "mutation": { + "id": { + "id": 981, + "clientID": "%ßİa ßbb" + }, + "result": {} + } + }, + { + "op": "put", + "mutation": { + "id": { + "id": 229, + "clientID": "ı" + }, + "result": { + "error": "app", + "message": "_ßa\nß", + "details": null + } + } + } + ] + }, + { + "pokeID": "Z" + } + ] + }, + { + "messages": [ + "00", + "00", + "005b5d", + "00", + "00" + ], + "ok": true, + "parts": [] + }, + { + "messages": [ + "005b7b22706f6b654944223a22c3a9c4b0e4b8ad6225222c226c6173744d75746174696f6e4944", + "004368616e676573223a7b2230223a3835332c227a223a3934367d2c22646573697265645175657269657350617463686573223a7b22223a5b7b226f70223a22636c656172227d2c7b226f70223a22707574222c2268617368223a22cea9222c2274746c223a", + "00307d2c7b226f70223a22707574222c2268617368223a5b315d2c2274746c223a377d5d7d2c22726f77735061746368223a5b7b226f70223a22757064617465222c227461626c654e616d65223a22636f6d6d656e74222c226964223a7b226964223a66616c73652c226e223a2261227d2c226d65726765223a7b226e223a22e4b8ad6241c39f2020f09f9880222c22223a5b32363630365d7d7d2c7b226f70223a2264656c222c227461626c654e616d65223a2275736572222c226964223a7b226964223a66616c73657d2c22c3a9223a5b66616c73655d7d5d7d2c7b2264657369726564517565", + "007269657350617463686573223a7b7d7d5d" + ], + "ok": false + }, + { + "messages": [ + "005b7b22706f6b654944223a225f5c22e4b8ad5f41415c22222c22676f74517565726965735061746368223a5b7b226f", + "0070223a22707574222c2268617368223a22f09d929cc39fe4b8adf09d929c222c2274746c223a36353839317d2c7b226f70223a22636c656172227d5d2c22726f77735061746368223a5b7b226f70223a2270", + "007574222c227461626c654e616d65223a2275736572222c2276616c7565223a7b226964223a6e756c6c2c227469746c65223a2230222c226e223a22c3a9227d7d2c7b226f70223a22707574222c227461626c654e616d65223a226973737565222c2276616c7565223a7b226e223a7b22c4b0c3a962e4b8adee8080c39f223a22222c22c3a95c22223a6e756c6c2c22e4b8ade4b8adc4b06142223a2261ee8080227d7d7d5d7d5d" + ], + "ok": true, + "parts": [ + { + "gotQueriesPatch": [ + { + "op": "put", + "hash": "𝒜ß中𝒜", + "ttl": 65891 + }, + { + "op": "clear" + } + ], + "pokeID": "_\"中_AA\"", + "rowsPatch": [ + { + "op": "put", + "tableName": "user", + "value": { + "id": null, + "title": "0", + "n": "é" + } + }, + { + "op": "put", + "tableName": "issue", + "value": { + "n": { + "İéb中ß": "", + "é\"": null, + "中中İaB": "a" + } + } + } + ] + } + ] + }, + { + "messages": [ + "00", + "00", + "00", + "005b", + "005d", + "00" + ], + "ok": true, + "parts": [] + }, + { + "messages": [ + "005b", + "007b7d", + "005d", + "00", + "00" + ], + "ok": false + }, + { + "messages": [ + "00", + "005b7b22706f6b654944223a225c225c6e41", + "005c5c5c6e222c2267", + "006f74517565726965735061746368223a5b5d2c", + "00226d75746174696f6e735061746368223a7b227a4578747261223a317d7d5d" + ], + "ok": false + }, + { + "messages": [ + "005b7b22706f6b654944223a22255c6e20c39f222c226c6173744d75746174696f6e49444368616e676573223a7b225c7530303166223a3330302c22ee8080415c5cee80805ff09d929c5fc39f223a3336327d2c22646573697265645175657269657350617463686573223a7b225c5c5c6ec4b0c4b05c6ef09d929c223a5b5d2c22f09d929c223a5b7b226f70223a22636c656172227d5d7d2c22676f74517565726965735061746368223a5b5d2c22223a31652b32317d2c7b22706f6b654944223a2261222c22646573697265645175657269657350617463686573223a7b7d2c22726f77735061746368223a5b7b226f70223a2264656c222c227461626c654e616d65223a22c3a974c3a9222c226964223a7b226964223a22222c226e223a22f09f9880227d7d2c7b", + "00226f70223a22707574222c227461626c654e616d65223a2275736572222c2276616c7565223a7b226e223a22c3a9222c22f09f9880223a7b7d7d7d2c7b226f70223a2264656c222c227461626c654e616d65223a226973737565222c226964223a7b226964223a22f09f9880227d7d2c7b226f70223a22757064617465222c227461626c654e616d65223a226973737565222c226964223a7b226964223a747275652c226e223a22227d2c226d65726765223a7b226964223a225c5cc3a95f41c4b062222c227469746c65223a5b747275652c22f09f98805c5c5c22c3a9615fc39fe4b8ad222c6e756c6c5d2c226e223a302e352c22f09f9880223a22222c22223a7b22c39f223a363734332c22f09f98805c5c5f41c39f41223a2d322e32357d7d7d2c7b226f70223a2264656c222c227461626c654e616d65223a22c3a974c3a9222c226964223a7b226964223a2261227d7d5d2c226d75746174696f6e735061746368223a5b7b226f70223a2264656c222c226964223a7b226964223a3530392c22636c69656e744944223a22ee8080", + "00227d2c22223a31652d377d2c7b226f70223a22707574222c226d75746174696f6e223a7b226964223a7b226964223a3339342c22636c69656e744944223a225c22f09d929cf09f9880c4b05c5c61c4b0c4b0227d2c22726573756c74223a7b226572726f72223a22616c726561647950726f636573736564222c2264657461696c73223a7b22625c5cc39ff09f98805c5c5c5c5c5c223a66616c73652c22f09d929c41c3a9c3a9c4b025223a2262c39fc39f425c6e5c225c22227d7d2c22223a66616c73657d2c227a45", + "0078747261223a7b22ee8080c39f25f09f9880223a6e756c6c7d7d5d7d2c7b22706f6b654944223a22f09f9880222c22646573697265645175657269657350617463686573223a7b225c5c42f09d929c223a5b7b226f70223a22636c656172227d2c7b226f70223a22707574222c2268617368223a22227d5d2c2241223a5b7b226f70223a22707574222c2268617368223a22227d5d7d2c22726f77735061746368223a5b7b226f70223a22757064617465222c227461626c654e616d65223a2275736572222c226964223a7b226964223a312e357d2c226d65726765223a7b226e223a5b747275652c226220615f225d2c22223a6e756c6c7d7d5d7d5d" + ], + "ok": true, + "parts": [ + { + "lastMutationIDChanges": { + "\u001f": 300, + "A\\_𝒜_ß": 362 + }, + "desiredQueriesPatches": { + "\\\nİİ\n𝒜": [], + "𝒜": [ + { + "op": "clear" + } + ] + }, + "gotQueriesPatch": [], + "pokeID": "%\n ß" + }, + { + "desiredQueriesPatches": {}, + "pokeID": "a", + "rowsPatch": [ + { + "op": "del", + "tableName": "été", + "id": { + "id": "", + "n": "😀" + } + }, + { + "op": "put", + "tableName": "user", + "value": { + "n": "é", + "😀": {} + } + }, + { + "op": "del", + "tableName": "issue", + "id": { + "id": "😀" + } + }, + { + "op": "update", + "tableName": "issue", + "id": { + "id": true, + "n": "" + }, + "merge": { + "id": "\\é_Aİb", + "title": [ + true, + "😀\\\"éa_ß中", + null + ], + "n": 0.5, + "😀": "", + "": { + "ß": 6743, + "😀\\_AßA": -2.25 + } + } + }, + { + "op": "del", + "tableName": "été", + "id": { + "id": "a" + } + } + ], + "mutationsPatch": [ + { + "op": "del", + "id": { + "id": 509, + "clientID": "" + } + }, + { + "op": "put", + "mutation": { + "id": { + "id": 394, + "clientID": "\"𝒜😀İ\\aİİ" + }, + "result": { + "error": "alreadyProcessed", + "details": { + "b\\ß😀\\\\\\": false, + "𝒜Aééİ%": "bßßB\n\"\"" + } + } + } + } + ] + }, + { + "desiredQueriesPatches": { + "\\B𝒜": [ + { + "op": "clear" + }, + { + "op": "put", + "hash": "" + } + ], + "A": [ + { + "op": "put", + "hash": "" + } + ] + }, + "pokeID": "😀", + "rowsPatch": [ + { + "op": "update", + "tableName": "user", + "id": { + "id": 1.5 + }, + "merge": { + "n": [ + true, + "b a_" + ], + "": null + } + } + ] + } + ] + }, + { + "messages": [ + "005b7b22706f6b654944223a22efbfbd222c226c6173744d75746174696f6e49444368616e676573223a7b225c5c5c22c4b0616142e4b8ad223a3131322c22c3a9223a3639332c22ee80802520e4b8ad5fc39f223a3537367d2c22726f77735061746368223a5b7b226f70223a22757064617465222c227461626c654e616d65223a22c3a974c3a9222c226964223a7b226964223a317d7d2c7b226f70223a22636c656172227d2c7b226f70223a22707574222c227461626c654e616d65223a226973737565222c2276616c7565223a7b226964223a66616c73652c22f09f9880223a22227d7d5d7d2c7b22706f6b654944223a22e4b8ad5c6ef09f9880425c5c25222c22676f74517565726965735061746368223a5b5d7d2c7b22706f6b654944223a2239222c22646573697265645175657269657350617463686573223a7b7d2c22726f77735061746368223a5b7b226f70223a22757064617465222c227461626c654e616d65223a226973737565222c226964223a7b226964223a22f09f9880222c226e223a317d2c226d65726765223a7b227469746c65223a224242ee80805c6e222c22f09f9880223a7b22c3a961f09f98", + "0080e4b8ad5c22223a2261c39f222c22f09f98806261c3a9223a302c2220223a6e756c6c7d2c22223a393030373139393235343734303939327d7d2c7b226f70223a22636c656172227d5d2c226d75746174696f6e735061746368223a5b7b226f70223a2264656c222c226964223a7b226964223a3535392c22636c69656e744944223a2220f09f9880222c226e6577", + "004669656c64223a226120f09f9880ee8080227d7d2c7b226f70223a22707574222c226d75746174696f6e223a7b226964223a7b226964223a3435322c22636c69656e744944223a22f09f9880c4b05c22227d2c22726573756c74223a7b7d7d7d5d7d5d" + ], + "ok": true, + "parts": [ + { + "lastMutationIDChanges": { + "\\\"İaaB中": 112, + "é": 693, + "% 中_ß": 576 + }, + "pokeID": "�", + "rowsPatch": [ + { + "op": "update", + "tableName": "été", + "id": { + "id": 1 + } + }, + { + "op": "clear" + }, + { + "op": "put", + "tableName": "issue", + "value": { + "id": false, + "😀": "" + } + } + ] + }, + { + "pokeID": "中\n😀B\\%", + "gotQueriesPatch": [] + }, + { + "desiredQueriesPatches": {}, + "pokeID": "9", + "rowsPatch": [ + { + "op": "update", + "tableName": "issue", + "id": { + "id": "😀", + "n": 1 + }, + "merge": { + "title": "BB\n", + "😀": { + "éa😀中\"": "aß", + "😀baé": 0, + " ": null + }, + "": 9007199254740992 + } + }, + { + "op": "clear" + } + ], + "mutationsPatch": [ + { + "op": "del", + "id": { + "id": 559, + "clientID": " 😀" + } + }, + { + "op": "put", + "mutation": { + "id": { + "id": 452, + "clientID": "😀İ\"" + }, + "result": {} + } + } + ] + } + ] + }, + { + "messages": [ + "005b7b22706f6b654944223a2239222c226c6173744d75746174696f6e4944436861", + "00", + "006e676573223a7b22f09d929cc3a941223a3938352c225c22f09f", + "009880223a3238387d2c22726f77", + "00735061746368223a5b5d2c226d75746174696f6e7350617463", + "0068223a5b5d7d5d", + "00" + ], + "ok": true, + "parts": [ + { + "pokeID": "9", + "lastMutationIDChanges": { + "𝒜éA": 985, + "\"😀": 288 + }, + "rowsPatch": [], + "mutationsPatch": [] + } + ] + }, + { + "messages": [ + "005b7b22706f6b654944223a225c2242c3a95c5cc4b0c4b0222c22646573697265645175657269657350617463686573223a7b7d2c22726f77735061746368223a5b7b226f70223a22636c656172227d2c7b226f70223a22636c656172227d2c7b226f70223a2264656c222c227461626c654e616d65223a22c3a974c3a9222c226964223a7b226964223a22f09f9880227d7d2c7b226f70223a22757064617465222c227461626c654e616d65223a22c3a974c3a9222c226964223a7b226964223a312e352c226e223a312e357d2c226d65726765223a5b6e756c6c5d2c22636f6e73747261696e223a5b5d2c22c3a9223a66616c73657d2c7b226f70223a22707574222c227461626c654e616d65223a22636f6d6d656e74222c2276616c7565223a7b226964223a7b7d2c226e223a7b22c3a95f223a22415c5cc39f227d2c22f09f9880223a225c22227d2c227a4578747261223a2262227d5d7d2c7b22706f6b654944223a2261222c22726f77735061746368223a5b7b226f70223a22707574222c227461626c654e616d65223a22c3a974c3a9222c2276616c7565223a7b227469746c65223a5b5d2c22f09f9880223a5b226162222c302c6e756c6c5d2c22223a7b7d7d7d2c7b226f70223a22707574222c227461626c654e616d65223a22636f6d6d656e74222c2276616c7565223a7b22f09f9880223a66616c73657d7d2c7b226f70223a22707574222c227461626c654e616d65223a22c3a974c3a9222c2276616c7565223a7b227469746c65223a747275652c22f09f9880223a747275657d7d2c7b226f70223a22707574222c227461626c654e616d65223a22636f6d6d656e74222c2276616c7565223a7b227469746c65223a37373230302c226e223a2d322e32357d7d2c7b226f70223a22757064617465222c227461626c654e616d65223a226973737565222c226964223a7b226964223a66616c73657d7d5d2c227a4578747261223a37333736367d2c7b22706f6b654944223a2241222c226c6173744d75746174696f6e49444368616e676573223a7b22223a3136332c2242e4b8ad61c4b05c5cc4b0223a3334377d2c22646573697265645175657269657350617463686573223a7b7d7d5d" + ], + "ok": true, + "parts": [ + { + "desiredQueriesPatches": {}, + "pokeID": "\"Bé\\İİ", + "rowsPatch": [ + { + "op": "clear" + }, + { + "op": "clear" + }, + { + "op": "del", + "tableName": "été", + "id": { + "id": "😀" + } + }, + { + "op": "update", + "tableName": "été", + "id": { + "id": 1.5, + "n": 1.5 + }, + "merge": [ + null + ], + "constrain": [] + }, + { + "op": "put", + "tableName": "comment", + "value": { + "id": {}, + "n": { + "é_": "A\\ß" + }, + "😀": "\"" + } + } + ] + }, + { + "pokeID": "a", + "rowsPatch": [ + { + "op": "put", + "tableName": "été", + "value": { + "title": [], + "😀": [ + "ab", + 0, + null + ], + "": {} + } + }, + { + "op": "put", + "tableName": "comment", + "value": { + "😀": false + } + }, + { + "op": "put", + "tableName": "été", + "value": { + "title": true, + "😀": true + } + }, + { + "op": "put", + "tableName": "comment", + "value": { + "title": 77200, + "n": -2.25 + } + }, + { + "op": "update", + "tableName": "issue", + "id": { + "id": false + } + } + ] + }, + { + "pokeID": "A", + "lastMutationIDChanges": { + "": 163, + "B中aİ\\İ": 347 + }, + "desiredQueriesPatches": {} + } + ] + }, + { + "messages": [ + "00", + "00", + "005b", + "005d", + "00" + ], + "ok": true, + "parts": [] + }, + { + "messages": [ + "005b5d" + ], + "ok": true, + "parts": [] + }, + { + "messages": [ + "005b7b22706f6b654944223a22f09d929c222c22646573697265645175657269657350617463686573223a7b22e4b8adf09f98805f616120223a5b7b226f70223a22707574222c", + "002268617368223a22c39f222c2274746c223a37333838307d2c7b226f70223a2264656c222c2268617368223a225c7530303030227d2c7b226f70223a22707574222c2268617368223a2241e4b8adf09f988025c4b0c4b0227d5d2c22ee8080416142223a5b7b226f70223a227075", + "0074222c2268617368223a22f09f9880c4b02061c3a9c4b0ee8080222c2274746c223a307d2c7b226f70223a22636c656172227d5d7d2c22676f74517565726965735061746368223a5b5d2c226d", + "0075746174696f6e735061746368223a5b7b226f70223a2264656c222c226964223a7b226964223a3736302c22636c69656e744944223a22c3a9f09f9880414262c4b0c39f227d2c22c3a9223a6e756c6c7d5d7d2c7b22706f6b654944223a22c4b05c5c62c39f5c2220f09f9880222c226c6173744d75746174696f6e49444368616e676573223a7b22616262f09f9880f09f9880223a3931382c225c6ec39fc3a9f09f9880f09f9880c3a9223a3833322c2220625c5ce4b8adee80805c5c223a32387d2c22646573697265645175657269657350617463686573223a7b7d2c22726f77735061746368223a5b7b226f70223a22707574222c227461626c654e616d65223a226973737565222c2276616c7565223a7b7d7d2c7b226f70223a22707574222c227461626c654e616d65223a22c3a974c3a9222c2276616c7565223a7b22f09f9880223a2220227d2c22f09f9880223a5b225c22f09d929cf09d929ce4b8ad222c225c5c222c35323830315d7d2c7b226f70223a22707574222c227461626c654e616d65223a2275736572222c2276616c7565223a7b226964223a5b747275655d2c227469746c65223a747275652c226e223a7b22f09d929c20c39f5f5f223a22625f5c5c5c5c227d7d2c22223a31323139337d2c7b226f70223a22707574222c227461626c654e616d65223a226973737565222c2276616c7565223a7b226964223a7b7d2c22f09f9880223a2220227d7d2c7b226f70223a22707574222c227461626c654e616d65223a22c3a974c3a9222c2276616c7565223a7b227469746c65223a5b66616c73652c325d2c226e223a747275652c22223a312e37393736393331333438363233313537652b3330387d7d5d7d2c7b22706f6b654944223a22616263222c226c6173744d75746174696f6e49444368616e676573223a7b22255c22223a3736352c2261f09f9880223a3439337d2c22646573697265645175657269657350617463686573223a7b22223a5b5d7d7d5d" + ], + "ok": true, + "parts": [ + { + "desiredQueriesPatches": { + "中😀_aa ": [ + { + "op": "put", + "hash": "ß", + "ttl": 73880 + }, + { + "op": "del", + "hash": "\u0000" + }, + { + "op": "put", + "hash": "A中😀%İİ" + } + ], + "AaB": [ + { + "op": "put", + "hash": "😀İ aéİ", + "ttl": 0 + }, + { + "op": "clear" + } + ] + }, + "gotQueriesPatch": [], + "pokeID": "𝒜", + "mutationsPatch": [ + { + "op": "del", + "id": { + "id": 760, + "clientID": "é😀ABbİß" + } + } + ] + }, + { + "lastMutationIDChanges": { + "abb😀😀": 918, + "\nßé😀😀é": 832, + " b\\中\\": 28 + }, + "desiredQueriesPatches": {}, + "pokeID": "İ\\bß\" 😀", + "rowsPatch": [ + { + "op": "put", + "tableName": "issue", + "value": {} + }, + { + "op": "put", + "tableName": "été", + "value": { + "😀": " " + } + }, + { + "op": "put", + "tableName": "user", + "value": { + "id": [ + true + ], + "title": true, + "n": { + "𝒜 ß__": "b_\\\\" + } + } + }, + { + "op": "put", + "tableName": "issue", + "value": { + "id": {}, + "😀": " " + } + }, + { + "op": "put", + "tableName": "été", + "value": { + "title": [ + false, + 2 + ], + "n": true, + "": 1.7976931348623157e+308 + } + } + ] + }, + { + "pokeID": "abc", + "lastMutationIDChanges": { + "%\"": 765, + "a😀": 493 + }, + "desiredQueriesPatches": { + "": [] + } + } + ] + }, + { + "messages": [ + "005b7b22706f6b654944223a22222c226c6173744d7574", + "006174696f6e49444368616e", + "00676573223a7b7d2c22726f77735061746368223a5b5d7d5d" + ], + "ok": true, + "parts": [ + { + "pokeID": "", + "lastMutationIDChanges": {}, + "rowsPatch": [] + } + ] + }, + { + "messages": [ + "005b7b22706f6b654944223a22e4b8adf09d929c5fc39f205c22c3a962222c22676f74517565726965735061746368223a5b7b226f70223a22707574222c2268617368223a22f09d929c5f4161625c6ec4b05c22222c2274746c223a38333935337d2c7b226f70223a22707574222c2268617368223a22c4b1227d5d2c22726f77735061746368223a5b7b226f70223a22707574222c227461626c654e616d65223a22636f6d6d656e74222c2276616c7565223a7b227469746c65223a22c3a9625c225c6e5c22222c22f09f9880223a747275652c22223a31323331397d7d5d2c226d75746174696f6e735061746368223a5b7b226f70223a22707574222c226d75746174696f6e223a7b226964223a7b226964223a3234382c22636c69656e744944223a225c6ec3a9425c6e227d2c22726573756c74223a7b7d7d7d2c7b226f70223a22707574222c226d75746174696f6e223a7b226964223a7b226964223a3937382c22636c69656e744944223a225c5c227d2c22726573756c74223a7b7d7d2c22f09f9880223a2261f09f9880227d2c7b226f70223a2264656c222c226964223a7b226964223a3239372c22636c69656e744944223a2262c39f5c6e625fc3a9f09f9880227d7d5d7d2c7b22706f6b654944223a22f09d929c42425c22255ff09d929c222c22676f74517565726965735061746368223a5b7b226f70223a22707574222c2268617368223a225c7530303030222c2274746c223a34393138317d2c7b226f70223a2264656c222c2268617368223a2230227d5d7d2c7b22706f6b654944223a22222c226c6173744d75746174696f6e49444368616e676573223a7b7d2c22646573697265645175657269657350617463686573223a7b22223a5b5d2c22c4b0ee8080f09f98805c5c5c5c5f223a5b7b226f70223a2264656c222c2268617368223a22227d2c7b226f70223a22707574222c2268617368223a225c5cc39fc4b020c39fc3a95c5c222c2274746c223a312e37393736393331333438363233313537652b3330382c226e65774669656c64223a7b22c3a95f5c6e4162c39f223a312c22f09d929cc39f25223a6e756c6c7d7d5d7d2c22726f77735061746368223a5b7b226f70223a22707574222c227461626c654e616d65223a22636f6d6d656e74222c2276616c7565223a7b7d7d2c7b226f70223a22757064617465222c227461626c654e616d65223a22636f6d6d656e74222c226964223a7b226964223a66616c73652c226e223a307d2c226d65726765223a7b226964223a7b7d2c227469746c65223a5b6e756c6c2c6e756c6c5d2c22f09f9880223a7b2225205f5c22223a312c22223a6e756c6c2c225c5c615c6e2561223a31652d377d2c22223a317d2c22636f6e73747261696e223a5b227469746c65222c22222c227469746c65225d7d2c7b226f70223a22707574222c227461626c654e616d65223a22636f6d6d656e74222c2276616c7565223a7b226964223a7b22c39fc39f5c225c5c5c6e5c6e223a302c225f25c3a962223a6e756c6c2c22c3a942e4b8ad", + "00223a6e756c6c7d2c226e223a5b22c3a95c5cc4b0222c312e37393736393331333438363233313537652b3330385d7d7d2c7b226f70223a22707574222c227461626c654e616d65223a226973737565222c2276616c7565223a7b7d7d5d7d5d" + ], + "ok": true, + "parts": [ + { + "gotQueriesPatch": [ + { + "op": "put", + "hash": "𝒜_Aab\nİ\"", + "ttl": 83953 + }, + { + "op": "put", + "hash": "ı" + } + ], + "pokeID": "中𝒜_ß \"éb", + "rowsPatch": [ + { + "op": "put", + "tableName": "comment", + "value": { + "title": "éb\"\n\"", + "😀": true, + "": 12319 + } + } + ], + "mutationsPatch": [ + { + "op": "put", + "mutation": { + "id": { + "id": 248, + "clientID": "\néB\n" + }, + "result": {} + } + }, + { + "op": "put", + "mutation": { + "id": { + "id": 978, + "clientID": "\\" + }, + "result": {} + } + }, + { + "op": "del", + "id": { + "id": 297, + "clientID": "bß\nb_é😀" + } + } + ] + }, + { + "pokeID": "𝒜BB\"%_𝒜", + "gotQueriesPatch": [ + { + "op": "put", + "hash": "\u0000", + "ttl": 49181 + }, + { + "op": "del", + "hash": "0" + } + ] + }, + { + "lastMutationIDChanges": {}, + "pokeID": "", + "desiredQueriesPatches": { + "": [], + "İ😀\\\\_": [ + { + "op": "del", + "hash": "" + }, + { + "op": "put", + "hash": "\\ßİ ßé\\", + "ttl": 1.7976931348623157e+308 + } + ] + }, + "rowsPatch": [ + { + "op": "put", + "tableName": "comment", + "value": {} + }, + { + "op": "update", + "tableName": "comment", + "id": { + "id": false, + "n": 0 + }, + "merge": { + "id": {}, + "title": [ + null, + null + ], + "😀": { + "% _\"": 1, + "": null, + "\\a\n%a": 1e-7 + }, + "": 1 + }, + "constrain": [ + "title", + "", + "title" + ] + }, + { + "op": "put", + "tableName": "comment", + "value": { + "id": { + "ßß\"\\\n\n": 0, + "_%éb": null, + "éB中": null + }, + "n": [ + "é\\İ", + 1.7976931348623157e+308 + ] + } + }, + { + "op": "put", + "tableName": "issue", + "value": {} + } + ] + } + ] + }, + { + "messages": [ + "005b5d" + ], + "ok": true, + "parts": [] + }, + { + "messages": [ + "005b7b22706f6b654944223a2261222c226c6173744d75746174696f6e49444368616e676573223a7b22426162c3a9415c5cc39f42223a3230322c2261223a3436352c22e4b8ad5c22e4b8ad5f62f09f98802041223a3638337d2c22646573697265645175657269657350617463686573223a7b22c39f223a5b7b226f70223a22707574222c2268617368223a2241425c22f09d929c25c4b0227d5d7d7d5d" + ], + "ok": true, + "parts": [ + { + "pokeID": "a", + "lastMutationIDChanges": { + "BabéA\\ßB": 202, + "a": 465, + "中\"中_b😀 A": 683 + }, + "desiredQueriesPatches": { + "ß": [ + { + "op": "put", + "hash": "AB\"𝒜%İ" + } + ] + } + } + ] + }, + { + "messages": [ + "005b", + "005d", + "00", + "00" + ], + "ok": true, + "parts": [] + }, + { + "messages": [ + "005b7b22706f6b654944223a2262615c5c41625c6e222c226c6173744d75746174696f6e49444368616e676573223a7b7d2c22676f74517565726965735061746368223a5b5d2c22726f77735061746368223a5b7b226f70223a22707574222c227461626c654e616d65223a226973737565222c2276616c7565223a7b226e223a225fc3a95f222c22223a7b22e4b8adc3a9254161223a6e756c6c2c22c4b020f09d929c223a6e756c6c2c222042c39f223a2242e4b8ad25f09f988062227d7d7d2c7b226f70223a2264656c222c227461626c654e616d65223a22c3", + "00a974c3a9222c226964223a7b226964223a307d2c226e65774669656c64223a7b226242204262223a2262222c225c6e42415fc39fc39f5c6ec39f223a225c6e61227d7d2c7b226f70223a22636c656172227d2c7b226f70223a22636c656172227d5d7d2c7b22706f6b654944223a22cea9222c226c6173744d75746174696f6e49444368616e676573223a7b22cf89223a3632392c22205c5c61c39f223a33327d2c22726f77735061746368223a5b7b226f70223a22707574222c227461626c654e616d65223a22c3a974c3a9222c2276616c7565223a7b226e223a2241ee80806162f09d929c25f09f9880222c22f09f9880223a5b5d7d7d2c7b226f70223a2264656c222c227461626c654e616d65223a2275736572222c226964223a7b226964223a66616c73657d7d5d7d2c7b22706f6b654944223a22222c22676f74517565726965735061746368223a5b7b226f70223a22636c656172227d2c7b226f70223a22707574222c2268617368223a22f09f9880f09d929c415c6e62415c6e227d5d2c22726f77735061746368223a5b5d2c226d75746174696f6e735061746368223a5b7b226f70223a22707574222c226d75746174696f6e223a7b226964223a7b226964223a3632332c22636c69656e744944223a2261c3a9f09d929cf09f98805f61222c22223a7b7d7d2c22726573756c74223a7b226572726f72223a22617070222c2264657461696c73223a7b22ee8080223a5b2d322e32355d2c22c4b042e4b8ad20615f25223a225c6e42227d7d7d2c22f09f9880223a66616c73657d5d7d5d" + ], + "ok": true, + "parts": [ + { + "lastMutationIDChanges": {}, + "gotQueriesPatch": [], + "pokeID": "ba\\Ab\n", + "rowsPatch": [ + { + "op": "put", + "tableName": "issue", + "value": { + "n": "_é_", + "": { + "中é%Aa": null, + "İ 𝒜": null, + " Bß": "B中%😀b" + } + } + }, + { + "op": "del", + "tableName": "été", + "id": { + "id": 0 + } + }, + { + "op": "clear" + }, + { + "op": "clear" + } + ] + }, + { + "lastMutationIDChanges": { + "ω": 629, + " \\aß": 32 + }, + "pokeID": "Ω", + "rowsPatch": [ + { + "op": "put", + "tableName": "été", + "value": { + "n": "Aab𝒜%😀", + "😀": [] + } + }, + { + "op": "del", + "tableName": "user", + "id": { + "id": false + } + } + ] + }, + { + "gotQueriesPatch": [ + { + "op": "clear" + }, + { + "op": "put", + "hash": "😀𝒜A\nbA\n" + } + ], + "rowsPatch": [], + "pokeID": "", + "mutationsPatch": [ + { + "op": "put", + "mutation": { + "id": { + "id": 623, + "clientID": "aé𝒜😀_a" + }, + "result": { + "error": "app", + "details": { + "": [ + -2.25 + ], + "İB中 a_%": "\nB" + } + } + } + } + ] + } + ] + }, + { + "messages": [ + "005b7b", + "0022706f6b654944223a302c22646573", + "00697265645175657269657350", + "00617463", + "00686573223a7b7d7d5d" + ], + "ok": false + }, + { + "messages": [ + "005b7b22706f6b654944223a22ee8080c39fee8080222c226c6173744d75746174696f6e49444368616e676573223a7b22c3a9223a", + "003930352c225c22223a3230352c", + "0022223a3838347d", + "002c22676f74517565726965735061746368223a5b", + "005d7d5d" + ], + "ok": true, + "parts": [ + { + "pokeID": "ß", + "lastMutationIDChanges": { + "é": 905, + "\"": 205, + "": 884 + }, + "gotQueriesPatch": [] + } + ] + }, + { + "messages": [ + "005b5d" + ], + "ok": true, + "parts": [] + }, + { + "messages": [ + "005b7b22706f6b654944223a22c4b02061c4b042ee80805f222c226c6173744d75746174696f6e49444368616e676573223a7b22e4b8ade69687223a35", + "00387d2c22676f74517565726965735061746368223a5b5d2c22726f77735061746368223a5b7b226f70223a22636c656172227d2c7b226f70223a22707574222c227461626c654e616d65223a22c3a974c3a9222c2276616c7565223a7b226964223a5b22f09d929cc3a9f09d929c222c22415c6e222c39313131385d7d7d2c7b226f70223a2264656c222c227461626c654e616d65223a22c3a974c3a9222c226964223a7b226964223a22f09f9880222c226e223a22f09f9880227d7d5d7d2c7b22706f6b654944223a22efbfbd222c226c6173744d75746174696f6e49444368616e676573223a7b22c3a9223a3134352c22c4b0ee808041c3a9256120223a3437392c22ee8080223a3136377d2c22676f74517565726965735061746368223a5b5d2c2272", + "006f77735061746368223a5b7b226f70223a2264656c222c2274", + "0061626c654e616d65223a22c3a974c3a9222c226964223a7b226964223a312e352c226e223a317d7d2c7b226f70223a2264656c222c227461626c654e616d65223a2275736572222c226964223a7b226964223a747275652c226e223a747275657d2c22f09f9880223a31652d377d2c7b226f70223a22636c656172222c22223a6e756c6c7d5d7d2c7b22706f6b654944223a22f09f988025ee8080415c6e222c226c6173744d75746174696f6e49444368616e676573223a7b225fee80805c5c426161223a3436332c22efac80223a3238312c225c22223a3731317d2c22676f74517565726965735061746368223a5b5d2c22c3a9223a66616c73657d5d" + ], + "ok": true, + "parts": [ + { + "lastMutationIDChanges": { + "中文": 58 + }, + "gotQueriesPatch": [], + "pokeID": "İ aİB_", + "rowsPatch": [ + { + "op": "clear" + }, + { + "op": "put", + "tableName": "été", + "value": { + "id": [ + "𝒜é𝒜", + "A\n", + 91118 + ] + } + }, + { + "op": "del", + "tableName": "été", + "id": { + "id": "😀", + "n": "😀" + } + } + ] + }, + { + "lastMutationIDChanges": { + "é": 145, + "İAé%a ": 479, + "": 167 + }, + "gotQueriesPatch": [], + "pokeID": "�", + "rowsPatch": [ + { + "op": "del", + "tableName": "été", + "id": { + "id": 1.5, + "n": 1 + } + }, + { + "op": "del", + "tableName": "user", + "id": { + "id": true, + "n": true + } + }, + { + "op": "clear" + } + ] + }, + { + "lastMutationIDChanges": { + "_\\Baa": 463, + "ff": 281, + "\"": 711 + }, + "gotQueriesPatch": [], + "pokeID": "😀%A\n" + } + ] + }, + { + "messages": [ + "00", + "00", + "005b", + "005d" + ], + "ok": true, + "parts": [] + }, + { + "messages": [ + "00", + "00", + "005b", + "005d", + "00", + "00", + "00" + ], + "ok": true, + "parts": [] + }, + { + "messages": [ + "005b7b22706f6b654944223a22c39f222c226c6173744d75746174696f6e49444368616e676573223a7b7d2c22726f77735061746368223a5b5d7d2c7b22706f6b654944223a2242222c22726f77735061746368223a5b7b226f70223a2264656c222c227461626c654e616d65223a22c3a974c3a9222c226964223a7b226964223a22222c226e223a22f09f9880227d7d2c7b226f70223a22757064617465222c227461626c654e616d65223a22636f6d6d656e74222c226964223a7b226964223a317d2c226d65726765223a7b226e223a2242c39fc3a9f09f9880c3a961222c22f09f9880223a6e756c6c2c22223a22f09d929c625c6ee4b8ad227d2c22636f6e73747261696e223a5b22f09f9880222c226964222c227469746c65225d2c22223a5b22c3a95c6e5f222c747275655d7d2c7b226f70223a22757064617465222c227461626c654e616d65223a226973737565222c226964223a7b226964223a66616c73657d2c226d657267", + "0065223a7b226964223a5b6e756c6c5d2c22f09f9880223a31652b32317d7d2c7b226f70223a22757064617465222c", + "00227461626c654e616d65223a2275736572222c226964223a7b226964223a307d7d5d2c22c3a9223a747275657d2c7b22706f6b654944223a2241c4b0222c22646573697265645175657269657350617463686573223a7b22c4b05c6e5fc4b0223a5b7b226f70223a22707574222c2268617368223a2225f09f988041c39ff09d929cf09f9880227d2c7b226f70223a22636c656172227d2c7b226f70223a22707574222c2268617368223a2262227d5d2c225c7530303166223a5b7b226f70223a22707574222c2268617368223a22222c2274746c223a35652d3332347d2c7b226f70223a2264656c222c2268617368223a225c5c5c22e4b8ad5c6ec4b0e4b8adee8080", + "00c3a9227d5d7d2c22676f74517565726965735061746368223a5b7b226f70223a2264656c222c2268617368223a22ee8080c39f6225227d5d2c22726f77735061746368223a5b7b226f70223a22707574222c227461626c654e616d65223a22636f6d6d656e74222c2276616c7565223a7b226964223a5b5d7d7d5d2c226d75746174696f6e735061746368223a5b7b226f70223a2264656c222c226964223a7b226964223a3333342c22636c69656e744944223a22c4b0227d7d2c7b226f70223a22707574222c226d75746174696f6e223a7b226964223a7b226964223a38302c22636c69656e744944223a2261227d2c22726573756c74223a7b2264617461223a5b2d315d7d7d7d5d2c227a4578747261223a5b5d7d5d" + ], + "ok": true, + "parts": [ + { + "pokeID": "ß", + "lastMutationIDChanges": {}, + "rowsPatch": [] + }, + { + "pokeID": "B", + "rowsPatch": [ + { + "op": "del", + "tableName": "été", + "id": { + "id": "", + "n": "😀" + } + }, + { + "op": "update", + "tableName": "comment", + "id": { + "id": 1 + }, + "merge": { + "n": "Bßé😀éa", + "😀": null, + "": "𝒜b\n中" + }, + "constrain": [ + "😀", + "id", + "title" + ] + }, + { + "op": "update", + "tableName": "issue", + "id": { + "id": false + }, + "merge": { + "id": [ + null + ], + "😀": 1e+21 + } + }, + { + "op": "update", + "tableName": "user", + "id": { + "id": 0 + } + } + ] + }, + { + "desiredQueriesPatches": { + "İ\n_İ": [ + { + "op": "put", + "hash": "%😀Aß𝒜😀" + }, + { + "op": "clear" + }, + { + "op": "put", + "hash": "b" + } + ], + "\u001f": [ + { + "op": "put", + "hash": "", + "ttl": 5e-324 + }, + { + "op": "del", + "hash": "\\\"中\nİ中é" + } + ] + }, + "gotQueriesPatch": [ + { + "op": "del", + "hash": "ßb%" + } + ], + "pokeID": "Aİ", + "rowsPatch": [ + { + "op": "put", + "tableName": "comment", + "value": { + "id": [] + } + } + ], + "mutationsPatch": [ + { + "op": "del", + "id": { + "id": 334, + "clientID": "İ" + } + }, + { + "op": "put", + "mutation": { + "id": { + "id": 80, + "clientID": "a" + }, + "result": { + "data": [ + -1 + ] + } + } + } + ] + } + ] + }, + { + "messages": [ + "00", + "00", + "00", + "005b", + "005d", + "00", + "00" + ], + "ok": true, + "parts": [] + }, + { + "messages": [ + "005b7b22706f6b654944223a22614262f09d929c222c22646573697265645175657269657350617463686573223a7b22c4b0223a5b7b226f70223a22707574222c2268617368223a22615c5c222c22f09f9880223a5b22c4b0225d7d2c7b226f70223a22636c656172222c22223a383231317d2c7b226f70223a22707574222c2268617368223a22222c22c3a9223a7b22e4b8ad25223a2261222c225c6e61f09d929c5f223a302e357d7d5d2c22223a5b5d7d2c22676f74517565726965735061746368223a5b7b226f70223a2264656c222c2268617368223a225c", + "0022227d5d7d2c7b22706f6b654944223a2261c39fe4b8ade4b8ad205c5c222c226c6173744d75746174696f6e49444368616e676573223a7b22223a3535397d2c22676f74517565726965735061746368223a5b7b226f70223a22636c656172227d2c7b226f70223a22636c656172227d5d2c22726f77735061746368223a5b7b226f70223a22707574222c227461626c654e616d65223a22c3a974c3a9222c2276616c7565223a7b226e", + "00223a2262222c22f09f9880223a2d312c22223a7b225c5cee808020e4b8ad223a35373133397d7d7d2c7b226f70223a2264656c222c227461626c654e616d65223a226973737565222c226964223a7b226964223a312e352c226e223a66616c73657d7d2c7b226f70223a2264656c222c227461626c654e616d65223a22c3a974c3a9222c226964223a7b226964223a31", + "002c226e223a307d2c226e65774669656c64223a22c4b0e4b8ad20227d2c7b226f70223a2264656c222c227461626c654e616d65223a2275736572222c226964223a7b226964223a307d7d5d2c226d75746174696f6e735061746368223a5b7b226f70223a22707574222c226d75746174696f6e223a7b226964223a7b226964223a3538352c22636c69656e744944223a22e4b8ad227d2c22726573756c74223a7b226572726f72223a66616c73652c226d657373616765223a5b22f09f988061ee80805c22222c38393035335d7d7d2c226e65774669656c64223a6e756c6c7d2c7b226f70223a2264656c222c226964223a7b226964223a3631342c22636c69656e744944223a225c6eee808041222c226e65774669656c64223a5b66616c73652c6e756c6c5d7d7d5d2c227a4578747261223a22625ff09f9880f09d929c41f09d929c5f227d5d" + ], + "ok": true, + "parts": [ + { + "pokeID": "aBb𝒜", + "desiredQueriesPatches": { + "İ": [ + { + "op": "put", + "hash": "a\\" + }, + { + "op": "clear" + }, + { + "op": "put", + "hash": "" + } + ], + "": [] + }, + "gotQueriesPatch": [ + { + "op": "del", + "hash": "\"" + } + ] + }, + { + "lastMutationIDChanges": { + "": 559 + }, + "gotQueriesPatch": [ + { + "op": "clear" + }, + { + "op": "clear" + } + ], + "pokeID": "aß中中 \\", + "rowsPatch": [ + { + "op": "put", + "tableName": "été", + "value": { + "n": "b", + "😀": -1, + "": { + "\\ 中": 57139 + } + } + }, + { + "op": "del", + "tableName": "issue", + "id": { + "id": 1.5, + "n": false + } + }, + { + "op": "del", + "tableName": "été", + "id": { + "id": 1, + "n": 0 + } + }, + { + "op": "del", + "tableName": "user", + "id": { + "id": 0 + } + } + ], + "mutationsPatch": [ + { + "op": "put", + "mutation": { + "id": { + "id": 585, + "clientID": "中" + }, + "result": { + "error": false, + "message": [ + "😀a\"", + 89053 + ] + } + } + }, + { + "op": "del", + "id": { + "id": 614, + "clientID": "\nA" + } + } + ] + } + ] + }, + { + "messages": [ + "005b7b22706f6b654944223a225fc4b0222c226c6173744d75746174696f6e49444368616e676573223a7b2239223a343632", + "002c22f09f988061223a3730312c225c5c5f5c5c20223a3532387d2c22676f74517565726965735061746368223a5b5d2c22726f77735061746368223a5b7b226f70223a22636c656172227d2c7b226f70223a226465", + "006c222c227461626c654e616d65223a226973737565222c226964223a7b226964223a2261222c226e223a22f09f9880227d7d2c7b226f70223a2264656c222c227461626c654e616d65223a22636f6d6d656e74222c226964223a7b226964223a66616c73652c226e223a307d7d2c7b226f70223a22707574222c227461626c654e616d65223a22c3a974c3a9222c2276616c7565223a7b226964223a6e756c6c2c226e223a2225f09f98805c2220227d7d5d2c22", + "006d75746174696f6e735061746368223a5b7b226f70223a22707574222c226d75746174696f6e223a7b226964223a7b226964223a3935302c22636c69656e744944223a2220e4b8ad5c2262f09d929c5c6eee808062227d2c22726573756c74223a7b226572726f72223a22756e6b6e6f776e4572726f72227d7d2c226e65774669656c64223a5b66616c73652c33303039372c2265cc81225d7d2c7b226f70223a2264656c222c226964223a7b226964223a3633352c22636c69656e744944223a22e4b8ade4b8ad5f227d7d2c7b226f70223a22707574222c226d75746174696f6e223a7b226964223a7b226964223a3935382c22636c69656e744944223a22f09d929cf09d929c2562415f5c5c5c5c227d2c22726573756c74223a7b226572726f72223a22c39ff09f98805c6e42ee808025222c226d", + "00657373616765223a747275657d7d7d5d7d2c7b22706f6b654944223a225c5c222c226c6173744d75746174696f6e49444368616e676573223a7b22223a3834322c224225ee8080223a3837372c22e4b8ade69687223a3534347d2c22676f74517565726965735061746368223a5b5d2c22726f77735061746368223a5b7b226f70223a22707574222c227461626c654e616d65223a226973737565222c2276616c7565223a7b227469746c65223a22c39f205c22255c5cc4b0222c226e223a22f09d929c6161c3a9f09d929cf09d929cc3a9227d7d2c7b226f70223a22707574222c227461626c654e616d65223a22c3a974c3a9222c2276616c7565223a7b22f09f9880223a302e352c22223a7b225f223a2241227d7d7d2c7b226f70223a2264656c222c227461626c654e616d65223a2275736572222c226964223a7b226964223a747275657d7d2c7b226f7022", + "003a2264656c222c227461626c654e616d65223a22c3a974c3a9222c226964223a7b226964223a22222c226e223a747275657d7d2c7b226f70223a22707574222c227461626c654e616d65223a22c3a974c3a9222c2276616c7565223a7b226964223a7b2230223a66616c73652c22c3a9624262c3a961c3a942223a302c226161c39f2562223a66616c73657d2c226e223a7b2220f09d929c42223a2262227d2c22f09f9880223a5b2d322e32352c323134373438333634382c375d7d7d5d7d2c7b22706f6b654944223a22c39ff09d929cc4b05c6e222c22726f77735061746368223a5b7b226f70223a22707574222c227461626c654e616d65223a2275736572222c2276616c7565223a7b226964223a5b22f09f9880222c66616c73652c35333033395d7d7d2c7b226f70223a2264656c222c227461626c654e616d65223a2275736572222c226964223a7b226964223a2261227d7d5d7d5d" + ], + "ok": true, + "parts": [ + { + "lastMutationIDChanges": { + "9": 462, + "😀a": 701, + "\\_\\ ": 528 + }, + "gotQueriesPatch": [], + "pokeID": "_İ", + "rowsPatch": [ + { + "op": "clear" + }, + { + "op": "del", + "tableName": "issue", + "id": { + "id": "a", + "n": "😀" + } + }, + { + "op": "del", + "tableName": "comment", + "id": { + "id": false, + "n": 0 + } + }, + { + "op": "put", + "tableName": "été", + "value": { + "id": null, + "n": "%😀\" " + } + } + ], + "mutationsPatch": [ + { + "op": "put", + "mutation": { + "id": { + "id": 950, + "clientID": " 中\"b𝒜\nb" + }, + "result": { + "error": "unknownError" + } + } + }, + { + "op": "del", + "id": { + "id": 635, + "clientID": "中中_" + } + }, + { + "op": "put", + "mutation": { + "id": { + "id": 958, + "clientID": "𝒜𝒜%bA_\\\\" + }, + "result": { + "error": "ß😀\nB%", + "message": true + } + } + } + ] + }, + { + "lastMutationIDChanges": { + "": 842, + "B%": 877, + "中文": 544 + }, + "gotQueriesPatch": [], + "pokeID": "\\", + "rowsPatch": [ + { + "op": "put", + "tableName": "issue", + "value": { + "title": "ß \"%\\İ", + "n": "𝒜aaé𝒜𝒜é" + } + }, + { + "op": "put", + "tableName": "été", + "value": { + "😀": 0.5, + "": { + "_": "A" + } + } + }, + { + "op": "del", + "tableName": "user", + "id": { + "id": true + } + }, + { + "op": "del", + "tableName": "été", + "id": { + "id": "", + "n": true + } + }, + { + "op": "put", + "tableName": "été", + "value": { + "id": { + "0": false, + "ébBbéaéB": 0, + "aaß%b": false + }, + "n": { + " 𝒜B": "b" + }, + "😀": [ + -2.25, + 2147483648, + 7 + ] + } + } + ] + }, + { + "pokeID": "ß𝒜İ\n", + "rowsPatch": [ + { + "op": "put", + "tableName": "user", + "value": { + "id": [ + "😀", + false, + 53039 + ] + } + }, + { + "op": "del", + "tableName": "user", + "id": { + "id": "a" + } + } + ] + } + ] + }, + { + "messages": [ + "005b7b22706f6b654944223a225c6e5c6ec4b05f415f222c22646573697265645175657269657350617463686573223a7b225c7530303030223a5b7b226f70223a22636c656172227d2c7b226f70223a2264656c222c2268617368223a2220c39f41227d5d7d2c226d75746174696f6e735061746368223a5b5d7d2c7b22706f6b654944223a22c4b05c225c6e25222c226c6173744d75746174696f6e49444368616e676573223a7b7d2c22676f74517565726965735061746368223a5b7b226f70223a2264656c222c2268617368223a22c3a9f09d929c25e4b8adc3a9c4b0227d5d2c22726f77735061746368223a5b7b226f70223a22636c656172227d2c7b226f70223a22757064617465222c227461626c654e616d65223a226973737565222c226964223a7b226964223a2261227d2c22c3a9223a7b2242f09f9880255c5c4142223a383934382c2261f09f9880223a747275657d7d2c7b226f70223a2264656c222c227461626c654e616d65223a2275736572222c226964223a7b226964223a66616c73657d7d2c7b226f70223a22707574222c227461626c654e616d65223a22636f6d6d656e74222c2276616c7565223a7b226964223a6e756c6c2c226e223a302c22f09f9880223a31652d372c22223a7b22415c6e223a32313233352c22cea9223a6e756c6c2c2261e4b8ad223a22efac80227d7d7d2c7b226f70223a2264656c222c227461626c654e616d65223a22c3a974c3a9222c226964223a7b226964223a302c226e223a317d7d5d2c226d75746174696f6e735061746368223a5b7b226f70223a2264656c222c226964223a7b226964223a3737332c22636c69656e744944223a", + "00225c6eee8080c3a9625c5c62227d7d5d7d2c7b22706f6b654944223a22205fe4b8adee8080625f222c226c6173744d75746174696f6e49444368616e676573223a7b222025223a3533382c22f09f9880ee80805c22e4b8ade4b8adee80", + "0080223a3832337d2c22646573697265645175657269657350617463686573223a7b22c39fc3a95fc3a925f09f9880223a5b7b226f70223a22636c656172227d2c7b226f70223a22707574222c2268617368223a2261c39f412025222c2274746c223a302e357d5d2c225c22f09d929c5c5c223a5b5d7d2c22676f74517565726965735061746368223a5b5d2c22726f7773", + "005061746368223a5b7b226f70223a22707574222c227461626c654e616d65223a22636f6d6d656e74222c2276616c7565223a7b226964223a22222c226e223a7b22c39fee8080c39f223a35323637372c22223a", + "006e756c6c2c225c6e42c3a9c4b062e4b8ad6261223a6e756c6c7d2c22223a5b5d7d7d2c7b226f70223a22707574222c227461626c654e616d65223a22636f6d6d656e74222c2276616c7565223a7b226964223a225c22c4b0e4b8ad4262f09d929ce4b8ad41222c227469746c65223a5b5d2c226e223a66616c73652c22f09f9880223a747275657d2c227a4578747261223a7b22f09d929cc4b0c3a9e4b8ad223a66616c73657d7d5d7d5d" + ], + "ok": true, + "parts": [ + { + "pokeID": "\n\nİ_A_", + "desiredQueriesPatches": { + "\u0000": [ + { + "op": "clear" + }, + { + "op": "del", + "hash": " ßA" + } + ] + }, + "mutationsPatch": [] + }, + { + "lastMutationIDChanges": {}, + "gotQueriesPatch": [ + { + "op": "del", + "hash": "é𝒜%中éİ" + } + ], + "pokeID": "İ\"\n%", + "rowsPatch": [ + { + "op": "clear" + }, + { + "op": "update", + "tableName": "issue", + "id": { + "id": "a" + } + }, + { + "op": "del", + "tableName": "user", + "id": { + "id": false + } + }, + { + "op": "put", + "tableName": "comment", + "value": { + "id": null, + "n": 0, + "😀": 1e-7, + "": { + "A\n": 21235, + "Ω": null, + "a中": "ff" + } + } + }, + { + "op": "del", + "tableName": "été", + "id": { + "id": 0, + "n": 1 + } + } + ], + "mutationsPatch": [ + { + "op": "del", + "id": { + "id": 773, + "clientID": "\néb\\b" + } + } + ] + }, + { + "lastMutationIDChanges": { + " %": 538, + "😀\"中中": 823 + }, + "desiredQueriesPatches": { + "ßé_é%😀": [ + { + "op": "clear" + }, + { + "op": "put", + "hash": "aßA %", + "ttl": 0.5 + } + ], + "\"𝒜\\": [] + }, + "gotQueriesPatch": [], + "pokeID": " _中b_", + "rowsPatch": [ + { + "op": "put", + "tableName": "comment", + "value": { + "id": "", + "n": { + "ßß": 52677, + "": null, + "\nBéİb中ba": null + }, + "": [] + } + }, + { + "op": "put", + "tableName": "comment", + "value": { + "id": "\"İ中Bb𝒜中A", + "title": [], + "n": false, + "😀": true + } + } + ] + } + ] + }, + { + "messages": [ + "005b7b22706f6b654944", + "00223a22f09f988041ee8080c39f5c22222c22676f74517565726965735061746368223a5b5d2c22726f77735061746368223a2270756c6c227d2c7b22706f6b654944223a22416161222c226c6173744d75746174696f6e49444368616e676573223a7b7d2c22646573697265645175657269657350617463686573223a7b22f09f988061223a5b5d2c2220f09f9880e4b8ad5c6e41e4b8ad223a5b5d7d2c22726f77735061746368223a5b7b226f70223a22707574222c227461626c654e616d65223a22636f6d6d656e74222c2276616c7565223a7b227469746c65223a6e756c6c2c226e223a66616c73652c22f0", + "009f9880223a747275657d7d2c7b226f70223a22707574222c227461626c654e616d65223a22c3a974c3a9222c2276616c7565223a7b7d7d2c7b226f70223a22757064617465222c227461626c654e616d65223a2275736572222c226964223a7b226964223a312e357d2c226d65726765223a7b226e223a2225c39f615c5c615c5ce4b8adc39f222c22223a7b2242f09d929c5c6e5c5cc39f20223a312e37393736393331333438363233313537652b3330387d2c226d657373616765223a2268747470227d7d5d7d5d" + ], + "ok": false + } + ] +} diff --git a/packages/zero-conformance/vectors/protocol-sec-protocols.json b/packages/zero-conformance/vectors/protocol-sec-protocols.json new file mode 100644 index 0000000000..a2d1515738 --- /dev/null +++ b/packages/zero-conformance/vectors/protocol-sec-protocols.json @@ -0,0 +1,2906 @@ +{ + "formatVersion": 1, + "kind": "protocol-sec-protocols", + "description": "The Sec-WebSocket-Protocol value (`encodeSecProtocols` and `decodeSecProtocols`). `decoded` is what `decodeSecProtocols(encoded)` returns, if it does not throw. Cases with `initConnectionMessage` or `authToken` were encoded from them.", + "cases": [ + { + "encoded": "e30%3D", + "ok": true, + "decoded": {} + }, + { + "encoded": "eyJhdXRoVG9rZW4iOiIifQ%3D%3D", + "ok": true, + "decoded": { + "authToken": "" + }, + "authToken": "" + }, + { + "encoded": "eyJhdXRoVG9rZW4iOiJleUpoYkdjaU9pSklVekkxTmlKOS5leUp6ZFdJaU9pSXhJbjAuc2lnIn0%3D", + "ok": true, + "decoded": { + "authToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.sig" + }, + "authToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.sig" + }, + { + "encoded": "eyJpbml0Q29ubmVjdGlvbk1lc3NhZ2UiOlsiaW5pdENvbm5lY3Rpb24iLHsiZGVzaXJlZFF1ZXJpZXNQYXRjaCI6W119XX0%3D", + "ok": true, + "decoded": { + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [] + } + ] + }, + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [] + } + ] + }, + { + "encoded": "e30", + "ok": true, + "decoded": {} + }, + { + "encoded": "e30%3d", + "ok": true, + "decoded": {} + }, + { + "encoded": "e3%200%3D", + "ok": true, + "decoded": {} + }, + { + "encoded": " e30= ", + "ok": true, + "decoded": {} + }, + { + "encoded": "77u/e30=", + "ok": true, + "decoded": {} + }, + { + "encoded": "e30==", + "ok": false + }, + { + "encoded": "e3=0", + "ok": false + }, + { + "encoded": "e30%", + "ok": false + }, + { + "encoded": "e30%3", + "ok": false + }, + { + "encoded": "%E0%A4%A", + "ok": false + }, + { + "encoded": "%C3%A9", + "ok": false + }, + { + "encoded": "!!!!", + "ok": false + }, + { + "encoded": "a", + "ok": false + }, + { + "encoded": "", + "ok": false + }, + { + "encoded": "77u%2FeyJhdXRoVG9rZW4iOiJhIn0%3D", + "ok": true, + "decoded": { + "authToken": "a" + } + }, + { + "encoded": "eyJhdXRoVG9rZW4iOiL%2F5LgifQ%3D%3D", + "ok": true, + "decoded": { + "authToken": "��" + } + }, + { + "encoded": "NQ%3D%3D", + "ok": true, + "decoded": 5 + }, + { + "encoded": "bm90IGpzb24%3D", + "ok": false + }, + { + "encoded": "eyJpbml0Q29ubmVjdGlvbk1lc3NhZ2UiOlsiaW5pdENvbm5lY3Rpb24iLHsiZGVzaXJlZFF1ZXJpZXNQYXRjaCI6W3sib3AiOiJwdXQiLCJoYXNoIjoiYmHEsEEiLCJ0dGwiOjcsImFzdCI6eyJ0YWJsZSI6Imlzc3VlIiwiYWxpYXMiOiJpIiwic2NoZW1hIjoicHVibGljIiwicmVsYXRlZCI6W3siY29ycmVsYXRpb24iOnsicGFyZW50RmllbGQiOlsiaWQiXSwiY2hpbGRGaWVsZCI6WyJpc3N1ZUlEIl19LCJzdWJxdWVyeSI6eyJ0YWJsZSI6ImNvbW1lbnQiLCJhbGlhcyI6ImNvbW1lbnRzIiwib3JkZXJCeSI6W1siaWQiLCJkZXNjIl1dfSwic3lzdGVtIjoicGVybWlzc2lvbnMiLCJoaWRkZW4iOnRydWV9XSwic3RhcnQiOnsicm93Ijp7ImlkIjoiYSIsIm4iOm51bGx9LCJleGNsdXNpdmUiOnRydWV9fX1dLCJ1c2VyUXVlcnlVUkwiOiJBIiwiYWN0aXZlQ2xpZW50cyI6WyLDn1xcYUJCIl19XX0%3D", + "ok": true, + "decoded": { + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "baİA", + "ttl": 7, + "ast": { + "table": "issue", + "alias": "i", + "schema": "public", + "related": [ + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueID" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "orderBy": [ + [ + "id", + "desc" + ] + ] + }, + "system": "permissions", + "hidden": true + } + ], + "start": { + "row": { + "id": "a", + "n": null + }, + "exclusive": true + } + } + } + ], + "userQueryURL": "A", + "activeClients": [ + "ß\\aBB" + ] + } + ] + }, + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "baİA", + "ttl": 7, + "ast": { + "table": "issue", + "alias": "i", + "schema": "public", + "related": [ + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueID" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "orderBy": [ + [ + "id", + "desc" + ] + ] + }, + "system": "permissions", + "hidden": true + } + ], + "start": { + "row": { + "id": "a", + "n": null + }, + "exclusive": true + } + } + } + ], + "userQueryURL": "A", + "activeClients": [ + "ß\\aBB" + ] + } + ] + }, + { + "encoded": "eyJpbml0Q29ubmVjdGlvbk1lc3NhZ2UiOlsiaW5pdENvbm5lY3Rpb24iLHsiZGVzaXJlZFF1ZXJpZXNQYXRjaCI6W3sib3AiOiJjbGVhciJ9LHsib3AiOiJwdXQiLCJoYXNoIjoi8J%2BYgEFfYkHEsGFhIiwiYXN0Ijp7InRhYmxlIjoiaXNzdWUiLCJhbGlhcyI6ImkiLCJzY2hlbWEiOiJwdWJsaWMiLCJyZWxhdGVkIjpbeyJjb3JyZWxhdGlvbiI6eyJwYXJlbnRGaWVsZCI6WyJpZCJdLCJjaGlsZEZpZWxkIjpbImlzc3VlSUQiXX0sInN1YnF1ZXJ5Ijp7InRhYmxlIjoiY29tbWVudCIsImFsaWFzIjoiY29tbWVudHMiLCJvcmRlckJ5IjpbWyJpZCIsImRlc2MiXV19LCJzeXN0ZW0iOiJwZXJtaXNzaW9ucyIsImhpZGRlbiI6dHJ1ZX1dLCJzdGFydCI6eyJyb3ciOnsiaWQiOiJhIiwibiI6bnVsbH0sImV4Y2x1c2l2ZSI6dHJ1ZX19LCJuYW1lIjoiw6kiLCJhcmdzIjpbIsOfIGFiw58iXX1dLCJjbGllbnRTY2hlbWEiOnsidGFibGVzIjp7IvCdkpwiOnsiY29sdW1ucyI6e30sInByaW1hcnlLZXkiOltdfSwiYWIiOnsiY29sdW1ucyI6e30sInByaW1hcnlLZXkiOltdfX19LCJkZWxldGVkIjp7fSwidXNlclF1ZXJ5SGVhZGVycyI6e319XSwiYXV0aFRva2VuIjoiXG7DqWLEsGLwn5iAIn0%3D", + "ok": true, + "decoded": { + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "clear" + }, + { + "op": "put", + "hash": "😀A_bAİaa", + "ast": { + "table": "issue", + "alias": "i", + "schema": "public", + "related": [ + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueID" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "orderBy": [ + [ + "id", + "desc" + ] + ] + }, + "system": "permissions", + "hidden": true + } + ], + "start": { + "row": { + "id": "a", + "n": null + }, + "exclusive": true + } + }, + "name": "é", + "args": [ + "ß abß" + ] + } + ], + "clientSchema": { + "tables": { + "𝒜": { + "columns": {}, + "primaryKey": [] + }, + "ab": { + "columns": {}, + "primaryKey": [] + } + } + }, + "deleted": {}, + "userQueryHeaders": {} + } + ], + "authToken": "\nébİb😀" + }, + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "clear" + }, + { + "op": "put", + "hash": "😀A_bAİaa", + "ast": { + "table": "issue", + "alias": "i", + "schema": "public", + "related": [ + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueID" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "orderBy": [ + [ + "id", + "desc" + ] + ] + }, + "system": "permissions", + "hidden": true + } + ], + "start": { + "row": { + "id": "a", + "n": null + }, + "exclusive": true + } + }, + "name": "é", + "args": [ + "ß abß" + ] + } + ], + "clientSchema": { + "tables": { + "𝒜": { + "columns": {}, + "primaryKey": [] + }, + "ab": { + "columns": {}, + "primaryKey": [] + } + } + }, + "deleted": {}, + "userQueryHeaders": {} + } + ], + "authToken": "\nébİb😀" + }, + { + "encoded": "eyJpbml0Q29ubmVjdGlvbk1lc3NhZ2UiOlsiaW5pdENvbm5lY3Rpb24iLHsiZGVzaXJlZFF1ZXJpZXNQYXRjaCI6W3sib3AiOiJkZWwiLCJoYXNoIjoiZcyBIn0seyJvcCI6InB1dCIsImhhc2giOiJcdTAwMDAiLCJhc3QiOnsidGFibGUiOiJpc3N1ZSJ9LCJhcmdzIjpbNjcyODddfSx7Im9wIjoiZGVsIiwiaGFzaCI6Is%2BJIn1dfV19", + "ok": true, + "decoded": { + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "del", + "hash": "é" + }, + { + "op": "put", + "hash": "\u0000", + "ast": { + "table": "issue" + }, + "args": [ + 67287 + ] + }, + { + "op": "del", + "hash": "ω" + } + ] + } + ] + }, + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "del", + "hash": "é" + }, + { + "op": "put", + "hash": "\u0000", + "ast": { + "table": "issue" + }, + "args": [ + 67287 + ] + }, + { + "op": "del", + "hash": "ω" + } + ] + } + ] + }, + { + "encoded": "eyJpbml0Q29ubmVjdGlvbk1lc3NhZ2UiOlsiaW5pdENvbm5lY3Rpb24iLHsiZGVzaXJlZFF1ZXJpZXNQYXRjaCI6W3sib3AiOiJjbGVhciJ9LHsib3AiOiJjbGVhciJ9LHsib3AiOiJwdXQiLCJoYXNoIjoiXFzwnZKcxLBcXPCdkpxCIiwidHRsIjozODQ2OSwibmFtZSI6IlwiIEFcXCJ9XSwidXNlclF1ZXJ5SGVhZGVycyI6e30sImFjdGl2ZUNsaWVudHMiOlsiIl19XSwiYXV0aFRva2VuIjoiYfCfmIAifQ%3D%3D", + "ok": true, + "decoded": { + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "clear" + }, + { + "op": "clear" + }, + { + "op": "put", + "hash": "\\𝒜İ\\𝒜B", + "ttl": 38469, + "name": "\" A\\" + } + ], + "userQueryHeaders": {}, + "activeClients": [ + "" + ] + } + ], + "authToken": "a😀" + }, + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "clear" + }, + { + "op": "clear" + }, + { + "op": "put", + "hash": "\\𝒜İ\\𝒜B", + "ttl": 38469, + "name": "\" A\\" + } + ], + "userQueryHeaders": {}, + "activeClients": [ + "" + ] + } + ], + "authToken": "a😀" + }, + { + "encoded": "eyJpbml0Q29ubmVjdGlvbk1lc3NhZ2UiOlsiaW5pdENvbm5lY3Rpb24iLHsiZGVzaXJlZFF1ZXJpZXNQYXRjaCI6W3sib3AiOiJjbGVhciJ9XSwiY2xpZW50U2NoZW1hIjp7InRhYmxlcyI6eyIiOnsiY29sdW1ucyI6eyJhIjp7InR5cGUiOiJib29sZWFuIn0sIklzc3VlIjp7InR5cGUiOiJqc29uIn0sImIiOnsidHlwZSI6Impzb24ifX0sInByaW1hcnlLZXkiOlsiSXNzdWUiXX0sIu6AgCI6eyJjb2x1bW5zIjp7Il8iOnsidHlwZSI6Im51bWJlciJ9LCJjb21tZW50Ijp7InR5cGUiOiJzdHJpbmcifX0sInByaW1hcnlLZXkiOltdfSwiXyI6eyJjb2x1bW5zIjp7IvCfmIAiOnsidHlwZSI6InN0cmluZyJ9LCLugIAiOnsidHlwZSI6Im51bWJlciJ9fSwicHJpbWFyeUtleSI6WyLwn5iAIiwi7oCAIl19fX0sInVzZXJQdXNoSGVhZGVycyI6eyJh8J%2BYgCI6IuS4rSIsImJfQsOpw5%2FugIDugIAiOiLwn5iAYSJ9LCJ1c2VyUXVlcnlVUkwiOiIgIPCdkpzwn5iAxLBCw6nugIAiLCJhY3RpdmVDbGllbnRzIjpbIsOpQiIsIu6AgPCfmIDugIDkuK3kuK3kuK1cbiJdLCJ0cmFjZXBhcmVudCI6Ilwiw58lw59B7oCAIn1dfQ%3D%3D", + "ok": true, + "decoded": { + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "clear" + } + ], + "clientSchema": { + "tables": { + "": { + "columns": { + "a": { + "type": "boolean" + }, + "Issue": { + "type": "json" + }, + "b": { + "type": "json" + } + }, + "primaryKey": [ + "Issue" + ] + }, + "": { + "columns": { + "_": { + "type": "number" + }, + "comment": { + "type": "string" + } + }, + "primaryKey": [] + }, + "_": { + "columns": { + "😀": { + "type": "string" + }, + "": { + "type": "number" + } + }, + "primaryKey": [ + "😀", + "" + ] + } + } + }, + "userPushHeaders": { + "a😀": "中", + "b_Béß": "😀a" + }, + "userQueryURL": " 𝒜😀İBé", + "activeClients": [ + "éB", + "😀中中中\n" + ], + "traceparent": "\"ß%ßA" + } + ] + }, + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "clear" + } + ], + "clientSchema": { + "tables": { + "": { + "columns": { + "a": { + "type": "boolean" + }, + "Issue": { + "type": "json" + }, + "b": { + "type": "json" + } + }, + "primaryKey": [ + "Issue" + ] + }, + "": { + "columns": { + "_": { + "type": "number" + }, + "comment": { + "type": "string" + } + }, + "primaryKey": [] + }, + "_": { + "columns": { + "😀": { + "type": "string" + }, + "": { + "type": "number" + } + }, + "primaryKey": [ + "😀", + "" + ] + } + } + }, + "userPushHeaders": { + "a😀": "中", + "b_Béß": "😀a" + }, + "userQueryURL": " 𝒜😀İBé", + "activeClients": [ + "éB", + "😀中中中\n" + ], + "traceparent": "\"ß%ßA" + } + ] + }, + { + "encoded": "eyJpbml0Q29ubmVjdGlvbk1lc3NhZ2UiOlsiaW5pdENvbm5lY3Rpb24iLHsiZGVzaXJlZFF1ZXJpZXNQYXRjaCI6W10sImNsaWVudFNjaGVtYSI6eyJ0YWJsZXMiOnsiXyI6eyJjb2x1bW5zIjp7ImFiIjp7InR5cGUiOiJqc29uIn0sIu6AgCI6eyJ0eXBlIjoiYm9vbGVhbiJ9fSwicHJpbWFyeUtleSI6WyLugIAiXX0sIvCfmIAiOnsiY29sdW1ucyI6eyLwnZKcIjp7InR5cGUiOiJzdHJpbmcifSwi7oCAIjp7InR5cGUiOiJib29sZWFuIn0sImNvbW1lbnQiOnsidHlwZSI6Im51bGwifX0sInByaW1hcnlLZXkiOlsi8J2SnCIsImNvbW1lbnQiXX19fSwidXNlclB1c2hIZWFkZXJzIjp7IiI6IiDDn%2FCfmIDDnyIsIvCfmIDwn5iAQu6AgO6AgCI6IiBB8J2SnGLwnZKcw6lBIn0sInVzZXJRdWVyeVVSTCI6IjkiLCJ0cmFjZXBhcmVudCI6IiBcbsSww5%2FDn2HEsGIifV0sImF1dGhUb2tlbiI6IvCdkpwifQ%3D%3D", + "ok": true, + "decoded": { + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [], + "clientSchema": { + "tables": { + "_": { + "columns": { + "ab": { + "type": "json" + }, + "": { + "type": "boolean" + } + }, + "primaryKey": [ + "" + ] + }, + "😀": { + "columns": { + "𝒜": { + "type": "string" + }, + "": { + "type": "boolean" + }, + "comment": { + "type": "null" + } + }, + "primaryKey": [ + "𝒜", + "comment" + ] + } + } + }, + "userPushHeaders": { + "": " ß😀ß", + "😀😀B": " A𝒜b𝒜éA" + }, + "userQueryURL": "9", + "traceparent": " \nİßßaİb" + } + ], + "authToken": "𝒜" + }, + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [], + "clientSchema": { + "tables": { + "_": { + "columns": { + "ab": { + "type": "json" + }, + "": { + "type": "boolean" + } + }, + "primaryKey": [ + "" + ] + }, + "😀": { + "columns": { + "𝒜": { + "type": "string" + }, + "": { + "type": "boolean" + }, + "comment": { + "type": "null" + } + }, + "primaryKey": [ + "𝒜", + "comment" + ] + } + } + }, + "userPushHeaders": { + "": " ß😀ß", + "😀😀B": " A𝒜b𝒜éA" + }, + "userQueryURL": "9", + "traceparent": " \nİßßaİb" + } + ], + "authToken": "𝒜" + }, + { + "encoded": "eyJpbml0Q29ubmVjdGlvbk1lc3NhZ2UiOlsiaW5pdENvbm5lY3Rpb24iLHsiZGVzaXJlZFF1ZXJpZXNQYXRjaCI6W3sib3AiOiJkZWwiLCJoYXNoIjoiIn0seyJvcCI6InB1dCIsImhhc2giOiJfXG5cIiIsInR0bCI6MjE0NzQ4MzY0OCwiYXN0Ijp7InRhYmxlIjoiaXNzdWUiLCJ3aGVyZSI6eyJ0eXBlIjoic2ltcGxlIiwib3AiOiI9IiwibGVmdCI6eyJ0eXBlIjoiY29sdW1uIiwibmFtZSI6ImlkIn0sInJpZ2h0Ijp7InR5cGUiOiJsaXRlcmFsIiwidmFsdWUiOiIxIn19LCJvcmRlckJ5IjpbWyJpZCIsImFzYyJdXSwibGltaXQiOjEwfX0seyJvcCI6ImRlbCIsImhhc2giOiIgIn1dLCJkZWxldGVkIjp7ImNsaWVudElEcyI6W10sImNsaWVudEdyb3VwSURzIjpbXX0sInVzZXJRdWVyeUhlYWRlcnMiOnsiIjoiw58ifX1dLCJhdXRoVG9rZW4iOiJfIGEifQ%3D%3D", + "ok": true, + "decoded": { + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "del", + "hash": "" + }, + { + "op": "put", + "hash": "_\n\"", + "ttl": 2147483648, + "ast": { + "table": "issue", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "id" + }, + "right": { + "type": "literal", + "value": "1" + } + }, + "orderBy": [ + [ + "id", + "asc" + ] + ], + "limit": 10 + } + }, + { + "op": "del", + "hash": " " + } + ], + "deleted": { + "clientIDs": [], + "clientGroupIDs": [] + }, + "userQueryHeaders": { + "": "ß" + } + } + ], + "authToken": "_ a" + }, + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "del", + "hash": "" + }, + { + "op": "put", + "hash": "_\n\"", + "ttl": 2147483648, + "ast": { + "table": "issue", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "id" + }, + "right": { + "type": "literal", + "value": "1" + } + }, + "orderBy": [ + [ + "id", + "asc" + ] + ], + "limit": 10 + } + }, + { + "op": "del", + "hash": " " + } + ], + "deleted": { + "clientIDs": [], + "clientGroupIDs": [] + }, + "userQueryHeaders": { + "": "ß" + } + } + ], + "authToken": "_ a" + }, + { + "encoded": "eyJpbml0Q29ubmVjdGlvbk1lc3NhZ2UiOlsiaW5pdENvbm5lY3Rpb24iLHsiZGVzaXJlZFF1ZXJpZXNQYXRjaCI6W3sib3AiOiJwdXQiLCJoYXNoIjoiXFxcIlwiJSIsIm5hbWUiOiJhIiwiYXJncyI6W2ZhbHNlXX0seyJvcCI6InB1dCIsImhhc2giOiLugIDkuK3kuK1cbiIsImFzdCI6eyJ0YWJsZSI6Imlzc3VlIn19XSwidXNlclF1ZXJ5VVJMIjoi7oCAXG4ifV0sImF1dGhUb2tlbiI6Iu6AgEIifQ%3D%3D", + "ok": true, + "decoded": { + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "\\\"\"%", + "name": "a", + "args": [ + false + ] + }, + { + "op": "put", + "hash": "中中\n", + "ast": { + "table": "issue" + } + } + ], + "userQueryURL": "\n" + } + ], + "authToken": "B" + }, + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "\\\"\"%", + "name": "a", + "args": [ + false + ] + }, + { + "op": "put", + "hash": "中中\n", + "ast": { + "table": "issue" + } + } + ], + "userQueryURL": "\n" + } + ], + "authToken": "B" + }, + { + "encoded": "eyJpbml0Q29ubmVjdGlvbk1lc3NhZ2UiOlsiaW5pdENvbm5lY3Rpb24iLHsiZGVzaXJlZFF1ZXJpZXNQYXRjaCI6W3sib3AiOiJwdXQiLCJoYXNoIjoiIiwiYXN0Ijp7InRhYmxlIjoiaXNzdWUiLCJhbGlhcyI6ImkiLCJzY2hlbWEiOiJwdWJsaWMiLCJyZWxhdGVkIjpbeyJjb3JyZWxhdGlvbiI6eyJwYXJlbnRGaWVsZCI6WyJpZCJdLCJjaGlsZEZpZWxkIjpbImlzc3VlSUQiXX0sInN1YnF1ZXJ5Ijp7InRhYmxlIjoiY29tbWVudCIsImFsaWFzIjoiY29tbWVudHMiLCJvcmRlckJ5IjpbWyJpZCIsImRlc2MiXV19LCJzeXN0ZW0iOiJwZXJtaXNzaW9ucyIsImhpZGRlbiI6dHJ1ZX1dLCJzdGFydCI6eyJyb3ciOnsiaWQiOiJhIiwibiI6bnVsbH0sImV4Y2x1c2l2ZSI6dHJ1ZX19LCJhcmdzIjpbbnVsbF19LHsib3AiOiJjbGVhciJ9XSwidXNlclB1c2hVUkwiOiIgxLBcbiXDqcOpIiwidXNlclB1c2hIZWFkZXJzIjp7fSwidXNlclF1ZXJ5SGVhZGVycyI6e30sImFjdGl2ZUNsaWVudHMiOltdfV19", + "ok": true, + "decoded": { + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "", + "ast": { + "table": "issue", + "alias": "i", + "schema": "public", + "related": [ + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueID" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "orderBy": [ + [ + "id", + "desc" + ] + ] + }, + "system": "permissions", + "hidden": true + } + ], + "start": { + "row": { + "id": "a", + "n": null + }, + "exclusive": true + } + }, + "args": [ + null + ] + }, + { + "op": "clear" + } + ], + "userPushURL": " İ\n%éé", + "userPushHeaders": {}, + "userQueryHeaders": {}, + "activeClients": [] + } + ] + }, + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "", + "ast": { + "table": "issue", + "alias": "i", + "schema": "public", + "related": [ + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueID" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "orderBy": [ + [ + "id", + "desc" + ] + ] + }, + "system": "permissions", + "hidden": true + } + ], + "start": { + "row": { + "id": "a", + "n": null + }, + "exclusive": true + } + }, + "args": [ + null + ] + }, + { + "op": "clear" + } + ], + "userPushURL": " İ\n%éé", + "userPushHeaders": {}, + "userQueryHeaders": {}, + "activeClients": [] + } + ] + }, + { + "encoded": "eyJpbml0Q29ubmVjdGlvbk1lc3NhZ2UiOlsiaW5pdENvbm5lY3Rpb24iLHsiZGVzaXJlZFF1ZXJpZXNQYXRjaCI6W3sib3AiOiJjbGVhciJ9LHsib3AiOiJkZWwiLCJoYXNoIjoiw6lcIsOpIEIifSx7Im9wIjoicHV0IiwiaGFzaCI6IsOp8J%2BYgEFfIiwidHRsIjoyMTQ3NDgzNjQ4fV0sImNsaWVudFNjaGVtYSI6eyJ0YWJsZXMiOnt9fSwidXNlclB1c2hIZWFkZXJzIjp7IvCdkpwiOiJlzIEifSwidXNlclF1ZXJ5VVJMIjoiIEHEsCXkuK1cIvCdkpxiIiwidXNlclF1ZXJ5SGVhZGVycyI6e30sImFjdGl2ZUNsaWVudHMiOltdfV0sImF1dGhUb2tlbiI6Il8ifQ%3D%3D", + "ok": true, + "decoded": { + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "clear" + }, + { + "op": "del", + "hash": "é\"é B" + }, + { + "op": "put", + "hash": "é😀A_", + "ttl": 2147483648 + } + ], + "clientSchema": { + "tables": {} + }, + "userPushHeaders": { + "𝒜": "é" + }, + "userQueryURL": " Aİ%中\"𝒜b", + "userQueryHeaders": {}, + "activeClients": [] + } + ], + "authToken": "_" + }, + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "clear" + }, + { + "op": "del", + "hash": "é\"é B" + }, + { + "op": "put", + "hash": "é😀A_", + "ttl": 2147483648 + } + ], + "clientSchema": { + "tables": {} + }, + "userPushHeaders": { + "𝒜": "é" + }, + "userQueryURL": " Aİ%中\"𝒜b", + "userQueryHeaders": {}, + "activeClients": [] + } + ], + "authToken": "_" + }, + { + "encoded": "e30%3D", + "ok": true, + "decoded": {} + }, + { + "encoded": "eyJpbml0Q29ubmVjdGlvbk1lc3NhZ2UiOlsiaW5pdENvbm5lY3Rpb24iLHsiZGVzaXJlZFF1ZXJpZXNQYXRjaCI6W119XSwiYXV0aFRva2VuIjoiMCJ9", + "ok": true, + "decoded": { + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [] + } + ], + "authToken": "0" + }, + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [] + } + ], + "authToken": "0" + }, + { + "encoded": "eyJpbml0Q29ubmVjdGlvbk1lc3NhZ2UiOlsiaW5pdENvbm5lY3Rpb24iLHsiZGVzaXJlZFF1ZXJpZXNQYXRjaCI6W3sib3AiOiJwdXQiLCJoYXNoIjoiw59cXPCdkpzEsCIsInR0bCI6MS43OTc2OTMxMzQ4NjIzMTU3ZSszMDgsImFzdCI6eyJ0YWJsZSI6Imlzc3VlIiwid2hlcmUiOnsidHlwZSI6InNpbXBsZSIsIm9wIjoiPSIsImxlZnQiOnsidHlwZSI6ImNvbHVtbiIsIm5hbWUiOiJvd25lcklEIn0sInJpZ2h0Ijp7InR5cGUiOiJzdGF0aWMiLCJhbmNob3IiOiJhdXRoRGF0YSIsImZpZWxkIjpbInVzZXIiLCJzdWIiXX19fX1dLCJjbGllbnRTY2hlbWEiOnsidGFibGVzIjp7ImIiOnsiY29sdW1ucyI6eyJhIjp7InR5cGUiOiJqc29uIn19LCJwcmltYXJ5S2V5IjpbImEiXX19fX1dLCJhdXRoVG9rZW4iOiIifQ%3D%3D", + "ok": true, + "decoded": { + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "ß\\𝒜İ", + "ttl": 1.7976931348623157e+308, + "ast": { + "table": "issue", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "ownerID" + }, + "right": { + "type": "static", + "anchor": "authData", + "field": [ + "user", + "sub" + ] + } + } + } + } + ], + "clientSchema": { + "tables": { + "b": { + "columns": { + "a": { + "type": "json" + } + }, + "primaryKey": [ + "a" + ] + } + } + } + } + ], + "authToken": "" + }, + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "ß\\𝒜İ", + "ttl": 1.7976931348623157e+308, + "ast": { + "table": "issue", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "ownerID" + }, + "right": { + "type": "static", + "anchor": "authData", + "field": [ + "user", + "sub" + ] + } + } + } + } + ], + "clientSchema": { + "tables": { + "b": { + "columns": { + "a": { + "type": "json" + } + }, + "primaryKey": [ + "a" + ] + } + } + } + } + ], + "authToken": "" + }, + { + "encoded": "eyJpbml0Q29ubmVjdGlvbk1lc3NhZ2UiOlsiaW5pdENvbm5lY3Rpb24iLHsiZGVzaXJlZFF1ZXJpZXNQYXRjaCI6W3sib3AiOiJkZWwiLCJoYXNoIjoizqkifSx7Im9wIjoiZGVsIiwiaGFzaCI6InoifSx7Im9wIjoicHV0IiwiaGFzaCI6IsOfXG4iLCJhc3QiOnsidGFibGUiOiJpc3N1ZSIsImFsaWFzIjoiaSIsInNjaGVtYSI6InB1YmxpYyIsInJlbGF0ZWQiOlt7ImNvcnJlbGF0aW9uIjp7InBhcmVudEZpZWxkIjpbImlkIl0sImNoaWxkRmllbGQiOlsiaXNzdWVJRCJdfSwic3VicXVlcnkiOnsidGFibGUiOiJjb21tZW50IiwiYWxpYXMiOiJjb21tZW50cyIsIm9yZGVyQnkiOltbImlkIiwiZGVzYyJdXX0sInN5c3RlbSI6InBlcm1pc3Npb25zIiwiaGlkZGVuIjp0cnVlfV0sInN0YXJ0Ijp7InJvdyI6eyJpZCI6ImEiLCJuIjpudWxsfSwiZXhjbHVzaXZlIjp0cnVlfX19XSwidXNlclF1ZXJ5SGVhZGVycyI6eyLDqSI6IuS4reaWhyIsIl9fX2FfXCIiOiJBXFwgXCJCIn19XSwiYXV0aFRva2VuIjoi8J2SnFxuYiBcbiBiIn0%3D", + "ok": true, + "decoded": { + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "del", + "hash": "Ω" + }, + { + "op": "del", + "hash": "z" + }, + { + "op": "put", + "hash": "ß\n", + "ast": { + "table": "issue", + "alias": "i", + "schema": "public", + "related": [ + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueID" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "orderBy": [ + [ + "id", + "desc" + ] + ] + }, + "system": "permissions", + "hidden": true + } + ], + "start": { + "row": { + "id": "a", + "n": null + }, + "exclusive": true + } + } + } + ], + "userQueryHeaders": { + "é": "中文", + "___a_\"": "A\\ \"B" + } + } + ], + "authToken": "𝒜\nb \n b" + }, + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "del", + "hash": "Ω" + }, + { + "op": "del", + "hash": "z" + }, + { + "op": "put", + "hash": "ß\n", + "ast": { + "table": "issue", + "alias": "i", + "schema": "public", + "related": [ + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueID" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "orderBy": [ + [ + "id", + "desc" + ] + ] + }, + "system": "permissions", + "hidden": true + } + ], + "start": { + "row": { + "id": "a", + "n": null + }, + "exclusive": true + } + } + } + ], + "userQueryHeaders": { + "é": "中文", + "___a_\"": "A\\ \"B" + } + } + ], + "authToken": "𝒜\nb \n b" + }, + { + "encoded": "eyJpbml0Q29ubmVjdGlvbk1lc3NhZ2UiOlsiaW5pdENvbm5lY3Rpb24iLHsiZGVzaXJlZFF1ZXJpZXNQYXRjaCI6W3sib3AiOiJkZWwiLCJoYXNoIjoiIF9cXMOfJVxuXG7DqSJ9LHsib3AiOiJwdXQiLCJoYXNoIjoiIiwidHRsIjo2MDQ0MSwiYXN0Ijp7InRhYmxlIjoiaXNzdWUiLCJhbGlhcyI6ImkiLCJzY2hlbWEiOiJwdWJsaWMiLCJyZWxhdGVkIjpbeyJjb3JyZWxhdGlvbiI6eyJwYXJlbnRGaWVsZCI6WyJpZCJdLCJjaGlsZEZpZWxkIjpbImlzc3VlSUQiXX0sInN1YnF1ZXJ5Ijp7InRhYmxlIjoiY29tbWVudCIsImFsaWFzIjoiY29tbWVudHMiLCJvcmRlckJ5IjpbWyJpZCIsImRlc2MiXV19LCJzeXN0ZW0iOiJwZXJtaXNzaW9ucyIsImhpZGRlbiI6dHJ1ZX1dLCJzdGFydCI6eyJyb3ciOnsiaWQiOiJhIiwibiI6bnVsbH0sImV4Y2x1c2l2ZSI6dHJ1ZX19fSx7Im9wIjoiY2xlYXIifV19XSwiYXV0aFRva2VuIjoiYSJ9", + "ok": true, + "decoded": { + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "del", + "hash": " _\\ß%\n\né" + }, + { + "op": "put", + "hash": "", + "ttl": 60441, + "ast": { + "table": "issue", + "alias": "i", + "schema": "public", + "related": [ + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueID" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "orderBy": [ + [ + "id", + "desc" + ] + ] + }, + "system": "permissions", + "hidden": true + } + ], + "start": { + "row": { + "id": "a", + "n": null + }, + "exclusive": true + } + } + }, + { + "op": "clear" + } + ] + } + ], + "authToken": "a" + }, + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "del", + "hash": " _\\ß%\n\né" + }, + { + "op": "put", + "hash": "", + "ttl": 60441, + "ast": { + "table": "issue", + "alias": "i", + "schema": "public", + "related": [ + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueID" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "orderBy": [ + [ + "id", + "desc" + ] + ] + }, + "system": "permissions", + "hidden": true + } + ], + "start": { + "row": { + "id": "a", + "n": null + }, + "exclusive": true + } + } + }, + { + "op": "clear" + } + ] + } + ], + "authToken": "a" + }, + { + "encoded": "e30%3D", + "ok": true, + "decoded": {} + }, + { + "encoded": "eyJpbml0Q29ubmVjdGlvbk1lc3NhZ2UiOlsiaW5pdENvbm5lY3Rpb24iLHsiZGVzaXJlZFF1ZXJpZXNQYXRjaCI6W3sib3AiOiJwdXQiLCJoYXNoIjoiYu6AgMOfIiwidHRsIjoxZS03LCJhc3QiOnsidGFibGUiOiJpc3N1ZSIsIndoZXJlIjp7InR5cGUiOiJzaW1wbGUiLCJvcCI6Ij0iLCJsZWZ0Ijp7InR5cGUiOiJjb2x1bW4iLCJuYW1lIjoib3duZXJJRCJ9LCJyaWdodCI6eyJ0eXBlIjoic3RhdGljIiwiYW5jaG9yIjoiYXV0aERhdGEiLCJmaWVsZCI6WyJ1c2VyIiwic3ViIl19fX0sImFyZ3MiOltudWxsXX0seyJvcCI6ImNsZWFyIn0seyJvcCI6InB1dCIsImhhc2giOiJcXGJf8J%2BYgCIsInR0bCI6MjE0NzQ4MzY0OCwiYXN0Ijp7InRhYmxlIjoiaXNzdWUiLCJhbGlhcyI6ImkiLCJzY2hlbWEiOiJwdWJsaWMiLCJyZWxhdGVkIjpbeyJjb3JyZWxhdGlvbiI6eyJwYXJlbnRGaWVsZCI6WyJpZCJdLCJjaGlsZEZpZWxkIjpbImlzc3VlSUQiXX0sInN1YnF1ZXJ5Ijp7InRhYmxlIjoiY29tbWVudCIsImFsaWFzIjoiY29tbWVudHMiLCJvcmRlckJ5IjpbWyJpZCIsImRlc2MiXV19LCJzeXN0ZW0iOiJwZXJtaXNzaW9ucyIsImhpZGRlbiI6dHJ1ZX1dLCJzdGFydCI6eyJyb3ciOnsiaWQiOiJhIiwibiI6bnVsbH0sImV4Y2x1c2l2ZSI6dHJ1ZX19LCJuYW1lIjoiw5%2FkuK3ugIDEsO6AgCIsImFyZ3MiOls2ODcyN119XSwidXNlclB1c2hIZWFkZXJzIjp7IkLDqV%2FDqfCdkpwiOiLDqSIsIiBC8J%2BYgCI6IvCfmIBcXGJcbmEifX1dLCJhdXRoVG9rZW4iOiLOqSJ9", + "ok": true, + "decoded": { + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "bß", + "ttl": 1e-7, + "ast": { + "table": "issue", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "ownerID" + }, + "right": { + "type": "static", + "anchor": "authData", + "field": [ + "user", + "sub" + ] + } + } + }, + "args": [ + null + ] + }, + { + "op": "clear" + }, + { + "op": "put", + "hash": "\\b_😀", + "ttl": 2147483648, + "ast": { + "table": "issue", + "alias": "i", + "schema": "public", + "related": [ + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueID" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "orderBy": [ + [ + "id", + "desc" + ] + ] + }, + "system": "permissions", + "hidden": true + } + ], + "start": { + "row": { + "id": "a", + "n": null + }, + "exclusive": true + } + }, + "name": "ß中İ", + "args": [ + 68727 + ] + } + ], + "userPushHeaders": { + "Bé_é𝒜": "é", + " B😀": "😀\\b\na" + } + } + ], + "authToken": "Ω" + }, + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "bß", + "ttl": 1e-7, + "ast": { + "table": "issue", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "ownerID" + }, + "right": { + "type": "static", + "anchor": "authData", + "field": [ + "user", + "sub" + ] + } + } + }, + "args": [ + null + ] + }, + { + "op": "clear" + }, + { + "op": "put", + "hash": "\\b_😀", + "ttl": 2147483648, + "ast": { + "table": "issue", + "alias": "i", + "schema": "public", + "related": [ + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueID" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "orderBy": [ + [ + "id", + "desc" + ] + ] + }, + "system": "permissions", + "hidden": true + } + ], + "start": { + "row": { + "id": "a", + "n": null + }, + "exclusive": true + } + }, + "name": "ß中İ", + "args": [ + 68727 + ] + } + ], + "userPushHeaders": { + "Bé_é𝒜": "é", + " B😀": "😀\\b\na" + } + } + ], + "authToken": "Ω" + }, + { + "encoded": "e30%3D", + "ok": true, + "decoded": {} + }, + { + "encoded": "eyJpbml0Q29ubmVjdGlvbk1lc3NhZ2UiOlsiaW5pdENvbm5lY3Rpb24iLHsiZGVzaXJlZFF1ZXJpZXNQYXRjaCI6W10sImNsaWVudFNjaGVtYSI6eyJ0YWJsZXMiOnsiaXNzdWUiOnsiY29sdW1ucyI6eyJpc3N1ZSI6eyJ0eXBlIjoianNvbiJ9fSwicHJpbWFyeUtleSI6WyJpc3N1ZSJdfX19LCJ1c2VyUXVlcnlVUkwiOiJiIiwidHJhY2VwYXJlbnQiOiLDqcOpX2HEsF8gICJ9XSwiYXV0aFRva2VuIjoiQuS4rSJ9", + "ok": true, + "decoded": { + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [], + "clientSchema": { + "tables": { + "issue": { + "columns": { + "issue": { + "type": "json" + } + }, + "primaryKey": [ + "issue" + ] + } + } + }, + "userQueryURL": "b", + "traceparent": "éé_aİ_ " + } + ], + "authToken": "B中" + }, + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [], + "clientSchema": { + "tables": { + "issue": { + "columns": { + "issue": { + "type": "json" + } + }, + "primaryKey": [ + "issue" + ] + } + } + }, + "userQueryURL": "b", + "traceparent": "éé_aİ_ " + } + ], + "authToken": "B中" + }, + { + "encoded": "eyJhdXRoVG9rZW4iOiJhIn0%3D", + "ok": true, + "decoded": { + "authToken": "a" + }, + "authToken": "a" + }, + { + "encoded": "eyJpbml0Q29ubmVjdGlvbk1lc3NhZ2UiOlsiaW5pdENvbm5lY3Rpb24iLHsiZGVzaXJlZFF1ZXJpZXNQYXRjaCI6W3sib3AiOiJkZWwiLCJoYXNoIjoiQSJ9LHsib3AiOiJjbGVhciJ9LHsib3AiOiJwdXQiLCJoYXNoIjoiYiIsInR0bCI6ODQ5NTIsImFyZ3MiOltbXSxudWxsXX1dLCJ1c2VyUHVzaFVSTCI6IkJcbsOfw58lXCIiLCJhY3RpdmVDbGllbnRzIjpbImHkuK1ixLBcbsSwIl0sInRyYWNlcGFyZW50IjoiXFwifV0sImF1dGhUb2tlbiI6ImFiIn0%3D", + "ok": true, + "decoded": { + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "del", + "hash": "A" + }, + { + "op": "clear" + }, + { + "op": "put", + "hash": "b", + "ttl": 84952, + "args": [ + [], + null + ] + } + ], + "userPushURL": "B\nßß%\"", + "activeClients": [ + "a中bİ\nİ" + ], + "traceparent": "\\" + } + ], + "authToken": "ab" + }, + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "del", + "hash": "A" + }, + { + "op": "clear" + }, + { + "op": "put", + "hash": "b", + "ttl": 84952, + "args": [ + [], + null + ] + } + ], + "userPushURL": "B\nßß%\"", + "activeClients": [ + "a中bİ\nİ" + ], + "traceparent": "\\" + } + ], + "authToken": "ab" + }, + { + "encoded": "eyJpbml0Q29ubmVjdGlvbk1lc3NhZ2UiOlsiaW5pdENvbm5lY3Rpb24iLHsiZGVzaXJlZFF1ZXJpZXNQYXRjaCI6W10sImNsaWVudFNjaGVtYSI6eyJ0YWJsZXMiOnt9fSwidXNlclF1ZXJ5SGVhZGVycyI6eyJcIuS4reS4rUHDqV8iOiJh8J%2BYgCJ9fV0sImF1dGhUb2tlbiI6IiJ9", + "ok": true, + "decoded": { + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [], + "clientSchema": { + "tables": {} + }, + "userQueryHeaders": { + "\"中中Aé_": "a😀" + } + } + ], + "authToken": "" + }, + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [], + "clientSchema": { + "tables": {} + }, + "userQueryHeaders": { + "\"中中Aé_": "a😀" + } + } + ], + "authToken": "" + }, + { + "encoded": "eyJpbml0Q29ubmVjdGlvbk1lc3NhZ2UiOlsiaW5pdENvbm5lY3Rpb24iLHsiZGVzaXJlZFF1ZXJpZXNQYXRjaCI6W3sib3AiOiJjbGVhciJ9XSwidXNlclF1ZXJ5VVJMIjoiw6kifV0sImF1dGhUb2tlbiI6IsOfIn0%3D", + "ok": true, + "decoded": { + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "clear" + } + ], + "userQueryURL": "é" + } + ], + "authToken": "ß" + }, + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "clear" + } + ], + "userQueryURL": "é" + } + ], + "authToken": "ß" + }, + { + "encoded": "eyJpbml0Q29ubmVjdGlvbk1lc3NhZ2UiOlsiaW5pdENvbm5lY3Rpb24iLHsiZGVzaXJlZFF1ZXJpZXNQYXRjaCI6W3sib3AiOiJkZWwiLCJoYXNoIjoiXyJ9LHsib3AiOiJwdXQiLCJoYXNoIjoixLDDnyUiLCJhc3QiOnsidGFibGUiOiJpc3N1ZSIsImFsaWFzIjoiaSIsInNjaGVtYSI6InB1YmxpYyIsInJlbGF0ZWQiOlt7ImNvcnJlbGF0aW9uIjp7InBhcmVudEZpZWxkIjpbImlkIl0sImNoaWxkRmllbGQiOlsiaXNzdWVJRCJdfSwic3VicXVlcnkiOnsidGFibGUiOiJjb21tZW50IiwiYWxpYXMiOiJjb21tZW50cyIsIm9yZGVyQnkiOltbImlkIiwiZGVzYyJdXX0sInN5c3RlbSI6InBlcm1pc3Npb25zIiwiaGlkZGVuIjp0cnVlfV0sInN0YXJ0Ijp7InJvdyI6eyJpZCI6ImEiLCJuIjpudWxsfSwiZXhjbHVzaXZlIjp0cnVlfX0sImFyZ3MiOltdfSx7Im9wIjoicHV0IiwiaGFzaCI6IsSxIiwiYXN0Ijp7InRhYmxlIjoiaXNzdWUiLCJhbGlhcyI6ImkiLCJzY2hlbWEiOiJwdWJsaWMiLCJyZWxhdGVkIjpbeyJjb3JyZWxhdGlvbiI6eyJwYXJlbnRGaWVsZCI6WyJpZCJdLCJjaGlsZEZpZWxkIjpbImlzc3VlSUQiXX0sInN1YnF1ZXJ5Ijp7InRhYmxlIjoiY29tbWVudCIsImFsaWFzIjoiY29tbWVudHMiLCJvcmRlckJ5IjpbWyJpZCIsImRlc2MiXV19LCJzeXN0ZW0iOiJwZXJtaXNzaW9ucyIsImhpZGRlbiI6dHJ1ZX1dLCJzdGFydCI6eyJyb3ciOnsiaWQiOiJhIiwibiI6bnVsbH0sImV4Y2x1c2l2ZSI6dHJ1ZX19LCJuYW1lIjoiYsSwQiXwn5iAJcOpw58ifV0sInVzZXJRdWVyeUhlYWRlcnMiOnsiw6kiOiIifSwidHJhY2VwYXJlbnQiOiIifV19", + "ok": true, + "decoded": { + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "del", + "hash": "_" + }, + { + "op": "put", + "hash": "İß%", + "ast": { + "table": "issue", + "alias": "i", + "schema": "public", + "related": [ + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueID" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "orderBy": [ + [ + "id", + "desc" + ] + ] + }, + "system": "permissions", + "hidden": true + } + ], + "start": { + "row": { + "id": "a", + "n": null + }, + "exclusive": true + } + }, + "args": [] + }, + { + "op": "put", + "hash": "ı", + "ast": { + "table": "issue", + "alias": "i", + "schema": "public", + "related": [ + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueID" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "orderBy": [ + [ + "id", + "desc" + ] + ] + }, + "system": "permissions", + "hidden": true + } + ], + "start": { + "row": { + "id": "a", + "n": null + }, + "exclusive": true + } + }, + "name": "bİB%😀%éß" + } + ], + "userQueryHeaders": { + "é": "" + }, + "traceparent": "" + } + ] + }, + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "del", + "hash": "_" + }, + { + "op": "put", + "hash": "İß%", + "ast": { + "table": "issue", + "alias": "i", + "schema": "public", + "related": [ + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueID" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "orderBy": [ + [ + "id", + "desc" + ] + ] + }, + "system": "permissions", + "hidden": true + } + ], + "start": { + "row": { + "id": "a", + "n": null + }, + "exclusive": true + } + }, + "args": [] + }, + { + "op": "put", + "hash": "ı", + "ast": { + "table": "issue", + "alias": "i", + "schema": "public", + "related": [ + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueID" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "orderBy": [ + [ + "id", + "desc" + ] + ] + }, + "system": "permissions", + "hidden": true + } + ], + "start": { + "row": { + "id": "a", + "n": null + }, + "exclusive": true + } + }, + "name": "bİB%😀%éß" + } + ], + "userQueryHeaders": { + "é": "" + }, + "traceparent": "" + } + ] + }, + { + "encoded": "eyJpbml0Q29ubmVjdGlvbk1lc3NhZ2UiOlsiaW5pdENvbm5lY3Rpb24iLHsiZGVzaXJlZFF1ZXJpZXNQYXRjaCI6W3sib3AiOiJkZWwiLCJoYXNoIjoiw6lhYu6AgMSwXG4ifSx7Im9wIjoicHV0IiwiaGFzaCI6IiAiLCJ0dGwiOjAuNSwiYXN0Ijp7InRhYmxlIjoiaXNzdWUiLCJ3aGVyZSI6eyJ0eXBlIjoiYW5kIiwiY29uZGl0aW9ucyI6W3sidHlwZSI6ImNvcnJlbGF0ZWRTdWJxdWVyeSIsIm9wIjoiRVhJU1RTIiwicmVsYXRlZCI6eyJjb3JyZWxhdGlvbiI6eyJwYXJlbnRGaWVsZCI6WyJvd25lcklEIl0sImNoaWxkRmllbGQiOlsiaWQiXX0sInN1YnF1ZXJ5Ijp7InRhYmxlIjoidXNlciJ9fX0seyJ0eXBlIjoic2ltcGxlIiwib3AiOiJJTiIsImxlZnQiOnsidHlwZSI6ImNvbHVtbiIsIm5hbWUiOiJuIn0sInJpZ2h0Ijp7InR5cGUiOiJsaXRlcmFsIiwidmFsdWUiOlsxLCJhIix0cnVlXX19XX19fV0sInVzZXJQdXNoVVJMIjoi8J2SnPCfmIBhQSIsInVzZXJRdWVyeVVSTCI6Ilxc5LitQV9cXCBBIiwidXNlclF1ZXJ5SGVhZGVycyI6eyLDqe6AgFwiXG5ixLBcXPCdkpwiOiJBw58ifX1dLCJhdXRoVG9rZW4iOiLwn5iAQiVhIn0%3D", + "ok": true, + "decoded": { + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "del", + "hash": "éabİ\n" + }, + { + "op": "put", + "hash": " ", + "ttl": 0.5, + "ast": { + "table": "issue", + "where": { + "type": "and", + "conditions": [ + { + "type": "correlatedSubquery", + "op": "EXISTS", + "related": { + "correlation": { + "parentField": [ + "ownerID" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "user" + } + } + }, + { + "type": "simple", + "op": "IN", + "left": { + "type": "column", + "name": "n" + }, + "right": { + "type": "literal", + "value": [ + 1, + "a", + true + ] + } + } + ] + } + } + } + ], + "userPushURL": "𝒜😀aA", + "userQueryURL": "\\中A_\\ A", + "userQueryHeaders": { + "é\"\nbİ\\𝒜": "Aß" + } + } + ], + "authToken": "😀B%a" + }, + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "del", + "hash": "éabİ\n" + }, + { + "op": "put", + "hash": " ", + "ttl": 0.5, + "ast": { + "table": "issue", + "where": { + "type": "and", + "conditions": [ + { + "type": "correlatedSubquery", + "op": "EXISTS", + "related": { + "correlation": { + "parentField": [ + "ownerID" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "user" + } + } + }, + { + "type": "simple", + "op": "IN", + "left": { + "type": "column", + "name": "n" + }, + "right": { + "type": "literal", + "value": [ + 1, + "a", + true + ] + } + } + ] + } + } + } + ], + "userPushURL": "𝒜😀aA", + "userQueryURL": "\\中A_\\ A", + "userQueryHeaders": { + "é\"\nbİ\\𝒜": "Aß" + } + } + ], + "authToken": "😀B%a" + }, + { + "encoded": "eyJpbml0Q29ubmVjdGlvbk1lc3NhZ2UiOlsiaW5pdENvbm5lY3Rpb24iLHsiZGVzaXJlZFF1ZXJpZXNQYXRjaCI6W3sib3AiOiJjbGVhciJ9XSwidXNlclF1ZXJ5SGVhZGVycyI6e30sInRyYWNlcGFyZW50IjoiYSJ9XSwiYXV0aFRva2VuIjoi8J%2BYgMOf8J2SnF8lQkFfIn0%3D", + "ok": true, + "decoded": { + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "clear" + } + ], + "userQueryHeaders": {}, + "traceparent": "a" + } + ], + "authToken": "😀ß𝒜_%BA_" + }, + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "clear" + } + ], + "userQueryHeaders": {}, + "traceparent": "a" + } + ], + "authToken": "😀ß𝒜_%BA_" + }, + { + "encoded": "eyJpbml0Q29ubmVjdGlvbk1lc3NhZ2UiOlsiaW5pdENvbm5lY3Rpb24iLHsiZGVzaXJlZFF1ZXJpZXNQYXRjaCI6W10sImRlbGV0ZWQiOnsiY2xpZW50R3JvdXBJRHMiOlsiJfCfmIBiIl19LCJhY3RpdmVDbGllbnRzIjpbXX1dLCJhdXRoVG9rZW4iOiJhYmMifQ%3D%3D", + "ok": true, + "decoded": { + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [], + "deleted": { + "clientGroupIDs": [ + "%😀b" + ] + }, + "activeClients": [] + } + ], + "authToken": "abc" + }, + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [], + "deleted": { + "clientGroupIDs": [ + "%😀b" + ] + }, + "activeClients": [] + } + ], + "authToken": "abc" + }, + { + "encoded": "eyJpbml0Q29ubmVjdGlvbk1lc3NhZ2UiOlsiaW5pdENvbm5lY3Rpb24iLHsiZGVzaXJlZFF1ZXJpZXNQYXRjaCI6W3sib3AiOiJwdXQiLCJoYXNoIjoi9I%2B%2FvyIsImFyZ3MiOls1ZS0zMjQsIvCfmIAg8J%2BYgCJdfV0sInVzZXJQdXNoVVJMIjoiYfCfmIAiLCJ1c2VyUHVzaEhlYWRlcnMiOnsi77%2B9IjoixLDEsFwi8J2SnCIsIsOfQeS4rcOpYeS4rSI6Il%2FkuK1f8J2SnO6AgEElIn0sInRyYWNlcGFyZW50IjoiZcyBIn1dLCJhdXRoVG9rZW4iOiIifQ%3D%3D", + "ok": true, + "decoded": { + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "􏿿", + "args": [ + 5e-324, + "😀 😀" + ] + } + ], + "userPushURL": "a😀", + "userPushHeaders": { + "�": "İİ\"𝒜", + "ßA中éa中": "_中_𝒜A%" + }, + "traceparent": "é" + } + ], + "authToken": "" + }, + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "􏿿", + "args": [ + 5e-324, + "😀 😀" + ] + } + ], + "userPushURL": "a😀", + "userPushHeaders": { + "�": "İİ\"𝒜", + "ßA中éa中": "_中_𝒜A%" + }, + "traceparent": "é" + } + ], + "authToken": "" + }, + { + "encoded": "eyJhdXRoVG9rZW4iOiJcIiJ9", + "ok": true, + "decoded": { + "authToken": "\"" + }, + "authToken": "\"" + }, + { + "encoded": "eyJpbml0Q29ubmVjdGlvbk1lc3NhZ2UiOlsiaW5pdENvbm5lY3Rpb24iLHsiZGVzaXJlZFF1ZXJpZXNQYXRjaCI6W119XSwiYXV0aFRva2VuIjoi8J2SnO6AgCJ9", + "ok": true, + "decoded": { + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [] + } + ], + "authToken": "𝒜" + }, + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [] + } + ], + "authToken": "𝒜" + }, + { + "encoded": "eyJpbml0Q29ubmVjdGlvbk1lc3NhZ2UiOlsiaW5pdENvbm5lY3Rpb24iLHsiZGVzaXJlZFF1ZXJpZXNQYXRjaCI6W10sImNsaWVudFNjaGVtYSI6eyJ0YWJsZXMiOnsiIjp7ImNvbHVtbnMiOnsiY29tbWVudCI6eyJ0eXBlIjoic3RyaW5nIn19LCJwcmltYXJ5S2V5IjpbImNvbW1lbnQiXX19fSwidXNlclB1c2hVUkwiOiJcIiIsImFjdGl2ZUNsaWVudHMiOlsiXFzwnZKcYmFfICIsIsSwIl19XSwiYXV0aFRva2VuIjoixLEifQ%3D%3D", + "ok": true, + "decoded": { + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [], + "clientSchema": { + "tables": { + "": { + "columns": { + "comment": { + "type": "string" + } + }, + "primaryKey": [ + "comment" + ] + } + } + }, + "userPushURL": "\"", + "activeClients": [ + "\\𝒜ba_ ", + "İ" + ] + } + ], + "authToken": "ı" + }, + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [], + "clientSchema": { + "tables": { + "": { + "columns": { + "comment": { + "type": "string" + } + }, + "primaryKey": [ + "comment" + ] + } + } + }, + "userPushURL": "\"", + "activeClients": [ + "\\𝒜ba_ ", + "İ" + ] + } + ], + "authToken": "ı" + }, + { + "encoded": "eyJpbml0Q29ubmVjdGlvbk1lc3NhZ2UiOlsiaW5pdENvbm5lY3Rpb24iLHsiZGVzaXJlZFF1ZXJpZXNQYXRjaCI6W3sib3AiOiJkZWwiLCJoYXNoIjoiX%2FCfmIBh7oCA7oCAw6kifSx7Im9wIjoiY2xlYXIifV0sImNsaWVudFNjaGVtYSI6eyJ0YWJsZXMiOnt9fSwidXNlclF1ZXJ5VVJMIjoiYiIsInVzZXJRdWVyeUhlYWRlcnMiOnsiIjoiYvCfmIBCIGIifSwiYWN0aXZlQ2xpZW50cyI6WyLDqSIsIlxuX%2BS4rVwi8J%2BYgGFhIl0sInRyYWNlcGFyZW50IjoiXHUwMDAwIn1dLCJhdXRoVG9rZW4iOiLvv70ifQ%3D%3D", + "ok": true, + "decoded": { + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "del", + "hash": "_😀aé" + }, + { + "op": "clear" + } + ], + "clientSchema": { + "tables": {} + }, + "userQueryURL": "b", + "userQueryHeaders": { + "": "b😀B b" + }, + "activeClients": [ + "é", + "\n_中\"😀aa" + ], + "traceparent": "\u0000" + } + ], + "authToken": "�" + }, + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "del", + "hash": "_😀aé" + }, + { + "op": "clear" + } + ], + "clientSchema": { + "tables": {} + }, + "userQueryURL": "b", + "userQueryHeaders": { + "": "b😀B b" + }, + "activeClients": [ + "é", + "\n_中\"😀aa" + ], + "traceparent": "\u0000" + } + ], + "authToken": "�" + }, + { + "encoded": "eyJpbml0Q29ubmVjdGlvbk1lc3NhZ2UiOlsiaW5pdENvbm5lY3Rpb24iLHsiZGVzaXJlZFF1ZXJpZXNQYXRjaCI6W3sib3AiOiJwdXQiLCJoYXNoIjoiIiwidHRsIjowLjV9LHsib3AiOiJjbGVhciJ9LHsib3AiOiJwdXQiLCJoYXNoIjoiw59iYiVCIiwidHRsIjo3LCJuYW1lIjoiYiIsImFyZ3MiOltdfV0sImFjdGl2ZUNsaWVudHMiOlsi8J%2BYgGEiLCJfXCIgw6lcbsSwXFwiXX1dLCJhdXRoVG9rZW4iOiJiJSDugIBB8J2SnEIifQ%3D%3D", + "ok": true, + "decoded": { + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "", + "ttl": 0.5 + }, + { + "op": "clear" + }, + { + "op": "put", + "hash": "ßbb%B", + "ttl": 7, + "name": "b", + "args": [] + } + ], + "activeClients": [ + "😀a", + "_\" é\nİ\\" + ] + } + ], + "authToken": "b% A𝒜B" + }, + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "", + "ttl": 0.5 + }, + { + "op": "clear" + }, + { + "op": "put", + "hash": "ßbb%B", + "ttl": 7, + "name": "b", + "args": [] + } + ], + "activeClients": [ + "😀a", + "_\" é\nİ\\" + ] + } + ], + "authToken": "b% A𝒜B" + }, + { + "encoded": "eyJpbml0Q29ubmVjdGlvbk1lc3NhZ2UiOlsiaW5pdENvbm5lY3Rpb24iLHsiZGVzaXJlZFF1ZXJpZXNQYXRjaCI6W3sib3AiOiJwdXQiLCJoYXNoIjoiICIsIm5hbWUiOiJcXO6AgMSwIn1dLCJkZWxldGVkIjp7fSwidXNlclF1ZXJ5SGVhZGVycyI6e319XX0%3D", + "ok": true, + "decoded": { + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": " ", + "name": "\\İ" + } + ], + "deleted": {}, + "userQueryHeaders": {} + } + ] + }, + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": " ", + "name": "\\İ" + } + ], + "deleted": {}, + "userQueryHeaders": {} + } + ] + }, + { + "encoded": "eyJpbml0Q29ubmVjdGlvbk1lc3NhZ2UiOlsiaW5pdENvbm5lY3Rpb24iLHsiZGVzaXJlZFF1ZXJpZXNQYXRjaCI6W3sib3AiOiJkZWwiLCJoYXNoIjoiQSJ9XSwiY2xpZW50U2NoZW1hIjp7InRhYmxlcyI6e319fV0sImF1dGhUb2tlbiI6IsKgIn0%3D", + "ok": true, + "decoded": { + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "del", + "hash": "A" + } + ], + "clientSchema": { + "tables": {} + } + } + ], + "authToken": " " + }, + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "del", + "hash": "A" + } + ], + "clientSchema": { + "tables": {} + } + } + ], + "authToken": " " + }, + { + "encoded": "eyJhdXRoVG9rZW4iOiIifQ%3D%3D", + "ok": true, + "decoded": { + "authToken": "" + }, + "authToken": "" + }, + { + "encoded": "e30%3D", + "ok": true, + "decoded": {} + }, + { + "encoded": "eyJpbml0Q29ubmVjdGlvbk1lc3NhZ2UiOlsiaW5pdENvbm5lY3Rpb24iLHsiZGVzaXJlZFF1ZXJpZXNQYXRjaCI6W3sib3AiOiJwdXQiLCJoYXNoIjoiXFxcblwiXG4l7oCAIiwidHRsIjo0MDQ1OCwiYXN0Ijp7InRhYmxlIjoiaXNzdWUiLCJhbGlhcyI6ImkiLCJzY2hlbWEiOiJwdWJsaWMiLCJyZWxhdGVkIjpbeyJjb3JyZWxhdGlvbiI6eyJwYXJlbnRGaWVsZCI6WyJpZCJdLCJjaGlsZEZpZWxkIjpbImlzc3VlSUQiXX0sInN1YnF1ZXJ5Ijp7InRhYmxlIjoiY29tbWVudCIsImFsaWFzIjoiY29tbWVudHMiLCJvcmRlckJ5IjpbWyJpZCIsImRlc2MiXV19LCJzeXN0ZW0iOiJwZXJtaXNzaW9ucyIsImhpZGRlbiI6dHJ1ZX1dLCJzdGFydCI6eyJyb3ciOnsiaWQiOiJhIiwibiI6bnVsbH0sImV4Y2x1c2l2ZSI6dHJ1ZX19LCJhcmdzIjpbXX0seyJvcCI6InB1dCIsImhhc2giOiLugIBi8J%2BYgMSwXFxcbmEiLCJuYW1lIjoiYiJ9LHsib3AiOiJjbGVhciJ9XSwiZGVsZXRlZCI6eyJjbGllbnRJRHMiOlsi77%2B9Iiwi5LitxLAiXX0sImFjdGl2ZUNsaWVudHMiOltdfV0sImF1dGhUb2tlbiI6IlxcxLBiXCJBYl9BIn0%3D", + "ok": true, + "decoded": { + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "\\\n\"\n%", + "ttl": 40458, + "ast": { + "table": "issue", + "alias": "i", + "schema": "public", + "related": [ + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueID" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "orderBy": [ + [ + "id", + "desc" + ] + ] + }, + "system": "permissions", + "hidden": true + } + ], + "start": { + "row": { + "id": "a", + "n": null + }, + "exclusive": true + } + }, + "args": [] + }, + { + "op": "put", + "hash": "b😀İ\\\na", + "name": "b" + }, + { + "op": "clear" + } + ], + "deleted": { + "clientIDs": [ + "�", + "中İ" + ] + }, + "activeClients": [] + } + ], + "authToken": "\\İb\"Ab_A" + }, + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "\\\n\"\n%", + "ttl": 40458, + "ast": { + "table": "issue", + "alias": "i", + "schema": "public", + "related": [ + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueID" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "orderBy": [ + [ + "id", + "desc" + ] + ] + }, + "system": "permissions", + "hidden": true + } + ], + "start": { + "row": { + "id": "a", + "n": null + }, + "exclusive": true + } + }, + "args": [] + }, + { + "op": "put", + "hash": "b😀İ\\\na", + "name": "b" + }, + { + "op": "clear" + } + ], + "deleted": { + "clientIDs": [ + "�", + "中İ" + ] + }, + "activeClients": [] + } + ], + "authToken": "\\İb\"Ab_A" + }, + { + "encoded": "eyJpbml0Q29ubmVjdGlvbk1lc3NhZ2UiOlsiaW5pdENvbm5lY3Rpb24iLHsiZGVzaXJlZFF1ZXJpZXNQYXRjaCI6W3sib3AiOiJkZWwiLCJoYXNoIjoiz4kifSx7Im9wIjoiY2xlYXIifSx7Im9wIjoicHV0IiwiaGFzaCI6IsOfQSIsInR0bCI6NWUtMzI0LCJhc3QiOnsidGFibGUiOiJpc3N1ZSJ9LCJuYW1lIjoiYWIifV0sInVzZXJQdXNoSGVhZGVycyI6eyLwnZKcw6lfxLDwnZKc8J2SnCAlIjoiX18ifSwidXNlclF1ZXJ5SGVhZGVycyI6eyJCXG7DqfCfmIDugIAiOiLugIBhXCLkuK0ifSwiYWN0aXZlQ2xpZW50cyI6WyJfXG7kuK0iLCJcXMOfw5%2FwnZKcYVwiIl19XX0%3D", + "ok": true, + "decoded": { + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "del", + "hash": "ω" + }, + { + "op": "clear" + }, + { + "op": "put", + "hash": "ßA", + "ttl": 5e-324, + "ast": { + "table": "issue" + }, + "name": "ab" + } + ], + "userPushHeaders": { + "𝒜é_İ𝒜𝒜 %": "__" + }, + "userQueryHeaders": { + "B\né😀": "a\"中" + }, + "activeClients": [ + "_\n中", + "\\ßß𝒜a\"" + ] + } + ] + }, + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "del", + "hash": "ω" + }, + { + "op": "clear" + }, + { + "op": "put", + "hash": "ßA", + "ttl": 5e-324, + "ast": { + "table": "issue" + }, + "name": "ab" + } + ], + "userPushHeaders": { + "𝒜é_İ𝒜𝒜 %": "__" + }, + "userQueryHeaders": { + "B\né😀": "a\"中" + }, + "activeClients": [ + "_\n中", + "\\ßß𝒜a\"" + ] + } + ] + }, + { + "encoded": "eyJpbml0Q29ubmVjdGlvbk1lc3NhZ2UiOlsiaW5pdENvbm5lY3Rpb24iLHsiZGVzaXJlZFF1ZXJpZXNQYXRjaCI6W3sib3AiOiJkZWwiLCJoYXNoIjoiXHUwMDAwIn0seyJvcCI6InB1dCIsImhhc2giOiJcImLwnZKcIiwidHRsIjowLjUsIm5hbWUiOiIgIGFf7oCAXCLwnZKcQiJ9LHsib3AiOiJwdXQiLCJoYXNoIjoiOSIsInR0bCI6MjE0NzQ4MzY0OCwiYXN0Ijp7InRhYmxlIjoiaXNzdWUiLCJ3aGVyZSI6eyJ0eXBlIjoic2ltcGxlIiwib3AiOiI9IiwibGVmdCI6eyJ0eXBlIjoiY29sdW1uIiwibmFtZSI6ImlkIn0sInJpZ2h0Ijp7InR5cGUiOiJsaXRlcmFsIiwidmFsdWUiOiIxIn19LCJvcmRlckJ5IjpbWyJpZCIsImFzYyJdXSwibGltaXQiOjEwfX1dLCJjbGllbnRTY2hlbWEiOnsidGFibGVzIjp7IiI6eyJjb2x1bW5zIjp7ImEiOnsidHlwZSI6InN0cmluZyJ9fSwicHJpbWFyeUtleSI6WyJhIl19LCJfIjp7ImNvbHVtbnMiOnsiZSI6eyJ0eXBlIjoianNvbiJ9fSwicHJpbWFyeUtleSI6WyJlIl19fX0sInVzZXJQdXNoVVJMIjoiXG5BQlwiIn1dLCJhdXRoVG9rZW4iOiJCXG5cbsOfIn0%3D", + "ok": true, + "decoded": { + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "del", + "hash": "\u0000" + }, + { + "op": "put", + "hash": "\"b𝒜", + "ttl": 0.5, + "name": " a_\"𝒜B" + }, + { + "op": "put", + "hash": "9", + "ttl": 2147483648, + "ast": { + "table": "issue", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "id" + }, + "right": { + "type": "literal", + "value": "1" + } + }, + "orderBy": [ + [ + "id", + "asc" + ] + ], + "limit": 10 + } + } + ], + "clientSchema": { + "tables": { + "": { + "columns": { + "a": { + "type": "string" + } + }, + "primaryKey": [ + "a" + ] + }, + "_": { + "columns": { + "e": { + "type": "json" + } + }, + "primaryKey": [ + "e" + ] + } + } + }, + "userPushURL": "\nAB\"" + } + ], + "authToken": "B\n\nß" + }, + "initConnectionMessage": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "del", + "hash": "\u0000" + }, + { + "op": "put", + "hash": "\"b𝒜", + "ttl": 0.5, + "name": " a_\"𝒜B" + }, + { + "op": "put", + "hash": "9", + "ttl": 2147483648, + "ast": { + "table": "issue", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "id" + }, + "right": { + "type": "literal", + "value": "1" + } + }, + "orderBy": [ + [ + "id", + "asc" + ] + ], + "limit": 10 + } + } + ], + "clientSchema": { + "tables": { + "": { + "columns": { + "a": { + "type": "string" + } + }, + "primaryKey": [ + "a" + ] + }, + "_": { + "columns": { + "e": { + "type": "json" + } + }, + "primaryKey": [ + "e" + ] + } + } + }, + "userPushURL": "\nAB\"" + } + ], + "authToken": "B\n\nß" + } + ] +} diff --git a/packages/zero-conformance/vectors/protocol-upstream.json b/packages/zero-conformance/vectors/protocol-upstream.json new file mode 100644 index 0000000000..62df15f43d --- /dev/null +++ b/packages/zero-conformance/vectors/protocol-upstream.json @@ -0,0 +1,4954 @@ +{ + "formatVersion": 1, + "kind": "protocol-upstream", + "description": "Upstream messages (`upstreamSchema`, an open tagged tuple union). `ok` is whether valita accepts the message in `passthrough` mode; `output` is its `strip` output. Deeply nested messages are given as JSON text, in `messageText` and `outputText`.", + "cases": [ + { + "messageText": "[\"changeDesiredQueries\",{\"desiredQueriesPatch\":[{\"op\":\"put\",\"hash\":\"h\",\"ast\":{\"table\":\"issue\",\"where\":{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"simple\",\"op\":\"=\",\"left\":{\"type\":\"column\",\"name\":\"id\"},\"right\":{\"type\":\"literal\",\"value\":\"1\"}}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}}}]}]", + "ok": true, + "outputText": "[\"changeDesiredQueries\",{\"desiredQueriesPatch\":[{\"op\":\"put\",\"hash\":\"h\",\"ast\":{\"table\":\"issue\",\"where\":{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"simple\",\"op\":\"=\",\"left\":{\"type\":\"column\",\"name\":\"id\"},\"right\":{\"type\":\"literal\",\"value\":\"1\"}}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}}}]}]" + }, + { + "messageText": "[\"changeDesiredQueries\",{\"desiredQueriesPatch\":[{\"op\":\"put\",\"hash\":\"h\",\"ast\":{\"table\":\"issue\",\"where\":{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"simple\",\"op\":\"=\",\"left\":{\"type\":\"column\",\"name\":\"id\"},\"right\":{\"type\":\"literal\",\"value\":\"1\"}}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}}}]}]", + "ok": true, + "outputText": "[\"changeDesiredQueries\",{\"desiredQueriesPatch\":[{\"op\":\"put\",\"hash\":\"h\",\"ast\":{\"table\":\"issue\",\"where\":{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"simple\",\"op\":\"=\",\"left\":{\"type\":\"column\",\"name\":\"id\"},\"right\":{\"type\":\"literal\",\"value\":\"1\"}}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}}}]}]" + }, + { + "messageText": "[\"changeDesiredQueries\",{\"desiredQueriesPatch\":[{\"op\":\"put\",\"hash\":\"h\",\"ast\":{\"table\":\"issue\",\"where\":{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"and\",\"conditions\":[{\"type\":\"simple\",\"op\":\"=\",\"left\":{\"type\":\"column\",\"name\":\"id\"},\"right\":{\"type\":\"literal\",\"value\":\"1\"}}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}}}]}]", + "ok": false + }, + { + "messageText": "[\"changeDesiredQueries\",{\"desiredQueriesPatch\":[{\"op\":\"put\",\"hash\":\"h\",\"ast\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\"}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}]", + "ok": true, + "outputText": "[\"changeDesiredQueries\",{\"desiredQueriesPatch\":[{\"op\":\"put\",\"hash\":\"h\",\"ast\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\"}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}]" + }, + { + "messageText": "[\"changeDesiredQueries\",{\"desiredQueriesPatch\":[{\"op\":\"put\",\"hash\":\"h\",\"ast\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\",\"related\":[{\"correlation\":{\"parentField\":[\"id\"],\"childField\":[\"id\"]},\"subquery\":{\"table\":\"issue\"}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}]", + "ok": false + }, + { + "message": [ + "ping", + {} + ], + "ok": true, + "output": [ + "ping", + {} + ] + }, + { + "message": [ + "ping", + { + "zExtra": 1 + } + ], + "ok": true, + "output": [ + "ping", + {} + ] + }, + { + "message": [ + "ping" + ], + "ok": false + }, + { + "message": [ + "ping", + {}, + 1 + ], + "ok": false + }, + { + "message": [ + "ping", + [] + ], + "ok": false + }, + { + "message": [ + "newMessage" + ], + "ok": true, + "output": [ + "newMessage" + ] + }, + { + "message": [ + "newMessage", + 1, + 2, + 3 + ], + "ok": true, + "output": [ + "newMessage", + 1, + 2, + 3 + ] + }, + { + "message": [ + "" + ], + "ok": true, + "output": [ + "" + ] + }, + { + "message": [ + 1, + {} + ], + "ok": false + }, + { + "message": [ + null + ], + "ok": false + }, + { + "message": [ + true, + {} + ], + "ok": false + }, + { + "message": [ + [ + "ping" + ], + {} + ], + "ok": false + }, + { + "message": [], + "ok": false + }, + { + "message": "ping", + "ok": false + }, + { + "message": {}, + "ok": false + }, + { + "message": [ + "initConnection", + { + "desiredQueriesPatch": [] + } + ], + "ok": true, + "output": [ + "initConnection", + { + "desiredQueriesPatch": [] + } + ] + }, + { + "message": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "h1", + "ttl": 300000, + "ast": { + "table": "issue", + "alias": "i", + "schema": "public", + "related": [ + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueID" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "orderBy": [ + [ + "id", + "desc" + ] + ] + }, + "system": "permissions", + "hidden": true + } + ], + "start": { + "row": { + "id": "a", + "n": null + }, + "exclusive": true + } + } + }, + { + "op": "put", + "hash": "h2", + "name": "issues", + "args": [ + 1, + "a", + { + "b": null + } + ] + }, + { + "op": "del", + "hash": "h3" + }, + { + "op": "clear" + } + ], + "clientSchema": { + "tables": { + "issue": { + "columns": { + "id": { + "type": "string" + }, + "n": { + "type": "number" + } + }, + "primaryKey": [ + "id" + ] + } + } + }, + "deleted": { + "clientIDs": [ + "a" + ], + "clientGroupIDs": [] + }, + "userPushURL": "https://example.com/push", + "userPushHeaders": { + "authorization": "Bearer x" + }, + "userQueryURL": "https://example.com/query", + "userQueryHeaders": {}, + "activeClients": [ + "c1", + "c2" + ], + "traceparent": "00-abc-def-01" + } + ], + "ok": true, + "output": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "h1", + "ttl": 300000, + "ast": { + "schema": "public", + "table": "issue", + "alias": "i", + "related": [ + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueID" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "orderBy": [ + [ + "id", + "desc" + ] + ] + }, + "system": "permissions", + "hidden": true + } + ], + "start": { + "row": { + "id": "a", + "n": null + }, + "exclusive": true + } + } + }, + { + "op": "put", + "hash": "h2", + "name": "issues", + "args": [ + 1, + "a", + { + "b": null + } + ] + }, + { + "op": "del", + "hash": "h3" + }, + { + "op": "clear" + } + ], + "clientSchema": { + "tables": { + "issue": { + "columns": { + "id": { + "type": "string" + }, + "n": { + "type": "number" + } + }, + "primaryKey": [ + "id" + ] + } + } + }, + "deleted": { + "clientIDs": [ + "a" + ], + "clientGroupIDs": [] + }, + "userPushURL": "https://example.com/push", + "userPushHeaders": { + "authorization": "Bearer x" + }, + "userQueryURL": "https://example.com/query", + "userQueryHeaders": {}, + "activeClients": [ + "c1", + "c2" + ], + "traceparent": "00-abc-def-01" + } + ] + }, + { + "message": [ + "initConnection", + { + "desiredQueriesPatch": [], + "clientSchema": { + "tables": { + "issue": { + "columns": { + "id": { + "type": "date" + } + }, + "primaryKey": [ + "id" + ] + } + } + } + } + ], + "ok": false + }, + { + "message": [ + "initConnection", + { + "desiredQueriesPatch": [], + "clientSchema": { + "tables": { + "issue": { + "columns": {} + } + } + } + } + ], + "ok": false + }, + { + "message": [ + "initConnection", + { + "desiredQueriesPatch": [], + "userPushHeaders": { + "a": 1 + } + } + ], + "ok": false + }, + { + "message": [ + "initConnection", + { + "desiredQueriesPatch": [], + "activeClients": "c1" + } + ], + "ok": false + }, + { + "message": [ + "initConnection", + {} + ], + "ok": false + }, + { + "message": [ + "changeDesiredQueries", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "h", + "ast": { + "table": "issue", + "zExtra": 1 + } + } + ] + } + ], + "ok": false + }, + { + "message": [ + "changeDesiredQueries", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "h", + "ast": { + "table": "issue", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "a", + "zExtra": 1 + }, + "right": { + "type": "literal", + "value": 1 + } + } + } + } + ] + } + ], + "ok": false + }, + { + "message": [ + "changeDesiredQueries", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "h", + "ast": { + "table": "issue", + "where": { + "type": "and", + "conditions": [], + "zExtra": 1 + } + } + } + ] + } + ], + "ok": false + }, + { + "message": [ + "changeDesiredQueries", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "h", + "ast": { + "table": "issue", + "related": [ + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "id" + ], + "zExtra": 1 + }, + "subquery": { + "table": "comment" + } + } + ] + } + } + ] + } + ], + "ok": false + }, + { + "message": [ + "changeDesiredQueries", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "h", + "ast": { + "table": "issue", + "related": [ + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "comment", + "zExtra": 1 + } + } + ] + } + } + ] + } + ], + "ok": false + }, + { + "message": [ + "changeDesiredQueries", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "h", + "ast": { + "table": "issue", + "start": { + "row": { + "id": 1 + }, + "exclusive": false, + "zExtra": 1 + } + } + } + ] + } + ], + "ok": false + }, + { + "message": [ + "changeDesiredQueries", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "h", + "ast": { + "table": "issue", + "start": { + "row": { + "zExtra": 1 + }, + "exclusive": false + } + } + } + ] + } + ], + "ok": true, + "output": [ + "changeDesiredQueries", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "h", + "ast": { + "table": "issue", + "start": { + "row": { + "zExtra": 1 + }, + "exclusive": false + } + } + } + ] + } + ] + }, + { + "message": [ + "changeDesiredQueries", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "h", + "ast": { + "table": "issue", + "orderBy": [ + [ + "id", + "asc", + "extra" + ] + ] + } + } + ] + } + ], + "ok": false + }, + { + "message": [ + "changeDesiredQueries", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "h", + "ast": { + "table": 1 + } + } + ] + } + ], + "ok": false + }, + { + "message": [ + "changeDesiredQueries", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "h", + "ast": null + } + ] + } + ], + "ok": false + }, + { + "message": [ + "changeDesiredQueries", + { + "desiredQueriesPatch": [ + { + "op": "put" + } + ] + } + ], + "ok": false + }, + { + "message": [ + "pull", + { + "clientGroupID": "g", + "cookie": null, + "requestID": "r" + } + ], + "ok": true, + "output": [ + "pull", + { + "clientGroupID": "g", + "cookie": null, + "requestID": "r" + } + ] + }, + { + "message": [ + "pull", + { + "clientGroupID": "g", + "cookie": "5", + "requestID": "r" + } + ], + "ok": true, + "output": [ + "pull", + { + "clientGroupID": "g", + "cookie": "5", + "requestID": "r" + } + ] + }, + { + "message": [ + "pull", + { + "clientGroupID": "g", + "requestID": "r" + } + ], + "ok": false + }, + { + "message": [ + "updateAuth", + { + "auth": "x" + } + ], + "ok": true, + "output": [ + "updateAuth", + { + "auth": "x" + } + ] + }, + { + "message": [ + "updateAuth", + {} + ], + "ok": false + }, + { + "message": [ + "push", + { + "clientGroupID": "g", + "mutations": [ + { + "type": "crud", + "id": 1, + "clientID": "c", + "name": "_zero_crud", + "args": [ + { + "ops": [ + { + "op": "insert", + "tableName": "issue", + "primaryKey": [ + "id" + ], + "value": { + "id": "a" + } + } + ] + } + ], + "timestamp": 1 + } + ], + "pushVersion": 1, + "timestamp": 1, + "requestID": "r" + } + ], + "ok": true, + "output": [ + "push", + { + "clientGroupID": "g", + "mutations": [ + { + "type": "crud", + "id": 1, + "clientID": "c", + "name": "_zero_crud", + "args": [ + { + "ops": [ + { + "op": "insert", + "tableName": "issue", + "primaryKey": [ + "id" + ], + "value": { + "id": "a" + } + } + ] + } + ], + "timestamp": 1 + } + ], + "pushVersion": 1, + "timestamp": 1, + "requestID": "r" + } + ] + }, + { + "message": [ + "push", + { + "clientGroupID": "g", + "mutations": [ + { + "type": "crud", + "id": 1, + "clientID": "c", + "name": "other", + "args": [ + { + "ops": [ + { + "op": "insert", + "tableName": "issue", + "primaryKey": [ + "id" + ], + "value": { + "id": "a" + } + } + ] + } + ], + "timestamp": 1 + } + ], + "pushVersion": 1, + "timestamp": 1, + "requestID": "r" + } + ], + "ok": false + }, + { + "message": [ + "push", + { + "clientGroupID": "g", + "mutations": [ + { + "type": "crud", + "id": 1, + "clientID": "c", + "name": "_zero_crud", + "args": [], + "timestamp": 1 + } + ], + "pushVersion": 1, + "timestamp": 1, + "requestID": "r" + } + ], + "ok": false + }, + { + "message": [ + "push", + { + "clientGroupID": "g", + "mutations": [ + { + "type": "crud", + "id": 1, + "clientID": "c", + "name": "_zero_crud", + "args": [ + { + "ops": [] + }, + { + "ops": [] + } + ], + "timestamp": 1 + } + ], + "pushVersion": 1, + "timestamp": 1, + "requestID": "r" + } + ], + "ok": false + }, + { + "message": [ + "push", + { + "clientGroupID": "g", + "mutations": [ + { + "type": "crud", + "id": 1, + "clientID": "c", + "name": "_zero_crud", + "args": [ + {} + ], + "timestamp": 1 + } + ], + "pushVersion": 1, + "timestamp": 1, + "requestID": "r" + } + ], + "ok": false + }, + { + "message": [ + "push", + { + "clientGroupID": "g", + "mutations": [ + { + "type": "custom", + "id": 1, + "clientID": "c", + "name": "_zero_crud", + "args": [ + { + "ops": [ + { + "op": "insert", + "tableName": "issue", + "primaryKey": [ + "id" + ], + "value": { + "id": "a" + } + } + ] + } + ], + "timestamp": 1 + } + ], + "pushVersion": 1, + "timestamp": 1, + "requestID": "r" + } + ], + "ok": true, + "output": [ + "push", + { + "clientGroupID": "g", + "mutations": [ + { + "type": "custom", + "id": 1, + "clientID": "c", + "name": "_zero_crud", + "args": [ + { + "ops": [ + { + "op": "insert", + "tableName": "issue", + "primaryKey": [ + "id" + ], + "value": { + "id": "a" + } + } + ] + } + ], + "timestamp": 1 + } + ], + "pushVersion": 1, + "timestamp": 1, + "requestID": "r" + } + ] + }, + { + "message": [ + "push", + { + "clientGroupID": "g", + "mutations": [ + { + "type": "crud", + "id": 1, + "clientID": "c", + "name": "_zero_crud", + "args": [ + { + "ops": [ + { + "op": "upsert", + "tableName": "t", + "primaryKey": [ + "a", + "b" + ], + "value": { + "a": 1, + "b": 2, + "c": null + } + }, + { + "op": "update", + "tableName": "t", + "primaryKey": [ + "a" + ], + "value": { + "a": 1 + } + }, + { + "op": "delete", + "tableName": "t", + "primaryKey": [ + "a" + ], + "value": { + "a": 1 + } + } + ] + } + ], + "timestamp": 1 + }, + { + "type": "custom", + "id": 2, + "clientID": "c", + "name": "issue.create", + "args": [ + { + "id": "a" + } + ], + "timestamp": 2 + } + ], + "pushVersion": 1, + "schemaVersion": 3, + "timestamp": 3, + "requestID": "r", + "auth": "token", + "traceparent": "t" + } + ], + "ok": true, + "output": [ + "push", + { + "clientGroupID": "g", + "mutations": [ + { + "type": "crud", + "id": 1, + "clientID": "c", + "name": "_zero_crud", + "args": [ + { + "ops": [ + { + "op": "upsert", + "tableName": "t", + "primaryKey": [ + "a", + "b" + ], + "value": { + "a": 1, + "b": 2, + "c": null + } + }, + { + "op": "update", + "tableName": "t", + "primaryKey": [ + "a" + ], + "value": { + "a": 1 + } + }, + { + "op": "delete", + "tableName": "t", + "primaryKey": [ + "a" + ], + "value": { + "a": 1 + } + } + ] + } + ], + "timestamp": 1 + }, + { + "type": "custom", + "id": 2, + "clientID": "c", + "name": "issue.create", + "args": [ + { + "id": "a" + } + ], + "timestamp": 2 + } + ], + "pushVersion": 1, + "schemaVersion": 3, + "timestamp": 3, + "requestID": "r", + "auth": "token", + "traceparent": "t" + } + ] + }, + { + "message": [ + "push", + { + "clientGroupID": "g", + "mutations": [ + { + "type": "crud", + "id": 1, + "clientID": "c", + "name": "_zero_crud", + "args": [ + { + "ops": [ + { + "op": "insert", + "tableName": "t", + "primaryKey": [], + "value": {} + } + ] + } + ], + "timestamp": 1 + } + ], + "pushVersion": 1, + "timestamp": 1, + "requestID": "r" + } + ], + "ok": false + }, + { + "message": [ + "push", + { + "clientGroupID": "g", + "mutations": [ + { + "type": "crud", + "id": 1, + "clientID": "c", + "name": "_zero_crud", + "args": [ + { + "ops": [ + { + "op": "delete", + "tableName": "t", + "primaryKey": [ + "a" + ], + "value": { + "a": null + } + } + ] + } + ], + "timestamp": 1 + } + ], + "pushVersion": 1, + "timestamp": 1, + "requestID": "r" + } + ], + "ok": false + }, + { + "message": [ + "push", + { + "clientGroupID": "g", + "mutations": [ + { + "type": "custom", + "id": 1, + "clientID": "c", + "name": "_zero_crud", + "args": [], + "timestamp": 1 + } + ], + "pushVersion": 1, + "timestamp": 1, + "requestID": "r" + } + ], + "ok": true, + "output": [ + "push", + { + "clientGroupID": "g", + "mutations": [ + { + "type": "custom", + "id": 1, + "clientID": "c", + "name": "_zero_crud", + "args": [], + "timestamp": 1 + } + ], + "pushVersion": 1, + "timestamp": 1, + "requestID": "r" + } + ] + }, + { + "message": [ + "closeConnection", + [] + ], + "ok": true, + "output": [ + "closeConnection", + [] + ] + }, + { + "message": [ + "closeConnection", + [ + 1, + "a", + null, + { + "b": [] + } + ] + ], + "ok": true, + "output": [ + "closeConnection", + [ + 1, + "a", + null, + { + "b": [] + } + ] + ] + }, + { + "message": [ + "closeConnection", + {} + ], + "ok": false + }, + { + "message": [ + "ackMutationResponses", + { + "id": 1, + "clientID": "c" + } + ], + "ok": true, + "output": [ + "ackMutationResponses", + { + "id": 1, + "clientID": "c" + } + ] + }, + { + "message": [ + "ackMutationResponses", + { + "id": "1", + "clientID": "c" + } + ], + "ok": false + }, + { + "message": [ + "inspect", + { + "id": "i", + "op": "queries" + } + ], + "ok": true, + "output": [ + "inspect", + { + "id": "i", + "op": "queries" + } + ] + }, + { + "message": [ + "inspect", + { + "id": "i", + "op": "queries", + "clientID": "c" + } + ], + "ok": true, + "output": [ + "inspect", + { + "id": "i", + "op": "queries", + "clientID": "c" + } + ] + }, + { + "message": [ + "inspect", + { + "id": "i", + "op": "authenticate", + "value": "secret" + } + ], + "ok": true, + "output": [ + "inspect", + { + "id": "i", + "op": "authenticate", + "value": "secret" + } + ] + }, + { + "message": [ + "inspect", + { + "id": "i", + "op": "authenticate" + } + ], + "ok": false + }, + { + "message": [ + "inspect", + { + "id": "i", + "op": "newOp" + } + ], + "ok": false + }, + { + "message": [ + "inspect", + { + "id": "i", + "op": "analyze-query", + "value": { + "table": "issue", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "id" + }, + "right": { + "type": "literal", + "value": "1" + } + }, + "orderBy": [ + [ + "id", + "asc" + ] + ], + "limit": 10 + }, + "ast": { + "table": "issue", + "where": { + "type": "and", + "conditions": [ + { + "type": "correlatedSubquery", + "op": "EXISTS", + "related": { + "correlation": { + "parentField": [ + "ownerID" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "user" + } + } + }, + { + "type": "simple", + "op": "IN", + "left": { + "type": "column", + "name": "n" + }, + "right": { + "type": "literal", + "value": [ + 1, + "a", + true + ] + } + } + ] + } + }, + "options": { + "vendedRows": true, + "syncedRows": false, + "joinPlans": true + }, + "name": "q", + "args": [] + } + ], + "ok": true, + "output": [ + "inspect", + { + "id": "i", + "op": "analyze-query", + "value": { + "table": "issue", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "id" + }, + "right": { + "type": "literal", + "value": "1" + } + }, + "orderBy": [ + [ + "id", + "asc" + ] + ], + "limit": 10 + }, + "ast": { + "table": "issue", + "where": { + "type": "and", + "conditions": [ + { + "type": "correlatedSubquery", + "op": "EXISTS", + "related": { + "correlation": { + "parentField": [ + "ownerID" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "user" + } + } + }, + { + "type": "simple", + "op": "IN", + "left": { + "type": "column", + "name": "n" + }, + "right": { + "type": "literal", + "value": [ + 1, + "a", + true + ] + } + } + ] + } + }, + "options": { + "vendedRows": true, + "syncedRows": false, + "joinPlans": true + }, + "name": "q", + "args": [] + } + ] + }, + { + "message": [ + "inspect", + { + "id": "i", + "op": "analyze-query", + "ast": { + "table": "issue", + "zExtra": 1 + } + } + ], + "ok": false + }, + { + "message": [ + "inspect", + { + "id": "i", + "op": "analyze-query", + "options": { + "vendedRows": 1 + } + } + ], + "ok": false + }, + { + "message": [ + "pull", + { + "clientGroupID": "\na%bİ\"İ", + "cookie": "", + "requestID": "\" \\中bAB" + } + ], + "ok": true, + "output": [ + "pull", + { + "clientGroupID": "\na%bİ\"İ", + "cookie": "", + "requestID": "\" \\中bAB" + } + ] + }, + { + "message": [ + "changeDesiredQueries", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "A\n%b𝒜", + "ttl": 2, + "ast": { + "table": "issue" + } + }, + { + "op": "put", + "hash": " ", + "name": "\\", + "": [] + }, + { + "op": "del", + "hash": "𝒜B中中" + } + ] + } + ], + "ok": true, + "output": [ + "changeDesiredQueries", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "A\n%b𝒜", + "ttl": 2, + "ast": { + "table": "issue" + } + }, + { + "op": "put", + "hash": " ", + "name": "\\" + }, + { + "op": "del", + "hash": "𝒜B中中" + } + ] + } + ] + }, + { + "message": [ + "closeConnection", + [] + ], + "ok": true, + "output": [ + "closeConnection", + [] + ] + }, + { + "message": [ + "ackMutationResponses", + { + "id": 371, + "clientID": " ßba\"é𝒜" + } + ], + "ok": true, + "output": [ + "ackMutationResponses", + { + "id": 371, + "clientID": " ßba\"é𝒜" + } + ] + }, + { + "message": [ + "ping", + { + "zExtra": " \\ßİ" + } + ], + "ok": true, + "output": [ + "ping", + {} + ] + }, + { + "message": [ + "push", + { + "clientGroupID": "ß", + "mutations": [], + "pushVersion": 1, + "timestamp": 2, + "requestID": " 𝒜%%𝒜b_" + } + ], + "ok": true, + "output": [ + "push", + { + "clientGroupID": "ß", + "mutations": [], + "pushVersion": 1, + "timestamp": 2, + "requestID": " 𝒜%%𝒜b_" + } + ] + }, + { + "message": [ + "changeDesiredQueries", + { + "desiredQueriesPatch": [ + { + "op": "clear" + } + ] + } + ], + "ok": true, + "output": [ + "changeDesiredQueries", + { + "desiredQueriesPatch": [ + { + "op": "clear" + } + ] + } + ] + }, + { + "message": [ + "closeConnection", + [] + ], + "ok": true, + "output": [ + "closeConnection", + [] + ] + }, + { + "message": [ + "pull", + { + "clientGroupID": "é", + "cookie": null, + "requestID": "" + } + ], + "ok": true, + "output": [ + "pull", + { + "clientGroupID": "é", + "cookie": null, + "requestID": "" + } + ] + }, + { + "message": [ + "changeDesiredQueries", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": " %B_中aİ", + "ast": { + "table": "issue", + "where": { + "type": "and", + "conditions": [ + { + "type": "correlatedSubquery", + "op": "EXISTS", + "related": { + "correlation": { + "parentField": [ + "ownerID" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "user" + } + } + }, + { + "type": "simple", + "op": "IN", + "left": { + "type": "column", + "name": "n" + }, + "right": { + "type": "literal", + "value": [ + 1, + "a", + true + ] + } + } + ] + } + }, + "args": [ + "􏿿", + true + ] + }, + { + "op": "clear" + }, + { + "op": "put", + "hash": "\n", + "ast": { + "table": "issue", + "where": { + "type": "and", + "conditions": [ + { + "type": "correlatedSubquery", + "op": "EXISTS", + "related": { + "correlation": { + "parentField": [ + "ownerID" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "user" + } + } + }, + { + "type": "simple", + "op": "IN", + "left": { + "type": "column", + "name": "n" + }, + "right": { + "type": "literal", + "value": [ + 1, + "a", + true + ] + } + } + ] + } + } + } + ], + "traceparent": "中文" + } + ], + "ok": true, + "output": [ + "changeDesiredQueries", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": " %B_中aİ", + "ast": { + "table": "issue", + "where": { + "type": "and", + "conditions": [ + { + "type": "correlatedSubquery", + "op": "EXISTS", + "related": { + "correlation": { + "parentField": [ + "ownerID" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "user" + } + } + }, + { + "type": "simple", + "op": "IN", + "left": { + "type": "column", + "name": "n" + }, + "right": { + "type": "literal", + "value": [ + 1, + "a", + true + ] + } + } + ] + } + }, + "args": [ + "􏿿", + true + ] + }, + { + "op": "clear" + }, + { + "op": "put", + "hash": "\n", + "ast": { + "table": "issue", + "where": { + "type": "and", + "conditions": [ + { + "type": "correlatedSubquery", + "op": "EXISTS", + "related": { + "correlation": { + "parentField": [ + "ownerID" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "user" + } + } + }, + { + "type": "simple", + "op": "IN", + "left": { + "type": "column", + "name": "n" + }, + "right": { + "type": "literal", + "value": [ + 1, + "a", + true + ] + } + } + ] + } + } + } + ], + "traceparent": "中文" + } + ] + }, + { + "message": [ + "deleteClients", + { + "clientIDs": [ + " %", + "é" + ], + "clientGroupIDs": [ + "b%" + ] + } + ], + "ok": true, + "output": [ + "deleteClients", + { + "clientIDs": [ + " %", + "é" + ], + "clientGroupIDs": [ + "b%" + ] + } + ] + }, + { + "message": [ + "changeDesiredQueries", + { + "desiredQueriesPatch": [], + "traceparent": "B𝒜𝒜B😀" + } + ], + "ok": true, + "output": [ + "changeDesiredQueries", + { + "desiredQueriesPatch": [], + "traceparent": "B𝒜𝒜B😀" + } + ] + }, + { + "message": [ + "inspect", + { + "id": "", + "op": "authenticate", + "value": "z" + } + ], + "ok": true, + "output": [ + "inspect", + { + "id": "", + "op": "authenticate", + "value": "z" + } + ] + }, + { + "message": [ + "closeConnection", + [] + ], + "ok": true, + "output": [ + "closeConnection", + [] + ] + }, + { + "message": [ + "push", + { + "clientGroupID": "_A\n中", + "mutations": [], + "pushVersion": 1, + "schemaVersion": 4, + "timestamp": 0.5, + "requestID": "b\nA\\Bé" + } + ], + "ok": true, + "output": [ + "push", + { + "clientGroupID": "_A\n中", + "mutations": [], + "pushVersion": 1, + "schemaVersion": 4, + "timestamp": 0.5, + "requestID": "b\nA\\Bé" + } + ] + }, + { + "message": [ + "pull", + { + "clientGroupID": "İb_", + "cookie": "", + "requestID": "B𝒜😀A" + } + ], + "ok": true, + "output": [ + "pull", + { + "clientGroupID": "İb_", + "cookie": "", + "requestID": "B𝒜😀A" + } + ] + }, + { + "message": [ + "closeConnection", + [] + ], + "ok": true, + "output": [ + "closeConnection", + [] + ] + }, + { + "message": [ + "push", + { + "clientGroupID": "b\"\\", + "mutations": [ + { + "type": "crud", + "id": 925, + "clientID": "中\nß_𝒜é\"A", + "timestamp": -2.25, + "name": "_zero_crud", + "args": [ + { + "ops": [ + { + "op": "upsert", + "tableName": "comment", + "primaryKey": [ + "id", + "n" + ], + "value": { + "id": 35275, + "title": [ + "B😀", + "_😀 " + ], + "n": "𝒜bA" + }, + "é": "_a中_中" + }, + { + "op": "upsert", + "tableName": "comment", + "primaryKey": [ + "id", + "n" + ], + "value": { + "title": [] + } + }, + { + "op": "insert", + "tableName": "issue", + "primaryKey": [ + "id", + "n" + ], + "value": { + "n": null + } + } + ], + "é": [ + "", + true, + null + ] + } + ], + "": "A_\\ß" + } + ], + "pushVersion": 1, + "timestamp": 2, + "requestID": "" + } + ], + "ok": true, + "output": [ + "push", + { + "clientGroupID": "b\"\\", + "mutations": [ + { + "type": "crud", + "id": 925, + "clientID": "中\nß_𝒜é\"A", + "name": "_zero_crud", + "timestamp": -2.25, + "args": [ + { + "ops": [ + { + "op": "upsert", + "tableName": "comment", + "primaryKey": [ + "id", + "n" + ], + "value": { + "id": 35275, + "title": [ + "B😀", + "_😀 " + ], + "n": "𝒜bA" + } + }, + { + "op": "upsert", + "tableName": "comment", + "primaryKey": [ + "id", + "n" + ], + "value": { + "title": [] + } + }, + { + "op": "insert", + "tableName": "issue", + "primaryKey": [ + "id", + "n" + ], + "value": { + "n": null + } + } + ] + } + ] + } + ], + "pushVersion": 1, + "timestamp": 2, + "requestID": "" + } + ] + }, + { + "message": [ + "push", + { + "clientGroupID": "\"\"İ ", + "mutations": [], + "pushVersion": 1, + "schemaVersion": 2, + "timestamp": -2.25, + "requestID": "bb_\\\\a \\", + "😀": [ + -1, + 2147483648, + null + ] + } + ], + "ok": true, + "output": [ + "push", + { + "clientGroupID": "\"\"İ ", + "mutations": [], + "pushVersion": 1, + "schemaVersion": 2, + "timestamp": -2.25, + "requestID": "bb_\\\\a \\" + } + ] + }, + { + "message": [ + "updateAuth", + { + "auth": "İ" + } + ], + "ok": true, + "output": [ + "updateAuth", + { + "auth": "İ" + } + ] + }, + { + "message": [ + "", + [ + 1e+21, + " " + ], + "�" + ], + "ok": true, + "output": [ + "", + [ + 1e+21, + " " + ], + "�" + ] + }, + { + "message": [ + "Ping" + ], + "ok": true, + "output": [ + "Ping" + ] + }, + { + "message": [ + "closeConnection", + [ + "𝒜é\\ß%Bb", + null + ] + ], + "ok": true, + "output": [ + "closeConnection", + [ + "𝒜é\\ß%Bb", + null + ] + ] + }, + { + "message": [ + "changeDesiredQueries", + { + "desiredQueriesPatch": [], + "traceparent": "𝒜" + } + ], + "ok": true, + "output": [ + "changeDesiredQueries", + { + "desiredQueriesPatch": [], + "traceparent": "𝒜" + } + ] + }, + { + "message": [ + "initConnection", + { + "desiredQueriesPatch": [], + "traceparent": "BB" + } + ], + "ok": true, + "output": [ + "initConnection", + { + "desiredQueriesPatch": [], + "traceparent": "BB" + } + ] + }, + { + "message": [ + "pull", + { + "clientGroupID": "a", + "cookie": null, + "requestID": "中文" + } + ], + "ok": true, + "output": [ + "pull", + { + "clientGroupID": "a", + "cookie": null, + "requestID": "中文" + } + ] + }, + { + "message": [ + "initConnection", + { + "desiredQueriesPatch": [], + "clientSchema": { + "tables": { + "Issue": { + "columns": { + "issue": { + "type": "json" + }, + "_": { + "type": "null" + }, + "𝒜": { + "type": "number", + "": { + "é𝒜𝒜Aa中": true, + "abc": "", + "": " %B\\İ" + } + }, + "e": { + "type": "boolean" + } + }, + "primaryKey": [] + }, + "": { + "columns": { + "a": { + "type": "string" + }, + "_": { + "type": "json" + }, + "b": { + "type": "boolean" + } + }, + "primaryKey": [ + "b" + ] + }, + "😀": { + "columns": { + "ab": { + "type": "number" + } + }, + "primaryKey": [ + "ab" + ] + } + } + }, + "userQueryHeaders": { + "z": "\n", + "ß ß\"İA": "𝒜é%é" + }, + "activeClients": [ + "" + ] + } + ], + "ok": true, + "output": [ + "initConnection", + { + "desiredQueriesPatch": [], + "clientSchema": { + "tables": { + "Issue": { + "columns": { + "issue": { + "type": "json" + }, + "_": { + "type": "null" + }, + "𝒜": { + "type": "number" + }, + "e": { + "type": "boolean" + } + }, + "primaryKey": [] + }, + "": { + "columns": { + "a": { + "type": "string" + }, + "_": { + "type": "json" + }, + "b": { + "type": "boolean" + } + }, + "primaryKey": [ + "b" + ] + }, + "😀": { + "columns": { + "ab": { + "type": "number" + } + }, + "primaryKey": [ + "ab" + ] + } + } + }, + "userQueryHeaders": { + "z": "\n", + "ß ß\"İA": "𝒜é%é" + }, + "activeClients": [ + "" + ] + } + ] + }, + { + "message": [ + "Ping", + "𝒜% b\\中İ", + 29381 + ], + "ok": true, + "output": [ + "Ping", + "𝒜% b\\中İ", + 29381 + ] + }, + { + "message": [ + "ackMutationResponses", + { + "id": 528, + "clientID": "\n" + } + ], + "ok": true, + "output": [ + "ackMutationResponses", + { + "id": 528, + "clientID": "\n" + } + ] + }, + { + "message": [ + "closeConnection", + [ + {}, + null + ] + ], + "ok": true, + "output": [ + "closeConnection", + [ + {}, + null + ] + ] + }, + { + "message": [ + "pull", + { + "clientGroupID": "", + "cookie": null, + "requestID": "a% _éb\"a", + "": { + " A\nb": -9007199254740992, + "%𝒜BA": null + } + } + ], + "ok": true, + "output": [ + "pull", + { + "clientGroupID": "", + "cookie": null, + "requestID": "a% _éb\"a" + } + ] + }, + { + "message": [ + "push", + { + "clientGroupID": "ßß", + "mutations": [ + { + "type": "crud", + "id": 410, + "clientID": "", + "timestamp": 4120, + "name": "_zero_crud", + "args": [ + { + "ops": [ + { + "op": "upsert", + "tableName": "user", + "primaryKey": [ + "id" + ], + "value": { + "title": 9007199254740992, + "n": 76073 + } + }, + { + "op": "update", + "tableName": "été", + "primaryKey": [ + "id", + "n" + ], + "value": { + "n": "éİB %B", + "": null + } + }, + { + "op": "insert", + "tableName": "user", + "primaryKey": [ + "id" + ], + "value": { + "😀": 9007199254740992 + } + } + ], + "é": "\\" + } + ], + "😀": [ + false + ] + }, + { + "type": "custom", + "id": 599, + "clientID": "\"", + "timestamp": -1, + "name": "İ%ß😀é😀😀\\", + "args": [] + } + ], + "pushVersion": 1, + "timestamp": 5e-324, + "requestID": "\\A\n中_𝒜B" + } + ], + "ok": true, + "output": [ + "push", + { + "clientGroupID": "ßß", + "mutations": [ + { + "type": "crud", + "id": 410, + "clientID": "", + "name": "_zero_crud", + "timestamp": 4120, + "args": [ + { + "ops": [ + { + "op": "upsert", + "tableName": "user", + "primaryKey": [ + "id" + ], + "value": { + "title": 9007199254740992, + "n": 76073 + } + }, + { + "op": "update", + "tableName": "été", + "primaryKey": [ + "id", + "n" + ], + "value": { + "n": "éİB %B", + "": null + } + }, + { + "op": "insert", + "tableName": "user", + "primaryKey": [ + "id" + ], + "value": { + "😀": 9007199254740992 + } + } + ] + } + ] + }, + { + "type": "custom", + "id": 599, + "clientID": "\"", + "timestamp": -1, + "name": "İ%ß😀é😀😀\\", + "args": [] + } + ], + "pushVersion": 1, + "timestamp": 5e-324, + "requestID": "\\A\n中_𝒜B" + } + ] + }, + { + "message": [ + "ackMutationResponses", + { + "id": 172, + "clientID": "ω" + } + ], + "ok": true, + "output": [ + "ackMutationResponses", + { + "id": 172, + "clientID": "ω" + } + ] + }, + { + "message": [ + "newMessage", + null + ], + "ok": true, + "output": [ + "newMessage", + null + ] + }, + { + "message": [ + "changeDesiredQueries", + { + "desiredQueriesPatch": [] + } + ], + "ok": true, + "output": [ + "changeDesiredQueries", + { + "desiredQueriesPatch": [] + } + ] + }, + { + "message": [ + "deleteClients", + { + "clientIDs": [ + "b", + "\n " + ] + } + ], + "ok": true, + "output": [ + "deleteClients", + { + "clientIDs": [ + "b", + "\n " + ] + } + ] + }, + { + "message": [ + "inspect", + { + "id": "aA😀%", + "op": "queries", + "clientID": "\n\"İ中_", + "": 1e+21 + } + ], + "ok": true, + "output": [ + "inspect", + { + "id": "aA😀%", + "op": "queries", + "clientID": "\n\"İ中_" + } + ] + }, + { + "message": [ + "push", + { + "clientGroupID": "b", + "mutations": [ + { + "type": "crud", + "id": 66, + "clientID": "_ßé_A", + "timestamp": -1, + "name": "_zero_crud", + "args": [ + { + "ops": [ + { + "op": "delete", + "tableName": "issue", + "primaryKey": [ + "id" + ], + "value": { + "id": true, + "n": true + } + }, + { + "op": "delete", + "tableName": "comment", + "primaryKey": [ + "id" + ], + "value": { + "id": "", + "n": 0 + } + } + ] + } + ] + } + ], + "pushVersion": 1, + "schemaVersion": 1, + "timestamp": 68021, + "requestID": "%béBA_ba", + "auth": "𝒜B 𝒜% " + } + ], + "ok": true, + "output": [ + "push", + { + "clientGroupID": "b", + "mutations": [ + { + "type": "crud", + "id": 66, + "clientID": "_ßé_A", + "timestamp": -1, + "name": "_zero_crud", + "args": [ + { + "ops": [ + { + "op": "delete", + "tableName": "issue", + "primaryKey": [ + "id" + ], + "value": { + "id": true, + "n": true + } + }, + { + "op": "delete", + "tableName": "comment", + "primaryKey": [ + "id" + ], + "value": { + "id": "", + "n": 0 + } + } + ] + } + ] + } + ], + "pushVersion": 1, + "schemaVersion": 1, + "timestamp": 68021, + "requestID": "%béBA_ba", + "auth": "𝒜B 𝒜% " + } + ] + }, + { + "message": [ + "pull", + { + "clientGroupID": "İ", + "cookie": null, + "requestID": "😀" + } + ], + "ok": true, + "output": [ + "pull", + { + "clientGroupID": "İ", + "cookie": null, + "requestID": "😀" + } + ] + }, + { + "message": [ + "ping", + {} + ], + "ok": true, + "output": [ + "ping", + {} + ] + }, + { + "message": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "%a%a中\"é", + "ast": { + "table": "issue", + "alias": "i", + "schema": "public", + "related": [ + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueID" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "orderBy": [ + [ + "id", + "desc" + ] + ] + }, + "system": "permissions", + "hidden": true + } + ], + "start": { + "row": { + "id": "a", + "n": null + }, + "exclusive": true + } + } + } + ] + } + ], + "ok": true, + "output": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "%a%a中\"é", + "ast": { + "schema": "public", + "table": "issue", + "alias": "i", + "related": [ + { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueID" + ] + }, + "subquery": { + "table": "comment", + "alias": "comments", + "orderBy": [ + [ + "id", + "desc" + ] + ] + }, + "system": "permissions", + "hidden": true + } + ], + "start": { + "row": { + "id": "a", + "n": null + }, + "exclusive": true + } + } + } + ] + } + ] + }, + { + "message": [ + "pull", + { + "clientGroupID": "", + "cookie": null, + "requestID": "😀", + "zExtra": 2 + } + ], + "ok": true, + "output": [ + "pull", + { + "clientGroupID": "", + "cookie": null, + "requestID": "😀" + } + ] + }, + { + "message": [ + "closeConnection", + [ + null + ] + ], + "ok": true, + "output": [ + "closeConnection", + [ + null + ] + ] + }, + { + "message": [ + "closeConnection", + [ + [ + "𝒜", + false, + "�" + ] + ] + ], + "ok": true, + "output": [ + "closeConnection", + [ + [ + "𝒜", + false, + "�" + ] + ] + ] + }, + { + "message": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "\\ß_İ \"", + "ttl": 1e-7, + "name": "BéßB" + } + ] + } + ], + "ok": true, + "output": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "\\ß_İ \"", + "ttl": 1e-7, + "name": "BéßB" + } + ] + } + ] + }, + { + "message": [ + "deleteClients", + { + "clientIDs": [ + "𝒜a", + "𝒜İ\\_İ\\😀\\" + ] + } + ], + "ok": true, + "output": [ + "deleteClients", + { + "clientIDs": [ + "𝒜a", + "𝒜İ\\_İ\\😀\\" + ] + } + ] + }, + { + "message": [ + "ackMutationResponses", + { + "id": 426, + "clientID": "İİA%\n%\n", + "newField": "\\" + } + ], + "ok": true, + "output": [ + "ackMutationResponses", + { + "id": 426, + "clientID": "İİA%\n%\n" + } + ] + }, + { + "message": [ + "closeConnection", + [] + ], + "ok": true, + "output": [ + "closeConnection", + [] + ] + }, + { + "message": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "\n_A", + "name": "_B \\a", + "args": [ + [ + false + ], + false + ] + } + ], + "clientSchema": { + "tables": { + "e": { + "columns": { + "𝒜": { + "type": "null" + }, + "comment": { + "type": "string" + }, + "Issue": { + "type": "null", + "😀": 5e-324 + } + }, + "primaryKey": [] + }, + "ab": { + "columns": { + "😀": { + "type": "number" + }, + "é": { + "type": "number" + }, + "": { + "type": "null" + }, + "𝒜": { + "type": "boolean" + } + }, + "primaryKey": [ + "😀" + ] + }, + "_": { + "columns": { + "": { + "type": "number" + }, + "𝒜": { + "type": "null" + }, + "issue": { + "type": "null" + } + }, + "primaryKey": [ + "", + "𝒜" + ] + }, + "𝒜": { + "columns": { + "": { + "type": "null" + } + }, + "primaryKey": [ + "" + ] + } + } + } + } + ], + "ok": true, + "output": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "\n_A", + "name": "_B \\a", + "args": [ + [ + false + ], + false + ] + } + ], + "clientSchema": { + "tables": { + "e": { + "columns": { + "𝒜": { + "type": "null" + }, + "comment": { + "type": "string" + }, + "Issue": { + "type": "null" + } + }, + "primaryKey": [] + }, + "ab": { + "columns": { + "😀": { + "type": "number" + }, + "é": { + "type": "number" + }, + "": { + "type": "null" + }, + "𝒜": { + "type": "boolean" + } + }, + "primaryKey": [ + "😀" + ] + }, + "_": { + "columns": { + "": { + "type": "number" + }, + "𝒜": { + "type": "null" + }, + "issue": { + "type": "null" + } + }, + "primaryKey": [ + "", + "𝒜" + ] + }, + "𝒜": { + "columns": { + "": { + "type": "null" + } + }, + "primaryKey": [ + "" + ] + } + } + } + } + ] + }, + { + "message": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "clear" + } + ], + "deleted": { + "clientIDs": [] + }, + "userPushURL": "\"" + } + ], + "ok": true, + "output": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "clear" + } + ], + "deleted": { + "clientIDs": [] + }, + "userPushURL": "\"" + } + ] + }, + { + "message": true, + "ok": false + }, + { + "message": [ + "updateAuth", + { + "auth": "İ" + }, + "updateAuth" + ], + "ok": false + }, + { + "message": [ + "push", + { + "clientGroupID": "b\"\\", + "mutations": [ + { + "type": "crud", + "id": 925, + "clientID": "中\nß_𝒜é\"A", + "timestamp": -2.25, + "name": "_zero_crud", + "args": [ + { + "ops": [ + { + "op": "upsert", + "tableName": "comment", + "primaryKey": [ + "id", + "n" + ], + "value": { + "title": [ + "B😀", + "_😀 " + ], + "n": "𝒜bA" + }, + "é": "_a中_中" + }, + { + "op": "upsert", + "tableName": "comment", + "primaryKey": [ + "id", + "n" + ], + "value": { + "title": [] + } + }, + { + "op": "insert", + "tableName": { + "zExtra": 1 + }, + "primaryKey": [ + "id", + "n" + ], + "value": { + "n": null + } + } + ], + "é": [ + "", + true, + null + ] + } + ], + "": "A_\\ß" + } + ], + "pushVersion": 1, + "timestamp": 2, + "requestID": "" + } + ], + "ok": false + }, + { + "message": "bogus", + "ok": false + }, + { + "message": [ + "_zero_crud", + { + "clientGroupID": "_A\n中", + "mutations": [], + "pushVersion": 1, + "schemaVersion": 4, + "timestamp": 0.5, + "requestID": "b\nA\\Bé" + } + ], + "ok": true, + "output": [ + "_zero_crud", + { + "clientGroupID": "_A\n中", + "mutations": [], + "pushVersion": 1, + "schemaVersion": 4, + "timestamp": 0.5, + "requestID": "b\nA\\Bé" + } + ] + }, + { + "message": [ + [], + true + ], + "ok": false + }, + { + "message": [ + "push", + { + "clientGroupID": "_A\n中", + "mutations": [], + "pushVersion": [], + "schemaVersion": 4, + "timestamp": 0.5, + "requestID": "b\nA\\Bé" + } + ], + "ok": false + }, + { + "message": [ + [], + { + "id": 426, + "clientID": "İİA%\n%\n", + "newField": "\\" + } + ], + "ok": false + }, + { + "message": [ + [ + "𝒜é\\ß%Bb", + null + ] + ], + "ok": false + }, + { + "message": [ + "initConnection", + null + ], + "ok": false + }, + { + "message": [ + "inspect", + { + "op": "zeroCache", + "value": "z" + } + ], + "ok": false + }, + { + "message": [ + {}, + { + "clientGroupID": "\na%bİ\"İ", + "requestID": "\" \\中bAB" + } + ], + "ok": false + }, + { + "message": [ + "pull" + ], + "ok": false + }, + { + "message": [ + "changeDesiredQueries", + { + "desiredQueriesPatch": "", + "traceparent": "𝒜" + } + ], + "ok": false + }, + { + "message": "", + "ok": false + }, + { + "message": [ + { + "clientIDs": [ + "b", + "\n " + ] + } + ], + "ok": false + }, + { + "message": [ + "changeDesiredQueries", + { + "traceparent": "𝒜" + } + ], + "ok": false + }, + { + "message": [ + 1 + ], + "ok": false + }, + { + "message": [ + [ + null + ], + null + ], + "ok": false + }, + { + "message": [ + {} + ], + "ok": false + }, + { + "message": [ + "changeDesiredQueries", + "clear" + ], + "ok": false + }, + { + "message": [ + "pull", + { + "clientGroupID": "", + "cookie": "", + "requestID": "a% _éb\"a", + "": { + " A\nb": -9007199254740992, + "%𝒜BA": null + } + } + ], + "ok": true, + "output": [ + "pull", + { + "clientGroupID": "", + "cookie": "", + "requestID": "a% _éb\"a" + } + ] + }, + { + "message": [ + "pull", + 0 + ], + "ok": false + }, + { + "message": "queries", + "ok": false + }, + { + "message": [ + "pull", + { + "clientGroupID": "", + "cookie": null, + "requestID": "a% _éb\"a", + "": { + " A\nb": -9007199254740992, + "%𝒜BA": "" + } + } + ], + "ok": true, + "output": [ + "pull", + { + "clientGroupID": "", + "cookie": null, + "requestID": "a% _éb\"a" + } + ] + }, + { + "message": [ + "pokePart", + { + "clientGroupID": "", + "cookie": null, + "requestID": "a% _éb\"a", + "": { + " A\nb": -9007199254740992, + "%𝒜BA": {} + } + } + ], + "ok": true, + "output": [ + "pokePart", + { + "clientGroupID": "", + "cookie": null, + "requestID": "a% _éb\"a", + "": { + " A\nb": -9007199254740992, + "%𝒜BA": {} + } + } + ] + }, + { + "message": [ + "closeConnection", + [], + "closeConnection" + ], + "ok": false + }, + { + "message": [ + "push", + { + "mutations": [], + "pushVersion": 1, + "timestamp": 2, + "requestID": " 𝒜%%𝒜b_" + } + ], + "ok": false + }, + { + "message": [ + {}, + { + "desiredQueriesPatch": [], + "deleted": { + "clientIDs": [] + }, + "userPushURL": "\"" + } + ], + "ok": false + }, + { + "message": [ + "pull", + { + "clientGroupID": "", + "requestID": "a% _éb\"a", + "": { + " A\nb": -9007199254740992, + "%𝒜BA": null + } + } + ], + "ok": false + }, + { + "message": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "custom", + "hash": "\\ß_İ \"", + "ttl": 1e-7, + "name": "BéßB" + } + ] + } + ], + "ok": false + }, + { + "message": [ + "initConnection", + { + "desiredQueriesPatch": [], + "clientSchema": { + "tables": { + "Issue": { + "columns": { + "issue": { + "type": "json" + }, + "_": { + "type": "null" + }, + "𝒜": { + "type": "number", + "": { + "é𝒜𝒜Aa中": true, + "abc": "", + "": " %B\\İ" + } + }, + "e": { + "type": "boolean" + } + }, + "primaryKey": [] + }, + "": { + "columns": { + "a": { + "type": "string" + }, + "_": { + "type": "json" + }, + "b": { + "type": 0 + } + }, + "primaryKey": [ + "b" + ] + }, + "😀": { + "columns": { + "ab": { + "type": "number" + } + }, + "primaryKey": [ + "ab" + ] + } + } + }, + "userQueryHeaders": { + "z": "\n", + "ß ß\"İA": "𝒜é%é" + }, + "activeClients": [ + "" + ] + } + ], + "ok": false + }, + { + "message": [ + "deleteClients", + { + "clientIDs": [ + "and", + "é" + ], + "clientGroupIDs": [ + 1 + ] + } + ], + "ok": false + }, + { + "message": [ + "closeConnection", + [ + [ + "𝒜", + false, + "�", + "𝒜" + ] + ] + ], + "ok": true, + "output": [ + "closeConnection", + [ + [ + "𝒜", + false, + "�", + "𝒜" + ] + ] + ] + }, + { + "message": [ + "changeDesiredQueries", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": " %B_中aİ", + "ast": { + "table": "issue", + "where": { + "type": "and", + "conditions": [ + { + "type": "correlatedSubquery", + "op": "EXISTS", + "related": { + "correlation": { + "parentField": [ + "ownerID" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "user" + } + } + }, + { + "type": "simple", + "left": { + "type": "column", + "name": "n" + }, + "right": { + "type": "literal", + "value": [ + 1, + "a", + true + ] + } + } + ] + } + }, + "args": [ + "􏿿", + true + ] + }, + { + "op": "clear" + }, + { + "op": "put", + "hash": "\n", + "ast": { + "table": "issue", + "where": { + "type": "and", + "conditions": [ + { + "type": "correlatedSubquery", + "op": "EXISTS", + "related": { + "correlation": { + "parentField": [ + "ownerID" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "user" + } + } + }, + { + "type": "simple", + "left": { + "type": "column", + "name": "n" + }, + "right": { + "type": "literal", + "value": [ + 1, + "a", + true + ] + } + } + ] + } + } + } + ], + "traceparent": "中文" + } + ], + "ok": false + }, + { + "message": [ + "𝒜% b\\中İ", + 29381 + ], + "ok": true, + "output": [ + "𝒜% b\\中İ", + 29381 + ] + }, + { + "message": [ + "delete", + [] + ], + "ok": true, + "output": [ + "delete", + [] + ] + }, + { + "message": [ + "closeConnection", + [ + {} + ] + ], + "ok": true, + "output": [ + "closeConnection", + [ + {} + ] + ] + }, + { + "message": [ + -1.5, + { + "id": "aA😀%", + "op": "queries", + "clientID": "\n\"İ中_", + "": 1e+21 + } + ], + "ok": false + }, + { + "message": [ + "http", + null + ], + "ok": true, + "output": [ + "http", + null + ] + }, + { + "message": [ + { + "desiredQueriesPatch": [] + } + ], + "ok": false + }, + { + "message": [ + "closeConnection", + "http" + ], + "ok": false + }, + { + "message": [ + "push", + { + "clientGroupID": "_A\n中", + "mutations": [], + "pushVersion": 1, + "schemaVersion": 4, + "timestamp": null, + "requestID": "b\nA\\Bé" + } + ], + "ok": false + }, + { + "message": "update", + "ok": false + }, + { + "message": [ + { + "id": "", + "op": "authenticate", + "value": "z" + } + ], + "ok": false + }, + { + "message": -1.5, + "ok": false + }, + { + "message": [ + "closeConnection", + [ + [ + "𝒜", + false + ] + ] + ], + "ok": true, + "output": [ + "closeConnection", + [ + [ + "𝒜", + false + ] + ] + ] + }, + { + "message": [ + "inspect", + { + "zExtra": 1 + } + ], + "ok": false + }, + { + "message": [ + "bogus", + "" + ], + "ok": true, + "output": [ + "bogus", + "" + ] + }, + { + "message": [ + "pull", + { + "clientGroupID": "a", + "cookie": null + } + ], + "ok": false + }, + { + "message": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "\\ß_İ \"", + "ttl": 1e-7, + "name": "BéßB" + } + ] + }, + "initConnection" + ], + "ok": false + }, + { + "message": [ + "inspect", + { + "id": "", + "op": "authenticate", + "value": "z" + }, + "inspect" + ], + "ok": false + }, + { + "message": [ + [ + 1 + ], + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "A\n%b𝒜", + "ttl": 2, + "ast": { + "table": "issue" + } + }, + { + "op": "put", + "hash": " ", + "name": "\\", + "": [] + }, + { + "op": "del", + "hash": "𝒜B中中" + } + ] + } + ], + "ok": false + }, + { + "message": "", + "ok": false + }, + { + "message": "app", + "ok": false + }, + { + "message": [ + [] + ], + "ok": false + }, + { + "message": [ + "push", + { + "clientGroupID": "ß", + "mutations": [], + "pushVersion": 1, + "timestamp": "2", + "requestID": " 𝒜%%𝒜b_" + } + ], + "ok": false + }, + { + "message": "update", + "ok": false + }, + { + "message": [ + "push", + { + "clientGroupID": "client", + "mutations": [ + { + "type": "http", + "id": 410, + "clientID": "", + "timestamp": 4120, + "name": "_zero_crud", + "args": [ + { + "ops": [ + { + "op": "upsert", + "tableName": "user", + "primaryKey": [ + "id" + ], + "value": { + "title": 9007199254740992, + "n": 76073 + } + }, + { + "op": "update", + "tableName": "été", + "primaryKey": [ + "id", + "n" + ], + "value": { + "n": "éİB %B", + "": null + } + }, + { + "op": "insert", + "tableName": "user", + "primaryKey": [ + "id" + ], + "value": { + "😀": 9007199254740992 + } + } + ], + "é": "\\" + } + ], + "😀": [ + false + ] + }, + { + "type": "custom", + "id": 599, + "clientID": "\"", + "timestamp": -1, + "name": "İ%ß😀é😀😀\\", + "args": [] + } + ], + "pushVersion": 1, + "timestamp": 5e-324, + "requestID": "\\A\n中_𝒜B" + } + ], + "ok": false + }, + { + "message": [], + "ok": false + }, + { + "message": [ + -1.5, + [] + ], + "ok": false + }, + { + "message": [ + "initConnection", + { + "desiredQueriesPatch": "queries", + "traceparent": "BB" + } + ], + "ok": false + }, + { + "message": -1.5, + "ok": false + }, + { + "message": [ + "closeConnection", + [], + true + ], + "ok": false + }, + { + "message": null, + "ok": false + }, + { + "message": [ + "updateAuth", + { + "auth": "error" + } + ], + "ok": true, + "output": [ + "updateAuth", + { + "auth": "error" + } + ] + }, + { + "message": [], + "ok": false + }, + { + "message": "custom", + "ok": false + }, + { + "message": [ + 0, + [ + {}, + null + ] + ], + "ok": false + }, + { + "message": [], + "ok": false + }, + { + "message": [ + 1 + ], + "ok": false + }, + { + "message": [ + "deleteClients", + { + "clientIDs": [ + " %", + {} + ], + "clientGroupIDs": [ + "b%" + ] + } + ], + "ok": false + }, + { + "message": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "\\ß_İ \"", + "ttl": 0, + "name": "BéßB" + } + ] + } + ], + "ok": true, + "output": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "\\ß_İ \"", + "ttl": 0, + "name": "BéßB" + } + ] + } + ] + }, + { + "message": [], + "ok": false + }, + { + "message": [ + [] + ], + "ok": false + }, + { + "message": { + "zExtra": 1 + }, + "ok": false + }, + { + "message": [ + "changeDesiredQueries", + { + "desiredQueriesPatch": [ + {} + ] + } + ], + "ok": false + }, + { + "message": [ + "changeDesiredQueries", + { + "desiredQueriesPatch": [] + } + ], + "ok": true, + "output": [ + "changeDesiredQueries", + { + "desiredQueriesPatch": [] + } + ] + }, + { + "message": [ + "updateAuth", + { + "auth": "İ" + }, + [ + 1 + ] + ], + "ok": false + }, + { + "message": [ + "changeDesiredQueries", + {} + ], + "ok": false + }, + { + "message": [ + "pull", + { + "clientGroupID": "a", + "requestID": "bogus" + } + ], + "ok": false + }, + { + "message": [ + "updateAuth", + {}, + { + "zExtra": 1 + } + ], + "ok": false + }, + { + "message": [ + "ping", + {}, + "ping" + ], + "ok": false + }, + { + "message": "crud", + "ok": false + }, + { + "message": [ + "initConnection", + { + "desiredQueriesPatch": "pull", + "traceparent": "BB" + } + ], + "ok": false + }, + { + "message": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "clear" + } + ], + "deleted": { + "clientIDs": [] + }, + "userPushURL": "\"" + }, + null + ], + "ok": false + }, + { + "message": [ + 1 + ], + "ok": false + }, + { + "message": [ + [ + 1 + ] + ], + "ok": false + }, + { + "message": [ + "initConnection", + { + "traceparent": "BB" + } + ], + "ok": false + }, + { + "message": [ + { + "desiredQueriesPatch": [ + { + "op": "clear" + } + ] + } + ], + "ok": false + }, + { + "message": [ + "push", + { + "clientGroupID": "b\"\\", + "mutations": [ + { + "type": "crud", + "id": 925, + "clientID": "中\nß_𝒜é\"A", + "timestamp": -2.25, + "name": "_zero_crud", + "args": [ + { + "ops": [ + { + "op": "upsert", + "tableName": "comment", + "primaryKey": [ + "id", + "n" + ], + "value": { + "id": 35275, + "title": [ + "B😀", + "_😀 " + ], + "n": "𝒜bA" + }, + "é": { + "zExtra": 1 + } + }, + { + "op": "upsert", + "tableName": "comment", + "primaryKey": [ + "id", + "n" + ], + "value": { + "title": [] + } + }, + { + "op": "insert", + "tableName": "issue", + "primaryKey": [ + "id", + "n" + ], + "value": { + "n": null + } + } + ], + "é": [ + "", + true, + null + ] + } + ], + "": "A_\\ß" + } + ], + "pushVersion": 1, + "timestamp": 2, + "requestID": "" + } + ], + "ok": true, + "output": [ + "push", + { + "clientGroupID": "b\"\\", + "mutations": [ + { + "type": "crud", + "id": 925, + "clientID": "中\nß_𝒜é\"A", + "name": "_zero_crud", + "timestamp": -2.25, + "args": [ + { + "ops": [ + { + "op": "upsert", + "tableName": "comment", + "primaryKey": [ + "id", + "n" + ], + "value": { + "id": 35275, + "title": [ + "B😀", + "_😀 " + ], + "n": "𝒜bA" + } + }, + { + "op": "upsert", + "tableName": "comment", + "primaryKey": [ + "id", + "n" + ], + "value": { + "title": [] + } + }, + { + "op": "insert", + "tableName": "issue", + "primaryKey": [ + "id", + "n" + ], + "value": { + "n": null + } + } + ] + } + ] + } + ], + "pushVersion": 1, + "timestamp": 2, + "requestID": "" + } + ] + }, + { + "message": [ + "Ping", + "Ping" + ], + "ok": true, + "output": [ + "Ping", + "Ping" + ] + }, + { + "message": [ + "initConnection", + { + "desiredQueriesPatch": [ + { + "op": "put", + "hash": "\n_A", + "name": "_B \\a", + "args": [ + [ + false + ], + false + ] + } + ], + "clientSchema": { + "tables": { + "e": { + "columns": { + "𝒜": { + "type": "null" + }, + "comment": { + "type": "string" + }, + "Issue": { + "type": "null", + "😀": 5e-324 + } + }, + "primaryKey": [] + }, + "ab": { + "columns": { + "😀": { + "type": "number" + }, + "é": { + "type": "number" + }, + "": { + "type": "null" + }, + "𝒜": { + "type": "boolean" + } + }, + "primaryKey": [ + "😀" + ] + }, + "_": { + "columns": { + "": { + "type": "number" + }, + "𝒜": { + "type": "null" + }, + "issue": { + "type": "null" + } + }, + "primaryKey": [ + null, + "𝒜" + ] + }, + "𝒜": { + "columns": { + "": { + "type": "null" + } + }, + "primaryKey": [ + "" + ] + } + } + } + } + ], + "ok": false + }, + { + "message": [ + "inspect", + { + "id": "", + "op": "authenticate", + "value": "Rebalance" + } + ], + "ok": true, + "output": [ + "inspect", + { + "id": "", + "op": "authenticate", + "value": "Rebalance" + } + ] + }, + { + "message": -1.5, + "ok": false + }, + { + "message": [ + "deleteClients", + { + "clientIDs": [ + "queries", + "é" + ], + "clientGroupIDs": [ + "b%" + ] + } + ], + "ok": true, + "output": [ + "deleteClients", + { + "clientIDs": [ + "queries", + "é" + ], + "clientGroupIDs": [ + "b%" + ] + } + ] + }, + { + "message": [ + "deleteClients", + { + "clientIDs": "parse", + "clientGroupIDs": [ + "b%" + ] + } + ], + "ok": false + }, + { + "message": [ + "deleteClients", + {} + ], + "ok": true, + "output": [ + "deleteClients", + {} + ] + }, + { + "message": [ + "changeDesiredQueries", + { + "desiredQueriesPatch": [], + "traceparent": "B𝒜𝒜B😀", + "error": "clear" + } + ], + "ok": true, + "output": [ + "changeDesiredQueries", + { + "desiredQueriesPatch": [], + "traceparent": "B𝒜𝒜B😀" + } + ] + }, + { + "message": [ + "ackMutationResponses", + { + "id": 528 + } + ], + "ok": false + } + ] +} diff --git a/swift/Sources/Zero/Protocol/ClientSchema.swift b/swift/Sources/Zero/Protocol/ClientSchema.swift new file mode 100644 index 0000000000..8f0c6ba080 --- /dev/null +++ b/swift/Sources/Zero/Protocol/ClientSchema.swift @@ -0,0 +1,65 @@ +/// The schema a client was built with, sent so zero-cache can check it +/// against the replica (`clientSchemaSchema` in +/// `packages/zero-protocol/src/client-schema.ts`). +public struct ClientSchema: Sendable, Hashable, JSONCodable { + /// `valueTypeSchema`. + public enum ValueType: String, Sendable, Hashable, CaseIterable { + case string, number, boolean, null, json + } + + /// `tableSchemaSchema`. Each column maps to its `{type}`. + public struct Table: Sendable, Hashable { + public var columns: [String: ValueType] + public var primaryKey: [String] + + public init(columns: [String: ValueType], primaryKey: [String]) { + self.columns = columns + self.primaryKey = primaryKey + } + } + + public var tables: [String: Table] + + public init(tables: [String: Table]) { + self.tables = tables + } + + public init(json: JSONValue) throws(DecodingError) { + let r = try JSONObjectReader(json, "client schema") + tables = try r.required("tables") { (v) throws(DecodingError) in + try JSONDecode.record(v) { (t) throws(DecodingError) -> Table in + let table = try JSONObjectReader(t, "table schema") + return Table( + columns: try table.required("columns") { (v) throws(DecodingError) in + try JSONDecode.record(v) { (c) throws(DecodingError) in + try JSONObjectReader(c, "column schema").literalUnion("type", ValueType.self) + } + }, + primaryKey: try table.required("primaryKey", JSONDecode.strings)) + } + } + } + + public var json: JSONValue { + .object([ + "tables": .object( + tables.mapValues { t in + [ + "columns": .object(t.columns.mapValues { ["type": .string($0.rawValue)] }), + "primaryKey": t.primaryKey.json, + ] + }) + ]) + } + + /// `normalizeClientSchema`: sorts each primary key, as JS `sort()` does, + /// by UTF-16 code units. JS also orders the table and column entries by + /// `<`; ``json`` has no order of its own and ``JSONSerializer`` writes keys + /// in that same order. + public func normalized() -> ClientSchema { + ClientSchema( + tables: tables.mapValues { t in + Table(columns: t.columns, primaryKey: t.primaryKey.sorted(by: StringCompare.utf16Less)) + }) + } +} diff --git a/swift/Sources/Zero/Protocol/Connect.swift b/swift/Sources/Zero/Protocol/Connect.swift new file mode 100644 index 0000000000..d575aff7b6 --- /dev/null +++ b/swift/Sources/Zero/Protocol/Connect.swift @@ -0,0 +1,263 @@ +/// The body of the `connected` message zero-cache sends once a connection +/// is set up (`connectedBodySchema` in `packages/zero-protocol/src/connect.ts`). +public struct ConnectedBody: Sendable, Hashable, JSONCodable { + public var wsid: String + public var timestamp: Double? + + public init(wsid: String, timestamp: Double? = nil) { + self.wsid = wsid + self.timestamp = timestamp + } + + public init(json: JSONValue) throws(DecodingError) { + let r = try JSONObjectReader(json, "connected body") + wsid = try r.string("wsid") + timestamp = try r.optionalNumber("timestamp") + } + + public var json: JSONValue { + var o = JSONObjectBuilder() + o.set("wsid", wsid) + o.set("timestamp", timestamp) + return o.value + } +} + +/// The body of the first message a client sends, usually in the +/// `Sec-WebSocket-Protocol` header (`initConnectionBodySchema` in +/// `packages/zero-protocol/src/connect.ts`). +public struct InitConnectionBody: Sendable, Hashable, JSONCodable { + public var desiredQueriesPatch: UpQueriesPatch + /// Sent only when the client has no server snapshot yet: the schema can be + /// large, and zero-cache keeps it for the client group. + public var clientSchema: ClientSchema? + public var deleted: DeleteClientsBody? + /// Configures the push endpoint of the API server. + public var userPushURL: String? + public var userPushHeaders: [String: String]? + /// Configures the query endpoint of the API server. + public var userQueryURL: String? + public var userQueryHeaders: [String: String]? + /// The client IDs that are running in the client group, so zero-cache can + /// inactivate the queries of the others. + public var activeClients: [String]? + /// A W3C traceparent header for distributed tracing. + public var traceparent: String? + + public init( + desiredQueriesPatch: UpQueriesPatch, clientSchema: ClientSchema? = nil, + deleted: DeleteClientsBody? = nil, userPushURL: String? = nil, + userPushHeaders: [String: String]? = nil, userQueryURL: String? = nil, + userQueryHeaders: [String: String]? = nil, activeClients: [String]? = nil, + traceparent: String? = nil + ) { + self.desiredQueriesPatch = desiredQueriesPatch + self.clientSchema = clientSchema + self.deleted = deleted + self.userPushURL = userPushURL + self.userPushHeaders = userPushHeaders + self.userQueryURL = userQueryURL + self.userQueryHeaders = userQueryHeaders + self.activeClients = activeClients + self.traceparent = traceparent + } + + public init(json: JSONValue) throws(DecodingError) { + let r = try JSONObjectReader(json, "initConnection body") + desiredQueriesPatch = try r.required("desiredQueriesPatch", JSONDecode.upQueriesPatch) + clientSchema = try r.optional("clientSchema", ClientSchema.init(json:)) + deleted = try r.optional("deleted", DeleteClientsBody.init(json:)) + userPushURL = try r.optionalString("userPushURL") + userPushHeaders = try r.optional("userPushHeaders", JSONDecode.stringRecord) + userQueryURL = try r.optionalString("userQueryURL") + userQueryHeaders = try r.optional("userQueryHeaders", JSONDecode.stringRecord) + activeClients = try r.optional("activeClients", JSONDecode.strings) + traceparent = try r.optionalString("traceparent") + } + + public var json: JSONValue { + var o = JSONObjectBuilder() + o.set("desiredQueriesPatch", .array(desiredQueriesPatch.map(\.json))) + o.set("clientSchema", clientSchema?.json) + o.set("deleted", deleted?.json) + o.set("userPushURL", userPushURL) + o.set("userPushHeaders", userPushHeaders.map(JSONValue.stringRecord)) + o.set("userQueryURL", userQueryURL) + o.set("userQueryHeaders", userQueryHeaders.map(JSONValue.stringRecord)) + o.set("activeClients", activeClients.map(JSONValue.strings)) + o.set("traceparent", traceparent) + return o.value + } +} + +/// The `Sec-WebSocket-Protocol` value that carries the `initConnection` +/// message and the auth token (`encodeSecProtocols` and +/// `decodeSecProtocols` in `packages/zero-protocol/src/connect.ts`): the +/// UTF-8 of `JSON.stringify({initConnectionMessage, authToken})`, base64 +/// encoded, then URI encoded. +public enum SecProtocols { + public static func encode(initConnection: InitConnectionBody?, authToken: String?) -> String { + var o = JSONObjectBuilder() + o.set( + "initConnectionMessage", initConnection.map { JSONValue.message("initConnection", $0.json) }) + o.set("authToken", authToken) + let base64 = Base64.encode(JSONSerializer.data(o.value)) + // `encodeURIComponent` of base64 only escapes `+`, `/`, and `=`. + var out = "" + out.reserveCapacity(base64.utf8.count) + for c in base64.unicodeScalars { + switch c { + case "+": out += "%2B" + case "/": out += "%2F" + case "=": out += "%3D" + default: out.unicodeScalars.append(c) + } + } + return out + } + + /// Decodes a protocol header value into the JSON it carries, as + /// `JSON.parse` returns it: `decodeSecProtocols` does not validate it. + public static func decode(_ secProtocol: String) throws(DecodingError) -> JSONValue { + let base64 = try URIComponent.decode(secProtocol) + guard let bytes = Base64.forgivingDecode(base64) else { + throw DecodingError("The string to be decoded is not correctly encoded") + } + // `new TextDecoder().decode`: replaces invalid UTF-8 and drops a BOM. + let text = String( + decoding: bytes.starts(with: utf8BOM) ? bytes.dropFirst(3) : bytes[...], as: UTF8.self) + return try JSONValue.parseMessage(text) + } +} + +let utf8BOM: [UInt8] = [0xEF, 0xBB, 0xBF] + +/// Base64 as `btoa` and `atob` do it. +enum Base64 { + private static let alphabet = Array( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".utf8) + + static func encode(_ bytes: [UInt8]) -> String { + var out: [UInt8] = [] + out.reserveCapacity((bytes.count + 2) / 3 * 4) + var i = 0 + while i + 2 < bytes.count { + let n = UInt32(bytes[i]) << 16 | UInt32(bytes[i + 1]) << 8 | UInt32(bytes[i + 2]) + out += [ + alphabet[Int(n >> 18 & 63)], alphabet[Int(n >> 12 & 63)], alphabet[Int(n >> 6 & 63)], + alphabet[Int(n & 63)], + ] + i += 3 + } + let rest = bytes.count - i + if rest == 1 { + let n = UInt32(bytes[i]) << 16 + out += [alphabet[Int(n >> 18 & 63)], alphabet[Int(n >> 12 & 63)], 0x3D, 0x3D] + } else if rest == 2 { + let n = UInt32(bytes[i]) << 16 | UInt32(bytes[i + 1]) << 8 + out += [ + alphabet[Int(n >> 18 & 63)], alphabet[Int(n >> 12 & 63)], alphabet[Int(n >> 6 & 63)], 0x3D, + ] + } + return String(decoding: out, as: UTF8.self) + } + + /// The WHATWG "forgiving-base64 decode" that `atob` uses, or `nil` on + /// failure. + static func forgivingDecode(_ s: String) -> [UInt8]? { + var data: [UInt8] = [] + for c in s.unicodeScalars { + switch c.value { + // ASCII whitespace. + case 0x09, 0x0A, 0x0C, 0x0D, 0x20: continue + case 0..<0x80: data.append(UInt8(c.value)) + default: return nil + } + } + if data.count % 4 == 0 { + if data.last == 0x3D { data.removeLast() } + if data.last == 0x3D { data.removeLast() } + } + if data.count % 4 == 1 { return nil } + var out: [UInt8] = [] + out.reserveCapacity(data.count * 3 / 4) + var buffer: UInt32 = 0 + var bits = 0 + for c in data { + let v: UInt32 + switch c { + case 0x41...0x5A: v = UInt32(c - 0x41) + case 0x61...0x7A: v = UInt32(c - 0x61 + 26) + case 0x30...0x39: v = UInt32(c - 0x30 + 52) + case 0x2B: v = 62 + case 0x2F: v = 63 + default: return nil + } + buffer = buffer << 6 | v + bits += 6 + if bits >= 8 { + bits -= 8 + out.append(UInt8(buffer >> UInt32(bits) & 0xFF)) + } + } + return out + } +} + +/// `decodeURIComponent`. +enum URIComponent { + static func decode(_ s: String) throws(DecodingError) -> String { + let malformed = DecodingError("URI malformed") + var scalars = String.UnicodeScalarView() + let input = Array(s.unicodeScalars) + var i = 0 + func hexByte(at i: Int) -> UInt8? { + guard i + 2 < input.count, input[i] == "%", let hi = hexValue(input[i + 1]), + let lo = hexValue(input[i + 2]) + else { + return nil + } + return hi << 4 | lo + } + while i < input.count { + let c = input[i] + guard c == "%" else { + scalars.append(c) + i += 1 + continue + } + guard let first = hexByte(at: i) else { throw malformed } + i += 3 + if first < 0x80 { + // `decodeURIComponent` decodes reserved characters too. + scalars.append(Unicode.Scalar(first)) + continue + } + let count: Int + switch first { + case 0xC0...0xDF: count = 2 + case 0xE0...0xEF: count = 3 + case 0xF0...0xF7: count = 4 + default: throw malformed + } + var bytes = [first] + for _ in 1.. UInt8? { + switch c.value { + case 0x30...0x39: UInt8(c.value - 0x30) + case 0x41...0x46: UInt8(c.value - 0x41 + 10) + case 0x61...0x66: UInt8(c.value - 0x61 + 10) + default: nil + } + } +} diff --git a/swift/Sources/Zero/Protocol/Downstream.swift b/swift/Sources/Zero/Protocol/Downstream.swift new file mode 100644 index 0000000000..1a92e275d5 --- /dev/null +++ b/swift/Sources/Zero/Protocol/Downstream.swift @@ -0,0 +1,92 @@ +/// A message from zero-cache to the client (`downstreamSchema` in +/// `packages/zero-protocol/src/down.ts`). +/// +/// The union is closed: the client treats a message with a tag it doesn't +/// know as invalid and disconnects (`#onMessage` in +/// `packages/zero-client/src/client/zero.ts`, which parses in `passthrough` +/// mode). +public enum Downstream: Sendable, Hashable, JSONCodable { + case connected(ConnectedBody) + case error(ErrorBody) + case pong + case pokeStart(PokeStartBody) + case pokePart(PokePartBody) + case pokeEnd(PokeEndBody) + case pull(PullResponseBody) + case deleteClients(DeleteClientsBody) + case pushResponse(PushResponseBody) + case inspect(InspectDownBody) + /// Custom queries the API server failed to transform. + case transformError([ErroredQuery]) + + public var tag: String { + switch self { + case .connected: "connected" + case .error: "error" + case .pong: "pong" + case .pokeStart: "pokeStart" + case .pokePart: "pokePart" + case .pokeEnd: "pokeEnd" + case .pull: "pull" + case .deleteClients: "deleteClients" + case .pushResponse: "pushResponse" + case .inspect: "inspect" + case .transformError: "transformError" + } + } + + public init(json: JSONValue) throws(DecodingError) { + guard case .array(let a) = json else { throw DecodingError("Expected array") } + guard a.count == 2, case .string(let tag) = a[0] else { + throw DecodingError("Expected [tag, body] tuple") + } + func decode(_ decode: (JSONValue) throws(DecodingError) -> T) throws(DecodingError) -> T { + do { + return try decode(a[1]) + } catch { + throw error.at("1") + } + } + switch tag { + case "connected": self = .connected(try decode(ConnectedBody.init(json:))) + case "error": self = .error(try decode(ErrorBody.init(json:))) + case "pong": + _ = try decode(JSONDecode.object) + self = .pong + case "pokeStart": self = .pokeStart(try decode(PokeStartBody.init(json:))) + case "pokePart": self = .pokePart(try decode(PokePartBody.init(json:))) + case "pokeEnd": self = .pokeEnd(try decode(PokeEndBody.init(json:))) + case "pull": self = .pull(try decode(PullResponseBody.init(json:))) + case "deleteClients": self = .deleteClients(try decode(DeleteClientsBody.init(json:))) + case "pushResponse": self = .pushResponse(try decode(PushResponseBody.init(json:))) + case "inspect": self = .inspect(try decode(InspectDownBody.init(json:))) + case "transformError": self = .transformError(try decode(JSONDecode.erroredQueries)) + default: + throw DecodingError("Unknown message type \"\(tag)\"", path: ["0"]) + } + } + + /// Parses message text, as the client receives it. + public init(text: String) throws(DecodingError) { + try self.init(json: try JSONValue.parseMessage(text)) + } + + public var json: JSONValue { + switch self { + case .connected(let b): .message(tag, b.json) + case .error(let b): .message(tag, b.json) + case .pong: .message(tag, [:]) + case .pokeStart(let b): .message(tag, b.json) + case .pokePart(let b): .message(tag, b.json) + case .pokeEnd(let b): .message(tag, b.json) + case .pull(let b): .message(tag, b.json) + case .deleteClients(let b): .message(tag, b.json) + case .pushResponse(let b): .message(tag, b.json) + case .inspect(let b): .message(tag, b.json) + case .transformError(let b): .message(tag, .array(b.map(\.json))) + } + } + + /// The message text to send. + public var text: String { JSONSerializer.stringify(json) } +} diff --git a/swift/Sources/Zero/Protocol/ErrorBody.swift b/swift/Sources/Zero/Protocol/ErrorBody.swift new file mode 100644 index 0000000000..c20c308b85 --- /dev/null +++ b/swift/Sources/Zero/Protocol/ErrorBody.swift @@ -0,0 +1,223 @@ +/// The body of an `error` message (`errorBodySchema` in +/// `packages/zero-protocol/src/error.ts`). +public enum ErrorBody: Sendable, Hashable, JSONCodable { + case basic(BasicErrorBody) + case backoff(BackoffErrorBody) + case pushFailed(PushFailedBody) + case transformFailed(TransformFailedBody) + + public var kind: ErrorKind { + switch self { + case .basic(let b): b.kind + case .backoff(let b): b.kind + case .pushFailed: .pushFailed + case .transformFailed: .transformFailed + } + } + + public var message: String { + switch self { + case .basic(let b): b.message + case .backoff(let b): b.message + case .pushFailed(let b): b.message + case .transformFailed(let b): b.message + } + } + + public init(json: JSONValue) throws(DecodingError) { + let r = try JSONObjectReader(json, "error body") + let kind = try r.literalUnion("kind", ErrorKind.self) + if ErrorKind.basic.contains(kind) { + self = .basic( + BasicErrorBody( + kind: kind, message: try r.string("message"), + origin: try r.optional("origin") { (v) throws(DecodingError) in + let origin = try JSONDecode.literalUnion(v, ErrorOrigin.self) + guard origin != .client else { throw DecodingError("Unexpected origin \"client\"") } + return origin + })) + } else if ErrorKind.backoff.contains(kind) { + self = .backoff( + BackoffErrorBody( + kind: kind, message: try r.string("message"), + minBackoffMs: try r.optionalNumber("minBackoffMs"), + maxBackoffMs: try r.optionalNumber("maxBackoffMs"), + reconnectParams: try r.optional("reconnectParams", JSONDecode.stringRecord), + origin: try r.optional("origin") { (v) throws(DecodingError) in + try JSONDecode.literal(v, ErrorOrigin.zeroCache.rawValue) + return ErrorOrigin.zeroCache + })) + } else if kind == .pushFailed { + self = .pushFailed( + PushFailedBody( + message: try r.string("message"), + mutationIDs: try r.required("mutationIDs") { (v) throws(DecodingError) in + try JSONDecode.array(v, MutationID.init(json:)) + }, + details: r.optionalJSON("details"), + cause: try FailureCause(r, serverReasons: PushFailedBody.serverReasons))) + } else { + self = .transformFailed( + TransformFailedBody( + message: try r.string("message"), + queryIDs: try r.required("queryIDs", JSONDecode.strings), + details: r.optionalJSON("details"), + cause: try FailureCause(r, serverReasons: TransformFailedBody.serverReasons))) + } + } + + public var json: JSONValue { + var o = JSONObjectBuilder() + o.set("kind", .string(kind.rawValue)) + o.set("message", message) + switch self { + case .basic(let b): + o.set("origin", b.origin?.rawValue) + case .backoff(let b): + o.set("minBackoffMs", b.minBackoffMs) + o.set("maxBackoffMs", b.maxBackoffMs) + o.set("reconnectParams", b.reconnectParams.map(JSONValue.stringRecord)) + o.set("origin", b.origin?.rawValue) + case .pushFailed(let b): + o.set("mutationIDs", .array(b.mutationIDs.map(\.json))) + o.set("details", b.details) + b.cause.write(into: &o) + case .transformFailed(let b): + o.set("queryIDs", .strings(b.queryIDs)) + o.set("details", b.details) + b.cause.write(into: &o) + } + return o.value + } +} + +/// `basicErrorBodySchema`. `origin` is `server` or `zeroCache` when set. +public struct BasicErrorBody: Sendable, Hashable { + public var kind: ErrorKind + public var message: String + public var origin: ErrorOrigin? + + public init(kind: ErrorKind, message: String, origin: ErrorOrigin? = nil) { + self.kind = kind + self.message = message + self.origin = origin + } +} + +/// `backoffBodySchema`: zero-cache asks the client to reconnect later. +/// `origin` is `zeroCache` when set. +public struct BackoffErrorBody: Sendable, Hashable { + public var kind: ErrorKind + public var message: String + public var minBackoffMs: Double? + public var maxBackoffMs: Double? + /// Query parameters to send in the next reconnect only. + public var reconnectParams: [String: String]? + public var origin: ErrorOrigin? + + public init( + kind: ErrorKind, message: String, minBackoffMs: Double? = nil, maxBackoffMs: Double? = nil, + reconnectParams: [String: String]? = nil, origin: ErrorOrigin? = nil + ) { + self.kind = kind + self.message = message + self.minBackoffMs = minBackoffMs + self.maxBackoffMs = maxBackoffMs + self.reconnectParams = reconnectParams + self.origin = origin + } +} + +/// Why a push or transform failed: the `origin` and `reason` of +/// `pushFailedBodySchema` and `transformFailedBodySchema`, whose three +/// variants each allow different combinations. +public enum FailureCause: Sendable, Hashable { + /// `origin: 'server'`: the API server reported the failure. + case server(reason: ErrorReason) + /// `origin: 'zeroCache', reason: 'http'`: the API server answered with a + /// non-OK status. + case http(status: Double, bodyPreview: String?) + /// `origin: 'zeroCache'` with reason `timeout`, `parse`, or `internal`. + case zeroCache(reason: ErrorReason) + + public var origin: ErrorOrigin { + if case .server = self { return .server } + return .zeroCache + } + + public var reason: ErrorReason { + switch self { + case .server(let reason), .zeroCache(let reason): reason + case .http: .http + } + } + + static let zeroCacheReasons: Set = [.timeout, .parse, .internal] + + init(_ r: JSONObjectReader, serverReasons: Set) throws(DecodingError) { + // valita tries the variants in order; at most one accepts. + let origin = try r.required("origin") { (v) throws(DecodingError) in + let origin = try JSONDecode.literalUnion(v, ErrorOrigin.self) + guard origin != .client else { throw DecodingError("Unexpected origin \"client\"") } + return origin + } + if origin == .server { + self = .server(reason: try r.oneOf("reason", serverReasons)) + } else if try r.literalUnion("reason", ErrorReason.self) == .http { + self = .http(status: try r.number("status"), bodyPreview: try r.optionalString("bodyPreview")) + } else { + self = .zeroCache(reason: try r.oneOf("reason", Self.zeroCacheReasons)) + } + } + + func write(into o: inout JSONObjectBuilder) { + o.set("origin", origin.rawValue) + o.set("reason", reason.rawValue) + if case .http(let status, let bodyPreview) = self { + o.set("status", status) + o.set("bodyPreview", bodyPreview) + } + } +} + +/// `pushFailedBodySchema`: the API server or zero-cache failed to process +/// a push. `mutationIDs` may be a subset of the pushed mutations. +public struct PushFailedBody: Sendable, Hashable { + public var message: String + public var mutationIDs: [MutationID] + public var details: JSONValue? + public var cause: FailureCause + + static let serverReasons: Set = [ + .database, .parse, .outOfOrderMutation, .unsupportedPushVersion, .internal, + ] + + public init( + message: String, mutationIDs: [MutationID], details: JSONValue? = nil, cause: FailureCause + ) { + self.message = message + self.mutationIDs = mutationIDs + self.details = details + self.cause = cause + } +} + +/// `transformFailedBodySchema`: the API server or zero-cache failed to +/// transform custom queries. +public struct TransformFailedBody: Sendable, Hashable { + public var message: String + public var queryIDs: [String] + public var details: JSONValue? + public var cause: FailureCause + + static let serverReasons: Set = [.database, .parse, .internal] + + public init( + message: String, queryIDs: [String], details: JSONValue? = nil, cause: FailureCause + ) { + self.message = message + self.queryIDs = queryIDs + self.details = details + self.cause = cause + } +} diff --git a/swift/Sources/Zero/Protocol/Inspect.swift b/swift/Sources/Zero/Protocol/Inspect.swift new file mode 100644 index 0000000000..9c43673076 --- /dev/null +++ b/swift/Sources/Zero/Protocol/Inspect.swift @@ -0,0 +1,232 @@ +/// The body of an `inspect` request from the client +/// (`inspectUpBodySchema` in `packages/zero-protocol/src/inspect-up.ts`). +/// +/// It is kept as JSON, checked and stripped like valita does: the Swift +/// client has no inspector yet. +public struct InspectUpBody: Sendable, Hashable, JSONCodable { + public let json: JSONValue + + public init(json: JSONValue) throws(DecodingError) { + self.json = try InspectSchemas.up.parse(json) + } + + /// The request ID the response echoes. + public var id: String { json["id"]?.stringValue ?? "" } + /// `queries`, `metrics`, `version`, `authenticate`, or `analyze-query`. + public var op: String { json["op"]?.stringValue ?? "" } +} + +/// The body of an `inspect` response from zero-cache +/// (`inspectDownBodySchema` in `packages/zero-protocol/src/inspect-down.ts`). +/// +/// It is kept as JSON, checked and stripped like valita does: the Swift +/// client has no inspector yet. +public struct InspectDownBody: Sendable, Hashable, JSONCodable { + public let json: JSONValue + + public init(json: JSONValue) throws(DecodingError) { + self.json = try InspectSchemas.down.parse(json) + } + + /// The ID of the request this answers. + public var id: String { json["id"]?.stringValue ?? "" } + /// `queries`, `metrics`, `version`, `authenticated`, `analyze-query`, or + /// `error`. + public var op: String { json["op"]?.stringValue ?? "" } + /// The response, whose shape depends on ``op``. + public var value: JSONValue { json["value"] ?? .null } +} + +/// The inspect schemas of `packages/zero-protocol/src/inspect-up.ts`, +/// `inspect-down.ts`, and `analyze-query-result.ts`. +enum InspectSchemas { + typealias S = ValitaSchema + + private static func op(_ name: String, _ fields: [String: S.Field] = [:]) -> S { + .object(fields.merging(["id": .required(.string), "op": .required(.literal(name))]) { $1 }) + } + + private static let ast = S.custom { (v) throws(DecodingError) in try AST(json: v).json } + private static let strictAST = S.custom { (v) throws(DecodingError) in + try StrictAST.decode(v).json + } + private static let condition = S.custom { (v) throws(DecodingError) in + try AST.Condition(json: v).json + } + private static let ordering = S.array( + .tuple([.string, .union([.literal("asc"), .literal("desc")])])) + /// `tdigestSchema` (`packages/shared/src/tdigest-schema.ts`). + private static let tdigest = S.nonEmptyArray(.number) + private static let row = S.record(.any) + + static let up = S.union([ + op("queries", ["clientID": .optional(.string)]), + op("metrics"), + op("version"), + op("authenticate", ["value": .required(.string)]), + op( + "analyze-query", + [ + "value": .optional(strictAST), + "options": .optional( + .object([ + "vendedRows": .optional(.boolean), "syncedRows": .optional(.boolean), + "joinPlans": .optional(.boolean), + ])), + "ast": .optional(strictAST), + "name": .optional(.string), + "args": .optional(.array(.any)), + ]), + ]) + + private static let queryServerMetrics = S.object([ + "query-hydration-server-ms": .optional(.number), + "query-update-server": .required(tdigest), + ]) + + private static let inspectQueryRow = S.object([ + "clientID": .required(.string), + "queryID": .required(.string), + "ast": .required(.nullable(ast)), + "name": .required(.nullable(.string)), + "args": .required(.nullable(.array(.any))), + "got": .required(.boolean), + "deleted": .required(.boolean), + "ttl": .required(.number), + "inactivatedAt": .required(.nullable(.number)), + "rowCount": .required(.number), + "metrics": .optional(.nullable(queryServerMetrics)), + ]) + + private static let serverMetrics = S.object([ + "query-materialization-server": .required(tdigest), + "query-update-server": .required(tdigest), + ]) + + private static let rowCountsBySource = S.record(.record(.number)) + private static let rowsBySource = S.record(.record(.array(row))) + + private static let costEstimate = S.object([ + "startupCost": .required(.number), + "scanEst": .required(.number), + "cost": .required(.number), + "returnedRows": .required(.number), + "selectivity": .required(.number), + "limit": .optional(.number), + ]) + + /// `plannerConstraintSchema`, a record of `v.union(v.unknown(), v.null())`. + private static let plannerConstraint = S.record(.any) + private static let constraints = S.record(.nullable(plannerConstraint)) + + private static func event(_ type: String, _ fields: [String: S.Field]) -> S { + .object(fields.merging(["type": .required(.literal(type))]) { $1 }) + } + + private static let joinStates = S.array( + .object(["join": .required(.string), "type": .required(.string)])) + + private static let planDebugEvent = S.union([ + event( + "attempt-start", + ["attemptNumber": .required(.number), "totalAttempts": .required(.number)]), + event( + "connection-costs", + [ + "attemptNumber": .required(.number), + "costs": .required( + .array( + .object([ + "connection": .required(.string), + "cost": .required(.number), + "costEstimate": .required(costEstimate), + "pinned": .required(.boolean), + "constraints": .required(constraints), + "constraintCosts": .required(.record(costEstimate)), + ]))), + ]), + event( + "connection-selected", + [ + "attemptNumber": .required(.number), "connection": .required(.string), + "cost": .required(.number), "isRoot": .required(.boolean), + ]), + event( + "constraints-propagated", + [ + "attemptNumber": .required(.number), + "connectionConstraints": .required( + .array( + .object([ + "connection": .required(.string), + "constraints": .required(constraints), + "constraintCosts": .required(.record(costEstimate)), + ]))), + ]), + event( + "plan-complete", + [ + "attemptNumber": .required(.number), "totalCost": .required(.number), + "flipPattern": .required(.number), "joinStates": .required(joinStates), + ]), + event( + "plan-failed", ["attemptNumber": .required(.number), "reason": .required(.string)]), + event( + "best-plan-selected", + [ + "bestAttemptNumber": .required(.number), "totalCost": .required(.number), + "flipPattern": .required(.number), "joinStates": .required(joinStates), + ]), + event( + "node-cost", + [ + "attemptNumber": .optional(.number), + "nodeType": .required(.string), + "node": .required(.string), + "branchPattern": .required(.array(.number)), + "downstreamChildSelectivity": .required(.number), + "costEstimate": .required(costEstimate), + "filters": .optional(condition), + "ordering": .optional(ordering), + "joinType": .optional(.string), + ]), + event( + "node-constraint", + [ + "attemptNumber": .optional(.number), + "nodeType": .required(.string), + "node": .required(.string), + "branchPattern": .required(.array(.number)), + "constraint": .optional(.nullable(plannerConstraint)), + "from": .required(.string), + ]), + ]) + + /// `analyzeQueryResultSchema`. + private static let analyzeQueryResult = S.object([ + "warnings": .required(.array(.string)), + "syncedRows": .optional(.record(.array(row))), + "syncedRowCount": .required(.number), + "start": .required(.number), + "end": .required(.number), + "elapsed": .optional(.number), + "afterPermissions": .optional(.string), + "vendedRowCounts": .optional(rowCountsBySource), + "vendedRows": .optional(rowsBySource), + "sqlitePlans": .optional(.record(.array(.string))), + "readRows": .optional(rowsBySource), + "readRowCountsByQuery": .optional(rowCountsBySource), + "readRowCount": .optional(.number), + "dbScansByQuery": .optional(rowCountsBySource), + "joinPlans": .optional(.array(planDebugEvent)), + ]) + + static let down = S.union([ + op("queries", ["value": .required(.array(inspectQueryRow))]), + op("metrics", ["value": .required(serverMetrics)]), + op("version", ["value": .required(.string)]), + op("authenticated", ["value": .required(.boolean)]), + op("analyze-query", ["value": .required(analyzeQueryResult)]), + op("error", ["value": .required(.string)]), + ]) +} diff --git a/swift/Sources/Zero/Protocol/Messages.swift b/swift/Sources/Zero/Protocol/Messages.swift new file mode 100644 index 0000000000..90e9c2f30a --- /dev/null +++ b/swift/Sources/Zero/Protocol/Messages.swift @@ -0,0 +1,188 @@ +/// The body of a `changeDesiredQueries` message +/// (`packages/zero-protocol/src/change-desired-queries.ts`). +public struct ChangeDesiredQueriesBody: Sendable, Hashable, JSONCodable { + public var desiredQueriesPatch: UpQueriesPatch + /// A W3C traceparent header for distributed tracing. + public var traceparent: String? + + public init(desiredQueriesPatch: UpQueriesPatch, traceparent: String? = nil) { + self.desiredQueriesPatch = desiredQueriesPatch + self.traceparent = traceparent + } + + public init(json: JSONValue) throws(DecodingError) { + let r = try JSONObjectReader(json, "changeDesiredQueries body") + desiredQueriesPatch = try r.required("desiredQueriesPatch", JSONDecode.upQueriesPatch) + traceparent = try r.optionalString("traceparent") + } + + public var json: JSONValue { + var o = JSONObjectBuilder() + o.set("desiredQueriesPatch", .array(desiredQueriesPatch.map(\.json))) + o.set("traceparent", traceparent) + return o.value + } +} + +/// The body of a `pull` request (`pullRequestBodySchema` in +/// `packages/zero-protocol/src/pull.ts`). +public struct PullRequestBody: Sendable, Hashable, JSONCodable { + public var clientGroupID: String + public var cookie: Version? + public var requestID: String + + public init(clientGroupID: String, cookie: Version?, requestID: String) { + self.clientGroupID = clientGroupID + self.cookie = cookie + self.requestID = requestID + } + + public init(json: JSONValue) throws(DecodingError) { + let r = try JSONObjectReader(json, "pull request body") + clientGroupID = try r.string("clientGroupID") + cookie = try r.required("cookie", NullableVersion.decode) + requestID = try r.string("requestID") + } + + public var json: JSONValue { + [ + "clientGroupID": .string(clientGroupID), "cookie": NullableVersion.json(cookie), + "requestID": .string(requestID), + ] + } +} + +/// The body of a `pull` response (`pullResponseBodySchema` in +/// `packages/zero-protocol/src/pull.ts`). Pull is only used to recover +/// mutations, so it carries no patch. +public struct PullResponseBody: Sendable, Hashable, JSONCodable { + public var cookie: Version + /// The `requestID` of the pull request this answers. + public var requestID: String + public var lastMutationIDChanges: [String: Double] + + public init(cookie: Version, requestID: String, lastMutationIDChanges: [String: Double]) { + self.cookie = cookie + self.requestID = requestID + self.lastMutationIDChanges = lastMutationIDChanges + } + + public init(json: JSONValue) throws(DecodingError) { + let r = try JSONObjectReader(json, "pull response body") + cookie = try r.string("cookie") + requestID = try r.string("requestID") + lastMutationIDChanges = try r.required("lastMutationIDChanges", JSONDecode.numberRecord) + } + + public var json: JSONValue { + [ + "cookie": .string(cookie), "requestID": .string(requestID), + "lastMutationIDChanges": .numberRecord(lastMutationIDChanges), + ] + } +} + +/// The body of a `deleteClients` message, in either direction +/// (`packages/zero-protocol/src/delete-clients.ts`). +public struct DeleteClientsBody: Sendable, Hashable, JSONCodable { + public var clientIDs: [String]? + public var clientGroupIDs: [String]? + + public init(clientIDs: [String]? = nil, clientGroupIDs: [String]? = nil) { + self.clientIDs = clientIDs + self.clientGroupIDs = clientGroupIDs + } + + public init(json: JSONValue) throws(DecodingError) { + let r = try JSONObjectReader(json, "deleteClients body") + clientIDs = try r.optional("clientIDs", JSONDecode.strings) + clientGroupIDs = try r.optional("clientGroupIDs", JSONDecode.strings) + } + + public var json: JSONValue { + var o = JSONObjectBuilder() + o.set("clientIDs", clientIDs.map(JSONValue.strings)) + o.set("clientGroupIDs", clientGroupIDs.map(JSONValue.strings)) + return o.value + } +} + +/// The body of an `updateAuth` message +/// (`packages/zero-protocol/src/update-auth.ts`). +public struct UpdateAuthBody: Sendable, Hashable, JSONCodable { + public var auth: String + + public init(auth: String) { + self.auth = auth + } + + public init(json: JSONValue) throws(DecodingError) { + auth = try JSONObjectReader(json, "updateAuth body").string("auth") + } + + public var json: JSONValue { ["auth": .string(auth)] } +} + +/// A custom query the API server failed to transform (`erroredQuerySchema` +/// in `packages/zero-protocol/src/custom-queries.ts`). +public enum ErroredQuery: Sendable, Hashable, JSONCodable { + /// The query function threw. `message` is optional for backwards + /// compatibility. + case app(id: String, name: String, message: String?, details: JSONValue?) + /// The query's arguments failed to parse. + case parse(id: String, name: String, message: String, details: JSONValue?) + + public var id: String { + switch self { + case .app(let id, _, _, _), .parse(let id, _, _, _): id + } + } + + public var name: String { + switch self { + case .app(_, let name, _, _), .parse(_, let name, _, _): name + } + } + + public init(json: JSONValue) throws(DecodingError) { + let r = try JSONObjectReader(json, "errored query") + switch try r.string("error") { + case "app": + self = .app( + id: try r.string("id"), name: try r.string("name"), + message: try r.optionalString("message"), + details: r.optionalJSON("details")) + case "parse": + self = .parse( + id: try r.string("id"), name: try r.string("name"), message: try r.string("message"), + details: r.optionalJSON("details")) + case let error: throw DecodingError("Invalid query error \"\(error)\"", path: ["error"]) + } + } + + public var json: JSONValue { + var o = JSONObjectBuilder() + switch self { + case .app(let id, let name, let message, let details): + o.set("error", "app") + o.set("id", id) + o.set("name", name) + o.set("message", message) + o.set("details", details) + case .parse(let id, let name, let message, let details): + o.set("error", "parse") + o.set("id", id) + o.set("name", name) + o.set("message", message) + o.set("details", details) + } + return o.value + } +} + +extension JSONDecode { + /// The body of a `transformError` message. + static func erroredQueries(_ v: JSONValue) throws(DecodingError) -> [ErroredQuery] { + try array(v, ErroredQuery.init(json:)) + } +} diff --git a/swift/Sources/Zero/Protocol/Mutation.swift b/swift/Sources/Zero/Protocol/Mutation.swift new file mode 100644 index 0000000000..47690d5e6a --- /dev/null +++ b/swift/Sources/Zero/Protocol/Mutation.swift @@ -0,0 +1,271 @@ +/// The name of every CRUD mutation (`CRUD_MUTATION_NAME` in +/// `packages/zero-protocol/src/mutation.ts`). +public let crudMutationName = "_zero_crud" + +/// One operation of a CRUD mutation (`crudOpSchema` in +/// `packages/zero-protocol/src/mutation.ts`). +public enum CRUDOp: Sendable, Hashable, JSONCodable { + case insert(tableName: String, primaryKey: [String], value: Row) + case upsert(tableName: String, primaryKey: [String], value: Row) + /// `value` is a partial row with at least the primary key columns. + case update(tableName: String, primaryKey: [String], value: Row) + /// `value` holds the primary key columns. + case delete(tableName: String, primaryKey: [String], value: PrimaryKeyValueRecord) + + public init(json: JSONValue) throws(DecodingError) { + let r = try JSONObjectReader(json, "CRUD op") + let op = try r.string("op") + switch op { + case "insert", "upsert", "update", "delete": + let tableName = try r.string("tableName") + let primaryKey = try r.required("primaryKey", JSONDecode.compoundKey) + switch op { + case "insert": + self = .insert( + tableName: tableName, primaryKey: primaryKey, + value: try r.required("value", JSONDecode.row)) + case "upsert": + self = .upsert( + tableName: tableName, primaryKey: primaryKey, + value: try r.required("value", JSONDecode.row)) + case "update": + self = .update( + tableName: tableName, primaryKey: primaryKey, + value: try r.required("value", JSONDecode.row)) + default: + self = .delete( + tableName: tableName, primaryKey: primaryKey, + value: try r.required("value", JSONDecode.primaryKeyValueRecord)) + } + default: + throw DecodingError("Invalid CRUD op \"\(op)\"", path: ["op"]) + } + } + + public var json: JSONValue { + let (op, tableName, primaryKey, value): (String, String, [String], Row) = + switch self { + case .insert(let t, let pk, let v): ("insert", t, pk, v) + case .upsert(let t, let pk, let v): ("upsert", t, pk, v) + case .update(let t, let pk, let v): ("update", t, pk, v) + case .delete(let t, let pk, let v): ("delete", t, pk, v) + } + return [ + "op": .string(op), "tableName": .string(tableName), "primaryKey": primaryKey.json, + "value": .object(value), + ] + } +} + +/// A mutation of the legacy CRUD mutators (`crudMutationSchema`). Its name is +/// always ``crudMutationName`` and its args are `[{ops}]`. +public struct CRUDMutation: Sendable, Hashable { + public var id: Double + public var clientID: String + public var ops: [CRUDOp] + public var timestamp: Double + + public init(id: Double, clientID: String, ops: [CRUDOp], timestamp: Double) { + self.id = id + self.clientID = clientID + self.ops = ops + self.timestamp = timestamp + } +} + +/// A mutation of a custom mutator (`customMutationSchema`). +public struct CustomMutation: Sendable, Hashable { + public var id: Double + public var clientID: String + public var name: String + public var args: [JSONValue] + public var timestamp: Double + + public init(id: Double, clientID: String, name: String, args: [JSONValue], timestamp: Double) { + self.id = id + self.clientID = clientID + self.name = name + self.args = args + self.timestamp = timestamp + } +} + +/// A mutation as pushed to zero-cache (`mutationSchema`). +public enum Mutation: Sendable, Hashable, JSONCodable { + case crud(CRUDMutation) + case custom(CustomMutation) + + public var id: Double { + switch self { + case .crud(let m): m.id + case .custom(let m): m.id + } + } + + public var clientID: String { + switch self { + case .crud(let m): m.clientID + case .custom(let m): m.clientID + } + } + + public init(json: JSONValue) throws(DecodingError) { + let r = try JSONObjectReader(json, "mutation") + switch try r.literalUnion("type", MutationType.self) { + case .crud: + try r.literal("name", crudMutationName) + let ops = try r.required("args") { (v) throws(DecodingError) -> [CRUDOp] in + let args = try JSONDecode.array(v) + guard args.count == 1 else { + throw DecodingError("Expected array of length 1, got \(args.count)") + } + do throws(DecodingError) { + let arg = try JSONObjectReader(args[0], "CRUD arg") + return try arg.required("ops") { (v) throws(DecodingError) in + try JSONDecode.array(v, CRUDOp.init(json:)) + } + } catch { + throw error.at("0") + } + } + self = .crud( + CRUDMutation( + id: try r.number("id"), clientID: try r.string("clientID"), ops: ops, + timestamp: try r.number("timestamp"))) + case .custom: + self = .custom( + CustomMutation( + id: try r.number("id"), clientID: try r.string("clientID"), name: try r.string("name"), + args: try r.required("args", JSONDecode.array), timestamp: try r.number("timestamp"))) + } + } + + public var json: JSONValue { + switch self { + case .crud(let m): + return [ + "type": "crud", "id": .number(m.id), "clientID": .string(m.clientID), + "name": .string(crudMutationName), + "args": [["ops": .array(m.ops.map(\.json))]], + "timestamp": .number(m.timestamp), + ] + case .custom(let m): + return [ + "type": "custom", "id": .number(m.id), "clientID": .string(m.clientID), + "name": .string(m.name), "args": .array(m.args), "timestamp": .number(m.timestamp), + ] + } + } +} + +/// A failed mutation's result (`mutationErrorSchema` in +/// `packages/zero-protocol/src/mutation.ts`). The fields are kept as sent: +/// see ``MutationResult`` for which results are errors. +public struct MutationError: Sendable, Hashable { + /// `"app"` for an error the mutator threw; `"oooMutation"` or + /// `"alreadyProcessed"` for one from zero. + public var error: JSONValue + public var message: JSONValue? + public var details: JSONValue? + + public init(error: JSONValue, message: JSONValue? = nil, details: JSONValue? = nil) { + self.error = error + self.message = message + self.details = details + } + + /// An application error (`appErrorSchema`). + public static func app(message: String? = nil, details: JSONValue? = nil) -> MutationError { + MutationError(error: "app", message: message.map { .string($0) }, details: details) + } +} + +/// The result of running a mutation on the API server +/// (`mutationResultSchema` in `packages/zero-protocol/src/mutation.ts`). +/// +/// This follows how the JS client reads a result. It parses it in +/// `passthrough` mode and tells errors apart with `'error' in result` +/// (`packages/zero-client/src/client/mutation-tracker.ts`). The union's OK +/// branch, `{data?}`, has no discriminator and accepts every object, so it +/// is what valita returns: the object as sent if it has no `data`, and just +/// `{data}` if it does, because valita copies only the keys it knows once a +/// field's matcher returns a value, as `jsonSchema` always does. So a result +/// with `data` is OK even if it has an `error`, and any other result with an +/// `error`, whatever its value, is an error. +public enum MutationResult: Sendable, Hashable, JSONCodable { + /// `mutationOkSchema`, with the data the mutator returned, if any. + case ok(data: JSONValue?) + case error(MutationError) + + public init(json: JSONValue) throws(DecodingError) { + let r = try JSONObjectReader(json, "mutation result") + if let data = r.object["data"] { + self = .ok(data: data) + } else if let error = r.object["error"] { + self = .error( + MutationError(error: error, message: r.object["message"], details: r.object["details"])) + } else { + self = .ok(data: nil) + } + } + + public var json: JSONValue { + var o = JSONObjectBuilder() + switch self { + case .ok(let data): + o.set("data", data) + case .error(let e): + o.set("error", e.error) + o.set("message", e.message) + o.set("details", e.details) + } + return o.value + } +} + +/// `mutationResponseSchema`. +public struct MutationResponse: Sendable, Hashable, JSONCodable { + public var id: MutationID + public var result: MutationResult + + public init(id: MutationID, result: MutationResult) { + self.id = id + self.result = result + } + + public init(json: JSONValue) throws(DecodingError) { + let r = try JSONObjectReader(json, "mutation response") + id = try r.required("id", MutationID.init(json:)) + result = try r.required("result", MutationResult.init(json:)) + } + + public var json: JSONValue { + ["id": id.json, "result": result.json] + } +} + +/// A change to the mutation results zero-cache tracks for a client +/// (`patchOpSchema` in `packages/zero-protocol/src/mutations-patch.ts`). +public enum MutationPatchOp: Sendable, Hashable, JSONCodable { + case put(MutationResponse) + case del(MutationID) + + public init(json: JSONValue) throws(DecodingError) { + let r = try JSONObjectReader(json, "mutations patch op") + switch try r.string("op") { + case "put": self = .put(try r.required("mutation", MutationResponse.init(json:))) + case "del": self = .del(try r.required("id", MutationID.init(json:))) + case let op: throw DecodingError("Invalid mutations patch op \"\(op)\"", path: ["op"]) + } + } + + public var json: JSONValue { + switch self { + case .put(let m): ["op": "put", "mutation": m.json] + case .del(let id): ["op": "del", "id": id.json] + } + } +} + +/// `mutationsPatchSchema`. +public typealias MutationsPatch = [MutationPatchOp] diff --git a/swift/Sources/Zero/Protocol/Poke.swift b/swift/Sources/Zero/Protocol/Poke.swift new file mode 100644 index 0000000000..3f16dd6ce6 --- /dev/null +++ b/swift/Sources/Zero/Protocol/Poke.swift @@ -0,0 +1,196 @@ +/// The range of schema versions the server supports. +public struct SchemaVersions: Sendable, Hashable { + public var minSupportedVersion: Double + public var maxSupportedVersion: Double + + public init(minSupportedVersion: Double, maxSupportedVersion: Double) { + self.minSupportedVersion = minSupportedVersion + self.maxSupportedVersion = maxSupportedVersion + } +} + +/// Starts a poke (`pokeStartBodySchema` in +/// `packages/zero-protocol/src/poke.ts`). +public struct PokeStartBody: Sendable, Hashable, JSONCodable { + public var pokeID: String + /// The cookie the poke applies to; `nil` for a client that has never + /// pulled, as Replicache starts clients with a `null` cookie. + public var baseCookie: Version? + /// Always set when the poke has a `rowsPatch`. + public var schemaVersions: SchemaVersions? + public var timestamp: Double? + + public init( + pokeID: String, baseCookie: Version?, schemaVersions: SchemaVersions? = nil, + timestamp: Double? = nil + ) { + self.pokeID = pokeID + self.baseCookie = baseCookie + self.schemaVersions = schemaVersions + self.timestamp = timestamp + } + + public init(json: JSONValue) throws(DecodingError) { + let r = try JSONObjectReader(json, "pokeStart body") + pokeID = try r.string("pokeID") + baseCookie = try r.required("baseCookie", NullableVersion.decode) + schemaVersions = try r.optional("schemaVersions") { (v) throws(DecodingError) in + let s = try JSONObjectReader(v, "schema versions") + return SchemaVersions( + minSupportedVersion: try s.number("minSupportedVersion"), + maxSupportedVersion: try s.number("maxSupportedVersion")) + } + timestamp = try r.optionalNumber("timestamp") + } + + public var json: JSONValue { + var o = JSONObjectBuilder() + o.set("pokeID", pokeID) + o.set("baseCookie", NullableVersion.json(baseCookie)) + o.set( + "schemaVersions", + schemaVersions.map { + [ + "minSupportedVersion": .number($0.minSupportedVersion), + "maxSupportedVersion": .number($0.maxSupportedVersion), + ] + }) + o.set("timestamp", timestamp) + return o.value + } +} + +/// One part of a poke (`pokePartBodySchema` in +/// `packages/zero-protocol/src/poke.ts`). +public struct PokePartBody: Sendable, Hashable, JSONCodable { + public var pokeID: String + /// The new last mutation ID of each client that changed. + public var lastMutationIDChanges: [String: Double]? + /// Changes to each client's desired queries, by client ID. + public var desiredQueriesPatches: [String: QueriesPatch]? + /// Changes to the queries whose rows `rowsPatch` syncs. + public var gotQueriesPatch: QueriesPatch? + public var rowsPatch: RowsPatch? + public var mutationsPatch: MutationsPatch? + + public init( + pokeID: String, lastMutationIDChanges: [String: Double]? = nil, + desiredQueriesPatches: [String: QueriesPatch]? = nil, gotQueriesPatch: QueriesPatch? = nil, + rowsPatch: RowsPatch? = nil, mutationsPatch: MutationsPatch? = nil + ) { + self.pokeID = pokeID + self.lastMutationIDChanges = lastMutationIDChanges + self.desiredQueriesPatches = desiredQueriesPatches + self.gotQueriesPatch = gotQueriesPatch + self.rowsPatch = rowsPatch + self.mutationsPatch = mutationsPatch + } + + public init(json: JSONValue) throws(DecodingError) { + let r = try JSONObjectReader(json, "pokePart body") + lastMutationIDChanges = try r.optional("lastMutationIDChanges", JSONDecode.numberRecord) + desiredQueriesPatches = try r.optional("desiredQueriesPatches") { (v) throws(DecodingError) in + try JSONDecode.record(v, JSONDecode.queriesPatch) + } + gotQueriesPatch = try r.optional("gotQueriesPatch", JSONDecode.queriesPatch) + rowsPatch = try r.optional("rowsPatch") { (v) throws(DecodingError) in + try JSONDecode.array(v, RowPatchOp.init(json:)) + } + mutationsPatch = try r.optional("mutationsPatch") { (v) throws(DecodingError) in + try JSONDecode.array(v, MutationPatchOp.init(json:)) + } + pokeID = try r.string("pokeID") + } + + public var json: JSONValue { + var o = JSONObjectBuilder() + o.set("lastMutationIDChanges", lastMutationIDChanges.map(JSONValue.numberRecord)) + o.set( + "desiredQueriesPatches", + desiredQueriesPatches.map { JSONValue.object($0.mapValues { .array($0.map(\.json)) }) }) + o.set("gotQueriesPatch", gotQueriesPatch.map { JSONValue.array($0.map(\.json)) }) + o.set("rowsPatch", rowsPatch.map { JSONValue.array($0.map(\.json)) }) + o.set("mutationsPatch", mutationsPatch.map { JSONValue.array($0.map(\.json)) }) + o.set("pokeID", pokeID) + return o.value + } +} + +/// Ends a poke (`pokeEndBodySchema` in `packages/zero-protocol/src/poke.ts`). +public struct PokeEndBody: Sendable, Hashable, JSONCodable { + public var pokeID: String + /// The cookie after the poke. Ignore it, and it may be empty, if + /// `cancel` is set. + public var cookie: Version + /// Discard the poke without applying it. + public var cancel: Bool? + + public init(pokeID: String, cookie: Version, cancel: Bool? = nil) { + self.pokeID = pokeID + self.cookie = cookie + self.cancel = cancel + } + + public init(json: JSONValue) throws(DecodingError) { + let r = try JSONObjectReader(json, "pokeEnd body") + pokeID = try r.string("pokeID") + cookie = try r.string("cookie") + cancel = try r.optionalBool("cancel") + } + + public var json: JSONValue { + var o = JSONObjectBuilder() + o.set("pokeID", pokeID) + o.set("cookie", cookie) + o.set("cancel", cancel) + return o.value + } +} + +/// The binary poke chunks of protocol 52 and later +/// (`packages/zero-protocol/src/poke.ts`). Between `pokeStart` and `pokeEnd` +/// zero-cache may send the poke's parts as binary messages instead of +/// `pokePart` messages: each is a ``messageType`` byte and then the next +/// bytes of the UTF-8 JSON of a `PokePartBody[]`. A chunk can end inside a +/// UTF-8 sequence. +public enum PokeChunk { + /// `POKE_CHUNK_MESSAGE_TYPE`. + public static let messageType: UInt8 = 0x00 + /// `POKE_CHUNK_PROTOCOL_VERSION`. + public static let protocolVersion = 52 + + /// The payload of a binary message. Throws for a message of another type, + /// which the client treats as an invalid message + /// (`#onMessage` in `packages/zero-client/src/client/zero.ts`). + public static func payload( + ofBinaryMessage message: [UInt8] + ) throws(DecodingError) + -> ArraySlice + { + guard let type = message.first else { + throw DecodingError("Unknown binary message type: undefined") + } + guard type == messageType else { + throw DecodingError("Unknown binary message type: \(type)") + } + return message.dropFirst() + } + + /// Decodes the payloads of a poke's chunks, in order, into its parts + /// (`decodePokeChunks` in `packages/zero-client/src/client/zero-poke-handler.ts`): + /// strict UTF-8, a leading BOM dropped as `TextDecoder` does, then + /// `pokePartsSchema`. + public static func decode( + _ chunks: some Sequence> + ) throws(DecodingError) + -> [PokePartBody] + { + var bytes: [UInt8] = [] + for chunk in chunks { bytes.append(contentsOf: chunk) } + let payload = bytes.starts(with: utf8BOM) ? bytes[3...] : bytes[...] + guard let text = String(validating: payload, as: UTF8.self) else { + throw DecodingError("The encoded data was not valid for encoding utf-8") + } + return try JSONDecode.array(try JSONValue.parseMessage(text), PokePartBody.init(json:)) + } +} diff --git a/swift/Sources/Zero/Protocol/Primitives.swift b/swift/Sources/Zero/Protocol/Primitives.swift new file mode 100644 index 0000000000..6b34cebae5 --- /dev/null +++ b/swift/Sources/Zero/Protocol/Primitives.swift @@ -0,0 +1,45 @@ +/// A cookie: the version of the client view a client has +/// (`packages/zero-protocol/src/version.ts`). Where the protocol allows a +/// `NullableVersion`, `nil` is the state before the first pull. +public typealias Version = String + +/// The values of a row's primary key columns, by column +/// (`primaryKeyValueRecordSchema` in `packages/zero-protocol/src/primary-key.ts`). +/// Each value is a string, number, or boolean. +public typealias PrimaryKeyValueRecord = [String: JSONValue] + +/// `packages/zero-protocol/src/mutation-id.ts`. +public struct MutationID: Sendable, Hashable, JSONCodable { + public var id: Double + public var clientID: String + + public init(id: Double, clientID: String) { + self.id = id + self.clientID = clientID + } + + public init(json: JSONValue) throws(DecodingError) { + let r = try JSONObjectReader(json, "mutation ID") + id = try r.number("id") + clientID = try r.string("clientID") + } + + public var json: JSONValue { + ["id": .number(id), "clientID": .string(clientID)] + } +} + +/// `nullableVersionSchema`: a string or `null`. +enum NullableVersion { + static func decode(_ v: JSONValue) throws(DecodingError) -> Version? { + switch v { + case .null: return nil + case .string(let s): return s + default: throw DecodingError("Expected string or null, got \(v.typeName)") + } + } + + static func json(_ v: Version?) -> JSONValue { + v.map { .string($0) } ?? .null + } +} diff --git a/swift/Sources/Zero/Protocol/ProtocolDecoding.swift b/swift/Sources/Zero/Protocol/ProtocolDecoding.swift new file mode 100644 index 0000000000..c7f4ba2c5f --- /dev/null +++ b/swift/Sources/Zero/Protocol/ProtocolDecoding.swift @@ -0,0 +1,129 @@ +/// Decoding helpers for the `zero-protocol` messages. They follow valita's +/// `passthrough` mode (`packages/shared/src/valita.ts`): unknown keys are +/// accepted and dropped, optional fields may be absent but not `null`. +extension JSONDecode { + /// `v.literal(expected)` for a string. + static func literal(_ v: JSONValue, _ expected: String) throws(DecodingError) { + guard case .string(let s) = v, s == expected else { + throw DecodingError("Expected literal \"\(expected)\", got \(v)") + } + } + + /// `v.literalUnion(...)` of the raw values of `R`. + static func literalUnion( + _ v: JSONValue, _: R.Type = R.self + ) throws(DecodingError) -> R where R.RawValue == String { + guard case .string(let s) = v, let r = R(rawValue: s) else { + throw DecodingError("Expected a \(R.self) literal, got \(v)") + } + return r + } + + /// `schema.nullable()`. + static func nullable( + _ v: JSONValue, _ decode: (JSONValue) throws(DecodingError) -> T + ) throws(DecodingError) -> T? { + if case .null = v { return nil } + return try decode(v) + } + + /// `v.array(v.string())`. + static func strings(_ v: JSONValue) throws(DecodingError) -> [String] { + try array(v, string) + } + + /// `v.record(v.string())`. + static func stringRecord(_ v: JSONValue) throws(DecodingError) -> [String: String] { + try record(v, string) + } + + /// `v.record(v.number())`. + static func numberRecord(_ v: JSONValue) throws(DecodingError) -> [String: Double] { + try record(v, number) + } + + /// `primaryKeyValueRecordSchema` (`packages/zero-protocol/src/primary-key.ts`). + static func primaryKeyValueRecord(_ v: JSONValue) throws(DecodingError) -> [String: JSONValue] { + try record(v, primaryKeyValue) + } + + /// `jsonObjectSchema` (`packages/shared/src/json-schema.ts`). It checks + /// `typeof v === 'object'`, so it accepts arrays as well as objects. + static func jsonObject(_ v: JSONValue) throws(DecodingError) -> JSONValue { + switch v { + case .object, .array: return v + default: throw DecodingError("Not a JSON object") + } + } + + /// The body of a `[tag, body]` tuple: exactly two elements, the first the + /// literal `tag`. + static func body(of v: JSONValue, tag: String) throws(DecodingError) -> JSONValue { + let a = try array(v) + guard a.count == 2 else { + throw DecodingError("Expected array of length 2, got \(a.count)") + } + do { + try literal(a[0], tag) + } catch { + throw error.at("0") + } + return a[1] + } +} + +extension JSONObjectReader { + /// A required `v.literalUnion(...)` field. + func literalUnion( + _ key: String, _ type: R.Type = R.self + ) throws(DecodingError) -> R where R.RawValue == String { + try required(key) { (v) throws(DecodingError) in try JSONDecode.literalUnion(v, type) } + } + + /// An optional `v.literalUnion(...)` field. + func optionalLiteralUnion( + _ key: String, _ type: R.Type = R.self + ) throws(DecodingError) -> R? where R.RawValue == String { + try optional(key) { (v) throws(DecodingError) in try JSONDecode.literalUnion(v, type) } + } + + /// A required field whose value is one of `allowed`. + func oneOf( + _ key: String, _ allowed: Set + ) throws(DecodingError) -> R where R.RawValue == String { + try required(key) { (v) throws(DecodingError) in + let r = try JSONDecode.literalUnion(v, R.self) + guard allowed.contains(r) else { throw DecodingError("Unexpected value \(v)") } + return r + } + } + + /// An optional `jsonSchema` field: any value. + func optionalJSON(_ key: String) -> JSONValue? { object[key] } +} + +extension JSONValue { + static func strings(_ a: [String]) -> JSONValue { .array(a.map { .string($0) }) } + + static func stringRecord(_ r: [String: String]) -> JSONValue { + .object(r.mapValues { .string($0) }) + } + + static func numberRecord(_ r: [String: Double]) -> JSONValue { + .object(r.mapValues { .number($0) }) + } + + /// A `[tag, body]` message. + static func message(_ tag: String, _ body: JSONValue) -> JSONValue { + .array([.string(tag), body]) + } + + /// Parses the text of a WebSocket message. + static func parseMessage(_ text: String) throws(DecodingError) -> JSONValue { + do { + return try JSONParser.parse(text) + } catch { + throw DecodingError(error.description) + } + } +} diff --git a/swift/Sources/Zero/Protocol/ProtocolEnums.swift b/swift/Sources/Zero/Protocol/ProtocolEnums.swift new file mode 100644 index 0000000000..bd33fdbc3a --- /dev/null +++ b/swift/Sources/Zero/Protocol/ProtocolEnums.swift @@ -0,0 +1,63 @@ +/// The kind of a protocol error +/// (`packages/zero-protocol/src/error-kind-enum.ts`). +public enum ErrorKind: String, Sendable, Hashable, CaseIterable { + case authInvalidated = "AuthInvalidated" + case clientNotFound = "ClientNotFound" + case invalidConnectionRequest = "InvalidConnectionRequest" + case invalidConnectionRequestBaseCookie = "InvalidConnectionRequestBaseCookie" + case invalidConnectionRequestLastMutationID = "InvalidConnectionRequestLastMutationID" + case invalidConnectionRequestClientDeleted = "InvalidConnectionRequestClientDeleted" + case invalidMessage = "InvalidMessage" + case invalidPush = "InvalidPush" + case pushFailed = "PushFailed" + case mutationFailed = "MutationFailed" + case mutationRateLimited = "MutationRateLimited" + case rebalance = "Rebalance" + case rehome = "Rehome" + case transformFailed = "TransformFailed" + case unauthorized = "Unauthorized" + case versionNotSupported = "VersionNotSupported" + case schemaVersionNotSupported = "SchemaVersionNotSupported" + case serverOverloaded = "ServerOverloaded" + case `internal` = "Internal" + + /// The kinds of a basic error body (`basicErrorKindSchema` in + /// `packages/zero-protocol/src/error.ts`). + public static let basic: Set = [ + .authInvalidated, .clientNotFound, .invalidConnectionRequest, + .invalidConnectionRequestBaseCookie, .invalidConnectionRequestLastMutationID, + .invalidConnectionRequestClientDeleted, .invalidMessage, .invalidPush, + .mutationRateLimited, .mutationFailed, .unauthorized, .versionNotSupported, + .schemaVersionNotSupported, .internal, + ] + + /// The kinds whose body carries backoff parameters + /// (`backoffErrorKindSchema`). + public static let backoff: Set = [.rebalance, .rehome, .serverOverloaded] +} + +/// Where an error came from +/// (`packages/zero-protocol/src/error-origin-enum.ts`). +public enum ErrorOrigin: String, Sendable, Hashable, CaseIterable { + case client + case server + case zeroCache +} + +/// Why a push or transform failed +/// (`packages/zero-protocol/src/error-reason-enum.ts`). +public enum ErrorReason: String, Sendable, Hashable, CaseIterable { + case database + case parse + case outOfOrderMutation = "oooMutation" + case unsupportedPushVersion + case `internal` + case http + case timeout +} + +/// `packages/zero-protocol/src/mutation-type-enum.ts`. +public enum MutationType: String, Sendable, Hashable, CaseIterable { + case crud + case custom +} diff --git a/swift/Sources/Zero/Protocol/Push.swift b/swift/Sources/Zero/Protocol/Push.swift new file mode 100644 index 0000000000..2e92704ae5 --- /dev/null +++ b/swift/Sources/Zero/Protocol/Push.swift @@ -0,0 +1,143 @@ +/// The body of a `push` message (`pushBodySchema` in +/// `packages/zero-protocol/src/push.ts`). +public struct PushBody: Sendable, Hashable, JSONCodable { + public var clientGroupID: String + public var mutations: [Mutation] + public var pushVersion: Double + /// The schema version of CRUD mutations. Custom mutations leave schema + /// versioning to the API server. + public var schemaVersion: Double? + public var timestamp: Double + public var requestID: String + /// Deprecated: auth is set for the client group by `initConnection` and + /// `updateAuth`. + public var auth: String? + /// A W3C traceparent header for distributed tracing. + public var traceparent: String? + + public init( + clientGroupID: String, mutations: [Mutation], pushVersion: Double, + schemaVersion: Double? = nil, timestamp: Double, requestID: String, auth: String? = nil, + traceparent: String? = nil + ) { + self.clientGroupID = clientGroupID + self.mutations = mutations + self.pushVersion = pushVersion + self.schemaVersion = schemaVersion + self.timestamp = timestamp + self.requestID = requestID + self.auth = auth + self.traceparent = traceparent + } + + public init(json: JSONValue) throws(DecodingError) { + let r = try JSONObjectReader(json, "push body") + clientGroupID = try r.string("clientGroupID") + mutations = try r.required("mutations") { (v) throws(DecodingError) in + try JSONDecode.array(v, Mutation.init(json:)) + } + pushVersion = try r.number("pushVersion") + schemaVersion = try r.optionalNumber("schemaVersion") + timestamp = try r.number("timestamp") + requestID = try r.string("requestID") + auth = try r.optionalString("auth") + traceparent = try r.optionalString("traceparent") + } + + public var json: JSONValue { + var o = JSONObjectBuilder() + o.set("clientGroupID", clientGroupID) + o.set("mutations", .array(mutations.map(\.json))) + o.set("pushVersion", pushVersion) + o.set("schemaVersion", schemaVersion) + o.set("timestamp", timestamp) + o.set("requestID", requestID) + o.set("auth", auth) + o.set("traceparent", traceparent) + return o.value + } +} + +/// The deprecated error responses to a push (`pushErrorSchema` in +/// `packages/zero-protocol/src/push.ts`). zero-cache now reports push +/// failures with `error` messages. +public enum PushError: Sendable, Hashable, JSONCodable { + case unsupportedPushVersion(mutationIDs: [MutationID]?) + case unsupportedSchemaVersion(mutationIDs: [MutationID]?) + case http(status: Double, details: String, mutationIDs: [MutationID]?) + case zeroPusher(details: String, mutationIDs: [MutationID]?) + + public var mutationIDs: [MutationID]? { + switch self { + case .unsupportedPushVersion(let ids), .unsupportedSchemaVersion(let ids): ids + case .http(_, _, let ids), .zeroPusher(_, let ids): ids + } + } + + public init(json: JSONValue) throws(DecodingError) { + let r = try JSONObjectReader(json, "push error") + let error = try r.string("error") + let ids = { () throws(DecodingError) in + try r.optional("mutationIDs") { (v) throws(DecodingError) in + try JSONDecode.array(v, MutationID.init(json:)) + } + } + switch error { + case "unsupportedPushVersion": self = .unsupportedPushVersion(mutationIDs: try ids()) + case "unsupportedSchemaVersion": self = .unsupportedSchemaVersion(mutationIDs: try ids()) + case "http": + self = .http( + status: try r.number("status"), details: try r.string("details"), mutationIDs: try ids()) + case "zeroPusher": + self = .zeroPusher(details: try r.string("details"), mutationIDs: try ids()) + default: + throw DecodingError("Invalid push error \"\(error)\"", path: ["error"]) + } + } + + public var json: JSONValue { + var o = JSONObjectBuilder() + switch self { + case .unsupportedPushVersion: o.set("error", "unsupportedPushVersion") + case .unsupportedSchemaVersion: o.set("error", "unsupportedSchemaVersion") + case .http(let status, let details, _): + o.set("error", "http") + o.set("status", status) + o.set("details", details) + case .zeroPusher(let details, _): + o.set("error", "zeroPusher") + o.set("details", details) + } + o.set("mutationIDs", mutationIDs.map { JSONValue.array($0.map(\.json)) }) + return o.value + } +} + +/// The body of a `pushResponse` message (`pushResponseBodySchema` in +/// `packages/zero-protocol/src/push.ts`). +/// +/// valita tries the OK variant first, and it has no discriminator: a body +/// with a valid `mutations` array is OK even if it also has an `error` key. +public enum PushResponseBody: Sendable, Hashable, JSONCodable { + case ok(mutations: [MutationResponse]) + case error(PushError) + + public init(json: JSONValue) throws(DecodingError) { + let r = try JSONObjectReader(json, "pushResponse body") + do { + self = .ok( + mutations: try r.required("mutations") { (v) throws(DecodingError) in + try JSONDecode.array(v, MutationResponse.init(json:)) + }) + } catch { + self = .error(try PushError(json: json)) + } + } + + public var json: JSONValue { + switch self { + case .ok(let mutations): ["mutations": .array(mutations.map(\.json))] + case .error(let e): e.json + } + } +} diff --git a/swift/Sources/Zero/Protocol/QueriesPatch.swift b/swift/Sources/Zero/Protocol/QueriesPatch.swift new file mode 100644 index 0000000000..19094fbd59 --- /dev/null +++ b/swift/Sources/Zero/Protocol/QueriesPatch.swift @@ -0,0 +1,109 @@ +/// A change to a set of queries as zero-cache sends it: a client's desired +/// queries or the queries whose rows are synced +/// (`patchOpSchema` in `packages/zero-protocol/src/queries-patch.ts`). +public enum QueriesPatchOp: Sendable, Hashable, JSONCodable { + case put(hash: String, ttl: Double?) + case del(hash: String) + case clear + + public init(json: JSONValue) throws(DecodingError) { + let r = try JSONObjectReader(json, "queries patch op") + switch try r.string("op") { + case "put": self = .put(hash: try r.string("hash"), ttl: try r.optionalNumber("ttl")) + case "del": self = .del(hash: try r.string("hash")) + case "clear": self = .clear + case let op: throw DecodingError("Invalid queries patch op \"\(op)\"", path: ["op"]) + } + } + + public var json: JSONValue { + switch self { + case .put(let hash, let ttl): + var o = JSONObjectBuilder() + o.set("op", "put") + o.set("hash", hash) + o.set("ttl", ttl) + return o.value + case .del(let hash): return ["op": "del", "hash": .string(hash)] + case .clear: return ["op": "clear"] + } + } +} + +/// `queriesPatchSchema`. +public typealias QueriesPatch = [QueriesPatchOp] + +/// A query a client wants synced (`upPutOpSchema`). A client query carries its +/// `ast`; a custom query its `name` and `args`. +public struct UpQueriesPut: Sendable, Hashable { + public var hash: String + public var ttl: Double? + public var ast: AST? + public var name: String? + public var args: [JSONValue]? + + public init( + hash: String, ttl: Double? = nil, ast: AST? = nil, name: String? = nil, + args: [JSONValue]? = nil + ) { + self.hash = hash + self.ttl = ttl + self.ast = ast + self.name = name + self.args = args + } +} + +/// A change to a client's desired queries, as the client sends it +/// (`upPatchOpSchema` in `packages/zero-protocol/src/queries-patch.ts`). +public enum UpQueriesPatchOp: Sendable, Hashable, JSONCodable { + case put(UpQueriesPut) + case del(hash: String) + case clear + + public init(json: JSONValue) throws(DecodingError) { + let r = try JSONObjectReader(json, "queries patch op") + switch try r.string("op") { + case "put": + self = .put( + UpQueriesPut( + hash: try r.string("hash"), + ttl: try r.optionalNumber("ttl"), + ast: try r.optional("ast", StrictAST.decode), + name: try r.optionalString("name"), + args: try r.optional("args", JSONDecode.array))) + case "del": self = .del(hash: try r.string("hash")) + case "clear": self = .clear + case let op: throw DecodingError("Invalid queries patch op \"\(op)\"", path: ["op"]) + } + } + + public var json: JSONValue { + switch self { + case .put(let put): + var o = JSONObjectBuilder() + o.set("op", "put") + o.set("hash", put.hash) + o.set("ttl", put.ttl) + o.set("ast", put.ast?.json) + o.set("name", put.name) + o.set("args", put.args.map { JSONValue.array($0) }) + return o.value + case .del(let hash): return ["op": "del", "hash": .string(hash)] + case .clear: return ["op": "clear"] + } + } +} + +/// `upQueriesPatchSchema`. +public typealias UpQueriesPatch = [UpQueriesPatchOp] + +extension JSONDecode { + static func queriesPatch(_ v: JSONValue) throws(DecodingError) -> QueriesPatch { + try array(v, QueriesPatchOp.init(json:)) + } + + static func upQueriesPatch(_ v: JSONValue) throws(DecodingError) -> UpQueriesPatch { + try array(v, UpQueriesPatchOp.init(json:)) + } +} diff --git a/swift/Sources/Zero/Protocol/RowsPatch.swift b/swift/Sources/Zero/Protocol/RowsPatch.swift new file mode 100644 index 0000000000..cfdd47ce6b --- /dev/null +++ b/swift/Sources/Zero/Protocol/RowsPatch.swift @@ -0,0 +1,67 @@ +/// A change to the synced rows (`rowPatchOpSchema` in +/// `packages/zero-protocol/src/row-patch.ts`). +public enum RowPatchOp: Sendable, Hashable, JSONCodable { + /// Inserts or replaces the row. + case put(tableName: String, value: Row) + /// Merges `merge` into the row with primary key `id`. `merge` is a + /// `jsonObjectSchema`, which checks `typeof v === 'object'` and so admits + /// an array as well as an object; it is kept as sent. `constrain` limits + /// the columns the row keeps. + case update( + tableName: String, id: PrimaryKeyValueRecord, merge: JSONValue?, constrain: [String]?) + /// Deletes the row with primary key `id`. + case del(tableName: String, id: PrimaryKeyValueRecord) + /// Deletes every row. + case clear + + public init(json: JSONValue) throws(DecodingError) { + let r = try JSONObjectReader(json, "row patch op") + switch try r.string("op") { + case "put": + self = .put( + tableName: try r.string("tableName"), value: try r.required("value", JSONDecode.row)) + case "update": + self = .update( + tableName: try r.string("tableName"), + id: try r.required("id", JSONDecode.primaryKeyValueRecord), + merge: try r.optional("merge", JSONDecode.jsonObject), + constrain: try r.optional("constrain", JSONDecode.strings)) + case "del": + self = .del( + tableName: try r.string("tableName"), + id: try r.required("id", JSONDecode.primaryKeyValueRecord)) + case "clear": self = .clear + case let op: throw DecodingError("Invalid row patch op \"\(op)\"", path: ["op"]) + } + } + + public var json: JSONValue { + switch self { + case .put(let tableName, let value): + return ["op": "put", "tableName": .string(tableName), "value": .object(value)] + case .update(let tableName, let id, let merge, let constrain): + var o = JSONObjectBuilder() + o.set("op", "update") + o.set("tableName", tableName) + o.set("id", .object(id)) + o.set("merge", merge) + o.set("constrain", constrain.map(JSONValue.strings)) + return o.value + case .del(let tableName, let id): + return ["op": "del", "tableName": .string(tableName), "id": .object(id)] + case .clear: + return ["op": "clear"] + } + } +} + +/// `rowsPatchSchema`. +public typealias RowsPatch = [RowPatchOp] + +extension JSONDecode { + /// `rowSchema` (`packages/zero-protocol/src/data.ts`): a record of JSON + /// values. + static func row(_ v: JSONValue) throws(DecodingError) -> Row { + try object(v) + } +} diff --git a/swift/Sources/Zero/Protocol/StrictAST.swift b/swift/Sources/Zero/Protocol/StrictAST.swift new file mode 100644 index 0000000000..6128f2d121 --- /dev/null +++ b/swift/Sources/Zero/Protocol/StrictAST.swift @@ -0,0 +1,115 @@ +/// `strictASTSchema` (`packages/zero-protocol/src/ast.ts`), for ASTs a +/// client sends: nested at most ``maxDepth`` deep, and parsed in valita's +/// `strict` mode even inside a lenient parse, so an AST with a key zero +/// doesn't know is rejected rather than run as a different query. +enum StrictAST { + /// `MAX_AST_DEPTH`. + static let maxDepth = 50 + + static func decode(_ v: JSONValue) throws(DecodingError) -> AST { + if exceedsDepth(v) { + throw DecodingError("AST exceeds the maximum depth of \(maxDepth)") + } + try checkAST(v) + return try AST(json: v) + } + + /// `exceedsASTDepth`: every step down counts, except the one from an AST + /// into its where clause. + static func exceedsDepth(_ ast: JSONValue, maxDepth: Int = maxDepth) -> Bool { + astExceedsDepth(ast, 0, maxDepth) + } + + /// `isPlainObject` (`packages/shared/src/deep-merge.ts`) is true for + /// arrays too; they have none of the keys looked at. + private static func plainObject(_ v: JSONValue?) -> [String: JSONValue]? { + switch v { + case .object(let o)?: o + case .array?: [:] + default: nil + } + } + + private static func astExceedsDepth(_ ast: JSONValue?, _ depth: Int, _ maxDepth: Int) -> Bool { + if depth > maxDepth { return true } + guard let o = plainObject(ast) else { return false } + if conditionExceedsDepth(o["where"], depth, maxDepth) { return true } + guard case .array(let related)? = o["related"] else { return false } + return related.contains { r in + guard let r = plainObject(r) else { return false } + return astExceedsDepth(r["subquery"], depth + 1, maxDepth) + } + } + + private static func conditionExceedsDepth( + _ cond: JSONValue?, _ depth: Int, _ maxDepth: Int + ) -> Bool { + if depth > maxDepth { return true } + guard let o = plainObject(cond) else { return false } + if case .array(let conditions)? = o["conditions"], + conditions.contains(where: { conditionExceedsDepth($0, depth + 1, maxDepth) }) + { + return true + } + if let related = plainObject(o["related"]) { + return astExceedsDepth(related["subquery"], depth + 1, maxDepth) + } + return false + } + + // The keys each object of the AST may have. `AST(json:)` checks the + // values; this only rejects the keys `strict` mode would. + + private static func only(_ o: [String: JSONValue], _ keys: Set) throws(DecodingError) { + for k in o.keys where !keys.contains(k) { + throw DecodingError("Unexpected key", path: [k]) + } + } + + private static func checkAST(_ v: JSONValue) throws(DecodingError) { + guard case .object(let o) = v else { return } + try only(o, ["schema", "table", "alias", "where", "related", "limit", "orderBy", "start"]) + if let w = o["where"] { try checkCondition(w) } + if case .array(let related)? = o["related"] { + for r in related { try checkCorrelatedSubquery(r) } + } + if case .object(let start)? = o["start"] { try only(start, ["row", "exclusive"]) } + } + + private static func checkCorrelatedSubquery(_ v: JSONValue) throws(DecodingError) { + guard case .object(let o) = v else { return } + try only(o, ["correlation", "hidden", "system", "subquery"]) + if case .object(let c)? = o["correlation"] { try only(c, ["parentField", "childField"]) } + if let s = o["subquery"] { try checkAST(s) } + } + + private static func checkCondition(_ v: JSONValue) throws(DecodingError) { + guard case .object(let o) = v else { return } + switch o["type"]?.stringValue { + case "simple": + try only(o, ["type", "op", "left", "right"]) + if let l = o["left"] { try checkValuePosition(l) } + if let r = o["right"] { try checkValuePosition(r) } + case "and", "or": + try only(o, ["type", "conditions"]) + if case .array(let conditions)? = o["conditions"] { + for c in conditions { try checkCondition(c) } + } + case "correlatedSubquery": + try only(o, ["type", "related", "op", "flip", "scalar"]) + if let r = o["related"] { try checkCorrelatedSubquery(r) } + default: + return + } + } + + private static func checkValuePosition(_ v: JSONValue) throws(DecodingError) { + guard case .object(let o) = v else { return } + switch o["type"]?.stringValue { + case "literal": try only(o, ["type", "value"]) + case "column": try only(o, ["type", "name"]) + case "static": try only(o, ["type", "anchor", "field"]) + default: return + } + } +} diff --git a/swift/Sources/Zero/Protocol/Upstream.swift b/swift/Sources/Zero/Protocol/Upstream.swift new file mode 100644 index 0000000000..774d2d93f1 --- /dev/null +++ b/swift/Sources/Zero/Protocol/Upstream.swift @@ -0,0 +1,94 @@ +/// A message from the client to zero-cache (`upstreamSchema` in +/// `packages/zero-protocol/src/up.ts`). +public enum Upstream: Sendable, Hashable, JSONCodable { + case initConnection(InitConnectionBody) + case ping + case deleteClients(DeleteClientsBody) + case changeDesiredQueries(ChangeDesiredQueriesBody) + case pull(PullRequestBody) + case updateAuth(UpdateAuthBody) + case push(PushBody) + /// Sent before the client closes the socket. The body is any array. + case closeConnection([JSONValue]) + case inspect(InspectUpBody) + /// Tells zero-cache the client has seen the mutation results up to the + /// given ID, so it can delete them. + case ackMutationResponses(MutationID) + /// A message whose string tag this build doesn't know, kept as sent. + /// `upstreamSchema` is an open `taggedTupleUnion`, so a newer client's + /// messages parse (`v.UnrecognizedTuple`). + case unrecognized([JSONValue]) + + public var tag: String { + switch self { + case .initConnection: "initConnection" + case .ping: "ping" + case .deleteClients: "deleteClients" + case .changeDesiredQueries: "changeDesiredQueries" + case .pull: "pull" + case .updateAuth: "updateAuth" + case .push: "push" + case .closeConnection: "closeConnection" + case .inspect: "inspect" + case .ackMutationResponses: "ackMutationResponses" + case .unrecognized(let a): a.first?.stringValue ?? "" + } + } + + public init(json: JSONValue) throws(DecodingError) { + guard case .array(let a) = json else { throw DecodingError("Expected array") } + guard case .string(let tag)? = a.first else { + throw DecodingError("Expected string", path: ["0"]) + } + // A known tag picks its tuple, which must have exactly two elements. + func decode(_ decode: (JSONValue) throws(DecodingError) -> T) throws(DecodingError) -> T { + let body = try JSONDecode.body(of: json, tag: tag) + do { + return try decode(body) + } catch { + throw error.at("1") + } + } + switch tag { + case "initConnection": self = .initConnection(try decode(InitConnectionBody.init(json:))) + case "ping": + _ = try decode(JSONDecode.object) + self = .ping + case "deleteClients": self = .deleteClients(try decode(DeleteClientsBody.init(json:))) + case "changeDesiredQueries": + self = .changeDesiredQueries(try decode(ChangeDesiredQueriesBody.init(json:))) + case "pull": self = .pull(try decode(PullRequestBody.init(json:))) + case "updateAuth": self = .updateAuth(try decode(UpdateAuthBody.init(json:))) + case "push": self = .push(try decode(PushBody.init(json:))) + case "closeConnection": self = .closeConnection(try decode(JSONDecode.array)) + case "inspect": self = .inspect(try decode(InspectUpBody.init(json:))) + case "ackMutationResponses": + self = .ackMutationResponses(try decode(MutationID.init(json:))) + default: self = .unrecognized(a) + } + } + + /// Parses message text, as zero-cache receives it. + public init(text: String) throws(DecodingError) { + try self.init(json: try JSONValue.parseMessage(text)) + } + + public var json: JSONValue { + switch self { + case .initConnection(let b): .message(tag, b.json) + case .ping: .message(tag, [:]) + case .deleteClients(let b): .message(tag, b.json) + case .changeDesiredQueries(let b): .message(tag, b.json) + case .pull(let b): .message(tag, b.json) + case .updateAuth(let b): .message(tag, b.json) + case .push(let b): .message(tag, b.json) + case .closeConnection(let b): .message(tag, .array(b)) + case .inspect(let b): .message(tag, b.json) + case .ackMutationResponses(let id): .message(tag, id.json) + case .unrecognized(let a): .array(a) + } + } + + /// The message text to send. + public var text: String { JSONSerializer.stringify(json) } +} diff --git a/swift/Sources/Zero/Protocol/ValitaSchema.swift b/swift/Sources/Zero/Protocol/ValitaSchema.swift new file mode 100644 index 0000000000..9d720f7a87 --- /dev/null +++ b/swift/Sources/Zero/Protocol/ValitaSchema.swift @@ -0,0 +1,94 @@ +/// A small interpreter of valita schemas (`packages/shared/src/valita.ts`), +/// for messages kept as JSON rather than decoded into Swift types. +/// +/// ``parse(_:)`` accepts what valita accepts in `passthrough` mode and +/// returns what it returns in `strip` mode: objects lose the keys their +/// schema doesn't name. A union picks its first option that accepts. +indirect enum ValitaSchema: Sendable { + case string + case number + case boolean + case null + /// `v.unknown()` and `jsonSchema`. + case any + case literal(String) + case array(ValitaSchema) + /// `v.tuple([s]).concat(v.array(s))`. + case nonEmptyArray(ValitaSchema) + case tuple([ValitaSchema]) + case record(ValitaSchema) + case object([String: Field]) + case union([ValitaSchema]) + case nullable(ValitaSchema) + /// A schema with a Swift decoder; returns the decoded value's JSON. + case custom(@Sendable (JSONValue) throws(DecodingError) -> JSONValue) + + enum Field: Sendable { + case required(ValitaSchema) + case optional(ValitaSchema) + } + + func parse(_ v: JSONValue) throws(DecodingError) -> JSONValue { + switch self { + case .string: + _ = try JSONDecode.string(v) + return v + case .number: + _ = try JSONDecode.number(v) + return v + case .boolean: + _ = try JSONDecode.bool(v) + return v + case .null: + guard case .null = v else { throw DecodingError("Expected null, got \(v.typeName)") } + return v + case .any: + return v + case .literal(let expected): + try JSONDecode.literal(v, expected) + return v + case .array(let element): + return .array(try JSONDecode.array(v, element.parse)) + case .nonEmptyArray(let element): + let a = try JSONDecode.array(v, element.parse) + if a.isEmpty { throw DecodingError("Expected non-empty array") } + return .array(a) + case .tuple(let elements): + let a = try JSONDecode.array(v) + guard a.count == elements.count else { + throw DecodingError("Expected array of length \(elements.count), got \(a.count)") + } + var out: [JSONValue] = [] + for (i, (e, schema)) in zip(a, elements).enumerated() { + do { + out.append(try schema.parse(e)) + } catch { + throw error.at(String(i)) + } + } + return .array(out) + case .record(let element): + return .object(try JSONDecode.record(v, element.parse)) + case .object(let fields): + let r = try JSONObjectReader(v) + var out: [String: JSONValue] = [:] + for (key, field) in fields { + switch field { + case .required(let schema): out[key] = try r.required(key, schema.parse) + case .optional(let schema): out[key] = try r.optional(key, schema.parse) + } + } + return .object(out) + case .union(let options): + for option in options { + if let out = try? option.parse(v) { return out } + } + throw DecodingError("No union option matched the \(v.typeName)") + case .nullable(let schema): + if case .null = v { return v } + return try schema.parse(v) + case .custom(let decode): + return try decode(v) + } + } +} diff --git a/swift/Tests/ConformanceTests/ProtocolConformanceTests.swift b/swift/Tests/ConformanceTests/ProtocolConformanceTests.swift new file mode 100644 index 0000000000..0d620ad8bc --- /dev/null +++ b/swift/Tests/ConformanceTests/ProtocolConformanceTests.swift @@ -0,0 +1,118 @@ +import Testing + +@testable import Zero + +/// The zero-protocol wire messages, against the vectors of +/// `packages/zero-conformance/src/protocol/messages.test.ts`. +@Suite(.enabled(if: Vectors.available)) +struct ProtocolConformanceTests { + /// A case's message and expected output, given as JSON or, for deeply + /// nested messages, as JSON text. + static func messageAndOutput(_ c: JSONValue) throws -> (JSONValue, JSONValue?) { + if let text = c["messageText"]?.stringValue { + return ( + try JSONParser.parse(text), try c["outputText"]?.stringValue.map(JSONParser.parse) + ) + } + return (c["message"]!, c["output"]) + } + + static func check(_ c: JSONValue, as _: M.Type) throws { + let (message, output) = try messageAndOutput(c) + let ok = c["ok"]!.boolValue! + let decoded: M + do { + decoded = try M(json: message) + } catch { + #expect(!ok, "rejected \(message): \(error)") + return + } + #expect(ok, "accepted \(message)") + guard ok, let output else { return } + #expect(decoded.json == output, "\(message)") + // What a message encodes to decodes to the same message. + #expect(try M(json: decoded.json) == decoded, "\(message)") + } + + @Test func downstream() throws { + for c in try Vectors.cases("protocol-downstream") { try Self.check(c, as: Downstream.self) } + } + + @Test(.enabled(if: Vectors.generatedAvailable)) func downstreamFuzz() throws { + for c in try Vectors.generatedCases("protocol-downstream-fuzz") { + try Self.check(c, as: Downstream.self) + } + } + + @Test func upstream() throws { + for c in try Vectors.cases("protocol-upstream") { try Self.check(c, as: Upstream.self) } + } + + @Test(.enabled(if: Vectors.generatedAvailable)) func upstreamFuzz() throws { + for c in try Vectors.generatedCases("protocol-upstream-fuzz") { + try Self.check(c, as: Upstream.self) + } + } + + static func bytes(hex: String) -> [UInt8] { + let digits = Array(hex.utf8) + return stride(from: 0, to: digits.count, by: 2).map { + UInt8(String(decoding: digits[$0..<$0 + 2], as: UTF8.self), radix: 16)! + } + } + + static func checkPokeChunks(_ c: JSONValue) { + let messages = c["messages"]!.arrayValue!.map { bytes(hex: $0.stringValue!) } + let ok = c["ok"]!.boolValue! + do { + var payloads: [ArraySlice] = [] + for m in messages { payloads.append(try PokeChunk.payload(ofBinaryMessage: m)) } + let parts = try PokeChunk.decode(payloads) + #expect(ok, "accepted \(c["messages"]!)") + if ok { #expect(JSONValue.array(parts.map(\.json)) == c["parts"]!, "\(c["messages"]!)") } + } catch { + #expect(!ok, "rejected \(c["messages"]!): \(error)") + } + } + + @Test func pokeChunks() throws { + for c in try Vectors.cases("protocol-poke-chunks") { Self.checkPokeChunks(c) } + } + + @Test(.enabled(if: Vectors.generatedAvailable)) func pokeChunksFuzz() throws { + for c in try Vectors.generatedCases("protocol-poke-chunks-fuzz") { Self.checkPokeChunks(c) } + } + + @Test func secProtocols() throws { + for c in try Vectors.cases("protocol-sec-protocols") { + let encoded = c["encoded"]!.stringValue! + let ok = c["ok"]!.boolValue! + do { + let decoded = try SecProtocols.decode(encoded) + #expect(ok, "accepted \(encoded)") + if ok { #expect(decoded == c["decoded"]!, "\(encoded)") } + } catch { + #expect(!ok, "rejected \(encoded): \(error)") + } + let message = c["initConnectionMessage"] + let authToken = c["authToken"]?.stringValue + guard message != nil || authToken != nil else { continue } + // The Swift encoding differs in key order, but carries the same values. + let body = try message.map { + try InitConnectionBody(json: try JSONDecode.body(of: $0, tag: "initConnection")) + } + let swiftEncoded = SecProtocols.encode(initConnection: body, authToken: authToken) + #expect(try SecProtocols.decode(swiftEncoded) == c["decoded"]!, "\(encoded)") + } + } + + @Test func clientSchema() throws { + for c in try Vectors.cases("protocol-client-schema") { + let schema = try ClientSchema(json: c["schema"]!) + #expect(schema.json == c["schema"]!) + #expect( + JSONSerializer.stringify(schema.normalized().json) == c["normalized"]!.stringValue!, + "\(c["schema"]!)") + } + } +} diff --git a/swift/Tests/ZeroTests/ProtocolTests.swift b/swift/Tests/ZeroTests/ProtocolTests.swift new file mode 100644 index 0000000000..8809ef8536 --- /dev/null +++ b/swift/Tests/ZeroTests/ProtocolTests.swift @@ -0,0 +1,147 @@ +import Testing + +@testable import Zero + +/// The protocol's valita edge cases, for checkouts without the conformance +/// vectors. +struct ProtocolTests { + @Test func messageRoundTripsThroughText() throws { + let push = Upstream.push( + PushBody( + clientGroupID: "g", + mutations: [ + .custom( + CustomMutation( + id: 1, clientID: "c", name: "issue.create", args: [["id": "a"]], timestamp: 5)), + .crud( + CRUDMutation( + id: 2, clientID: "c", + ops: [.delete(tableName: "issue", primaryKey: ["id"], value: ["id": "a"])], + timestamp: 6)), + ], + pushVersion: 1, timestamp: 7, requestID: "r")) + #expect(try Upstream(text: push.text) == push) + #expect(push.json[1]?["mutations"]?[1]?["name"] == "_zero_crud") + } + + @Test func upstreamIsAnOpenTaggedTupleUnion() throws { + #expect(try Upstream(json: ["newMessage", 1, 2]) == .unrecognized(["newMessage", 1, 2])) + #expect(try Upstream(json: ["ping", ["zExtra": 1]]) == .ping) + #expect(throws: DecodingError.self) { try Upstream(json: [1, [:]]) } + #expect(throws: DecodingError.self) { try Upstream(json: ["ping"]) } + #expect(throws: DecodingError.self) { try Upstream(json: []) } + #expect(throws: DecodingError.self) { try Downstream(json: ["newMessage", [:]]) } + } + + @Test func clientASTsAreStrict() throws { + func put(_ ast: JSONValue) -> JSONValue { + ["changeDesiredQueries", ["desiredQueriesPatch": [["op": "put", "hash": "h", "ast": ast]]]] + } + _ = try Upstream( + json: put(["table": "issue", "start": ["row": ["any": 1], "exclusive": true]])) + #expect(throws: DecodingError.self) { + try Upstream(json: put(["table": "issue", "zExtra": 1])) + } + #expect(throws: DecodingError.self) { + try Upstream( + json: put(["table": "issue", "where": ["type": "and", "conditions": [], "x": 1]])) + } + // zero-cache's own ASTs are not strict. + let row: JSONValue = [ + "clientID": "c", "queryID": "q", "ast": ["table": "issue", "zExtra": 1], "name": nil, + "args": nil, "got": true, "deleted": false, "ttl": 0, "inactivatedAt": nil, "rowCount": 0, + ] + let inspect = try Downstream(json: ["inspect", ["id": "1", "op": "queries", "value": [row]]]) + #expect(inspect.json[1]?["value"]?[0]?["ast"] == ["table": "issue"]) + } + + @Test func clientASTsAreAtMost50Deep() throws { + func nested(_ n: Int) -> JSONValue { + var ast: JSONValue = ["table": "issue"] + for _ in 0.. MutationResult { try MutationResult(json: json) } + #expect(try result([:]) == .ok(data: nil)) + #expect(try result(["data": [1]]) == .ok(data: [1])) + #expect(try result(["error": "app", "message": "m"]) == .error(.app(message: "m"))) + // Any object is a result; with `data` it is OK, otherwise `error` makes it an error. + #expect(try result(["error": "app", "data": 1]) == .ok(data: 1)) + #expect(try result(["error": 5]) == .error(MutationError(error: 5))) + #expect(throws: DecodingError.self) { try result([]) } + } + + @Test func pushResponseTriesTheOKVariantFirst() throws { + #expect( + try PushResponseBody(json: ["mutations": [], "error": "http", "status": 500, "details": "d"]) + == .ok(mutations: [])) + #expect( + try PushResponseBody(json: ["mutations": "x", "error": "zeroPusher", "details": "d"]) + == .error(.zeroPusher(details: "d", mutationIDs: nil))) + } + + @Test func errorBodyVariants() throws { + let body: JSONValue = [ + "kind": "PushFailed", "message": "m", "mutationIDs": [], "origin": "zeroCache", + "reason": "http", + ] + #expect(throws: DecodingError.self) { try ErrorBody(json: body) } + var withStatus = body.objectValue! + withStatus["status"] = 503 + #expect( + try ErrorBody(json: .object(withStatus)) + == .pushFailed( + PushFailedBody(message: "m", mutationIDs: [], cause: .http(status: 503, bodyPreview: nil)) + )) + #expect(throws: DecodingError.self) { + try ErrorBody(json: ["kind": "Rebalance", "message": "m", "origin": "server"]) + } + } + + @Test func pokeChunksMaySplitUTF8Sequences() throws { + let payload = Array(#"[{"pokeID":"中"}]"#.utf8) + let split = payload.firstIndex(of: 0xE4)! + 1 + let messages = [[0] + payload[.. Date: Mon, 19 Oct 2026 05:46:17 +0000 Subject: [PATCH 007/104] 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 --- .github/workflows/swift.yml | 5 ++++- packages/zero-conformance/src/vectors.ts | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 975cb2d7df..9163b682b9 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -29,9 +29,12 @@ jobs: - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: version: 11.11.0 + # The vectors are JS's answers, so they come from the repo's pinned Node. + # Node 24.22's JSON.parse misreads some escaped keys (see + # assertSoundJSONParse in packages/zero-conformance/src/vectors.ts). - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 24.x + node-version-file: .nvmrc package-manager-cache: false - run: pnpm install --frozen-lockfile # Fails when the checked-in vectors no longer match the JS behavior. Also diff --git a/packages/zero-conformance/src/vectors.ts b/packages/zero-conformance/src/vectors.ts index b85c4a5f71..4879b5e1b9 100644 --- a/packages/zero-conformance/src/vectors.ts +++ b/packages/zero-conformance/src/vectors.ts @@ -1,5 +1,6 @@ import {mkdirSync, readFileSync, writeFileSync} from 'node:fs'; import {fileURLToPath} from 'node:url'; +import {runInNewContext} from 'node:vm'; import {expect} from 'vitest'; /** @@ -13,6 +14,28 @@ const generatedDir = fileURLToPath(new URL('../generated/', import.meta.url)); export const updateVectors = process.env['UPDATE_VECTORS'] === '1'; +/** + * Throws when this runtime's `JSON.parse` misreads escaped keys. Once Node + * 24.22's V8 has parsed an object keyed by a backslash, it reads the JSON text + * `{"\n":2}` as keyed by a backslash too. Vectors written on such a runtime + * would hold its wrong answers, so generate them with the Node in `.nvmrc`. + * The probe runs in a fresh context because the bug depends on what the + * process parsed before. + */ +function assertSoundJSONParse(): void { + const key: unknown = runInNewContext( + String.raw`JSON.parse('{"\\\\":1}'); Object.keys(JSON.parse('{"\\n":2}'))[0]`, + ); + if (key !== '\n') { + throw new Error( + `JSON.parse in Node ${process.version} misreads escaped keys; ` + + 'generate vectors with the Node version in .nvmrc', + ); + } +} + +assertSoundJSONParse(); + export type VectorFile = { formatVersion: number; kind: string; From 0d03fef3bc4235beeb96264971453168d52e756e Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 19 Oct 2026 05:57:21 +0000 Subject: [PATCH 008/104] 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 --- .agents/setup | 11 +- .github/workflows/swift.yml | 53 ++++++ packages/zero-conformance/src/e2e/app.ts | 61 +++++++ packages/zero-conformance/src/e2e/fixture.sql | 68 +++++++ packages/zero-conformance/src/e2e/server.ts | 159 ++++++++++++++++ swift/Package.swift | 13 ++ swift/Sources/Zero/Client/Transport.swift | 102 +++++++++++ swift/Sources/Zero/Schema/NameMapper.swift | 170 ++++++++++++++++++ swift/Tests/E2ETests/NIOTransport.swift | 139 ++++++++++++++ .../Tests/E2ETests/WalkingSkeletonTests.swift | 134 ++++++++++++++ 10 files changed, 909 insertions(+), 1 deletion(-) create mode 100644 packages/zero-conformance/src/e2e/app.ts create mode 100644 packages/zero-conformance/src/e2e/fixture.sql create mode 100644 packages/zero-conformance/src/e2e/server.ts create mode 100644 swift/Sources/Zero/Client/Transport.swift create mode 100644 swift/Sources/Zero/Schema/NameMapper.swift create mode 100644 swift/Tests/E2ETests/NIOTransport.swift create mode 100644 swift/Tests/E2ETests/WalkingSkeletonTests.swift diff --git a/.agents/setup b/.agents/setup index 41c9afe0e9..b81ad429fb 100755 --- a/.agents/setup +++ b/.agents/setup @@ -7,7 +7,8 @@ export DEBIAN_FRONTEND=noninteractive echo '==> Checking native build prerequisites' missing=() -for package in build-essential python3 pkg-config curl ca-certificates xz-utils; do +# postgresql backs the Swift end-to-end tests (packages/zero-conformance/src/e2e). +for package in build-essential python3 pkg-config curl ca-certificates xz-utils postgresql; do if [[ "$(dpkg-query -W -f='${Status}' "$package" 2>/dev/null || true)" != 'install ok installed' ]]; then missing+=("$package") fi @@ -60,6 +61,14 @@ pnpm --version PATH="${PNPM_HOME:-$HOME/.local/share/pnpm}/bin:$PATH" \ pnpm config set --global verify-deps-before-run false +# zero-cache runs from source only on Node 24 (`using`), which the Swift +# end-to-end server needs; the .nvmrc Node stays the default. +echo '==> Installing Node 24 for zero-cache into ~/.local/node24' +if [[ "$("$HOME/.local/node24/bin/node" --version 2>/dev/null || true)" != v24.* ]]; then + time N_PREFIX="$HOME/.local/node24" n install 24 +fi +"$HOME/.local/node24/bin/node" --version + echo '==> Installing workspace dependencies from the lockfile' time pnpm install --frozen-lockfile diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 9163b682b9..68ec3062e2 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -71,3 +71,56 @@ jobs: run: swift build --build-tests - name: Test run: swift test --skip-build + + e2e: + name: Swift end-to-end (Linux) + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + version: 11.11.0 + # zero-cache runs from source, which needs Node 24 (`using`). + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.x + package-manager-cache: false + - run: pnpm install --frozen-lockfile + - name: Start Postgres + run: | + sudo systemctl start postgresql.service + sudo -u postgres psql -q -c "ALTER SYSTEM SET wal_level = logical" \ + -c "ALTER USER postgres PASSWORD 'postgres'" + sudo systemctl restart postgresql.service + until pg_isready -q -h 127.0.0.1; do sleep 1; done + - name: Install Swift + run: | + curl -sSfL -o "$RUNNER_TEMP/swift.tar.gz" \ + https://download.swift.org/swift-6.2.4-release/ubuntu2404/swift-6.2.4-RELEASE/swift-6.2.4-RELEASE-ubuntu24.04.tar.gz + mkdir -p "$RUNNER_TEMP/swift" + tar -xzf "$RUNNER_TEMP/swift.tar.gz" -C "$RUNNER_TEMP/swift" --strip-components=1 + echo "$RUNNER_TEMP/swift/usr/bin" >> "$GITHUB_PATH" + - name: Build + working-directory: swift + run: swift build --build-tests + - name: Start zero-cache + env: + ZERO_E2E_PG: postgresql://postgres:postgres@127.0.0.1:5432/postgres + run: | + node packages/zero-conformance/src/e2e/server.ts > "$RUNNER_TEMP/server.log" 2>&1 & + for _ in $(seq 1 120); do + grep -q '^ready ' "$RUNNER_TEMP/server.log" && exit 0 + sleep 1 + done + cat "$RUNNER_TEMP/server.log" + exit 1 + - name: Test + working-directory: swift + env: + ZERO_E2E_URL: ws://127.0.0.1:4848 + run: swift test --skip-build --filter E2ETests + - name: Server log + if: failure() + run: cat "$RUNNER_TEMP/server.log" diff --git a/packages/zero-conformance/src/e2e/app.ts b/packages/zero-conformance/src/e2e/app.ts new file mode 100644 index 0000000000..503fbb278f --- /dev/null +++ b/packages/zero-conformance/src/e2e/app.ts @@ -0,0 +1,61 @@ +/** + * The app the Swift end-to-end tests talk to: named queries and mutators over + * the zql test schema. The Swift tests define the same names + * (`swift/Tests/E2ETests/`), as an app's Swift client would. + */ +import type {ReadonlyJSONValue} from '../../../shared/src/json.ts'; +import {defineMutators} from '../../../zql/src/mutate/mutator-registry.ts'; +import {defineMutator} from '../../../zql/src/mutate/mutator.ts'; +import {createBuilder} from '../../../zql/src/query/create-builder.ts'; +import { + defineQueries, + defineQuery, +} from '../../../zql/src/query/query-registry.ts'; +import {schema} from '../../../zql/src/query/test/test-schemas.ts'; + +export {schema}; + +const zql = createBuilder(schema); + +export const queries = defineQueries({ + issues: { + open: defineQuery(() => + zql.issue + .where('closed', false) + .related('comments') + .related('owner') + .orderBy('createdAt', 'asc'), + ), + byID: defineQuery(({args}: {args: string}) => + zql.issue.where('id', args).related('comments').one(), + ), + }, +}); + +type CreateIssue = {id: string; title: string; ownerId?: string | undefined}; + +export const mutators = defineMutators({ + issue: { + create: defineMutator( + async ({tx, args}: {tx: AnyTx; args: CreateIssue}) => { + await tx.mutate.issue.insert({ + id: args.id, + title: args.title, + description: '', + closed: false, + ownerId: args.ownerId ?? null, + createdAt: 100, + }); + }, + ), + close: defineMutator(async ({tx, args}: {tx: AnyTx; args: string}) => { + await tx.mutate.issue.update({id: args, closed: true}); + }), + }, +}); + +// The mutators run against the schema's CRUD API without a typed transaction. +// oxlint-disable-next-line @typescript-eslint/no-explicit-any +type AnyTx = any; + +export type Args = ReadonlyJSONValue | undefined; diff --git a/packages/zero-conformance/src/e2e/fixture.sql b/packages/zero-conformance/src/e2e/fixture.sql new file mode 100644 index 0000000000..2a97455e8c --- /dev/null +++ b/packages/zero-conformance/src/e2e/fixture.sql @@ -0,0 +1,68 @@ +-- The upstream database for the end-to-end tests: the tables of the zql test +-- schema (packages/zql/src/query/test/test-schemas.ts) under their server +-- names, with a small seed. + +CREATE TABLE users ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + metadata JSONB +); + +CREATE TABLE issues ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT NOT NULL, + closed BOOLEAN NOT NULL, + owner_id TEXT REFERENCES users(id), + "createdAt" DOUBLE PRECISION NOT NULL +); + +CREATE TABLE comments ( + id TEXT PRIMARY KEY, + "authorId" TEXT NOT NULL, + issue_id TEXT NOT NULL, + text TEXT NOT NULL, + "createdAt" DOUBLE PRECISION NOT NULL +); + +CREATE TABLE label ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE +); + +CREATE TABLE "issueLabel" ( + "issueId" TEXT NOT NULL, + "labelId" TEXT NOT NULL, + PRIMARY KEY ("issueId", "labelId") +); + +CREATE TABLE revision ( + id TEXT PRIMARY KEY, + "authorId" TEXT NOT NULL, + "commentId" TEXT NOT NULL, + text TEXT NOT NULL +); + +INSERT INTO users (id, name, metadata) VALUES + ('u1', 'Alice', '{"registrar": "github", "email": "alice@example.com"}'), + ('u2', 'Bob', NULL), + ('u3', 'Çağrı', '{"registrar": "google", "email": "c@example.com", "altContacts": ["x"]}'); + +INSERT INTO issues (id, title, description, closed, owner_id, "createdAt") VALUES + ('i1', 'Crash on launch', 'It crashes', false, 'u1', 1), + ('i2', 'Slow sync', '', true, 'u2', 2), + ('i3', 'Emoji 😀 in titles', 'é vs é', false, NULL, 3.5), + ('i4', 'Dark mode', 'Please', false, 'u1', 4); + +INSERT INTO comments (id, "authorId", issue_id, text, "createdAt") VALUES + ('c1', 'u2', 'i1', 'Same here', 10), + ('c2', 'u1', 'i1', 'Fixed?', 11), + ('c3', 'u3', 'i3', '👍', 12); + +INSERT INTO label (id, name) VALUES ('l1', 'bug'), ('l2', 'feature'); + +INSERT INTO "issueLabel" ("issueId", "labelId") VALUES + ('i1', 'l1'), ('i2', 'l1'), ('i4', 'l2'); + +INSERT INTO revision (id, "authorId", "commentId", text) VALUES + ('r1', 'u1', 'c2', 'Fixed!'); diff --git a/packages/zero-conformance/src/e2e/server.ts b/packages/zero-conformance/src/e2e/server.ts new file mode 100644 index 0000000000..57289eba8e --- /dev/null +++ b/packages/zero-conformance/src/e2e/server.ts @@ -0,0 +1,159 @@ +/** + * Runs the server side of the Swift end-to-end tests: a fresh Postgres + * database loaded from `fixture.sql`, an API server for the app's named + * queries and mutators, and zero-cache from this repository's source. + * + * node packages/zero-conformance/src/e2e/server.ts + * + * Needs Node 24 (zero-cache's source uses `using`) and `psql` on the PATH. + * Prints `ready ` once zero-cache serves, and stops everything + * on SIGINT or SIGTERM. + * + * Environment: + * ZERO_E2E_PG admin connection URL + * (default postgresql://postgres@127.0.0.1:5432/postgres) + * ZERO_E2E_DB database to (re)create (default zero_e2e) + * ZERO_E2E_PORT zero-cache port (default 4848) + * ZERO_E2E_API_PORT API server port (default 4850) + * ZERO_E2E_DIR working directory for the replica (default a temp dir) + */ +import {execFileSync, spawn} from 'node:child_process'; +import {mkdtempSync} from 'node:fs'; +import {createServer, type IncomingMessage} from 'node:http'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import type {ReadonlyJSONValue} from '../../../shared/src/json.ts'; +import {zeroPostgresJS} from '../../../zero-server/src/adapters/postgresjs.ts'; +import {handleMutateRequest} from '../../../zero-server/src/process-mutations.ts'; +import {handleQueryRequest} from '../../../zero-server/src/queries/process-queries.ts'; +import {mustGetMutator} from '../../../zql/src/mutate/mutator-registry.ts'; +import {mustGetQuery} from '../../../zql/src/query/query-registry.ts'; +import {mutators, queries, schema} from './app.ts'; + +const env = process.env; +const adminURL = + env['ZERO_E2E_PG'] ?? 'postgresql://postgres@127.0.0.1:5432/postgres'; +const dbName = env['ZERO_E2E_DB'] ?? 'zero_e2e'; +const port = Number(env['ZERO_E2E_PORT'] ?? 4848); +const apiPort = Number(env['ZERO_E2E_API_PORT'] ?? 4850); +const workDir = env['ZERO_E2E_DIR'] ?? mkdtempSync(join(tmpdir(), 'zero-e2e-')); + +const here = (path: string) => fileURLToPath(new URL(path, import.meta.url)); +const upstreamURL = (() => { + const url = new URL(adminURL); + url.pathname = `/${dbName}`; + return url.toString(); +})(); + +function psql(url: string, ...args: string[]) { + execFileSync('psql', [url, '-v', 'ON_ERROR_STOP=1', '-q', ...args], { + stdio: ['ignore', 'ignore', 'inherit'], + }); +} + +psql(adminURL, '-c', `DROP DATABASE IF EXISTS ${dbName} WITH (FORCE)`); +psql(adminURL, '-c', `CREATE DATABASE ${dbName}`); +psql(upstreamURL, '-f', here('./fixture.sql')); + +const dbProvider = zeroPostgresJS(schema, upstreamURL); + +async function readJSON(req: IncomingMessage): Promise { + let body = ''; + for await (const chunk of req) { + body += chunk; + } + return JSON.parse(body) as ReadonlyJSONValue; +} + +const api = createServer(async (req, res) => { + const url = new URL(req.url ?? '/', `http://${req.headers.host}`); + try { + let response: unknown; + if (req.method === 'POST' && url.pathname === '/query') { + response = await handleQueryRequest({ + handler: (name, args) => + mustGetQuery(queries, name).fn({args, ctx: undefined}), + schema, + query: Object.fromEntries(url.searchParams), + body: await readJSON(req), + userID: null, + logLevel: 'info', + }); + } else if (req.method === 'POST' && url.pathname === '/mutate') { + response = await handleMutateRequest({ + dbProvider, + handler: transact => + transact((tx, name, args) => + // The registry is typed over the default schema, not this one. + mustGetMutator(mutators, name).fn({ + tx: tx as never, + args, + ctx: undefined, + }), + ), + query: Object.fromEntries(url.searchParams), + body: await readJSON(req), + userID: null, + logLevel: 'info', + }); + } else { + res.writeHead(404).end(); + return; + } + res.writeHead(200, {'content-type': 'application/json'}); + res.end(JSON.stringify(response)); + } catch (e) { + res.writeHead(500).end(String(e)); + } +}); +await new Promise(resolve => api.listen(apiPort, '127.0.0.1', resolve)); + +const zeroCache = spawn( + process.execPath, + [here('../../../zero-cache/src/server/runner/main.ts')], + { + stdio: ['ignore', 'inherit', 'inherit'], + env: { + ...env, + ['NODE_ENV']: 'development', + ['ZERO_UPSTREAM_DB']: upstreamURL, + ['ZERO_REPLICA_FILE']: join(workDir, 'replica.db'), + ['ZERO_PORT']: String(port), + ['ZERO_QUERY_URL']: `http://127.0.0.1:${apiPort}/query`, + ['ZERO_MUTATE_URL']: `http://127.0.0.1:${apiPort}/mutate`, + ['ZERO_NUM_SYNC_WORKERS']: '1', + ['ZERO_LOG_LEVEL']: env['ZERO_LOG_LEVEL'] ?? 'info', + }, + }, +); + +function stop(code: number) { + zeroCache.kill('SIGTERM'); + api.close(); + void dbProvider; + process.exit(code); +} +zeroCache.on('exit', code => { + process.stderr.write(`zero-cache exited with ${code}\n`); + stop(1); +}); +process.on('SIGINT', () => stop(0)); +process.on('SIGTERM', () => stop(0)); + +for (let i = 0; ; i++) { + try { + const res = await fetch(`http://127.0.0.1:${port}/`); + if (res.ok) { + break; + } + } catch { + // Not listening yet. + } + if (i > 600) { + process.stderr.write('zero-cache did not start\n'); + stop(1); + } + await new Promise(resolve => setTimeout(resolve, 100)); +} +process.stdout.write(`ready ws://127.0.0.1:${port}\n`); diff --git a/swift/Package.swift b/swift/Package.swift index 145e27e45a..f9d7cb0d78 100644 --- a/swift/Package.swift +++ b/swift/Package.swift @@ -9,12 +9,25 @@ let package = Package( .library(name: "ZeroUI", targets: ["ZeroUI"]), .library(name: "ZeroTesting", targets: ["ZeroTesting"]), ], + dependencies: [ + // Test-only: the WebSocket transport of the Linux end-to-end tests. + .package(url: "https://github.com/apple/swift-nio.git", from: "2.104.0") + ], targets: [ .target(name: "Zero"), .target(name: "ZeroUI", dependencies: ["Zero"]), .target(name: "ZeroTesting", dependencies: ["Zero"]), .testTarget(name: "ZeroTests", dependencies: ["Zero", "ZeroTesting"]), .testTarget(name: "ConformanceTests", dependencies: ["Zero", "ZeroTesting"]), + .testTarget( + name: "E2ETests", + dependencies: [ + "Zero", + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOPosix", package: "swift-nio"), + .product(name: "NIOHTTP1", package: "swift-nio"), + .product(name: "NIOWebSocket", package: "swift-nio"), + ]), ], swiftLanguageModes: [.v6] ) diff --git a/swift/Sources/Zero/Client/Transport.swift b/swift/Sources/Zero/Client/Transport.swift new file mode 100644 index 0000000000..da16741d0b --- /dev/null +++ b/swift/Sources/Zero/Client/Transport.swift @@ -0,0 +1,102 @@ +/// A WebSocket message. +public enum SocketMessage: Sendable, Hashable { + case text(String) + case binary([UInt8]) +} + +/// Opens the sync socket. The engine never touches a socket itself: the Apple +/// layer implements this with `URLSessionWebSocketTask`, and tests replay +/// scenarios through an in-memory transport. +public protocol Transport: Sendable { + /// Opens a WebSocket to `url`, sending `secProtocol` (when non-nil) as + /// the `Sec-WebSocket-Protocol` header. + func connect(url: String, secProtocol: String?) async throws -> any Socket +} + +/// An open WebSocket. +public protocol Socket: AnyObject, Sendable { + func send(_ message: SocketMessage) async throws + /// The next message, or nil once the socket has closed. + func receive() async throws -> SocketMessage? + func close(code: Int, reason: String) async +} + +/// The query parameters of the sync connect URL, in the order JS sets them +/// (`createConnectionURL` in `packages/zero-client/src/client/zero.ts`). +public struct ConnectionURL: Sendable, Hashable { + public var socketOrigin: String + public var clientID: String + public var clientGroupID: String + public var userID: String? + /// The cookie of the client's last server snapshot, nil for none. + public var baseCookie: String? + /// The `ts` parameter, `performance.now()` in JS. + public var timestamp: Double + public var lastMutationID: Double + public var wsid: String + public var profileID: String + public var debugPerf: Bool + public var additionalParams: [(String, String)] + + public init( + socketOrigin: String, clientID: String, clientGroupID: String, userID: String? = nil, + baseCookie: String? = nil, timestamp: Double, lastMutationID: Double, wsid: String, + profileID: String, debugPerf: Bool = false, additionalParams: [(String, String)] = [] + ) { + self.socketOrigin = socketOrigin + self.clientID = clientID + self.clientGroupID = clientGroupID + self.userID = userID + self.baseCookie = baseCookie + self.timestamp = timestamp + self.lastMutationID = lastMutationID + self.wsid = wsid + self.profileID = profileID + self.debugPerf = debugPerf + self.additionalParams = additionalParams + } + + public static func == (a: ConnectionURL, b: ConnectionURL) -> Bool { a.string == b.string } + public func hash(into hasher: inout Hasher) { hasher.combine(string) } + + public var string: String { + var params: [(String, String)] = [ + ("clientID", clientID), ("clientGroupID", clientGroupID), + ] + if let userID { params.append(("userID", userID)) } + params += [ + ("baseCookie", baseCookie ?? ""), ("ts", JSNumber.format(timestamp)), + ("lmid", JSNumber.format(lastMutationID)), ("wsid", wsid), ("profileID", profileID), + ] + if debugPerf { params.append(("debugPerf", "true")) } + // A parameter the client sets wins; JS warns and skips the duplicate. + for (k, v) in additionalParams where !params.contains(where: { $0.0 == k }) { + params.append((k, v)) + } + let path = "/sync/v\(protocolVersion)/connect" + let base = socketOrigin.hasSuffix("/") ? socketOrigin + path.dropFirst() : socketOrigin + path + return base + "?" + + params.map { formEncode($0.0) + "=" + formEncode($0.1) }.joined(separator: "&") + } +} + +/// `application/x-www-form-urlencoded`, as `URLSearchParams` writes it. +func formEncode(_ s: String) -> String { + var out = "" + for byte in s.utf8 { + switch byte { + case UInt8(ascii: "a")...UInt8(ascii: "z"), UInt8(ascii: "A")...UInt8(ascii: "Z"), + UInt8(ascii: "0")...UInt8(ascii: "9"), UInt8(ascii: "*"), UInt8(ascii: "-"), + UInt8(ascii: "."), UInt8(ascii: "_"): + out.unicodeScalars.append(Unicode.Scalar(byte)) + case UInt8(ascii: " "): + out += "+" + default: + let hex = Array("0123456789ABCDEF".unicodeScalars) + out += "%" + out.unicodeScalars.append(hex[Int(byte >> 4)]) + out.unicodeScalars.append(hex[Int(byte & 0xF)]) + } + } + return out +} diff --git a/swift/Sources/Zero/Schema/NameMapper.swift b/swift/Sources/Zero/Schema/NameMapper.swift new file mode 100644 index 0000000000..08f33306df --- /dev/null +++ b/swift/Sources/Zero/Schema/NameMapper.swift @@ -0,0 +1,170 @@ +/// An unknown table or column met while mapping names. +public struct NameMappingError: Error, Sendable, Hashable, CustomStringConvertible { + public var description: String +} + +/// Maps table and column names between the client and the server +/// (`NameMapper` in `packages/zero-types/src/name-mapper.ts`). +public struct NameMapper: Sendable { + struct Destination: Sendable { + var tableName: String + var columns: [String: String] + var allColumnsSame: Bool + } + + let tables: [String: Destination] + + /// `clientToServer`. + public static func clientToServer(_ schema: ZeroSchema) -> NameMapper { + NameMapper(schema, fromClient: true) + } + + /// `serverToClient`. + public static func serverToClient(_ schema: ZeroSchema) -> NameMapper { + NameMapper(schema, fromClient: false) + } + + init(_ schema: ZeroSchema, fromClient: Bool) { + var tables: [String: Destination] = [:] + for (name, table) in schema.tables { + var allColumnsSame = true + var columns: [String: String] = [:] + for (column, c) in table.columns { + if let serverName = c.serverName, serverName != column { allColumnsSame = false } + if fromClient { + columns[column] = c.serverName ?? column + } else { + columns[c.serverName ?? column] = column + } + } + let serverName = table.serverName ?? name + tables[fromClient ? name : serverName] = Destination( + tableName: fromClient ? serverName : name, columns: columns, + allColumnsSame: allColumnsSame) + } + self.tables = tables + } + + private func table(_ src: String) throws(NameMappingError) -> Destination { + guard let table = tables[src] else { + throw NameMappingError(description: "unknown table \"\(src)\"") + } + return table + } + + public func tableName(_ src: String) throws(NameMappingError) -> String { + try table(src).tableName + } + + public func tableNameIfKnown(_ src: String) -> String? { tables[src]?.tableName } + + public func columnName(_ table: String, _ src: String) throws(NameMappingError) -> String { + guard let dst = try self.table(table).columns[src] else { + throw NameMappingError(description: "unknown column \"\(src)\" of \"\(table)\" table") + } + return dst + } + + /// Renames the known columns of `row`; others keep their names. + public func row(_ table: String, _ row: Row) throws(NameMappingError) -> Row { + let dest = try self.table(table) + if dest.allColumnsSame { return row } + var out: Row = [:] + for (column, value) in row { out[dest.columns[column] ?? column] = value } + return out + } + + public func columns(_ table: String, _ columns: [String]) throws(NameMappingError) -> [String] { + let dest = try self.table(table) + if dest.allColumnsSame { return columns } + return columns.map { dest.columns[$0] ?? $0 } + } + + /// `mapAST`: the AST with its table and column names mapped. + public func map(_ ast: AST) throws(NameMappingError) -> AST { + var out = ast + out.table = try tableName(ast.table) + out.where = try ast.where.map { (w) throws(NameMappingError) in try map(w, table: ast.table) } + if let related = ast.related { + var mapped: [AST.CorrelatedSubquery] = [] + for r in related { + var r2 = r + r2.correlation = try map(r.correlation, parent: ast.table, child: r.subquery.table) + r2.subquery = try map(r.subquery) + mapped.append(r2) + } + out.related = mapped + } + if let start = ast.start { + var row: Row = [:] + for (column, value) in start.row { row[try columnName(ast.table, column)] = value } + out.start = AST.Bound(row: row, exclusive: start.exclusive) + } + if let orderBy = ast.orderBy { + var mapped: AST.Ordering = [] + for part in orderBy { + mapped.append(AST.OrderPart(try columnName(ast.table, part.field), part.direction)) + } + out.orderBy = mapped + } + return out + } + + /// `mapCondition`. Only the left side of a comparison names a column. + public func map( + _ condition: AST.Condition, table: String + ) throws(NameMappingError) + -> AST.Condition + { + switch condition { + case .simple(let op, let left, let right): + if case .column(let name) = left { + return .simple(op: op, left: .column(try columnName(table, name)), right: right) + } + return condition + case .correlatedSubquery(var related, let op, let flip, let scalar): + related.correlation = try map( + related.correlation, parent: table, child: related.subquery.table) + related.subquery = try map(related.subquery) + return .correlatedSubquery(related: related, op: op, flip: flip, scalar: scalar) + case .and(let conditions): + var mapped: [AST.Condition] = [] + for c in conditions { mapped.append(try map(c, table: table)) } + return .and(mapped) + case .or(let conditions): + var mapped: [AST.Condition] = [] + for c in conditions { mapped.append(try map(c, table: table)) } + return .or(mapped) + } + } + + private func map( + _ correlation: AST.Correlation, parent: String, child: String + ) + throws(NameMappingError) -> AST.Correlation + { + var parentField: [String] = [] + for c in correlation.parentField { parentField.append(try columnName(parent, c)) } + var childField: [String] = [] + for c in correlation.childField { childField.append(try columnName(child, c)) } + return AST.Correlation(parentField: parentField, childField: childField) + } +} + +extension ZeroSchema { + /// The schema zero-cache checks the replica against, under server names + /// (`clientSchemaFrom` in `packages/zero-schema/src/builder/schema-builder.ts`). + public var clientSchema: ClientSchema { + var tables: [String: ClientSchema.Table] = [:] + for (name, table) in self.tables { + var columns: [String: ClientSchema.ValueType] = [:] + for (column, c) in table.columns { + columns[c.serverName ?? column] = ClientSchema.ValueType(rawValue: c.type.rawValue)! + } + tables[table.serverName ?? name] = ClientSchema.Table( + columns: columns, + primaryKey: table.primaryKey.map { table.columns[$0]?.serverName ?? $0 }) + } + return ClientSchema(tables: tables).normalized() + } +} diff --git a/swift/Tests/E2ETests/NIOTransport.swift b/swift/Tests/E2ETests/NIOTransport.swift new file mode 100644 index 0000000000..9d9bd49d3d --- /dev/null +++ b/swift/Tests/E2ETests/NIOTransport.swift @@ -0,0 +1,139 @@ +import NIOCore +import NIOHTTP1 +import NIOPosix +import NIOWebSocket +import Zero + +/// A `Transport` over SwiftNIO for the Linux end-to-end tests: plain `ws://` +/// only, and no TLS. +struct NIOTransport: Transport { + struct Failure: Error, CustomStringConvertible { + var description: String + } + + private enum Upgrade { + case websocket(NIOAsyncChannel) + case declined + } + + func connect(url: String, secProtocol: String?) async throws -> any Socket { + let (host, port, uri) = try Self.parse(url) + let upgrade: EventLoopFuture = try await ClientBootstrap( + group: MultiThreadedEventLoopGroup.singleton + ) + .connect(host: host, port: port) { channel in + channel.eventLoop.makeCompletedFuture { + let upgrader = NIOTypedWebSocketClientUpgrader(maxFrameSize: 1 << 24) { + channel, _ in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHandler( + NIOWebSocketFrameAggregator( + minNonFinalFragmentSize: 0, maxAccumulatedFrameCount: 1 << 16, + maxAccumulatedFrameSize: 1 << 26)) + return .websocket(try NIOAsyncChannel(wrappingChannelSynchronously: channel)) + } + } + var headers = HTTPHeaders() + headers.add(name: "Host", value: "\(host):\(port)") + if let secProtocol { headers.add(name: "Sec-WebSocket-Protocol", value: secProtocol) } + let head = HTTPRequestHead(version: .http1_1, method: .GET, uri: uri, headers: headers) + return try channel.pipeline.syncOperations.configureUpgradableHTTPClientPipeline( + configuration: .init( + upgradeConfiguration: .init( + upgradeRequestHead: head, upgraders: [upgrader], + notUpgradingCompletionHandler: { $0.eventLoop.makeCompletedFuture { .declined } }))) + } + } + switch try await upgrade.get() { + case .websocket(let channel): return NIOSocket(channel) + case .declined: throw Failure(description: "\(url) declined the WebSocket upgrade") + } + } + + /// `ws://host[:port]/path?query` → host, port, and request URI. + static func parse(_ url: String) throws -> (String, Int, String) { + guard url.hasPrefix("ws://") else { throw Failure(description: "not a ws:// URL: \(url)") } + let rest = url.dropFirst("ws://".count) + let slash = rest.firstIndex(of: "/") ?? rest.endIndex + let authority = rest[...AsyncIterator + private let outbound: AsyncStream.Continuation + private let task: Task + + init(_ channel: NIOAsyncChannel) { + let (inStream, received) = AsyncThrowingStream.makeStream() + let (outStream, outbound) = AsyncStream.makeStream() + self.inbound = inStream.makeAsyncIterator() + self.outbound = outbound + task = Task { + do { + try await channel.executeThenClose { frames, writer in + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + for await frame in outStream { try await writer.write(frame) } + } + group.addTask { + for try await frame in frames { + switch frame.opcode { + case .text: received.yield(.text(String(buffer: frame.unmaskedData))) + case .binary: received.yield(.binary(Array(buffer: frame.unmaskedData))) + case .ping: + outbound.yield( + WebSocketFrame( + fin: true, opcode: .pong, maskKey: .random(), data: frame.unmaskedData)) + case .connectionClose: return + default: break + } + } + } + try await group.next() + group.cancelAll() + } + } + received.finish() + } catch { + received.finish(throwing: error) + } + } + } + + func send(_ message: SocketMessage) async throws { + var data = ByteBuffer() + let opcode: WebSocketOpcode + switch message { + case .text(let s): + data.writeString(s) + opcode = .text + case .binary(let b): + data.writeBytes(b) + opcode = .binary + } + outbound.yield(WebSocketFrame(fin: true, opcode: opcode, maskKey: .random(), data: data)) + } + + func receive() async throws -> SocketMessage? { + try await inbound.next() + } + + func close(code: Int, reason: String) async { + var data = ByteBuffer() + data.write(webSocketErrorCode: WebSocketErrorCode(codeNumber: code)) + data.writeString(reason) + outbound.yield( + WebSocketFrame(fin: true, opcode: .connectionClose, maskKey: .random(), data: data)) + outbound.finish() + await task.value + } +} diff --git a/swift/Tests/E2ETests/WalkingSkeletonTests.swift b/swift/Tests/E2ETests/WalkingSkeletonTests.swift new file mode 100644 index 0000000000..8454f27ac4 --- /dev/null +++ b/swift/Tests/E2ETests/WalkingSkeletonTests.swift @@ -0,0 +1,134 @@ +import Foundation +import Testing +import Zero + +/// The end-to-end environment: `ZERO_E2E_URL` is the origin of a zero-cache +/// over the fixture database (`packages/zero-conformance/src/e2e/`). +enum E2E { + static let url = ProcessInfo.processInfo.environment["ZERO_E2E_URL"] + + /// The zql test schema the fixture database is built from + /// (`packages/zql/src/query/test/test-schemas.ts`). + static let schema = ZeroSchema( + tables: [ + TableSchema( + name: "issue", serverName: "issues", + columns: [ + ("id", ColumnSchema(.string)), ("title", ColumnSchema(.string)), + ("description", ColumnSchema(.string)), ("closed", ColumnSchema(.boolean)), + ("ownerId", ColumnSchema(.string, optional: true, serverName: "owner_id")), + ("createdAt", ColumnSchema(.number)), + ], + primaryKey: ["id"]), + TableSchema( + name: "user", serverName: "users", + columns: [ + ("id", ColumnSchema(.string)), ("name", ColumnSchema(.string)), + ("metadata", ColumnSchema(.json, optional: true)), + ], + primaryKey: ["id"]), + TableSchema( + name: "comment", serverName: "comments", + columns: [ + ("id", ColumnSchema(.string)), ("authorId", ColumnSchema(.string)), + ("issueId", ColumnSchema(.string, serverName: "issue_id")), + ("text", ColumnSchema(.string)), ("createdAt", ColumnSchema(.number)), + ], + primaryKey: ["id"]), + ], + relationships: [ + "issue": [ + "comments": [ + RelationshipHop( + sourceField: ["id"], destField: ["issueId"], destSchema: "comment", cardinality: .many) + ], + "owner": [ + RelationshipHop( + sourceField: ["ownerId"], destField: ["id"], destSchema: "user", cardinality: .one) + ], + ] + ]) +} + +/// M1.5: connect to a real zero-cache, ask for a named query, and receive +/// its rows. +@Suite(.enabled(if: E2E.url != nil)) +struct WalkingSkeletonTests { + @Test func syncsTheRowsOfAQuery() async throws { + // `issues.open` in packages/zero-conformance/src/e2e/app.ts. The server + // runs its own definition; the client only names it. + let hash = QueryHash.hashOfNameAndArgs("issues.open", []) + let clientID = "swift-\(UInt32.random(in: 0...UInt32.max))" + let initConnection = InitConnectionBody( + desiredQueriesPatch: [ + .put(UpQueriesPut(hash: hash, ttl: 60_000, name: "issues.open", args: [])) + ], + clientSchema: E2E.schema.clientSchema, activeClients: [clientID]) + let url = ConnectionURL( + socketOrigin: E2E.url!, clientID: clientID, clientGroupID: "\(clientID)-group", + timestamp: 0, lastMutationID: 0, wsid: "ws1", profileID: "p\(clientID)") + let socket = try await NIOTransport().connect( + url: url.string, + secProtocol: SecProtocols.encode(initConnection: initConnection, authToken: nil)) + + var connected = false + var chunks: [ArraySlice] = [] + var rows: [String: [Row]] = [:] + var gotQueries: Set = [] + receiving: while let message = try await socket.receive() { + switch message { + case .binary(let bytes): + chunks.append(try PokeChunk.payload(ofBinaryMessage: bytes)) + case .text(let text): + let downstream = try Downstream(text: text) + switch downstream { + case .connected: + connected = true + case .pokeStart: + chunks = [] + case .pokePart(let part): + apply([part]) + case .pokeEnd(let end): + if end.cancel != true { apply(try PokeChunk.decode(chunks)) } + if gotQueries.contains(hash) { break receiving } + case .error(let error): + Issue.record("zero-cache error: \(error)") + break receiving + default: + break + } + } + } + await socket.close(code: 1000, reason: "done") + + func apply(_ parts: [PokePartBody]) { + for part in parts { + if case .some(let patch) = part.gotQueriesPatch { + for op in patch { + if case .put(let h, _) = op { gotQueries.insert(h) } + } + } + for op in part.rowsPatch ?? [] { + if case .put(let table, let value) = op { rows[table, default: []].append(value) } + } + } + } + + #expect(connected) + #expect(gotQueries.contains(hash)) + let toClient = NameMapper.serverToClient(E2E.schema) + let issues = try (rows["issues"] ?? []).map { try toClient.row("issues", $0) } + let comments = try (rows["comments"] ?? []).map { try toClient.row("comments", $0) } + let users = try (rows["users"] ?? []).map { try toClient.row("users", $0) } + for (table, rows) in rows.sorted(by: { $0.key < $1.key }) { + for row in rows { print("[e2e] \(table) \(JSONSerializer.stringify(.object(row)))") } + } + // The fixture's open issues, their comments, and their owners. + #expect(Set(issues.map { $0["id"]! }) == ["i1", "i3", "i4"]) + #expect(Set(comments.map { $0["id"]! }) == ["c1", "c2", "c3"]) + #expect(Set(users.map { $0["id"]! }) == ["u1"]) + #expect(issues.first { $0["id"] == "i3" }?["title"] == "Emoji 😀 in titles") + #expect(issues.first { $0["id"] == "i1" }?["ownerId"] == "u1") + #expect(users.first?["metadata"] == ["registrar": "github", "email": "alice@example.com"]) + } +} From 1438f2f282b5885f5d697070f31509cb52f17e5f Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 19 Oct 2026 06:09:45 +0000 Subject: [PATCH 009/104] 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 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() 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 --- .github/workflows/swift.yml | 5 + packages/zero-swift-cli/package.json | 30 + packages/zero-swift-cli/src/cli.ts | 92 +++ packages/zero-swift-cli/src/codegen.test.ts | 45 ++ packages/zero-swift-cli/src/generate.ts | 547 ++++++++++++++++++ packages/zero-swift-cli/src/types.ts | 204 +++++++ .../zero-swift-cli/test/fixture-schema.ts | 89 +++ packages/zero-swift-cli/tsconfig.json | 7 + pnpm-lock.yaml | 25 + swift/DESIGN.md | 6 + swift/Sources/Zero/Query/Query.swift | 337 +++++++++++ swift/Sources/Zero/Query/ZeroValue.swift | 183 ++++++ .../ZeroTests/Generated/FixtureSchema.swift | 374 ++++++++++++ .../ZeroTests/Generated/TestSchema.swift | 507 ++++++++++++++++ swift/Tests/ZeroTests/TypedQueryTests.swift | 168 ++++++ 15 files changed, 2619 insertions(+) create mode 100644 packages/zero-swift-cli/package.json create mode 100644 packages/zero-swift-cli/src/cli.ts create mode 100644 packages/zero-swift-cli/src/codegen.test.ts create mode 100644 packages/zero-swift-cli/src/generate.ts create mode 100644 packages/zero-swift-cli/src/types.ts create mode 100644 packages/zero-swift-cli/test/fixture-schema.ts create mode 100644 packages/zero-swift-cli/tsconfig.json create mode 100644 swift/Sources/Zero/Query/Query.swift create mode 100644 swift/Sources/Zero/Query/ZeroValue.swift create mode 100644 swift/Tests/ZeroTests/Generated/FixtureSchema.swift create mode 100644 swift/Tests/ZeroTests/Generated/TestSchema.swift create mode 100644 swift/Tests/ZeroTests/TypedQueryTests.swift diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 68ec3062e2..8d5c234217 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -41,6 +41,11 @@ jobs: # writes the seeded fuzz corpora (generated/), which aren't checked in. - name: Check vectors are current run: pnpm --filter zero-conformance run test + # The Swift tests compile the Swift that zero-swift generates. + - name: Check generated Swift is current + run: pnpm --filter zero-swift run test + - name: Check types + run: pnpm --filter zero-conformance --filter zero-swift run check-types - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: generated-vectors diff --git a/packages/zero-swift-cli/package.json b/packages/zero-swift-cli/package.json new file mode 100644 index 0000000000..b3f9386190 --- /dev/null +++ b/packages/zero-swift-cli/package.json @@ -0,0 +1,30 @@ +{ + "name": "zero-swift", + "version": "0.0.0", + "private": true, + "description": "Generates the Swift row types and schema of a Zero app for the Zero Swift client.", + "license": "Apache-2.0", + "bin": { + "zero-swift": "./src/cli.ts" + }, + "type": "module", + "scripts": { + "test": "vitest run", + "generate": "UPDATE_GENERATED=1 vitest run", + "check-types": "tsc", + "lint": "oxlint --quiet --config ../../oxlint.config.ts src/", + "format": "oxfmt .", + "check-format": "oxfmt --check ." + }, + "dependencies": { + "typescript": "6.0.3" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "oxfmt": "^0.65.0", + "vitest": "^5.0.0", + "zero-schema": "workspace:*", + "zero-types": "workspace:*", + "zql": "workspace:*" + } +} diff --git a/packages/zero-swift-cli/src/cli.ts b/packages/zero-swift-cli/src/cli.ts new file mode 100644 index 0000000000..85de5e563b --- /dev/null +++ b/packages/zero-swift-cli/src/cli.ts @@ -0,0 +1,92 @@ +#!/usr/bin/env node +/** + * `zero-swift codegen`: generates the Swift row types, schema, and query + * builder of a Zero app from its TypeScript schema. + * + * zero-swift codegen [--schema schema.ts] [--out Schema.swift] + * [--config zero-swift.json] [--check] + * + * Options override the config file, which defaults to `zero-swift.json` when + * present. `--check` exits with an error when the output is out of date + * instead of writing it. + */ +import {existsSync, readFileSync, writeFileSync} from 'node:fs'; +import {relative, resolve} from 'node:path'; +import {pathToFileURL} from 'node:url'; +import {parseArgs} from 'node:util'; +import type {Schema} from '../../zero-types/src/schema.ts'; +import {generate, type Config} from './generate.ts'; +import {readCustomTypes} from './types.ts'; + +export async function codegenFile(config: Config, cwd: string) { + const schemaPath = resolve(cwd, config.schema ?? 'schema.ts'); + const exportName = config.export ?? 'schema'; + const module = (await import(pathToFileURL(schemaPath).href)) as Record< + string, + unknown + >; + const schema = module[exportName] as Schema | undefined; + if (!schema || typeof schema !== 'object' || !('tables' in schema)) { + throw new Error( + `${schemaPath} does not export a schema as "${exportName}"`, + ); + } + const customTypes = readCustomTypes(schemaPath, exportName); + return generate( + schema, + customTypes, + config, + relative(cwd, schemaPath).replaceAll('\\', '/'), + ); +} + +async function main(argv: string[]): Promise { + const {values, positionals} = parseArgs({ + args: argv, + allowPositionals: true, + options: { + schema: {type: 'string'}, + out: {type: 'string'}, + config: {type: 'string'}, + check: {type: 'boolean'}, + }, + }); + const [command = 'codegen'] = positionals; + if (command !== 'codegen') { + process.stderr.write(`Unknown command "${command}"\n`); + return 2; + } + const cwd = process.cwd(); + const configPath = + values.config ?? + (existsSync(resolve(cwd, 'zero-swift.json')) + ? 'zero-swift.json' + : undefined); + const fileConfig = configPath + ? (JSON.parse(readFileSync(resolve(cwd, configPath), 'utf8')) as Config) + : {}; + const config: Config = { + ...fileConfig, + ...(values.schema ? {schema: values.schema} : {}), + ...(values.out ? {output: values.out} : {}), + }; + const output = resolve(cwd, config.output ?? 'Schema.swift'); + const swift = await codegenFile(config, cwd); + const current = existsSync(output) ? readFileSync(output, 'utf8') : undefined; + if (values.check) { + if (current !== swift) { + process.stderr.write(`${relative(cwd, output)} is out of date\n`); + return 1; + } + return 0; + } + if (current !== swift) { + writeFileSync(output, swift); + } + process.stdout.write(`Wrote ${relative(cwd, output)}\n`); + return 0; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + process.exitCode = await main(process.argv.slice(2)); +} diff --git a/packages/zero-swift-cli/src/codegen.test.ts b/packages/zero-swift-cli/src/codegen.test.ts new file mode 100644 index 0000000000..e9f7837d96 --- /dev/null +++ b/packages/zero-swift-cli/src/codegen.test.ts @@ -0,0 +1,45 @@ +import {readFileSync, writeFileSync} from 'node:fs'; +import {fileURLToPath} from 'node:url'; +import {expect, test} from 'vitest'; +import {codegenFile} from './cli.ts'; +import type {Config} from './generate.ts'; + +const repo = fileURLToPath(new URL('../../../', import.meta.url)); +const update = process.env['UPDATE_GENERATED'] === '1'; + +/** + * The Swift tests compile and run these generated files, so they are checked + * in; this keeps them current with the generator and the schemas. + */ +const fixtures: {name: string; config: Config}[] = [ + { + name: 'zql test schema', + config: { + schema: 'packages/zql/src/query/test/test-schemas.ts', + output: 'swift/Tests/ZeroTests/Generated/TestSchema.swift', + schemaName: 'test', + }, + }, + { + name: 'codegen fixture schema', + config: { + schema: 'packages/zero-swift-cli/test/fixture-schema.ts', + output: 'swift/Tests/ZeroTests/Generated/FixtureSchema.swift', + schemaName: 'fixture', + zql: 'fixtureZQL', + typePrefix: 'Fx', + tables: {project: {columns: {position: {type: 'int'}}}}, + }, + }, +]; + +for (const {name, config} of fixtures) { + test(`generated Swift for the ${name} is current`, async () => { + const swift = await codegenFile(config, repo); + const path = repo + config.output; + if (update) { + writeFileSync(path, swift); + } + expect(swift).toBe(readFileSync(path, 'utf8')); + }); +} diff --git a/packages/zero-swift-cli/src/generate.ts b/packages/zero-swift-cli/src/generate.ts new file mode 100644 index 0000000000..cffd274fc5 --- /dev/null +++ b/packages/zero-swift-cli/src/generate.ts @@ -0,0 +1,547 @@ +/** + * Emits the Swift for a Zero schema: a row struct per table, the types of + * its enumerations and typed JSON columns, the runtime schema, and the + * `zql` query builder. + */ +import type {Schema} from '../../zero-types/src/schema.ts'; +import type {CustomTypes, FieldModel, TypeModel} from './types.ts'; + +export type Config = { + /** The TS schema module. Default `schema.ts`. */ + readonly schema?: string | undefined; + /** The export of the schema module that holds the schema. Default `schema`. */ + readonly export?: string | undefined; + /** The Swift file to write. Default `Schema.swift`. */ + readonly output?: string | undefined; + /** The `ZeroSchema` static the schema is generated as. Default `app`. */ + readonly schemaName?: string | undefined; + /** The global query builder. Default `zql`, of type `ZQL`. */ + readonly zql?: string | undefined; + /** Prepended to every generated type name. Default none. */ + readonly typePrefix?: string | undefined; + /** The access level of everything generated. Default `public`. */ + readonly access?: 'public' | 'internal' | undefined; + readonly tables?: + | Readonly< + Record< + string, + { + /** The row struct's name. Default the table name in PascalCase. */ + readonly type?: string | undefined; + readonly columns?: + | Readonly< + Record + > + | undefined; + } + > + > + | undefined; +}; + +/** Zero types the generated code names unqualified. */ +const reservedTypeNames = new Set([ + 'AnyQuery', + 'Cardinality', + 'ColumnSchema', + 'Column', + 'Condition', + 'Format', + 'Indirect', + 'JSONValue', + 'KeyPathNames', + 'Query', + 'QueryResult', + 'RelationshipHop', + 'Row', + 'RowExpression', + 'TableSchema', + 'ViewEntry', + 'ViewValue', + 'Zero', + 'ZeroEnum', + 'ZeroObjectReader', + 'ZeroObjectWriter', + 'ZeroRow', + 'ZeroSchema', + 'ZeroValue', + 'ZeroValueError', + 'Array', + 'Bool', + 'Dictionary', + 'Double', + 'Int', + 'Optional', + 'Set', + 'String', +]); + +const swiftKeywords = new Set( + ( + 'associatedtype borrowing class consuming deinit enum extension fileprivate ' + + 'func import init inout internal let nonisolated open operator package ' + + 'private precedencegroup protocol public rethrows static struct subscript ' + + 'typealias var break case catch continue default defer do else ' + + 'fallthrough for guard if in repeat return throw switch where while Any ' + + 'as await false is nil self Self super throws true try' + ).split(' '), +); + +const nonIdentifierChars = /[^A-Za-z0-9_]/g; +const leadingDigit = /^[0-9]/; +const wordBoundary = /([a-z0-9])([A-Z])/g; +const wordSeparators = /[^A-Za-z0-9]+/; +const identifierStart = /^[A-Za-z_]/; + +/** A name usable as a Swift identifier, backticked when it is a keyword. */ +export function identifier(name: string): string { + let id = name.replace(nonIdentifierChars, '_'); + if (leadingDigit.test(id) || id === '') { + id = '_' + id; + } + return swiftKeywords.has(id) ? '`' + id + '`' : id; +} + +function words(name: string): string[] { + return name + .replace(wordBoundary, '$1 $2') + .split(wordSeparators) + .filter(w => w !== ''); +} + +export function pascalCase(name: string): string { + const out = words(name) + .map(w => w[0].toUpperCase() + w.slice(1)) + .join(''); + return identifierStart.test(out) ? out : '_' + out; +} + +function camelCase(name: string): string { + const p = pascalCase(name); + return p[0] === '_' ? p : p[0].toLowerCase() + p.slice(1); +} + +function str(s: string): string { + return JSON.stringify(s); +} + +function strings(a: readonly string[]): string { + return `[${a.map(str).join(', ')}]`; +} + +type Named = + | {kind: 'enum'; name: string; cases: string[]} + | {kind: 'object'; name: string; fields: FieldModel[]}; + +class Emitter { + readonly #access: string; + readonly #named: Named[] = []; + readonly #names = new Set(); + /** Declared types by alias and shape, so a type used twice is one type. */ + readonly #byAlias = new Map(); + + readonly #prefix: string; + + constructor(access: string, prefix: string, rowNames: Iterable) { + this.#access = access === 'internal' ? '' : 'public '; + this.#prefix = prefix; + for (const n of rowNames) { + this.#names.add(n); + } + } + + get access() { + return this.#access; + } + + bare(name: string): string { + return this.#prefix && name.startsWith(this.#prefix) + ? name.slice(this.#prefix.length) + : name; + } + + get named(): readonly Named[] { + return this.#named; + } + + /** A free type name, without the prefix: the alias, else `fallback`. */ + #claimBare(preferred: string | undefined, fallback: string): string { + const free = (name: string) => + !this.#names.has(this.#prefix + name) && + !reservedTypeNames.has(this.#prefix + name); + for (const name of [preferred, fallback]) { + if (name && free(name)) { + this.#names.add(this.#prefix + name); + return name; + } + } + for (let i = 2; ; i++) { + const name = `${fallback}${i}`; + if (free(name)) { + this.#names.add(this.#prefix + name); + return name; + } + } + } + + /** The Swift type of a model, naming its enums and objects after `path`. */ + swiftType(model: TypeModel, path: string): string { + switch (model.kind) { + case 'string': + return 'String'; + case 'number': + return 'Double'; + case 'boolean': + return 'Bool'; + case 'json': + return 'JSONValue'; + case 'nullable': + return `${this.swiftType(model.wrapped, path)}?`; + case 'array': + return `[${this.swiftType(model.element, path)}]`; + case 'enum': + case 'object': { + const key = model.alias && `${model.alias}:${JSON.stringify(model)}`; + const existing = key && this.#byAlias.get(key); + if (existing) { + return existing; + } + const name = this.#prefix + this.#claimBare(model.alias, path); + if (key) { + this.#byAlias.set(key, name); + } + this.#named.push( + model.kind === 'enum' + ? {kind: 'enum', name, cases: model.cases} + : {kind: 'object', name, fields: model.fields}, + ); + return name; + } + } + } +} + +type Column = { + name: string; + property: string; + swiftType: string; + optional: boolean; +}; + +export function generate( + schema: Schema, + customTypes: CustomTypes, + config: Config, + source: string, +): string { + const tableNames = Object.keys(schema.tables); + const prefix = config.typePrefix ?? ''; + const rowNames = new Map(); + const used = new Set(); + for (const t of tableNames) { + let name = config.tables?.[t]?.type ?? prefix + pascalCase(t); + if (reservedTypeNames.has(name) || used.has(name)) { + name += 'Row'; + } + used.add(name); + rowNames.set(t, name); + } + const e = new Emitter(config.access ?? 'public', prefix, used); + const bare = (name: string) => + prefix && name.startsWith(prefix) ? name.slice(prefix.length) : name; + const pub = e.access; + const schemaName = config.schemaName ?? 'app'; + const zqlName = config.zql ?? 'zql'; + const zqlType = zqlName === 'zql' ? 'ZQL' : pascalCase(zqlName); + + const out: string[] = [ + `// Generated by \`zero-swift codegen\` from ${source}. Do not edit.`, + '// swift-format-ignore-file', + '', + 'import Zero', + '', + ]; + + const typeDecls: string[] = []; + for (const t of tableNames) { + const table = schema.tables[t]; + const row = rowNames.get(t)!; + const columns: Column[] = Object.entries(table.columns).map(([name, c]) => { + const custom = customTypes.get(t)?.get(name); + const override = config.tables?.[t]?.columns?.[name]?.type; + let base: string; + if (override === 'int') { + base = 'Int'; + } else if (c.type === 'number' || override === 'double') { + base = 'Double'; + } else if (c.type === 'boolean') { + base = 'Bool'; + } else if (c.type === 'string') { + base = + custom?.kind === 'enum' + ? e.swiftType(custom, bare(row) + pascalCase(name)) + : 'String'; + } else if (c.type === 'json') { + base = custom + ? e.swiftType(custom, bare(row) + pascalCase(name)) + : 'JSONValue'; + } else { + base = 'JSONValue'; + } + const optional = c.optional === true; + const swiftType = optional && !base.endsWith('?') ? base + '?' : base; + return {name, property: identifier(name), swiftType, optional}; + }); + + const relationships = Object.entries(schema.relationships[t] ?? {}).map( + ([name, hops]) => { + const last = hops.at(-1)!; + const dest = rowNames.get(last.destSchema)!; + return { + name, + property: identifier(name), + dest, + one: last.cardinality === 'one', + }; + }, + ); + + const identifiable = + table.primaryKey.length === 1 && + table.primaryKey[0] === 'id' && + columns.some(c => c.name === 'id' && !c.optional); + const conformances = ['ZeroRow', 'Codable', 'Hashable']; + if (identifiable) { + conformances.push('Identifiable'); + } + + const lines: string[] = []; + lines.push(`${pub}struct ${row}: ${conformances.join(', ')} {`); + for (const c of columns) { + lines.push(` ${pub}var ${c.property}: ${c.swiftType}`); + } + if (relationships.length) { + lines.push(''); + lines.push( + ' // Relationships, filled when a query asks for them with `related`.', + ); + for (const r of relationships) { + lines.push( + r.one + ? ` @Indirect ${pub}var ${r.property}: ${r.dest}?` + : ` ${pub}var ${r.property}: [${r.dest}]?`, + ); + } + } + + const params = [ + ...columns.map( + c => `${c.property}: ${c.swiftType}${c.optional ? ' = nil' : ''}`, + ), + ...relationships.map( + r => `${r.property}: ${r.one ? `${r.dest}?` : `[${r.dest}]?`} = nil`, + ), + ]; + lines.push(''); + lines.push(` ${pub}init(`); + lines.push(params.map(p => ` ${p}`).join(',\n')); + lines.push(' ) {'); + for (const c of [...columns, ...relationships]) { + lines.push(` self.${c.property} = ${c.property}`); + } + lines.push(' }'); + + lines.push(''); + lines.push(` ${pub}static let tableName = ${str(t)}`); + lines.push(''); + lines.push(` ${pub}static let columns: KeyPathNames<${row}> = [`); + for (const c of columns) { + lines.push(` \\${row}.${c.property}: ${str(c.name)},`); + } + lines.push(' ]'); + lines.push(''); + lines.push( + ` ${pub}static let relationships: KeyPathNames<${row}> = ${ + relationships.length ? '[' : '[:]' + }`, + ); + if (relationships.length) { + for (const r of relationships) { + lines.push(` \\${row}.${r.property}: ${str(r.name)},`); + } + lines.push(' ]'); + } + + lines.push(''); + lines.push(` ${pub}init(entry: ViewEntry) throws(ZeroValueError) {`); + for (const c of columns) { + lines.push(` ${c.property} = try entry.column(${str(c.name)})`); + } + for (const r of relationships) { + lines.push(` ${r.property} = try entry.relationship(${str(r.name)})`); + } + lines.push(' }'); + + lines.push(''); + lines.push(` ${pub}var columnValues: Row {`); + lines.push(' ['); + for (const c of columns) { + lines.push(` ${str(c.name)}: ${c.property}.zeroValue,`); + } + lines.push(' ]'); + lines.push(' }'); + lines.push('}'); + typeDecls.push(lines.join('\n')); + } + + // Named types are discovered while emitting the rows, and object fields + // can name more of them. + const namedDecls: string[] = []; + for (let i = 0; i < e.named.length; i++) { + const n = e.named[i]; + namedDecls.push( + n.kind === 'enum' ? enumDecl(pub, n) : objectDecl(e, pub, n), + ); + } + + out.push(...typeDecls.flatMap(d => [d, ''])); + out.push(...namedDecls.flatMap(d => [d, ''])); + out.push(schemaDecl(pub, schema, schemaName)); + out.push(''); + out.push( + `/// The queries of \`ZeroSchema.${schemaName}\`, one per table: \`${zqlName}.${tableNames[0] ?? 'table'}.where(…)\`.`, + ); + out.push(`${pub}struct ${zqlType}: Sendable {`); + out.push(` ${pub}init() {}`); + for (const t of tableNames) { + const row = rowNames.get(t)!; + out.push(''); + out.push( + ` ${pub}var ${identifier(t)}: Query<${row}, [${row}]> { Query(AnyQuery(.${schemaName}, ${str(t)})) }`, + ); + } + out.push('}'); + out.push(''); + out.push(`${pub}let ${identifier(zqlName)} = ${zqlType}()`); + out.push(''); + return out.join('\n'); +} + +function enumDecl(pub: string, n: {name: string; cases: string[]}): string { + const used = new Set(); + const lines = [ + `${pub}enum ${n.name}: String, ZeroEnum, Codable, CaseIterable {`, + ]; + for (const value of n.cases) { + let c = camelCase(value); + while (used.has(c)) { + c += '_'; + } + used.add(c); + const id = identifier(c); + lines.push(c === value ? ` case ${id}` : ` case ${id} = ${str(value)}`); + } + lines.push('}'); + return lines.join('\n'); +} + +function objectDecl( + e: Emitter, + pub: string, + n: {name: string; fields: FieldModel[]}, +): string { + const fields = n.fields.map(f => { + const base = e.swiftType(f.type, e.bare(n.name) + pascalCase(f.name)); + const type = f.optional && !base.endsWith('?') ? base + '?' : base; + return {...f, property: identifier(f.name), swiftType: type}; + }); + const lines = [`${pub}struct ${n.name}: ZeroValue, Codable, Hashable {`]; + for (const f of fields) { + lines.push(` ${pub}var ${f.property}: ${f.swiftType}`); + } + lines.push(''); + lines.push(` ${pub}init(`); + lines.push( + fields + .map( + f => + ` ${f.property}: ${f.swiftType}${f.swiftType.endsWith('?') ? ' = nil' : ''}`, + ) + .join(',\n'), + ); + lines.push(' ) {'); + for (const f of fields) { + lines.push(` self.${f.property} = ${f.property}`); + } + lines.push(' }'); + lines.push(''); + lines.push(` ${pub}init(zeroValue: JSONValue) throws(ZeroValueError) {`); + lines.push(` let o = try ZeroObjectReader(zeroValue, ${str(n.name)})`); + for (const f of fields) { + lines.push(` ${f.property} = try o.field(${str(f.name)})`); + } + lines.push(' }'); + lines.push(''); + lines.push(` ${pub}var zeroValue: JSONValue {`); + lines.push(' var o = ZeroObjectWriter()'); + for (const f of fields) { + lines.push( + f.optional + ? ` o.setIfPresent(${str(f.name)}, ${f.property})` + : ` o.set(${str(f.name)}, ${f.property})`, + ); + } + lines.push(' return o.value'); + lines.push(' }'); + lines.push('}'); + return lines.join('\n'); +} + +function schemaDecl(pub: string, schema: Schema, name: string): string { + const lines = ['extension ZeroSchema {']; + lines.push(` ${pub}static let ${identifier(name)} = ZeroSchema(`); + lines.push(' tables: ['); + for (const [t, table] of Object.entries(schema.tables)) { + lines.push(' TableSchema('); + lines.push( + ` name: ${str(t)},${table.serverName ? ` serverName: ${str(table.serverName)},` : ''}`, + ); + lines.push(' columns: ['); + for (const [c, col] of Object.entries(table.columns)) { + const args = [`.${col.type}`]; + if (col.optional) args.push('optional: true'); + if (col.serverName) args.push(`serverName: ${str(col.serverName)}`); + lines.push(` (${str(c)}, ColumnSchema(${args.join(', ')})),`); + } + lines.push(' ],'); + const keys = table.uniqueKeys?.length + ? `,\n uniqueKeys: [${table.uniqueKeys.map(strings).join(', ')}]` + : ''; + lines.push(` primaryKey: ${strings(table.primaryKey)}${keys}),`); + } + lines.push(' ],'); + const rels = Object.entries(schema.relationships).filter( + ([, r]) => Object.keys(r).length > 0, + ); + if (rels.length === 0) { + lines.push(' relationships: [:])'); + } else { + lines.push(' relationships: ['); + for (const [t, byName] of rels) { + lines.push(` ${str(t)}: [`); + for (const [r, hops] of Object.entries(byName)) { + lines.push(` ${str(r)}: [`); + for (const h of hops) { + lines.push( + ` RelationshipHop(sourceField: ${strings(h.sourceField)}, destField: ${strings(h.destField)}, destSchema: ${str(h.destSchema)}, cardinality: .${h.cardinality}),`, + ); + } + lines.push(' ],'); + } + lines.push(' ],'); + } + lines.push(' ])'); + } + lines.push('}'); + return lines.join('\n'); +} diff --git a/packages/zero-swift-cli/src/types.ts b/packages/zero-swift-cli/src/types.ts new file mode 100644 index 0000000000..a705d393ee --- /dev/null +++ b/packages/zero-swift-cli/src/types.ts @@ -0,0 +1,204 @@ +/** + * Reads the TypeScript types of a schema's columns. Enumerations and typed + * JSON columns exist only in the types (`enumeration<'a' | 'b'>()`, + * `json<{…}>()`): at runtime the schema says `string` and `json`. + */ +import {dirname, resolve} from 'node:path'; +import ts from 'typescript'; + +export type TypeModel = + | {readonly kind: 'string' | 'number' | 'boolean' | 'json'} + | {readonly kind: 'enum'; readonly alias?: string; readonly cases: string[]} + | { + readonly kind: 'object'; + readonly alias?: string; + readonly fields: FieldModel[]; + } + | {readonly kind: 'array'; readonly element: TypeModel} + | {readonly kind: 'nullable'; readonly wrapped: TypeModel}; + +export type FieldModel = { + readonly name: string; + readonly type: TypeModel; + /** Declared with `?`: may be left out. */ + readonly optional: boolean; +}; + +/** table → column → the model of its `customType`. */ +export type CustomTypes = Map>; + +const defaultOptions: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + allowImportingTsExtensions: true, + strict: true, + noEmit: true, + skipLibCheck: true, +}; + +export function readCustomTypes( + schemaPath: string, + exportName: string, +): CustomTypes { + const file = resolve(schemaPath); + const program = ts.createProgram([file], compilerOptions(file)); + const checker = program.getTypeChecker(); + const source = program.getSourceFile(file); + const module = source && checker.getSymbolAtLocation(source); + if (!module) { + throw new Error(`Cannot read the types of ${schemaPath}`); + } + let symbol = checker + .getExportsOfModule(module) + .find(s => s.name === exportName); + if (!symbol) { + throw new Error(`${schemaPath} does not export "${exportName}"`); + } + if (symbol.flags & ts.SymbolFlags.Alias) { + symbol = checker.getAliasedSymbol(symbol); + } + const schemaType = checker.getTypeOfSymbol(symbol); + const out: CustomTypes = new Map(); + const tables = property(checker, schemaType, 'tables'); + for (const table of tables ? checker.getPropertiesOfType(tables) : []) { + const columns = property( + checker, + checker.getTypeOfSymbol(table), + 'columns', + ); + const byColumn = new Map(); + for (const column of columns ? checker.getPropertiesOfType(columns) : []) { + const custom = property( + checker, + checker.getTypeOfSymbol(column), + 'customType', + ); + if (custom) { + byColumn.set(column.name, model(checker, custom)); + } + } + out.set(table.name, byColumn); + } + return out; +} + +function compilerOptions(file: string): ts.CompilerOptions { + const configPath = ts.findConfigFile(dirname(file), ts.sys.fileExists); + if (!configPath) { + return defaultOptions; + } + const parsed = ts.getParsedCommandLineOfConfigFile(configPath, undefined, { + ...ts.sys, + onUnRecoverableConfigFileDiagnostic: () => {}, + }); + return parsed + ? {...parsed.options, noEmit: true, skipLibCheck: true} + : defaultOptions; +} + +function property( + checker: ts.TypeChecker, + type: ts.Type, + name: string, +): ts.Type | undefined { + const p = checker.getPropertyOfType(type, name); + return p && checker.getTypeOfSymbol(p); +} + +const nullish = ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Void; + +function model(checker: ts.TypeChecker, type: ts.Type): TypeModel { + const alias = aliasName(type); + if (type.isUnion()) { + const parts = type.types.filter(t => !(t.flags & nullish)); + const nullable = parts.length !== type.types.length; + const inner = unionModel(checker, parts, alias); + return nullable ? {kind: 'nullable', wrapped: inner} : inner; + } + if (type.isIntersection()) { + // Branded primitives, such as `string & {__brand: 'ID'}`. + for (const t of type.types) { + if (t.flags & ts.TypeFlags.StringLike) return {kind: 'string'}; + if (t.flags & ts.TypeFlags.NumberLike) return {kind: 'number'}; + } + return {kind: 'json'}; + } + if (type.isStringLiteral()) { + return enumModel([type.value], alias); + } + if (type.flags & ts.TypeFlags.StringLike) return {kind: 'string'}; + if (type.flags & ts.TypeFlags.NumberLike) return {kind: 'number'}; + if (type.flags & ts.TypeFlags.BooleanLike) return {kind: 'boolean'}; + if (checker.isArrayType(type)) { + const [element] = checker.getTypeArguments(type as ts.TypeReference); + return { + kind: 'array', + element: element ? model(checker, element) : {kind: 'json'}, + }; + } + if (type.flags & ts.TypeFlags.Object && isPlainObject(checker, type)) { + const fields = checker.getPropertiesOfType(type).map(p => { + const optional = (p.flags & ts.SymbolFlags.Optional) !== 0; + let t = checker.getTypeOfSymbol(p); + if (optional) { + t = checker.getNonNullableType(t); + } + return {name: p.name, type: model(checker, t), optional}; + }); + return {kind: 'object', ...(alias ? {alias} : {}), fields}; + } + return {kind: 'json'}; +} + +function unionModel( + checker: ts.TypeChecker, + parts: ts.Type[], + alias: string | undefined, +): TypeModel { + if (parts.length === 0) return {kind: 'json'}; + if (parts.every(t => t.isStringLiteral())) { + return enumModel( + parts.map(t => (t as ts.StringLiteralType).value), + alias, + ); + } + if (parts.every(t => t.flags & ts.TypeFlags.BooleanLike)) { + return {kind: 'boolean'}; + } + if (parts.every(t => t.flags & ts.TypeFlags.NumberLike)) { + return {kind: 'number'}; + } + if (parts.length === 1) return model(checker, parts[0]); + return {kind: 'json'}; +} + +function enumModel(cases: string[], alias: string | undefined): TypeModel { + return {kind: 'enum', ...(alias ? {alias} : {}), cases}; +} + +function isPlainObject(checker: ts.TypeChecker, type: ts.Type): boolean { + return ( + checker.getPropertiesOfType(type).length > 0 && + type.getCallSignatures().length === 0 && + type.getConstructSignatures().length === 0 && + checker.getIndexInfosOfType(type).length === 0 + ); +} + +/** The name a type was declared under, when it was declared once by name. */ +function aliasName(type: ts.Type): string | undefined { + if (type.aliasSymbol && !type.aliasTypeArguments?.length) { + return type.aliasSymbol.name; + } + const name = type.getSymbol()?.name; + if ( + name && + !name.startsWith('__') && + type.flags & ts.TypeFlags.Object && + (type.getSymbol()!.flags & ts.SymbolFlags.Interface) !== 0 + ) { + return name; + } + return undefined; +} diff --git a/packages/zero-swift-cli/test/fixture-schema.ts b/packages/zero-swift-cli/test/fixture-schema.ts new file mode 100644 index 0000000000..016f71c01e --- /dev/null +++ b/packages/zero-swift-cli/test/fixture-schema.ts @@ -0,0 +1,89 @@ +/** + * A schema that exercises the corners of `zero-swift codegen`: keyword and + * reserved names, enumerations shared between columns, nested JSON types, + * integer overrides, compound keys, and two-hop relationships. + */ +import {relationships} from '../../zero-schema/src/builder/relationship-builder.ts'; +import {createSchema} from '../../zero-schema/src/builder/schema-builder.ts'; +import { + boolean, + enumeration, + json, + number, + string, + table, +} from '../../zero-schema/src/builder/table-builder.ts'; + +type Status = 'open' | 'in-progress' | 'closed'; + +type Settings = { + theme: 'light' | 'dark'; + fontSize: number; + tags: string[]; + shortcuts?: {key: string; command: string}[] | undefined; + extra: string | number | null; +}; + +const project = table('project') + .from('projects') + .columns({ + id: string(), + name: string(), + default: boolean(), + status: enumeration(), + settings: json(), + position: number(), + archivedStatus: enumeration().optional(), + }) + .primaryKey('id'); + +const query = table('query') + .columns({ + id: string(), + projectID: string().from('project_id'), + text: string(), + class: string().optional(), + }) + .primaryKey('id'); + +const member = table('member') + .columns({ + projectID: string(), + userID: string(), + role: enumeration<'owner' | 'editor' | 'viewer'>(), + }) + .primaryKey('projectID', 'userID'); + +const person = table('person') + .columns({ + id: string(), + name: string(), + nickname: string().optional(), + }) + .primaryKey('id') + .unique('name'); + +const projectRelationships = relationships(project, ({many}) => ({ + queries: many({ + sourceField: ['id'], + destField: ['projectID'], + destSchema: query, + }), + people: many( + {sourceField: ['id'], destField: ['projectID'], destSchema: member}, + {sourceField: ['userID'], destField: ['id'], destSchema: person}, + ), +})); + +const queryRelationships = relationships(query, ({one}) => ({ + project: one({ + sourceField: ['projectID'], + destField: ['id'], + destSchema: project, + }), +})); + +export const schema = createSchema({ + tables: [project, query, member, person], + relationships: [projectRelationships, queryRelationships], +}); diff --git a/packages/zero-swift-cli/tsconfig.json b/packages/zero-swift-cli/tsconfig.json new file mode 100644 index 0000000000..6abe3f945b --- /dev/null +++ b/packages/zero-swift-cli/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src", "test"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ac2b35a52..abe973901a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1590,6 +1590,31 @@ importers: specifier: workspace:* version: link:../zql + packages/zero-swift-cli: + dependencies: + typescript: + specifier: 6.0.3 + version: 6.0.3 + devDependencies: + '@types/node': + specifier: ^22.10.5 + version: 22.19.19 + oxfmt: + specifier: ^0.65.0 + version: 0.65.0 + vitest: + specifier: ^5.0.0 + version: 5.0.0(@opentelemetry/api@1.9.1)(@types/node@22.19.19)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(vite@8.0.13(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.47.1)(tsx@4.22.0)(yaml@2.9.0)) + zero-schema: + specifier: workspace:* + version: link:../zero-schema + zero-types: + specifier: workspace:* + version: link:../zero-types + zql: + specifier: workspace:* + version: link:../zql + packages/zero-types: devDependencies: oxfmt: diff --git a/swift/DESIGN.md b/swift/DESIGN.md index cf1dbe98ad..618e05dd14 100644 --- a/swift/DESIGN.md +++ b/swift/DESIGN.md @@ -283,7 +283,13 @@ zql.issue - Key paths map to columns through the generated table descriptors. - Generated row structs carry optional relationship properties (for example `comments: [Comment]?`) that are filled only when `related` requested them. + One relationships are stored through `@Indirect`, so two tables can refer to + each other without their structs containing each other. - `one()` returns `Row?`. +- Comparing with `nil` (`where(\.assignee, nil)`, `$0.assignee == nil`) uses + `IS`, since `= NULL` matches nothing. An explicit `.eq` keeps `=`. +- The typed builder wraps `AnyQuery`, the untyped port of `QueryImpl`, which + the IVM vectors check against JS. ### Mutate, errors, auth, logging diff --git a/swift/Sources/Zero/Query/Query.swift b/swift/Sources/Zero/Query/Query.swift new file mode 100644 index 0000000000..3b7ad850ca --- /dev/null +++ b/swift/Sources/Zero/Query/Query.swift @@ -0,0 +1,337 @@ +/// A row struct of a table, as `zero-swift codegen` generates it: the +/// table's columns as stored properties, and an optional property per +/// relationship that a query fills when it asks for it with `related`. +public protocol ZeroRow: Sendable { + /// The client name of the row's table. + static var tableName: String { get } + /// The client column name of each column property. + static var columns: KeyPathNames { get } + /// The relationship name of each relationship property. + static var relationships: KeyPathNames { get } + + /// Reads a row of a materialized view. + init(entry: ViewEntry) throws(ZeroValueError) + /// The row's columns, keyed by client column name. + var columnValues: Row { get } +} + +/// Names by key path: the columns or relationships of a row struct. Key +/// paths are immutable, so the table is safe to share. +public struct KeyPathNames: @unchecked Sendable, ExpressibleByDictionaryLiteral { + let names: [PartialKeyPath: String] + + public init(dictionaryLiteral elements: (PartialKeyPath, String)...) { + names = Dictionary(uniqueKeysWithValues: elements) + } + + public subscript(keyPath: PartialKeyPath) -> String? { names[keyPath] } +} + +extension ZeroRow { + static func column(_ keyPath: PartialKeyPath) -> String { + guard let name = columns[keyPath] else { + preconditionFailure("\(keyPath) is not a column of \(tableName)") + } + return name + } + + static func relationship(_ keyPath: PartialKeyPath) -> String { + guard let name = relationships[keyPath] else { + preconditionFailure("\(keyPath) is not a relationship of \(tableName)") + } + return name + } +} + +extension ViewEntry { + /// A column of the row, for generated `init(entry:)`s. + public func column(_ name: String) throws(ZeroValueError) -> V { + do { + return try V(zeroValue: row[name] ?? .null) + } catch { + throw ZeroValueError("\(name): \(error.description)") + } + } + + /// A many relationship: nil when the query didn't ask for it. + public func relationship(_ name: String) throws(ZeroValueError) -> [R]? { + guard let value = relationships[name] else { return nil } + var out: [R] = [] + for e in value.entries { out.append(try R(entry: e)) } + return out + } + + /// A one relationship: nil when the query didn't ask for it or no row + /// matches. + public func relationship(_ name: String) throws(ZeroValueError) -> R? { + guard let entry = relationships[name]?.entries.first else { return nil } + return try R(entry: entry) + } +} + +/// What a query materializes to: `[Row]`, or `Row?` after `one()`. +public protocol QueryResult: Sendable { + associatedtype Row: ZeroRow + init(view: ViewValue) throws(ZeroValueError) +} + +extension Array: QueryResult where Element: ZeroRow { + public typealias Row = Element + + public init(view: ViewValue) throws(ZeroValueError) { + var out: [Element] = [] + for e in view.entries { out.append(try Element(entry: e)) } + self = out + } +} + +extension Optional: QueryResult where Wrapped: ZeroRow { + public typealias Row = Wrapped + + public init(view: ViewValue) throws(ZeroValueError) { + self = try view.entries.first.map { (e) throws(ZeroValueError) in try Wrapped(entry: e) } + } +} + +/// A query over the rows of one table, typed by its generated row struct +/// (`Query` in `packages/zql/src/query/query.ts`). Columns and relationships +/// are named by key path. +/// +/// ```swift +/// zql.issue +/// .where(\.status, .open) +/// .where { $0.priority >= 2 && ($0.assignee == me || $0.assignee == nil) } +/// .whereExists(\.labels) { $0.where(\.name, "bug") } +/// .related(\.comments) { $0.orderBy(\.created, .desc).limit(10) } +/// .orderBy(\.modified, .desc) +/// .limit(50) +/// ``` +public struct Query: Sendable where Result.Row == Row { + /// The untyped query this one builds. + public let any: AnyQuery + + public init(_ any: AnyQuery) { + self.any = any + } + + public var ast: AST { any.ast } + public var format: Format { any.format } + public var hash: String { any.hash } + + /// Compares a column with a value. Comparing with nil uses `IS`, since + /// `= NULL` matches nothing. + public func `where`(_ column: KeyPath, _ value: V) -> Self { + Self(any.where((Column(Row.column(column)) == value).ast)) + } + + public func `where`( + _ column: KeyPath, _ op: AST.SimpleOperator, _ value: V + ) -> Self { + Self(any.where(Row.column(column), op, value.zeroValue)) + } + + /// `IN` and `NOT IN`. + public func `where`( + _ column: KeyPath, _ op: AST.SimpleOperator, _ values: [V] + ) -> Self { + Self(any.where(Row.column(column), op, .array(values.map(\.zeroValue)))) + } + + public func `where`( + _ column: KeyPath, _ op: AST.SimpleOperator, _ param: StaticParameter + ) -> Self { + Self(any.where(Row.column(column), op, param)) + } + + public func `where`(_ condition: (RowExpression) -> Condition) -> Self { + Self(any.where { condition(RowExpression(builder: $0)).ast }) + } + + public func whereExists( + _ relationship: KeyPath, flip: Bool? = nil, scalar: Bool? = nil, + _ sub: ((Query) -> Query)? = nil + ) -> Self { + Self(any.whereExists(Row.relationship(relationship), flip: flip, scalar: scalar, untyped(sub))) + } + + public func whereExists( + _ relationship: KeyPath, flip: Bool? = nil, scalar: Bool? = nil, + _ sub: ((Query) -> Query)? = nil + ) -> Self { + Self(any.whereExists(Row.relationship(relationship), flip: flip, scalar: scalar, untyped(sub))) + } + + /// Fills a many relationship of each row. + public func related( + _ relationship: KeyPath, _ sub: @escaping (Query) -> Query + ) -> Self where S.Row == R { + Self(any.related(Row.relationship(relationship)) { sub(Query($0)).any }) + } + + public func related(_ relationship: KeyPath) -> Self { + Self(any.related(Row.relationship(relationship))) + } + + /// Fills a one relationship of each row. + public func related( + _ relationship: KeyPath, _ sub: @escaping (Query) -> Query + ) -> Self where S.Row == R { + Self(any.related(Row.relationship(relationship)) { sub(Query($0)).any }) + } + + public func related(_ relationship: KeyPath) -> Self { + Self(any.related(Row.relationship(relationship))) + } + + public func orderBy( + _ column: KeyPath, _ direction: AST.Direction + ) + -> Self + { + Self(any.orderBy(Row.column(column), direction)) + } + + public func limit(_ limit: Int) -> Self { + Self(any.limit(limit)) + } + + /// Starts after `row`, or at it when `inclusive`. + public func start(_ row: Row, inclusive: Bool = false) -> Self { + Self(any.start(row.columnValues, inclusive: inclusive)) + } + + /// At most one row. + public func one() -> Query { + Query(any.one()) + } + + private func untyped( + _ sub: ((Query) -> Query)? + ) + -> ((AnyQuery) -> AnyQuery)? + { + sub.map { sub in { sub(Query($0)).any } } + } +} + +/// A condition of a `where`. +public struct Condition: Sendable, Hashable { + public let ast: AST.Condition + + public init(_ ast: AST.Condition) { + self.ast = ast + } + + public static func && (a: Condition, b: Condition) -> Condition { + Condition(andConditions([a.ast, b.ast])) + } + + public static func || (a: Condition, b: Condition) -> Condition { + Condition(orConditions([a.ast, b.ast])) + } + + public static prefix func ! (c: Condition) -> Condition { + Condition(negateCondition(c.ast)) + } + + /// Always true. + public static var `true`: Condition { Condition(.trueCondition) } + /// Always false. + public static var `false`: Condition { Condition(.falseCondition) } +} + +/// A column in a `where` expression. +public struct Column: Sendable, Hashable { + public let name: String + + public init(_ name: String) { + self.name = name + } + + func cmp(_ op: AST.SimpleOperator, _ value: JSONValue) -> Condition { + Condition(cmpCondition(name, op, .literal(value))) + } + + public static func == (c: Column, v: Value) -> Condition { + let value = v.zeroValue + return c.cmp(value == .null ? .is : .eq, value) + } + + public static func != (c: Column, v: Value) -> Condition { + let value = v.zeroValue + return c.cmp(value == .null ? .isNot : .ne, value) + } + + public static func < (c: Column, v: Value) -> Condition { c.cmp(.lt, v.zeroValue) } + public static func <= (c: Column, v: Value) -> Condition { c.cmp(.le, v.zeroValue) } + public static func > (c: Column, v: Value) -> Condition { c.cmp(.gt, v.zeroValue) } + public static func >= (c: Column, v: Value) -> Condition { c.cmp(.ge, v.zeroValue) } + + public func `is`(_ v: Value) -> Condition { cmp(.is, v.zeroValue) } + public func isNot(_ v: Value) -> Condition { cmp(.isNot, v.zeroValue) } + public func `in`(_ values: [Value]) -> Condition { cmp(.in, .array(values.map(\.zeroValue))) } + public func notIn(_ values: [Value]) -> Condition { + cmp(.notIn, .array(values.map(\.zeroValue))) + } + + public func compare(_ op: AST.SimpleOperator, _ param: StaticParameter) -> Condition { + Condition(cmpCondition(name, op, param.position)) + } +} + +extension Column where Value == String { + public func like(_ pattern: String) -> Condition { cmp(.like, .string(pattern)) } + public func notLike(_ pattern: String) -> Condition { cmp(.notLike, .string(pattern)) } + public func ilike(_ pattern: String) -> Condition { cmp(.ilike, .string(pattern)) } + public func notIlike(_ pattern: String) -> Condition { cmp(.notIlike, .string(pattern)) } +} + +extension Column where Value == String? { + public func like(_ pattern: String) -> Condition { cmp(.like, .string(pattern)) } + public func notLike(_ pattern: String) -> Condition { cmp(.notLike, .string(pattern)) } + public func ilike(_ pattern: String) -> Condition { cmp(.ilike, .string(pattern)) } + public func notIlike(_ pattern: String) -> Condition { cmp(.notIlike, .string(pattern)) } +} + +/// The row a `where` expression is about: its columns by key path, and +/// `exists` over its relationships. +@dynamicMemberLookup +public struct RowExpression: Sendable { + let builder: ExpressionBuilder + + public subscript(dynamicMember column: KeyPath) -> Column { + Column(Row.column(column)) + } + + public func exists( + _ relationship: KeyPath, flip: Bool? = nil, scalar: Bool? = nil, + _ sub: ((Query) -> Query)? = nil + ) -> Condition { + exists(Row.relationship(relationship), flip: flip, scalar: scalar, sub) + } + + public func exists( + _ relationship: KeyPath, flip: Bool? = nil, scalar: Bool? = nil, + _ sub: ((Query) -> Query)? = nil + ) -> Condition { + exists(Row.relationship(relationship), flip: flip, scalar: scalar, sub) + } + + private func exists( + _ name: String, flip: Bool?, scalar: Bool?, _ sub: ((Query) -> Query)? + ) -> Condition { + Condition( + builder.exists( + name, flip: flip, scalar: scalar, sub.map { sub in { sub(Query($0)).any } })) + } + + public func and(_ conditions: Condition...) -> Condition { + Condition(andConditions(conditions.map(\.ast))) + } + + public func or(_ conditions: Condition...) -> Condition { + Condition(orConditions(conditions.map(\.ast))) + } + + public func not(_ condition: Condition) -> Condition { !condition } +} diff --git a/swift/Sources/Zero/Query/ZeroValue.swift b/swift/Sources/Zero/Query/ZeroValue.swift new file mode 100644 index 0000000000..da56c6916f --- /dev/null +++ b/swift/Sources/Zero/Query/ZeroValue.swift @@ -0,0 +1,183 @@ +/// A value that doesn't fit the Swift type a row declares for it. +public struct ZeroValueError: Error, Sendable, Hashable, CustomStringConvertible { + public var description: String + + public init(_ description: String) { + self.description = description + } + + static func mismatch(_ expected: String, _ value: JSONValue) -> ZeroValueError { + ZeroValueError("expected \(expected), got \(value.typeName) \(value)") + } +} + +/// A Swift type a column holds. Generated row structs use these for their +/// columns; the query builder encodes compared values with them. +public protocol ZeroValue: Sendable { + /// Reads a value from a row; a missing column reads as `.null`. + init(zeroValue: JSONValue) throws(ZeroValueError) + var zeroValue: JSONValue { get } +} + +extension String: ZeroValue { + public init(zeroValue: JSONValue) throws(ZeroValueError) { + guard case .string(let s) = zeroValue else { throw .mismatch("a string", zeroValue) } + self = s + } + + public var zeroValue: JSONValue { .string(self) } +} + +extension Double: ZeroValue { + public init(zeroValue: JSONValue) throws(ZeroValueError) { + guard case .number(let n) = zeroValue else { throw .mismatch("a number", zeroValue) } + self = n + } + + public var zeroValue: JSONValue { .number(self) } +} + +/// For a `number()` column the codegen config declares an integer: the +/// value must be exact, which every integer up to 2^53 is. +extension Int: ZeroValue { + public init(zeroValue: JSONValue) throws(ZeroValueError) { + guard case .number(let n) = zeroValue, let i = Int(exactly: n), + abs(i) <= 1 << 53 + else { throw .mismatch("an integer", zeroValue) } + self = i + } + + public var zeroValue: JSONValue { .number(Double(self)) } +} + +extension Bool: ZeroValue { + public init(zeroValue: JSONValue) throws(ZeroValueError) { + guard case .bool(let b) = zeroValue else { throw .mismatch("a boolean", zeroValue) } + self = b + } + + public var zeroValue: JSONValue { .bool(self) } +} + +extension JSONValue: ZeroValue { + public init(zeroValue: JSONValue) { self = zeroValue } + public var zeroValue: JSONValue { self } +} + +/// An optional column: `.null` reads as nil. +extension Optional: ZeroValue where Wrapped: ZeroValue { + public init(zeroValue: JSONValue) throws(ZeroValueError) { + self = zeroValue == .null ? nil : try Wrapped(zeroValue: zeroValue) + } + + public var zeroValue: JSONValue { self?.zeroValue ?? .null } +} + +/// The JSON arrays a `json()` column can hold. +extension Array: ZeroValue where Element: ZeroValue { + public init(zeroValue: JSONValue) throws(ZeroValueError) { + guard case .array(let a) = zeroValue else { throw .mismatch("an array", zeroValue) } + var out: [Element] = [] + out.reserveCapacity(a.count) + for e in a { out.append(try Element(zeroValue: e)) } + self = out + } + + public var zeroValue: JSONValue { .array(map(\.zeroValue)) } +} + +/// A string enumeration (`enumeration<…>()`), generated as a `String` enum. +public protocol ZeroEnum: ZeroValue, RawRepresentable where RawValue == String {} + +extension ZeroEnum { + public init(zeroValue: JSONValue) throws(ZeroValueError) { + guard case .string(let s) = zeroValue, let v = Self(rawValue: s) else { + throw .mismatch("one of the cases of \(Self.self)", zeroValue) + } + self = v + } + + public var zeroValue: JSONValue { .string(rawValue) } +} + +/// Reads the fields of a JSON object, for generated `json()` types. +public struct ZeroObjectReader: Sendable { + public let object: [String: JSONValue] + + public init(_ value: JSONValue, _ type: String) throws(ZeroValueError) { + guard case .object(let o) = value else { throw .mismatch("a \(type) object", value) } + object = o + } + + /// A field; a missing one reads as `.null`, so only an optional takes it. + public func field(_ name: String) throws(ZeroValueError) -> V { + do { + return try V(zeroValue: object[name] ?? .null) + } catch { + throw ZeroValueError("\(name): \(error.description)") + } + } +} + +/// Writes the fields of a JSON object. +public struct ZeroObjectWriter: Sendable { + public private(set) var object: [String: JSONValue] = [:] + + public init() {} + + public mutating func set(_ name: String, _ value: some ZeroValue) { + object[name] = value.zeroValue + } + + /// An optional field (`name?: T`): nil leaves it out. + public mutating func setIfPresent(_ name: String, _ value: V?) { + if let value { object[name] = value.zeroValue } + } + + public var value: JSONValue { .object(object) } +} + +/// Stores a one relationship of a row struct out of line, so two tables can +/// relate to each other (`Issue.oneComment` and `Comment.issue`) without +/// their structs containing each other. +@propertyWrapper +public struct Indirect: Sendable { + private var storage: [Value] + + public init(wrappedValue: Value?) { + storage = wrappedValue.map { [$0] } ?? [] + } + + public var wrappedValue: Value? { + get { storage.first } + set { storage = newValue.map { [$0] } ?? [] } + } +} + +extension Indirect: Equatable where Value: Equatable {} +extension Indirect: Hashable where Value: Hashable {} + +extension Indirect: Codable where Value: Codable { + public init(from decoder: any Decoder) throws { + let c = try decoder.singleValueContainer() + self.init(wrappedValue: c.decodeNil() ? nil : try c.decode(Value.self)) + } + + public func encode(to encoder: any Encoder) throws { + var c = encoder.singleValueContainer() + if let v = wrappedValue { try c.encode(v) } else { try c.encodeNil() } + } +} + +extension KeyedDecodingContainer { + /// Lets a row decode without the relationships it wasn't queried with. + public func decode(_ type: Indirect.Type, forKey key: Key) throws -> Indirect { + try decodeIfPresent(type, forKey: key) ?? Indirect(wrappedValue: nil) + } +} + +extension KeyedEncodingContainer { + public mutating func encode(_ value: Indirect, forKey key: Key) throws { + try encodeIfPresent(value.wrappedValue, forKey: key) + } +} diff --git a/swift/Tests/ZeroTests/Generated/FixtureSchema.swift b/swift/Tests/ZeroTests/Generated/FixtureSchema.swift new file mode 100644 index 0000000000..376efe25be --- /dev/null +++ b/swift/Tests/ZeroTests/Generated/FixtureSchema.swift @@ -0,0 +1,374 @@ +// Generated by `zero-swift codegen` from packages/zero-swift-cli/test/fixture-schema.ts. Do not edit. +// swift-format-ignore-file + +import Zero + +public struct FxProject: ZeroRow, Codable, Hashable, Identifiable { + public var id: String + public var name: String + public var `default`: Bool + public var status: FxStatus + public var settings: FxSettings + public var position: Int + public var archivedStatus: FxStatus? + + // Relationships, filled when a query asks for them with `related`. + public var queries: [FxQuery]? + public var people: [FxPerson]? + + public init( + id: String, + name: String, + `default`: Bool, + status: FxStatus, + settings: FxSettings, + position: Int, + archivedStatus: FxStatus? = nil, + queries: [FxQuery]? = nil, + people: [FxPerson]? = nil + ) { + self.id = id + self.name = name + self.`default` = `default` + self.status = status + self.settings = settings + self.position = position + self.archivedStatus = archivedStatus + self.queries = queries + self.people = people + } + + public static let tableName = "project" + + public static let columns: KeyPathNames = [ + \FxProject.id: "id", + \FxProject.name: "name", + \FxProject.`default`: "default", + \FxProject.status: "status", + \FxProject.settings: "settings", + \FxProject.position: "position", + \FxProject.archivedStatus: "archivedStatus", + ] + + public static let relationships: KeyPathNames = [ + \FxProject.queries: "queries", + \FxProject.people: "people", + ] + + public init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id") + name = try entry.column("name") + `default` = try entry.column("default") + status = try entry.column("status") + settings = try entry.column("settings") + position = try entry.column("position") + archivedStatus = try entry.column("archivedStatus") + queries = try entry.relationship("queries") + people = try entry.relationship("people") + } + + public var columnValues: Row { + [ + "id": id.zeroValue, + "name": name.zeroValue, + "default": `default`.zeroValue, + "status": status.zeroValue, + "settings": settings.zeroValue, + "position": position.zeroValue, + "archivedStatus": archivedStatus.zeroValue, + ] + } +} + +public struct FxQuery: ZeroRow, Codable, Hashable, Identifiable { + public var id: String + public var projectID: String + public var text: String + public var `class`: String? + + // Relationships, filled when a query asks for them with `related`. + @Indirect public var project: FxProject? + + public init( + id: String, + projectID: String, + text: String, + `class`: String? = nil, + project: FxProject? = nil + ) { + self.id = id + self.projectID = projectID + self.text = text + self.`class` = `class` + self.project = project + } + + public static let tableName = "query" + + public static let columns: KeyPathNames = [ + \FxQuery.id: "id", + \FxQuery.projectID: "projectID", + \FxQuery.text: "text", + \FxQuery.`class`: "class", + ] + + public static let relationships: KeyPathNames = [ + \FxQuery.project: "project", + ] + + public init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id") + projectID = try entry.column("projectID") + text = try entry.column("text") + `class` = try entry.column("class") + project = try entry.relationship("project") + } + + public var columnValues: Row { + [ + "id": id.zeroValue, + "projectID": projectID.zeroValue, + "text": text.zeroValue, + "class": `class`.zeroValue, + ] + } +} + +public struct FxMember: ZeroRow, Codable, Hashable { + public var projectID: String + public var userID: String + public var role: FxMemberRole + + public init( + projectID: String, + userID: String, + role: FxMemberRole + ) { + self.projectID = projectID + self.userID = userID + self.role = role + } + + public static let tableName = "member" + + public static let columns: KeyPathNames = [ + \FxMember.projectID: "projectID", + \FxMember.userID: "userID", + \FxMember.role: "role", + ] + + public static let relationships: KeyPathNames = [:] + + public init(entry: ViewEntry) throws(ZeroValueError) { + projectID = try entry.column("projectID") + userID = try entry.column("userID") + role = try entry.column("role") + } + + public var columnValues: Row { + [ + "projectID": projectID.zeroValue, + "userID": userID.zeroValue, + "role": role.zeroValue, + ] + } +} + +public struct FxPerson: ZeroRow, Codable, Hashable, Identifiable { + public var id: String + public var name: String + public var nickname: String? + + public init( + id: String, + name: String, + nickname: String? = nil + ) { + self.id = id + self.name = name + self.nickname = nickname + } + + public static let tableName = "person" + + public static let columns: KeyPathNames = [ + \FxPerson.id: "id", + \FxPerson.name: "name", + \FxPerson.nickname: "nickname", + ] + + public static let relationships: KeyPathNames = [:] + + public init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id") + name = try entry.column("name") + nickname = try entry.column("nickname") + } + + public var columnValues: Row { + [ + "id": id.zeroValue, + "name": name.zeroValue, + "nickname": nickname.zeroValue, + ] + } +} + +public enum FxStatus: String, ZeroEnum, Codable, CaseIterable { + case `open` + case inProgress = "in-progress" + case closed +} + +public struct FxSettings: ZeroValue, Codable, Hashable { + public var theme: FxSettingsTheme + public var fontSize: Double + public var tags: [String] + public var shortcuts: [FxSettingsShortcuts]? + public var extra: JSONValue? + + public init( + theme: FxSettingsTheme, + fontSize: Double, + tags: [String], + shortcuts: [FxSettingsShortcuts]? = nil, + extra: JSONValue? = nil + ) { + self.theme = theme + self.fontSize = fontSize + self.tags = tags + self.shortcuts = shortcuts + self.extra = extra + } + + public init(zeroValue: JSONValue) throws(ZeroValueError) { + let o = try ZeroObjectReader(zeroValue, "FxSettings") + theme = try o.field("theme") + fontSize = try o.field("fontSize") + tags = try o.field("tags") + shortcuts = try o.field("shortcuts") + extra = try o.field("extra") + } + + public var zeroValue: JSONValue { + var o = ZeroObjectWriter() + o.set("theme", theme) + o.set("fontSize", fontSize) + o.set("tags", tags) + o.setIfPresent("shortcuts", shortcuts) + o.set("extra", extra) + return o.value + } +} + +public enum FxMemberRole: String, ZeroEnum, Codable, CaseIterable { + case owner + case editor + case viewer +} + +public enum FxSettingsTheme: String, ZeroEnum, Codable, CaseIterable { + case light + case dark +} + +public struct FxSettingsShortcuts: ZeroValue, Codable, Hashable { + public var key: String + public var command: String + + public init( + key: String, + command: String + ) { + self.key = key + self.command = command + } + + public init(zeroValue: JSONValue) throws(ZeroValueError) { + let o = try ZeroObjectReader(zeroValue, "FxSettingsShortcuts") + key = try o.field("key") + command = try o.field("command") + } + + public var zeroValue: JSONValue { + var o = ZeroObjectWriter() + o.set("key", key) + o.set("command", command) + return o.value + } +} + +extension ZeroSchema { + public static let fixture = ZeroSchema( + tables: [ + TableSchema( + name: "project", serverName: "projects", + columns: [ + ("id", ColumnSchema(.string)), + ("name", ColumnSchema(.string)), + ("default", ColumnSchema(.boolean)), + ("status", ColumnSchema(.string)), + ("settings", ColumnSchema(.json)), + ("position", ColumnSchema(.number)), + ("archivedStatus", ColumnSchema(.string, optional: true)), + ], + primaryKey: ["id"]), + TableSchema( + name: "query", + columns: [ + ("id", ColumnSchema(.string)), + ("projectID", ColumnSchema(.string, serverName: "project_id")), + ("text", ColumnSchema(.string)), + ("class", ColumnSchema(.string, optional: true)), + ], + primaryKey: ["id"]), + TableSchema( + name: "member", + columns: [ + ("projectID", ColumnSchema(.string)), + ("userID", ColumnSchema(.string)), + ("role", ColumnSchema(.string)), + ], + primaryKey: ["projectID", "userID"]), + TableSchema( + name: "person", + columns: [ + ("id", ColumnSchema(.string)), + ("name", ColumnSchema(.string)), + ("nickname", ColumnSchema(.string, optional: true)), + ], + primaryKey: ["id"], + uniqueKeys: [["name"]]), + ], + relationships: [ + "project": [ + "queries": [ + RelationshipHop(sourceField: ["id"], destField: ["projectID"], destSchema: "query", cardinality: .many), + ], + "people": [ + RelationshipHop(sourceField: ["id"], destField: ["projectID"], destSchema: "member", cardinality: .many), + RelationshipHop(sourceField: ["userID"], destField: ["id"], destSchema: "person", cardinality: .many), + ], + ], + "query": [ + "project": [ + RelationshipHop(sourceField: ["projectID"], destField: ["id"], destSchema: "project", cardinality: .one), + ], + ], + ]) +} + +/// The queries of `ZeroSchema.fixture`, one per table: `fixtureZQL.project.where(…)`. +public struct FixtureZQL: Sendable { + public init() {} + + public var project: Query { Query(AnyQuery(.fixture, "project")) } + + public var query: Query { Query(AnyQuery(.fixture, "query")) } + + public var member: Query { Query(AnyQuery(.fixture, "member")) } + + public var person: Query { Query(AnyQuery(.fixture, "person")) } +} + +public let fixtureZQL = FixtureZQL() diff --git a/swift/Tests/ZeroTests/Generated/TestSchema.swift b/swift/Tests/ZeroTests/Generated/TestSchema.swift new file mode 100644 index 0000000000..383b9373c3 --- /dev/null +++ b/swift/Tests/ZeroTests/Generated/TestSchema.swift @@ -0,0 +1,507 @@ +// Generated by `zero-swift codegen` from packages/zql/src/query/test/test-schemas.ts. Do not edit. +// swift-format-ignore-file + +import Zero + +public struct Issue: ZeroRow, Codable, Hashable, Identifiable { + public var id: String + public var title: String + public var description: String + public var closed: Bool + public var ownerId: String? + public var createdAt: Double + + // Relationships, filled when a query asks for them with `related`. + @Indirect public var owner: User? + public var comments: [Comment]? + @Indirect public var oneComment: Comment? + public var ownerComments: [Comment]? + public var labels: [Label]? + + public init( + id: String, + title: String, + description: String, + closed: Bool, + ownerId: String? = nil, + createdAt: Double, + owner: User? = nil, + comments: [Comment]? = nil, + oneComment: Comment? = nil, + ownerComments: [Comment]? = nil, + labels: [Label]? = nil + ) { + self.id = id + self.title = title + self.description = description + self.closed = closed + self.ownerId = ownerId + self.createdAt = createdAt + self.owner = owner + self.comments = comments + self.oneComment = oneComment + self.ownerComments = ownerComments + self.labels = labels + } + + public static let tableName = "issue" + + public static let columns: KeyPathNames = [ + \Issue.id: "id", + \Issue.title: "title", + \Issue.description: "description", + \Issue.closed: "closed", + \Issue.ownerId: "ownerId", + \Issue.createdAt: "createdAt", + ] + + public static let relationships: KeyPathNames = [ + \Issue.owner: "owner", + \Issue.comments: "comments", + \Issue.oneComment: "oneComment", + \Issue.ownerComments: "ownerComments", + \Issue.labels: "labels", + ] + + public init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id") + title = try entry.column("title") + description = try entry.column("description") + closed = try entry.column("closed") + ownerId = try entry.column("ownerId") + createdAt = try entry.column("createdAt") + owner = try entry.relationship("owner") + comments = try entry.relationship("comments") + oneComment = try entry.relationship("oneComment") + ownerComments = try entry.relationship("ownerComments") + labels = try entry.relationship("labels") + } + + public var columnValues: Row { + [ + "id": id.zeroValue, + "title": title.zeroValue, + "description": description.zeroValue, + "closed": closed.zeroValue, + "ownerId": ownerId.zeroValue, + "createdAt": createdAt.zeroValue, + ] + } +} + +public struct User: ZeroRow, Codable, Hashable, Identifiable { + public var id: String + public var name: String + public var metadata: UserMetadata? + + // Relationships, filled when a query asks for them with `related`. + public var issues: [Issue]? + + public init( + id: String, + name: String, + metadata: UserMetadata? = nil, + issues: [Issue]? = nil + ) { + self.id = id + self.name = name + self.metadata = metadata + self.issues = issues + } + + public static let tableName = "user" + + public static let columns: KeyPathNames = [ + \User.id: "id", + \User.name: "name", + \User.metadata: "metadata", + ] + + public static let relationships: KeyPathNames = [ + \User.issues: "issues", + ] + + public init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id") + name = try entry.column("name") + metadata = try entry.column("metadata") + issues = try entry.relationship("issues") + } + + public var columnValues: Row { + [ + "id": id.zeroValue, + "name": name.zeroValue, + "metadata": metadata.zeroValue, + ] + } +} + +public struct Comment: ZeroRow, Codable, Hashable, Identifiable { + public var id: String + public var authorId: String + public var issueId: String + public var text: String + public var createdAt: Double + + // Relationships, filled when a query asks for them with `related`. + @Indirect public var issue: Issue? + public var revisions: [Revision]? + @Indirect public var author: User? + + public init( + id: String, + authorId: String, + issueId: String, + text: String, + createdAt: Double, + issue: Issue? = nil, + revisions: [Revision]? = nil, + author: User? = nil + ) { + self.id = id + self.authorId = authorId + self.issueId = issueId + self.text = text + self.createdAt = createdAt + self.issue = issue + self.revisions = revisions + self.author = author + } + + public static let tableName = "comment" + + public static let columns: KeyPathNames = [ + \Comment.id: "id", + \Comment.authorId: "authorId", + \Comment.issueId: "issueId", + \Comment.text: "text", + \Comment.createdAt: "createdAt", + ] + + public static let relationships: KeyPathNames = [ + \Comment.issue: "issue", + \Comment.revisions: "revisions", + \Comment.author: "author", + ] + + public init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id") + authorId = try entry.column("authorId") + issueId = try entry.column("issueId") + text = try entry.column("text") + createdAt = try entry.column("createdAt") + issue = try entry.relationship("issue") + revisions = try entry.relationship("revisions") + author = try entry.relationship("author") + } + + public var columnValues: Row { + [ + "id": id.zeroValue, + "authorId": authorId.zeroValue, + "issueId": issueId.zeroValue, + "text": text.zeroValue, + "createdAt": createdAt.zeroValue, + ] + } +} + +public struct Revision: ZeroRow, Codable, Hashable, Identifiable { + public var id: String + public var authorId: String + public var commentId: String + public var text: String + + // Relationships, filled when a query asks for them with `related`. + @Indirect public var comment: Comment? + @Indirect public var author: User? + + public init( + id: String, + authorId: String, + commentId: String, + text: String, + comment: Comment? = nil, + author: User? = nil + ) { + self.id = id + self.authorId = authorId + self.commentId = commentId + self.text = text + self.comment = comment + self.author = author + } + + public static let tableName = "revision" + + public static let columns: KeyPathNames = [ + \Revision.id: "id", + \Revision.authorId: "authorId", + \Revision.commentId: "commentId", + \Revision.text: "text", + ] + + public static let relationships: KeyPathNames = [ + \Revision.comment: "comment", + \Revision.author: "author", + ] + + public init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id") + authorId = try entry.column("authorId") + commentId = try entry.column("commentId") + text = try entry.column("text") + comment = try entry.relationship("comment") + author = try entry.relationship("author") + } + + public var columnValues: Row { + [ + "id": id.zeroValue, + "authorId": authorId.zeroValue, + "commentId": commentId.zeroValue, + "text": text.zeroValue, + ] + } +} + +public struct Label: ZeroRow, Codable, Hashable, Identifiable { + public var id: String + public var name: String + + // Relationships, filled when a query asks for them with `related`. + public var issues: [Issue]? + + public init( + id: String, + name: String, + issues: [Issue]? = nil + ) { + self.id = id + self.name = name + self.issues = issues + } + + public static let tableName = "label" + + public static let columns: KeyPathNames
/` key. + static func sourceName(_ key: String) -> String { + let rest = key.utf8.dropFirst(Keys.entitiesPrefix.utf8.count) + let end = rest.firstIndex(of: UInt8(ascii: "/")) ?? rest.endIndex + return String(rest[rest.startIndex.. ApplicationError { + if let e = error as? ApplicationError { return e } + return ApplicationError(errorMessage(error)) + } +} + +/// `getErrorMessage` for a Swift error. +func errorMessage(_ error: any Error) -> String { + if let e = error as? ApplicationError { return e.message } + if let e = error as? ConnectionError { return e.message } + return String(describing: error) +} + +/// Tracks the mutations waiting for the server's verdict and settles their +/// `server` results (`packages/zero-client/src/client/mutation-tracker.ts`). +@MainActor +final class MutationTracker { + final class Entry { + var mutationID: Double? + let server: Deferred + init(_ server: Deferred) { self.server = server } + } + + /// In insertion order, which is mutation ID order. + private var outstanding: [(id: Int, entry: Entry)] = [] + private var ephemeralIDsByMutationID: [Double: Int] = [:] + private var allMutationsAppliedListeners: [() -> Void] = [] + private var nextEphemeralID = 0 + private var clientID: String? + private var largestOutstandingMutationID: Double = 0 + private var currentMutationID: Double = 0 + + private let ackMutations: (MutationID) -> Void + private let onFatalError: (ConnectionError) -> Void + + init( + ackMutations: @escaping (MutationID) -> Void, + onFatalError: @escaping (ConnectionError) -> Void + ) { + self.ackMutations = ackMutations + self.onFatalError = onFatalError + } + + func setClientID(_ clientID: String) { self.clientID = clientID } + + var size: Int { outstanding.count } + + func trackMutation() -> (ephemeralID: Int, server: Deferred) { + nextEphemeralID += 1 + let server = Deferred() + outstanding.append((nextEphemeralID, Entry(server))) + return (nextEphemeralID, server) + } + + func mutationIDAssigned(_ ephemeralID: Int, _ mutationID: Double) { + guard let entry = entry(ephemeralID) else { return } + entry.mutationID = mutationID + ephemeralIDsByMutationID[mutationID] = ephemeralID + largestOutstandingMutationID = max(largestOutstandingMutationID, mutationID) + } + + /// Rejects a mutation the client could not run. It was not stored. + func rejectMutation(_ ephemeralID: Int, _ error: any Error) { + guard let entry = entry(ephemeralID) else { return } + settle(ephemeralID, entry, .failure(ApplicationError.wrapping(error))) + } + + /// Rejects every outstanding mutation, when the client is disconnected, + /// in error, or closed. + func rejectAllOutstandingMutations(_ error: ConnectionError) { + guard !outstanding.isEmpty else { return } + for (id, entry) in outstanding { settle(id, entry, .failure(error)) } + largestOutstandingMutationID = currentMutationID + notifyAllMutationsApplied() + } + + /// The watch of `m//`: mutation results zero-cache poked down. + func processMutationResponses(_ diff: KVDiff) throws { + guard let clientID else { return } + let prefix = Keys.mutationsPrefix + clientID + "/" + var largest: Double = 0 + for op in diff { + let suffix = String(op.key.utf8.dropFirst(prefix.utf8.count))! + guard let mutationID = Double(suffix) else { + throw ConnectionError.client( + .internal, + message: "MutationTracker received a diff with an invalid mutation ID: \(op.key)") + } + largest = max(largest, mutationID) + switch op { + case .add(_, let value): + let result = try MutationResult(json: value) + switch result { + case .ok(let data): try processMutationOk(clientID, mutationID, data) + case .error(let e): try processMutationError(clientID, mutationID, e) + } + case .del: + break + case .change: + throw ConnectionError.client( + .internal, message: "MutationTracker does not expect change operations") + } + } + if largest > 0 { ackMutations(MutationID(id: largest, clientID: clientID)) } + } + + func processPushResponse(_ response: PushResponseBody) throws { + switch response { + case .error(let e): + onFatalError(Self.fatalError(e)) + case .ok(let mutations): + for m in mutations { + switch m.result { + case .ok(let data): try processMutationOk(m.id.clientID, m.id.id, data) + case .error(let e): try processMutationError(m.id.clientID, m.id.id, e) + } + } + } + } + + static func fatalError(_ e: PushError) -> ConnectionError { + let (message, cause): (String, FailureCause) = + switch e { + case .unsupportedPushVersion: ("Unsupported push version", .zeroCache(reason: .internal)) + case .unsupportedSchemaVersion: ("Unsupported schema version", .zeroCache(reason: .internal)) + case .http(let status, let details, _): + ( + "Fetch from API server returned non-OK status \(JSNumber.format(status)): \(details)", + .http(status: status, bodyPreview: nil) + ) + case .zeroPusher(let details, _): + ("ZeroPusher error: \(details)", .zeroCache(reason: .internal)) + } + return .server(.pushFailed(PushFailedBody(message: message, mutationIDs: [], cause: cause))) + } + + /// Resolves the mutations the server has applied (`lmidAdvanced`). + func onConnected(_ lastMutationID: Double) { try? lmidAdvanced(lastMutationID) } + + func lmidAdvanced(_ lastMutationID: Double) throws { + guard lastMutationID >= currentMutationID else { + throw PokeError("lmid must be greater than or equal to current lmid") + } + if lastMutationID == currentMutationID { return } + currentMutationID = lastMutationID + while let (id, entry) = outstanding.first, let mid = entry.mutationID, mid <= lastMutationID { + settle(id, entry, .success(nil)) + } + if lastMutationID >= largestOutstandingMutationID { notifyAllMutationsApplied() } + } + + func onAllMutationsApplied(_ listener: @escaping () -> Void) { + allMutationsAppliedListeners.append(listener) + } + + private func notifyAllMutationsApplied() { + for l in allMutationsAppliedListeners { l() } + } + + private func entry(_ ephemeralID: Int) -> Entry? { + outstanding.first { $0.id == ephemeralID }?.entry + } + + private func processMutationError( + _ clientID: String, _ mid: Double, _ error: MutationError + ) + throws + { + guard clientID == self.clientID else { + throw ConnectionError.client(.internal, message: "received mutation for the wrong client") + } + guard let ephemeralID = ephemeralIDsByMutationID[mid], let entry = entry(ephemeralID) else { + return + } + let kind = error.error.stringValue ?? "" + if kind == "alreadyProcessed" { return } + if kind == "app" { + settle( + ephemeralID, entry, + .failure( + ApplicationError( + error.message?.stringValue ?? "Unknown application error: app", + details: error.details))) + } else { + let message = + kind == "oooMutation" + ? "Server reported an out-of-order mutation" + : "Unknown fallback error with mutation ID \(JSNumber.format(mid)): \(kind)" + settle( + ephemeralID, entry, + .failure( + ConnectionError.server( + .basic(BasicErrorBody(kind: .invalidPush, message: message, origin: .server))))) + } + if kind == "oooMutation" { + onFatalError( + .server( + .basic( + BasicErrorBody( + kind: .invalidPush, message: "Server reported an out-of-order mutation", + origin: .server)))) + } + } + + private func processMutationOk(_ clientID: String, _ mid: Double, _ data: JSONValue?) throws { + guard clientID == self.clientID else { + throw ConnectionError.client(.internal, message: "received mutation for the wrong client") + } + guard let ephemeralID = ephemeralIDsByMutationID[mid], let entry = entry(ephemeralID) else { + return + } + settle(ephemeralID, entry, .success(data)) + } + + private func settle(_ ephemeralID: Int, _ entry: Entry, _ result: Result) { + switch result { + case .success(let data): entry.server.resolve(data) + case .failure(let error): entry.server.reject(error) + } + outstanding.removeAll { $0.id == ephemeralID } + if let mid = entry.mutationID { ephemeralIDsByMutationID[mid] = nil } + } +} diff --git a/swift/Sources/Zero/Client/PokeHandler.swift b/swift/Sources/Zero/Client/PokeHandler.swift new file mode 100644 index 0000000000..758a4746e8 --- /dev/null +++ b/swift/Sources/Zero/Client/PokeHandler.swift @@ -0,0 +1,292 @@ +/// A merged poke, ready to apply (`PokeInternal`). +struct MergedPoke { + var baseCookie: JSONValue + var cookie: JSONValue + var lastMutationIDChanges: [String: Double] + var patch: [KVPatchOp] +} + +/// Assembles the multi-part pokes of the sync protocol and applies them at +/// most once per scheduler turn, merging the pokes that arrived in between +/// (`packages/zero-client/src/client/zero-poke-handler.ts`). +@MainActor +final class PokeHandler { + struct PokeEndResult { + var lastMutationIDChangeForSelf: Double? + var hasRows: Bool + } + + private struct Receiving { + var pokeStart: PokeStartBody + var parts: [PokePartBody] = [] + var chunks: [ArraySlice] = [] + } + + private struct Received { + var pokeStart: PokeStartBody + var parts: [PokePartBody] + var pokeEnd: PokeEndBody + } + + private let clientID: String + private let schema: ZeroSchema + private let serverToClient: NameMapper + private let scheduler: any Scheduler + /// Applies a merged poke; throws when it does not apply. + private let apply: (MergedPoke) throws -> Void + private let onPokeError: (String) -> Void + private let onApplied: (MergedPoke) throws -> Void + + private var receiving: Receiving? + private var buffer: [Received] = [] + private var playbackRunning = false + + init( + clientID: String, schema: ZeroSchema, scheduler: any Scheduler, + apply: @escaping (MergedPoke) throws -> Void, + onApplied: @escaping (MergedPoke) throws -> Void, + onPokeError: @escaping (String) -> Void + ) { + self.clientID = clientID + self.schema = schema + self.serverToClient = NameMapper.serverToClient(schema) + self.scheduler = scheduler + self.apply = apply + self.onApplied = onApplied + self.onPokeError = onPokeError + } + + func handlePokeStart(_ pokeStart: PokeStartBody) { + if let receiving { + handlePokeError( + "pokeStart \(Self.text(pokeStart)) while still receiving \(Self.text(receiving.pokeStart)) " + ) + return + } + receiving = Receiving(pokeStart: pokeStart) + } + + /// Returns the poke's last mutation ID change for this client. + func handlePokePart(_ part: PokePartBody) -> Double? { + guard part.pokeID == receiving?.pokeStart.pokeID else { + handlePokeError( + "pokePart for \(part.pokeID), when receiving \(receiving?.pokeStart.pokeID ?? "undefined")") + return nil + } + if !receiving!.chunks.isEmpty { + handlePokeError("received both pokePart and binary poke chunks") + return nil + } + receiving!.parts.append(part) + return part.lastMutationIDChanges?[clientID] + } + + func handlePokeChunk(_ chunk: ArraySlice) { + guard receiving != nil else { + handlePokeError("received a binary poke chunk without pokeStart") + return + } + if !receiving!.parts.isEmpty { + handlePokeError("received both pokePart and binary poke chunks") + return + } + receiving!.chunks.append(chunk) + } + + func handlePokeEnd(_ pokeEnd: PokeEndBody) -> PokeEndResult? { + guard let receiving, pokeEnd.pokeID == receiving.pokeStart.pokeID else { + handlePokeError( + "pokeEnd for \(pokeEnd.pokeID), when receiving \(receiving?.pokeStart.pokeID ?? "undefined")" + ) + return nil + } + if pokeEnd.cancel == true { + self.receiving = nil + return nil + } + var parts = receiving.parts + if !receiving.chunks.isEmpty { + do { + parts = try PokeChunk.decode(receiving.chunks) + } catch { + // `String(e)` of what `TextDecoder` and valita throw. + handlePokeError("TypeError: " + valitaMessage(error)) + return nil + } + } + var result = PokeEndResult(lastMutationIDChangeForSelf: nil, hasRows: false) + for part in parts { + if let lmid = part.lastMutationIDChanges?[clientID] { + result.lastMutationIDChangeForSelf = lmid + } + if let rows = part.rowsPatch, !rows.isEmpty { result.hasRows = true } + } + buffer.append(Received(pokeStart: receiving.pokeStart, parts: parts, pokeEnd: pokeEnd)) + self.receiving = nil + if !playbackRunning { startPlayback() } + return result + } + + func handleDisconnect() { clear() } + + private func startPlayback() { + playbackRunning = true + _ = scheduler.schedule(after: 0) { [weak self] in self?.scheduledCallback() } + } + + private func scheduledCallback() { + if buffer.isEmpty { + playbackRunning = false + return + } + _ = scheduler.schedule(after: 0) { [weak self] in self?.scheduledCallback() } + processPokesForFrame() + } + + private func processPokesForFrame() { + do { + let merged = try merge(buffer) + buffer.removeAll() + guard let merged else { return } + try apply(merged) + try onApplied(merged) + } catch { + handlePokeError(Self.errorString(error)) + } + } + + /// `String(error)` of the JS error the failure stands for. + static func errorString(_ error: any Error) -> String { + if let e = error as? PokeError { return "Error: " + e.message } + return "Error: " + String(describing: error) + } + + private func handlePokeError(_ message: String) { + clear() + onPokeError(message) + } + + private func clear() { + receiving = nil + buffer.removeAll() + } + + /// `mergePokes`. + private func merge(_ pokes: [Received]) throws -> MergedPoke? { + guard let first = pokes.first, let last = pokes.last else { return nil } + var merged = MergedPoke( + baseCookie: first.pokeStart.baseCookie.map { .string($0) } ?? .null, + cookie: .string(last.pokeEnd.cookie), lastMutationIDChanges: [:], patch: []) + var prevPokeEnd: PokeEndBody? + for poke in pokes { + if let prev = prevPokeEnd, let base = poke.pokeStart.baseCookie, + StringCompare.utf16(base, prev.cookie) > 0 + { + throw PokeError( + "unexpected cookie gap \(Self.text(prev)) \(Self.text(poke.pokeStart))" + ) + } + prevPokeEnd = poke.pokeEnd + for part in poke.parts { + if let changes = part.lastMutationIDChanges { + for (clientID, lmid) in changes { merged.lastMutationIDChanges[clientID] = lmid } + } + if let patches = part.desiredQueriesPatches { + for (clientID, patch) in patches.sorted(by: { StringCompare.utf16Less($0.key, $1.key) }) { + for op in patch { + merged.patch.append( + Self.queryPatchOp(op) { Keys.desiredQueriesKey(clientID: clientID, hash: $0) }) + } + } + } + if let patch = part.gotQueriesPatch { + for op in patch { merged.patch.append(Self.queryPatchOp(op, Keys.gotQueriesKey)) } + } + if let rows = part.rowsPatch { + for op in rows { + if let p = try rowPatchOp(op) { merged.patch.append(p) } + } + } + if let mutations = part.mutationsPatch { + for op in mutations { + switch op { + case .put(let response): + merged.patch.append( + .put( + key: Keys.mutationResponseKey( + clientID: response.id.clientID, id: response.id.id), + value: response.result.json)) + case .del(let id): + merged.patch.append( + .del(key: Keys.mutationResponseKey(clientID: id.clientID, id: id.id))) + } + } + } + } + } + return merged + } + + /// `JSON.stringify` of a body, its keys in the order zero-cache sends them. + static func text(_ b: PokeStartBody) -> String { + let json = b.json + return orderedJSON(json, ["pokeID", "baseCookie", "schemaVersions", "timestamp"]) + } + + static func text(_ b: PokeEndBody) -> String { + orderedJSON(b.json, ["pokeID", "cookie", "cancel"]) + } + + static func orderedJSON(_ json: JSONValue, _ order: [String]) -> String { + guard case .object(let o) = json else { return JSONSerializer.stringify(json) } + var parts: [String] = [] + for key in order { + guard let v = o[key] else { continue } + var s = "" + JSONSerializer.writeString(key, into: &s) + parts.append(s + ":" + JSONSerializer.stringify(v)) + } + return "{" + parts.joined(separator: ",") + "}" + } + + private static func queryPatchOp(_ op: QueriesPatchOp, _ key: (String) -> String) -> KVPatchOp { + switch op { + case .clear: .clear + case .del(let hash): .del(key: key(hash)) + case .put(let hash, _): .put(key: key(hash), value: .null) + } + } + + private func rowPatchOp(_ op: RowPatchOp) throws -> KVPatchOp? { + switch op { + case .clear: + return .clear + case .put(let serverTable, let value): + guard let table = serverToClient.tableNameIfKnown(serverTable) else { return nil } + let row = try serverToClient.row(serverTable, value) + return .put(key: try key(table, row), value: .object(row)) + case .del(let serverTable, let id): + guard let table = serverToClient.tableNameIfKnown(serverTable) else { return nil } + return .del(key: try key(table, try serverToClient.row(serverTable, id))) + case .update(let serverTable, let id, let merge, let constrain): + guard let table = serverToClient.tableNameIfKnown(serverTable) else { return nil } + let mergeRow = try merge.map { m -> Row in + try serverToClient.row(serverTable, m.objectValue ?? [:]) + } + return .update( + key: try key(table, try serverToClient.row(serverTable, id)), merge: mergeRow, + constrain: try constrain.map { try serverToClient.columns(serverTable, $0) }) + } + } + + private func key(_ table: String, _ row: Row) throws -> String { + try Keys.primaryKeyString(table: table, primaryKey: schema.tables[table]!.primaryKey, row: row) + } +} + +/// A poke that cannot be applied. `message` is the JS error's message. +struct PokeError: Error, CustomStringConvertible { + let message: String + init(_ message: String) { self.message = message } + var description: String { message } +} diff --git a/swift/Sources/Zero/Client/QueryContext.swift b/swift/Sources/Zero/Client/QueryContext.swift new file mode 100644 index 0000000000..3325db97e2 --- /dev/null +++ b/swift/Sources/Zero/Client/QueryContext.swift @@ -0,0 +1,400 @@ +/// Builds pipelines over the sources of an IVM branch. +final class BranchDelegate: BuilderDelegate { + let branch: IVMBranch + init(_ branch: IVMBranch) { self.branch = branch } + func getSource(_ tableName: String) -> (any Source)? { branch.source(tableName) } +} + +/// Materializes queries over an IVM branch, registers named ones with the +/// query manager, and flushes views when a transaction commits +/// (`ZeroContext` in `packages/zero-client/src/client/context.ts`, with +/// `materializeImpl`, `runImpl`, and `preloadImpl` from +/// `packages/zql/src/query/query-delegate-base.ts`). +@MainActor +final class QueryContext { + /// Builds a deferred pipeline; returns the release to call once every + /// pending pipeline is built. + typealias AttachPipeline = () throws -> () -> Void + + let branch: IVMBranch + private let delegate: BranchDelegate + /// Registers a named query with the server; returns its unregister. + private let addCustomQuery: (AST, CustomQueryID, TTL, @escaping GotCallback) -> () -> Void + private let updateCustomQuery: (CustomQueryID, TTL) -> Void + private let batchViewUpdatesImpl: (() -> Void) -> Void + private let microtask: (@escaping () -> Void) -> Void + private var commitListeners: [(id: Int, listener: () -> Void)] = [] + private var nextListenerID = 0 + /// Until the replica is loaded into the sources, pipelines are built + /// later, together (`markPipelinesReady`). + private(set) var pipelinesReady = false + private var pendingPipelines: [(id: Int, attach: AttachPipeline)] = [] + + init( + branch: IVMBranch, + addCustomQuery: @escaping (AST, CustomQueryID, TTL, @escaping GotCallback) -> () -> Void, + updateCustomQuery: @escaping (CustomQueryID, TTL) -> Void, + batchViewUpdates: @escaping (() -> Void) -> Void, + microtask: @escaping (@escaping () -> Void) -> Void + ) { + self.branch = branch + self.delegate = BranchDelegate(branch) + self.addCustomQuery = addCustomQuery + self.updateCustomQuery = updateCustomQuery + self.batchViewUpdatesImpl = batchViewUpdates + self.microtask = microtask + } + + /// A context for the queries a mutator runs: nothing is registered with + /// the server, and the branch is already populated. + static func local(_ branch: IVMBranch) -> QueryContext { + let context = QueryContext( + branch: branch, addCustomQuery: { _, _, _, _ in {} }, updateCustomQuery: { _, _ in }, + batchViewUpdates: { $0() }, microtask: { $0() }) + context.markPipelinesReady() + return context + } + + func batchViewUpdates(_ body: () -> Void) { + var performed = false + batchViewUpdatesImpl { + body() + performed = true + } + precondition(performed, "batchViewUpdates must call applyViewUpdates synchronously.") + } + + func onTransactionCommit(_ listener: @escaping () -> Void) -> () -> Void { + let id = nextListenerID + nextListenerID += 1 + commitListeners.append((id, listener)) + return { [weak self] in self?.commitListeners.removeAll { $0.id == id } } + } + + private func onPipelinesReady(_ attach: @escaping AttachPipeline) -> () -> Void { + precondition(!pipelinesReady, "onPipelinesReady called while pipelines are ready") + let id = nextListenerID + nextListenerID += 1 + pendingPipelines.append((id, attach)) + return { [weak self] in self?.pendingPipelines.removeAll { $0.id == id } } + } + + /// Builds and hydrates every deferred pipeline, in materialization order, + /// as one batch of view updates. + func markPipelinesReady() { + if pipelinesReady { return } + if pendingPipelines.isEmpty { + pipelinesReady = true + return + } + batchViewUpdates { + var releases: [() -> Void] = [] + while !pendingPipelines.isEmpty { + let (_, attach) = pendingPipelines.removeFirst() + // A failing view has already been marked as errored; the others + // are still built. + if let release = try? attach() { releases.append(release) } + } + pipelinesReady = true + for release in releases { release() } + endTransaction() + } + } + + /// Pushes `diff` into the branch and flushes the views. + func processChanges(_ diff: KVDiff) { + batchViewUpdates { + branch.apply(diff) + endTransaction() + } + } + + private func endTransaction() { + for (_, l) in commitListeners { l() } + } + + /// The settled state of a view's query: still waiting, confirmed by the + /// server, or failed. + private enum Completion { + case pending, complete, failed(ErroredQuery) + } + + func materialize(_ query: AnyQuery, ttl: TTL = .defaultTTL) throws -> MaterializedView { + let customQueryID = query.customQueryID + let ast = query.ast + let queryID = + customQueryID.map { QueryHash.hashOfNameAndArgs($0.name, $0.args) } + ?? QueryHash.hashOfAST(ast) + let microtask = self.microtask + let deferPipeline = !pipelinesReady + var attached = !deferPipeline + var got: Got = .no + var queryComplete: Completion = .pending + // `queryCompleteResolver`: the view hears about its settling one + // microtask after it (two for a failure, as `.then().catch()` does). + var resolved: Completion? + var onResolved: ((Completion) -> Void)? + let settle = { (r: Completion) in + guard resolved == nil else { return } + resolved = r + onResolved?(r) + } + var view: ArrayView? + var destroyed = false + + let maybeResolveComplete = { + if attached, got == .yes, case .pending = queryComplete { + queryComplete = .complete + settle(.complete) + } + } + let maybeMarkCached = { + if attached, got == .cached, case .pending = queryComplete { view?.markCached() } + } + let gotCallback: GotCallback = { value, error in + // Removals are deferred while mutations are pending, so a destroyed + // view can still hear from the query manager. + if destroyed { return } + if let error { + settle(.failed(error)) + queryComplete = .failed(error) + return + } + got = value + switch value { + case .cached: maybeMarkCached() + case .no: view?.unmarkCached() + case .yes: maybeResolveComplete() + } + } + + var removeCommitObserver: (() -> Void)? + var removePendingAttach: (() -> Void)? + let removeAddedQuery = + customQueryID.map { addCustomQuery(ast, $0, ttl, gotCallback) } ?? {} + + let input: any Input + let deferred: DeferredInput? + do { + if deferPipeline { + let delegate = self.delegate + let build = { () throws -> any Input in try buildPipeline(ast, delegate, queryID: queryID) } + // A probe gets the schema, and makes a query that cannot be built + // throw now rather than later. + let probe = try build() + let schema = probe.getSchema() + probe.destroy() + deferred = DeferredInput(schema: schema, build: build) + input = deferred! + } else { + deferred = nil + input = try buildPipeline(ast, delegate, queryID: queryID) + } + } catch { + removeAddedQuery() + throw error + } + + var created: ArrayView! + batchViewUpdates { + let v: ArrayView + switch queryComplete { + case .complete: v = ArrayView(input, format: query.format, error: nil, complete: true) + case .failed(let e): v = ArrayView(input, format: query.format, error: e, complete: false) + case .pending: + v = ArrayView(input, format: query.format, error: nil, complete: false) + onResolved = { [weak v] (r: Completion) in + switch r { + case .complete: microtask { v?.setResultType(.complete) } + case .failed(let e): microtask { microtask { v?.setResultType(.error, error: e) } } + case .pending: break + } + } + if let resolved { onResolved!(resolved) } + } + removeCommitObserver = onTransactionCommit { [weak v] in v?.flush() } + created = v + } + created.onDestroy = { + destroyed = true + removePendingAttach?() + removePendingAttach = nil + input.destroy() + removeCommitObserver?() + removeAddedQuery() + } + + if let deferred { + removePendingAttach = onPipelinesReady { [weak created] in + removePendingAttach = nil + if deferred.destroyed { return {} } + do { + created?.holdData() + try deferred.attach() + } catch { + let e = ErroredQuery.app( + id: queryID, name: customQueryID?.name ?? "", message: errorMessage(error), + details: nil) + queryComplete = .failed(e) + settle(.failed(e)) + throw error + } + return { + if destroyed { return } + attached = true + created?.releaseData() + maybeResolveComplete() + maybeMarkCached() + } + } + } + + view = created + maybeMarkCached() + let updateTTL: (TTL) -> Void = + customQueryID.map { id in { [weak self] ttl in self?.updateCustomQuery(id, ttl) } } ?? { _ in + } + return MaterializedView(created, updateTTL: updateTTL) + } + + enum RunType: String, Sendable { + case unknown, cached, complete + } + + /// `runImpl`: the rows now, or once the server has confirmed them. + func run( + _ query: AnyQuery, type: RunType = .unknown, ttl: TTL? = nil + ) throws -> Deferred< + ViewValue + > { + let v = try materialize(query, ttl: ttl ?? .defaultTTL) + let result = Deferred() + if type == .unknown { + // While the pipelines are not ready the view is deferred and this is + // empty: `unknown` does not wait for local data either. + let data = v.data + v.destroy() + result.resolve(data) + return result + } + _ = v.addListener { data, resultType, error in + if resultType == .complete || (type == .cached && resultType == .cached) { + v.destroy() + result.resolve(data) + } else if resultType == .error { + v.destroy() + result.reject(QueryError(error, data: data)) + } + } + return result + } + + /// The rows of `query` now, for a query run inside a mutator. + func snapshot(_ query: AnyQuery) throws -> ViewValue { + let v = try materialize(query) + let data = v.data + v.destroy() + return data + } + + /// `preloadImpl`: keeps a query's rows synced without a view. + func preload(_ query: AnyQuery, ttl: TTL = .defaultPreloadTTL) -> Preload { + let cached = Deferred() + let complete = Deferred() + guard let id = query.customQueryID else { + return Preload(cleanup: {}, cached: cached, complete: complete) + } + let cleanup = addCustomQuery(query.ast, id, ttl) { got, error in + if let error { + cached.reject(QueryError(error)) + complete.reject(QueryError(error)) + return + } + if got == .yes { + cached.resolve(()) + complete.resolve(()) + } else if got == .cached { + cached.resolve(()) + } + } + return Preload(cleanup: cleanup, cached: cached, complete: complete) + } +} + +/// What `preload` returns. +@MainActor +public struct Preload { + /// Stops syncing the query (after its TTL). + public let cleanup: () -> Void + /// Settles once the store holds a server-confirmed result, from this + /// connection or an earlier one. + public let cached: Deferred + /// Settles once the server has confirmed the result on this connection. + public let complete: Deferred +} + +/// A query the server could not run. +public struct QueryError: Error, Sendable, CustomStringConvertible { + public let error: ErroredQuery? + /// What the view held when the query failed. JS rejects `run` with it. + let data: ViewValue? + + init(_ error: ErroredQuery?, data: ViewValue? = nil) { + self.error = error + self.data = data + } + + public var description: String { + switch error { + case .app(_, _, let message, _)?: message ?? "Query failed" + case .parse(_, _, let message, _)?: message + case nil: "Query failed" + } + } +} + +/// A view of a query that stays current as data changes (`TypedView`). +/// +/// Unlike in JS, dropping the last reference to a view destroys it. +@MainActor +public final class MaterializedView { + let view: ArrayView + private let updateTTLImpl: (TTL) -> Void + private var destroyed = false + + init(_ view: ArrayView, updateTTL: @escaping (TTL) -> Void) { + self.view = view + self.updateTTLImpl = updateTTL + } + + isolated deinit { + destroy() + } + + public var data: ViewValue { view.data } + public var resultType: ResultType { view.listenerResultType.0 } + public var error: ErroredQuery? { view.listenerResultType.1 } + + /// Calls `listener` now and whenever the data or result type change. + @discardableResult + public func addListener( + _ listener: @escaping (ViewValue, ResultType, ErroredQuery?) -> Void + ) + -> () -> Void + { + view.addListener { [weak view] data, type in + listener(data, type, view?.listenerResultType.1) + } + } + + /// Stops updating the view and releases its query, which the server + /// keeps syncing for the query's TTL. + public func destroy() { + guard !destroyed else { return } + destroyed = true + view.destroy() + } + + /// Raises the query's TTL; a lower one is ignored. + public func updateTTL(_ ttl: TTL) { updateTTLImpl(ttl) } +} diff --git a/swift/Sources/Zero/Client/QueryManager.swift b/swift/Sources/Zero/Client/QueryManager.swift new file mode 100644 index 0000000000..72f3e370f2 --- /dev/null +++ b/swift/Sources/Zero/Client/QueryManager.swift @@ -0,0 +1,311 @@ +/// What a query's got callback hears: whether the server has confirmed the +/// query's rows on this connection (`true`), on an earlier one (`cached`), +/// or not (`false`). +enum Got: Equatable { + case no, cached, yes +} + +typealias GotCallback = (Got, ErroredQuery?) -> Void + +/// A dictionary that iterates in insertion order, like a JS `Map`. +struct OrderedMap { + private(set) var keys: [Key] = [] + private var values: [Key: Value] = [:] + + subscript(key: Key) -> Value? { + get { values[key] } + set { + if let newValue { + if values.updateValue(newValue, forKey: key) == nil { keys.append(key) } + } else if values.removeValue(forKey: key) != nil { + keys.removeAll { $0 == key } + } + } + } + + var count: Int { keys.count } + var isEmpty: Bool { keys.isEmpty } + var entries: [(key: Key, value: Value)] { keys.map { ($0, values[$0]!) } } +} + +/// Tracks the queries the client wants from the server, tells the server +/// about changes in batches, and reports which ones the server has sent +/// (`packages/zero-client/src/client/query-manager.ts`). +@MainActor +final class QueryManager { + final class Entry { + let normalized: AST + let name: String? + let args: [JSONValue]? + var count: Int + var gotCallbacks: [(id: Int, callback: GotCallback)] + var ttl: Double + + init( + normalized: AST, name: String?, args: [JSONValue]?, gotCallbacks: [(Int, GotCallback)], + ttl: Double + ) { + self.normalized = normalized + self.name = name + self.args = args + self.count = 1 + self.gotCallbacks = gotCallbacks + self.ttl = ttl + } + } + + private let clientID: String + private let clientToServer: NameMapper + private let send: (ChangeDesiredQueriesBody) -> Void + private let onFatalError: (ConnectionError) -> Void + private let mutationTracker: MutationTracker + private let scheduler: any Scheduler + private let queryChangeThrottleMs: Double + private let recentQueriesMaxSize: Int + + private var queries = OrderedMap() + private var recentQueries = OrderedMap() + private var gotQueries = Set() + private var gotQueriesAuthoritative = false + private var cachedQueries = Set() + private var awaitingPersistedGotDiff = true + private var pendingQueryChanges: UpQueriesPatch = [] + private var pendingRemovals: [() -> Void] = [] + private var batchTimer: (any ScheduledTimer)? + private var closedError: ConnectionError? + private var nextCallbackID = 0 + + init( + clientID: String, schema: ZeroSchema, mutationTracker: MutationTracker, + scheduler: any Scheduler, queryChangeThrottleMs: Double, recentQueriesMaxSize: Int, + send: @escaping (ChangeDesiredQueriesBody) -> Void, + onFatalError: @escaping (ConnectionError) -> Void + ) { + self.clientID = clientID + self.clientToServer = NameMapper.clientToServer(schema) + self.mutationTracker = mutationTracker + self.scheduler = scheduler + self.queryChangeThrottleMs = queryChangeThrottleMs + self.recentQueriesMaxSize = recentQueriesMaxSize + self.send = send + self.onFatalError = onFatalError + mutationTracker.onAllMutationsApplied { [weak self] in + guard let self, !self.pendingRemovals.isEmpty else { return } + let removals = self.pendingRemovals + self.pendingRemovals = [] + for r in removals { r() } + } + } + + /// The watch of `g/`. The first call is the got set already in the store. + func processGotQueries(_ diff: KVDiff) throws { + let persisted = awaitingPersistedGotDiff + awaitingPersistedGotDiff = false + var thrown: (any Error)? + for op in diff { + let hash = JSKey(String(op.key.utf8.dropFirst(Keys.gotQueriesPrefix.utf8.count))!) + switch op { + case .add: + gotQueries.insert(hash) + if gotQueriesAuthoritative { + thrown = thrown ?? fireGotCallbacks(hash, .yes) + } else if persisted { + cachedQueries.insert(hash) + thrown = thrown ?? fireGotCallbacks(hash, .cached) + } + case .del: + gotQueries.remove(hash) + cachedQueries.remove(hash) + thrown = thrown ?? fireGotCallbacks(hash, .no) + case .change: + break + } + } + if let thrown { throw thrown } + } + + private func fireGotCallbacks(_ hash: JSKey, _ got: Got) -> (any Error)? { + guard let entry = queries[hash] else { return nil } + for (_, cb) in entry.gotCallbacks { cb(got, nil) } + return nil + } + + /// Trusts the got set once the first poke after a connect is applied. + func markGotQueriesAuthoritative() { + if gotQueriesAuthoritative { return } + gotQueriesAuthoritative = true + cachedQueries.removeAll() + awaitingPersistedGotDiff = false + for hash in queries.keys where gotQueries.contains(hash) { + _ = fireGotCallbacks(hash, .yes) + } + } + + /// On disconnect: the next connection must confirm the got set again. + func clearGotQueriesAuthoritative() { + guard gotQueriesAuthoritative else { return } + gotQueriesAuthoritative = false + cachedQueries.formUnion(gotQueries) + } + + /// The changes to send so the server's desired queries for this client + /// (`d//` in the store) become ours, less what `lastPatch` + /// already sent. + func queriesPatch( + store: ReplicaStore, lastPatch: OrderedMap? = nil + ) -> OrderedMap { + let prefix = Keys.desiredQueriesPrefix + clientID + "/" + var existing: [JSKey] = [] + var existingSet = Set() + for e in store.main.scan(prefix: prefix) { + let hash = JSKey(String(e.key.utf8.dropFirst(prefix.utf8.count))!) + if existingSet.insert(hash).inserted { existing.append(hash) } + } + var patch = OrderedMap() + for hash in existing where queries[hash] == nil { + patch[hash] = .del(hash: hash.string) + } + for (hash, entry) in queries.entries where !existingSet.contains(hash) { + patch[hash] = put(hash.string, entry, ttl: entry.ttl) + } + if let lastPatch { + for (hash, op) in lastPatch.entries { + if case .put = op, patch[hash] == nil { patch[hash] = .del(hash: hash.string) } + } + for (hash, op) in patch.entries { + if let last = lastPatch[hash], Self.sameOp(last, op) { patch[hash] = nil } + } + } + return patch + } + + private static func sameOp(_ a: UpQueriesPatchOp, _ b: UpQueriesPatchOp) -> Bool { + switch (a, b) { + case (.put, .put), (.del, .del), (.clear, .clear): true + default: false + } + } + + private func put(_ hash: String, _ entry: Entry, ttl: Double) -> UpQueriesPatchOp { + .put( + UpQueriesPut( + hash: hash, ttl: ttl, ast: entry.name == nil ? entry.normalized : nil, name: entry.name, + args: entry.args)) + } + + func handleTransformErrors(_ errors: [ErroredQuery]) { + for error in errors { + guard let entry = queries[JSKey(error.id)] else { continue } + for (_, cb) in entry.gotCallbacks { cb(.no, error) } + } + } + + func handleClosed(_ reason: ConnectionError) { + guard closedError == nil else { return } + closedError = reason + for (hash, entry) in queries.entries { + let error = ErroredQuery.app( + id: hash.string, name: entry.name ?? "legacy", message: reason.message, + details: ["kind": .string(reason.kindName)]) + for (_, cb) in entry.gotCallbacks { cb(.no, error) } + } + } + + /// Registers a named query. Returns the function that unregisters it. + func addCustom( + _ ast: AST, name: String, args: [JSONValue], ttl: TTL, gotCallback: GotCallback? + ) -> () -> Void { + let normalized = ast.normalized() + let hash = QueryHash.hashOfNameAndArgs(name, args) + return add(JSKey(hash), normalized, name, args, ttl, gotCallback) + } + + private func add( + _ hash: JSKey, _ normalized: AST, _ name: String, _ args: [JSONValue], _ ttl: TTL, + _ gotCallback: GotCallback? + ) -> () -> Void { + let ttl = ttl.clamped + var callbackID: Int? + if gotCallback != nil { + nextCallbackID += 1 + callbackID = nextCallbackID + } + recentQueries[hash] = nil + let entry: Entry + if let existing = queries[hash] { + entry = existing + entry.count += 1 + updateEntry(entry, hash, ttl) + if let gotCallback { entry.gotCallbacks.append((callbackID!, gotCallback)) } + } else { + let mapped = (try? clientToServer.map(normalized)) ?? normalized + entry = Entry( + normalized: mapped, name: name, args: args, + gotCallbacks: gotCallback.map { [(callbackID!, $0)] } ?? [], ttl: ttl) + queries[hash] = entry + queueQueryChange(put(hash.string, entry, ttl: ttl)) + } + if let gotCallback { + let got: Got = + gotQueriesAuthoritative + ? (gotQueries.contains(hash) ? .yes : .no) + : (cachedQueries.contains(hash) ? .cached : .no) + gotCallback(got, nil) + } + var removed = false + return { [weak self] in + guard let self, !removed else { return } + removed = true + // Queries can't be removed while mutations are pending: a rebase + // could need data they keep in scope. + if self.mutationTracker.size > 0 { + self.pendingRemovals.append { [weak self] in self?.remove(entry, hash, callbackID) } + return + } + self.remove(entry, hash, callbackID) + } + } + + func updateCustom(name: String, args: [JSONValue], ttl: TTL) { + let hash = JSKey(QueryHash.hashOfNameAndArgs(name, args)) + guard let entry = queries[hash] else { return } + updateEntry(entry, hash, ttl.clamped) + } + + private func updateEntry(_ entry: Entry, _ hash: JSKey, _ ttl: Double) { + if TTL.compare(.milliseconds(ttl), .milliseconds(entry.ttl)) > 0 { + entry.ttl = ttl + queueQueryChange(put(hash.string, entry, ttl: ttl)) + } + } + + private func queueQueryChange(_ op: UpQueriesPatchOp) { + pendingQueryChanges.append(op) + if batchTimer == nil { + batchTimer = scheduler.schedule(after: queryChangeThrottleMs) { [weak self] in + self?.flushBatch() + } + } + } + + func flushBatch() { + batchTimer?.cancel() + batchTimer = nil + guard !pendingQueryChanges.isEmpty else { return } + let changes = pendingQueryChanges + pendingQueryChanges = [] + send(ChangeDesiredQueriesBody(desiredQueriesPatch: changes)) + } + + private func remove(_ entry: Entry, _ hash: JSKey, _ callbackID: Int?) { + if let callbackID { entry.gotCallbacks.removeAll { $0.id == callbackID } } + entry.count -= 1 + guard entry.count == 0 else { return } + recentQueries[hash] = () + if recentQueries.count > recentQueriesMaxSize, let lru = recentQueries.keys.first { + queries[lru] = nil + recentQueries[lru] = nil + queueQueryChange(.del(hash: lru.string)) + } + } +} diff --git a/swift/Sources/Zero/Client/ReplicaStore.swift b/swift/Sources/Zero/Client/ReplicaStore.swift new file mode 100644 index 0000000000..9770b9c6bc --- /dev/null +++ b/swift/Sources/Zero/Client/ReplicaStore.swift @@ -0,0 +1,302 @@ +/// A sorted key-value map, ordered by UTF-8 bytes like Replicache's B-tree. +/// Copies share nodes, so a copy is O(1) and writes copy only what they touch. +struct KVMap { + struct Entry { + let key: String + var value: JSONValue + } + + private(set) var tree = BTreeSet { StringCompare.utf8($0.key, $1.key) } + + init() {} + + var count: Int { tree.count } + + func get(_ key: String) -> JSONValue? { + tree.get(Entry(key: key, value: .null))?.value + } + + func has(_ key: String) -> Bool { get(key) != nil } + + mutating func set(_ key: String, _ value: JSONValue) { + tree.add(Entry(key: key, value: value)) + } + + @discardableResult + mutating func delete(_ key: String) -> Bool { + tree.delete(Entry(key: key, value: .null)) + } + + mutating func removeAll() { tree = BTreeSet { StringCompare.utf8($0.key, $1.key) } } + + /// The entries whose key starts with `prefix`, in key order. + func scan(prefix: String) -> [Entry] { + var out: [Entry] = [] + let it = tree.values { StringCompare.utf8($0.key, prefix) } + for e in it { + guard e.key.utf8.starts(with: prefix.utf8) else { break } + out.append(e) + } + return out + } + + var entries: BTreeSet.ForwardIterator { tree.values() } +} + +/// One change between two maps (`DiffOperation` in +/// `packages/replicache/src/btree/node.ts`). +enum KVDiffOp { + case add(key: String, newValue: JSONValue) + case del(key: String, oldValue: JSONValue) + case change(key: String, oldValue: JSONValue, newValue: JSONValue) + + var key: String { + switch self { + case .add(let k, _), .del(let k, _), .change(let k, _, _): k + } + } +} + +typealias KVDiff = [KVDiffOp] + +extension KVDiff { + /// The diff from `old` to `new` over `keys`, which must include every key + /// whose value differs. Ordered by key, as a B-tree diff is. + static func between(_ old: KVMap, _ new: KVMap, keys: Set) -> KVDiff { + var diff: KVDiff = [] + for key in keys.map(\.string).sorted(by: StringCompare.utf8Less) { + switch (old.get(key), new.get(key)) { + case (nil, nil): break + case (nil, let v?): diff.append(.add(key: key, newValue: v)) + case (let v?, nil): diff.append(.del(key: key, oldValue: v)) + case (let a?, let b?): if a != b { diff.append(.change(key: key, oldValue: a, newValue: b)) } + } + } + return diff + } + + /// The diff from `old` to `new` over every key of both. + static func between(_ old: KVMap, _ new: KVMap) -> KVDiff { + var keys = Set() + for e in old.entries { keys.insert(JSKey(e.key)) } + for e in new.entries { keys.insert(JSKey(e.key)) } + return between(old, new, keys: keys) + } + + /// The ops whose key starts with `prefix`. + func withPrefix(_ prefix: String) -> KVDiff { + filter { $0.key.utf8.starts(with: prefix.utf8) } + } +} + +/// A Replicache write transaction over a copy of a map. Records the keys it +/// writes so a later diff only has to look at those. +@MainActor +final class KVWrite { + private(set) var map: KVMap + private(set) var writes = Set() + /// Set when the whole map was cleared. + private(set) var cleared = false + + init(_ map: KVMap) { self.map = map } + + func has(_ key: String) -> Bool { map.has(key) } + func get(_ key: String) -> JSONValue? { map.get(key) } + + func set(_ key: String, _ value: JSONValue) { + map.set(key, value) + writes.insert(JSKey(key)) + } + + @discardableResult + func delete(_ key: String) -> Bool { + writes.insert(JSKey(key)) + return map.delete(key) + } + + func clear(keysOf old: KVMap) { + for e in old.entries { writes.insert(JSKey(e.key)) } + map.removeAll() + cleared = true + } +} + +/// A patch operation of a pull response (`PatchOperationInternal`). +enum KVPatchOp { + case put(key: String, value: JSONValue) + case update(key: String, merge: Row?, constrain: [String]?) + case del(key: String) + case clear + + var key: String? { + switch self { + case .put(let k, _), .update(let k, _, _), .del(let k): k + case .clear: nil + } + } +} + +/// The local state Replicache keeps for Zero, without its storage machinery: +/// the last server snapshot, and the pending local mutations whose effects +/// are applied on top of it in `main`. +@MainActor +final class ReplicaStore { + struct Snapshot { + var map = KVMap() + /// The server's cookie for the snapshot, `null` before the first poke. + var cookie: JSONValue = .null + var lastMutationIDs: [String: Double] = [:] + } + + struct PendingMutation { + let id: Double + let clientID: String + let name: String + let args: JSONValue + /// `performance.now()` when the mutation was made. + let timestamp: Double + /// The keys its last run wrote. + var writes: Set + } + + let clientID: String + private(set) var snapshot = Snapshot() + private(set) var main = KVMap() + private(set) var pending: [PendingMutation] = [] + + init(clientID: String) { self.clientID = clientID } + + var cookie: JSONValue { snapshot.cookie } + + /// The ID the next mutation of `clientID` gets. + func nextMutationID(for clientID: String) -> Double { + if let last = pending.last(where: { $0.clientID == clientID }) { return last.id + 1 } + return (snapshot.lastMutationIDs[clientID] ?? 0) + 1 + } + + /// Commits a mutation that ran in `write` over `main`. + func commitMutation(_ mutation: PendingMutation, _ write: KVWrite) -> KVDiff { + let diff = KVDiff.between(main, write.map, keys: write.writes) + pending.append(mutation) + main = write.map + return diff + } + + /// The keys whose value in `main` may differ from the snapshot's. + var pendingWrites: Set { + pending.reduce(into: Set()) { $0.formUnion($1.writes) } + } + + /// Replaces the snapshot and the pending mutations after a poke. Returns + /// the diff of `main`. + func advance( + to snapshot: Snapshot, main newMain: KVMap, pending newPending: [PendingMutation], + touched: Set? + ) -> KVDiff { + let diff = + touched.map { KVDiff.between(main, newMain, keys: $0) } ?? KVDiff.between(main, newMain) + self.snapshot = snapshot + self.pending = newPending + self.main = newMain + return diff + } + + /// Applies `patch` to `write` (`apply` in + /// `packages/replicache/src/sync/patch.ts`). + static func apply(_ patch: [KVPatchOp], to write: KVWrite) throws(PatchError) { + for op in optimize(patch) { + switch op { + case .clear: + write.clear(keysOf: write.map) + case .put(let key, let value): + write.set(key, value) + case .update(let key, let merge, let constrain): + let existing = write.get(key) + var entries: [(String, JSONValue)] = [] + if let existing { + guard case .object(let o) = existing else { + throw PatchError("Expected object for update of \(key)") + } + entries += o.map { ($0.key, $0.value) } + } + if let merge { entries += merge.map { ($0.key, $0.value) } } + var next: Row = [:] + for (k, v) in entries + where constrain == nil || constrain!.isEmpty || constrain!.contains(k) { + next[k] = v + } + write.set(key, .object(next)) + case .del(let key): + if write.has(key) { write.delete(key) } + } + } + } + + struct PatchError: Error, CustomStringConvertible { + let description: String + init(_ d: String) { description = d } + } + + /// `optimizePatch`: drops what the last `clear` and later puts and dels of + /// the same key make irrelevant, and sorts by key. A patch with an + /// `update` is applied as sent, from its last `clear`. + static func optimize(_ patch: [KVPatchOp]) -> [KVPatchOp] { + guard !patch.isEmpty else { return [] } + var start = 0 + var result: [KVPatchOp] = [] + var clearIndex = -1 + for i in stride(from: patch.count - 1, through: 0, by: -1) { + if case .clear = patch[i] { + result.append(.clear) + clearIndex = i + start = i + 1 + break + } + } + var order: [JSKey] = [] + var ops: [JSKey: KVPatchOp] = [:] + for op in patch[start...] { + switch op { + case .put(let key, _), .del(let key): + let k = JSKey(key) + if ops[k] == nil { order.append(k) } + ops[k] = op + case .update: + return clearIndex == -1 ? patch : Array(patch[clearIndex...]) + case .clear: + break + } + } + var rest: [KVPatchOp] = [] + for k in order { + let op = ops[k]! + if clearIndex != -1, case .del = op { continue } + rest.append(op) + } + rest.sort { StringCompare.utf8Less($0.key!, $1.key!) } + return result + rest + } +} + +/// `compareCookies` (`packages/replicache/src/cookies.ts`) for the cookies +/// Zero uses: null, strings, numbers, and objects with an `order`. +func compareCookies(_ a: JSONValue, _ b: JSONValue) -> Double { + if a == b { return 0 } + if a.isNull { return -1 } + if b.isNull { return 1 } + func value(_ c: JSONValue) -> JSONValue { + if case .object(let o) = c { return o["order"] ?? .null } + return c + } + let va = value(a) + let vb = value(b) + if case .number(let x) = va, case .number(let y) = vb { return x - y } + func string(_ v: JSONValue) -> String { + switch v { + case .string(let s): s + case .number(let n): JSNumber.format(n) + default: JSONSerializer.stringify(v) + } + } + return Double(StringCompare.utf16(string(va), string(vb))) +} diff --git a/swift/Sources/Zero/Client/Scheduler.swift b/swift/Sources/Zero/Client/Scheduler.swift new file mode 100644 index 0000000000..a6a1b0cc3b --- /dev/null +++ b/swift/Sources/Zero/Client/Scheduler.swift @@ -0,0 +1,150 @@ +/// The clock and timers the engine runs on: `Date.now()`, `performance.now()` +/// and `setTimeout` in JS. Tests swap in a manual scheduler so a scenario +/// runs the same way every time. +@MainActor +public protocol Scheduler: AnyObject { + /// Milliseconds since 1970 (`Date.now()`). + var now: Double { get } + /// Milliseconds on a monotonic clock (`performance.now()`). + var monotonicNow: Double { get } + /// Calls `action` on the main actor after `ms` milliseconds. + func schedule(after ms: Double, _ action: @escaping @MainActor () -> Void) -> any ScheduledTimer + /// Calls `action` every `ms` milliseconds until cancelled + /// (`setInterval`). + func scheduleRepeating( + every ms: Double, _ action: @escaping @MainActor () -> Void + ) + -> any ScheduledTimer + /// Calls `action` once the caller's synchronous code is done, before any + /// timer: where JS runs microtasks. The engine queues its follow-ups to + /// API calls here. By default a main-actor task. + func checkpoint(_ action: @escaping @MainActor () -> Void) +} + +extension Scheduler { + public func checkpoint(_ action: @escaping @MainActor () -> Void) { + Task { @MainActor in action() } + } +} + +/// A timer from `Scheduler.schedule`. +@MainActor +public protocol ScheduledTimer: AnyObject { + /// Stops the timer from firing. Does nothing once it has fired. + func cancel() +} + +/// The real clock, with timers on the main actor. +@MainActor +public final class SystemScheduler: Scheduler { + private let start = ContinuousClock.now + + public init() {} + + public var now: Double { wallClockMilliseconds() } + + public var monotonicNow: Double { Self.milliseconds(ContinuousClock.now - start) } + + public func schedule( + after ms: Double, _ action: @escaping @MainActor () -> Void + ) + -> any ScheduledTimer + { + let timer = SystemTimer() + timer.task = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(max(0, ms))) + guard !timer.cancelled else { return } + timer.cancelled = true + action() + } + return timer + } + + public func scheduleRepeating( + every ms: Double, _ action: @escaping @MainActor () -> Void + ) + -> any ScheduledTimer + { + let timer = SystemTimer() + timer.task = Task { @MainActor in + while !timer.cancelled { + try? await Task.sleep(for: .milliseconds(max(1, ms))) + if timer.cancelled { return } + action() + } + } + return timer + } + + static func milliseconds(_ d: Duration) -> Double { + let (s, attoseconds) = d.components + return Double(s) * 1000 + Double(attoseconds) / 1e15 + } +} + +@MainActor +final class SystemTimer: ScheduledTimer { + var task: Task? + var cancelled = false + + func cancel() { + cancelled = true + task?.cancel() + } +} + +#if canImport(Darwin) + import Darwin +#elseif canImport(Glibc) + import Glibc +#elseif canImport(Musl) + import Musl +#endif + +func wallClockMilliseconds() -> Double { + var ts = timespec() + clock_gettime(CLOCK_REALTIME, &ts) + return Double(ts.tv_sec) * 1000 + Double(ts.tv_nsec) / 1e6 +} + +/// A value that arrives later, like a JS promise: the first `resolve` or +/// `reject` wins. Awaiting `value` suspends until then; `result` reads it +/// without waiting. +@MainActor +public final class Deferred { + public private(set) var result: Result? + private var waiters: [CheckedContinuation] = [] + private var observers: [(Result) -> Void] = [] + + public init() {} + + init(resolved value: Value) { result = .success(value) } + + public var value: Value { + get async throws { + if let result { return try result.get() } + return try await withCheckedThrowingContinuation { waiters.append($0) } + } + } + + public var isSettled: Bool { result != nil } + + func resolve(_ value: Value) { settle(.success(value)) } + func reject(_ error: any Error) { settle(.failure(error)) } + + /// Calls `observer` once settled (right away if it already is). + func onSettle(_ observer: @escaping (Result) -> Void) { + if let result { observer(result) } else { observers.append(observer) } + } + + private func settle(_ r: Result) { + guard result == nil else { return } + result = r + let waiters = self.waiters + let observers = self.observers + self.waiters = [] + self.observers = [] + for w in waiters { w.resume(with: r) } + for o in observers { o(r) } + } +} diff --git a/swift/Sources/Zero/Client/Socket.swift b/swift/Sources/Zero/Client/Socket.swift new file mode 100644 index 0000000000..0bb42893e0 --- /dev/null +++ b/swift/Sources/Zero/Client/Socket.swift @@ -0,0 +1,112 @@ +/// What the engine sees of a WebSocket, on the main actor: it opens one per +/// connection attempt, sends text frames, and is told about frames and the +/// close. This is the shape of the browser `WebSocket` the JS client uses. +@MainActor +public protocol SocketFactory: AnyObject { + /// Starts opening a WebSocket to `url`, sending `secProtocol` (when + /// non-nil) as the `Sec-WebSocket-Protocol` header. Events arrive on + /// `events` until the engine closes the socket. + func open(url: String, secProtocol: String?, events: any SocketEvents) -> any SocketConnection +} + +/// An open or opening WebSocket. +@MainActor +public protocol SocketConnection: AnyObject { + func send(_ text: String) + /// Closes the socket. No more events are delivered after this. + func close(code: Int?) +} + +/// Receives the events of one socket. +@MainActor +public protocol SocketEvents: AnyObject { + func socketDidOpen() + func socketDidReceive(_ message: SocketMessage) + func socketDidClose(code: Int, reason: String, wasClean: Bool) +} + +/// Runs an async `Transport` as a `SocketFactory`. +@MainActor +public final class TransportSocketFactory: SocketFactory { + let transport: any Transport + + public init(_ transport: any Transport) { self.transport = transport } + + public func open( + url: String, secProtocol: String?, events: any SocketEvents + ) + -> any SocketConnection + { + TransportConnection(transport: transport, url: url, secProtocol: secProtocol, events: events) + } +} + +@MainActor +final class TransportConnection: SocketConnection { + private weak var events: (any SocketEvents)? + private var socket: (any Socket)? + private var pending: [String] = [] + private var closed = false + private var task: Task? + + init(transport: any Transport, url: String, secProtocol: String?, events: any SocketEvents) { + self.events = events + task = Task { @MainActor [weak self] in + let socket: any Socket + do { + socket = try await transport.connect(url: url, secProtocol: secProtocol) + } catch { + self?.finish(code: 1006, reason: String(describing: error), wasClean: false) + return + } + guard let self, !self.closed else { + await socket.close(code: 1000, reason: "") + return + } + self.socket = socket + self.events?.socketDidOpen() + for text in self.pending { self.write(text) } + self.pending = [] + do { + while let message = try await socket.receive() { + if self.closed { return } + self.events?.socketDidReceive(message) + } + self.finish(code: 1000, reason: "", wasClean: true) + } catch { + self.finish(code: 1006, reason: String(describing: error), wasClean: false) + } + } + } + + func send(_ text: String) { + guard !closed else { return } + if socket == nil { + pending.append(text) + } else { + write(text) + } + } + + private func write(_ text: String) { + guard let socket else { return } + Task { try? await socket.send(.text(text)) } + } + + func close(code: Int?) { + guard !closed else { return } + closed = true + events = nil + if let socket { + Task { await socket.close(code: code ?? 1000, reason: "") } + } + } + + private func finish(code: Int, reason: String, wasClean: Bool) { + guard !closed else { return } + closed = true + let events = self.events + self.events = nil + events?.socketDidClose(code: code, reason: reason, wasClean: wasClean) + } +} diff --git a/swift/Sources/Zero/Client/Transaction.swift b/swift/Sources/Zero/Client/Transaction.swift new file mode 100644 index 0000000000..567923a44d --- /dev/null +++ b/swift/Sources/Zero/Client/Transaction.swift @@ -0,0 +1,149 @@ +/// The transaction a mutator runs in on the client (`TransactionImpl` in +/// `packages/zero-client/src/client/custom.ts`). Writes go to the local +/// store and to the IVM branch the mutator reads through `run`. +@MainActor +public final class Transaction { + public enum Reason: String, Sendable { + /// The first run, when the app calls `mutate`. + case optimistic + /// A replay over newer server data while the mutation is pending. + case rebase + } + + /// Where the mutator runs: always `client` here. + public let location = "client" + public let clientID: String + public let mutationID: Int + public let reason: Reason + /// The auth token the client has. + public let token: String? + + let write: KVWrite + let branch: IVMBranch + let schema: ZeroSchema + private lazy var context = QueryContext.local(branch) + + init( + clientID: String, mutationID: Int, reason: Reason, token: String?, write: KVWrite, + branch: IVMBranch, schema: ZeroSchema + ) { + self.clientID = clientID + self.mutationID = mutationID + self.reason = reason + self.token = token + self.write = write + self.branch = branch + self.schema = schema + } + + /// The CRUD operations, by table: `tx.mutate.issue.insert(row)`. + public var mutate: TransactionMutate { TransactionMutate(tx: self) } + + /// The rows `query` returns over this transaction's data. + public func run(_ query: Query) throws -> S { + try S(view: context.snapshot(query.any)) + } + + /// The rows `query` returns over this transaction's data. + public func run(_ query: AnyQuery) throws -> ViewValue { + try context.snapshot(query) + } + + func tableSchema(_ table: String) throws -> TableSchema { + guard let t = schema.tables[table] else { throw ApplicationError("Unknown table: \(table)") } + return t + } + + func key(_ table: TableSchema, _ value: Row) throws -> String { + try Keys.primaryKeyString(table: table.name, primaryKey: table.primaryKey, row: value) + } + + private func source(_ table: String) -> MemorySource { + guard let source = branch.source(table) else { + preconditionFailure("No source for table \(table)") + } + return source + } + + /// `insert` in `crud-impl.ts`: a row that exists is left alone. Columns + /// the value leaves out are null. + func insert(_ tableName: String, _ value: Row) throws { + let table = try tableSchema(tableName) + let key = try key(table, value) + guard !write.has(key) else { return } + var row = value + for column in table.columnNames where row[column] == nil { row[column] = .null } + write.set(key, .object(row)) + source(tableName).push(.add(row)) + } + + func upsert(_ tableName: String, _ value: Row) throws { + let table = try tableSchema(tableName) + if write.has(try key(table, value)) { + try update(tableName, value) + } else { + try insert(tableName, value) + } + } + + /// Merges the value's columns into the row; a missing row is left alone. + func update(_ tableName: String, _ value: Row) throws { + let table = try tableSchema(tableName) + let key = try key(table, value) + guard let prev = write.get(key)?.objectValue else { return } + var next = prev + for (k, v) in value { next[k] = v } + write.set(key, .object(next)) + source(tableName).push(.edit(next, oldRow: prev)) + } + + func delete(_ tableName: String, _ id: Row) throws { + let table = try tableSchema(tableName) + let key = try key(table, id) + guard let prev = write.get(key)?.objectValue else { return } + write.delete(key) + source(tableName).push(.remove(prev)) + } +} + +/// `tx.mutate`: the tables of the schema. +@MainActor +@dynamicMemberLookup +public struct TransactionMutate { + let tx: Transaction + + public subscript(dynamicMember table: String) -> TableMutator { + TableMutator(tx: tx, table: table) + } + + public func table(_ name: String) -> TableMutator { TableMutator(tx: tx, table: name) } + + /// Inserts a row unless one with its primary key exists. + public func insert(_ row: R) throws { + try tx.insert(R.tableName, row.columnValues) + } + + /// Inserts a row, or replaces the columns of the existing one. + public func upsert(_ row: R) throws { + try tx.upsert(R.tableName, row.columnValues) + } +} + +/// The CRUD operations on one table (`TableCRUD` in +/// `packages/zql/src/mutate/crud.ts`). Values are keyed by client column +/// name. +@MainActor +public struct TableMutator { + let tx: Transaction + public let table: String + + /// Inserts a row unless one with its primary key exists. Columns it + /// leaves out are null. + public func insert(_ value: Row) throws { try tx.insert(table, value) } + /// Inserts a row, or merges the value into the existing one. + public func upsert(_ value: Row) throws { try tx.upsert(table, value) } + /// Merges the value's columns into the row with its primary key, if any. + public func update(_ value: Row) throws { try tx.update(table, value) } + /// Deletes the row with the primary key in `id`, if any. + public func delete(_ id: Row) throws { try tx.delete(table, id) } +} diff --git a/swift/Sources/Zero/Client/ZeroClient.swift b/swift/Sources/Zero/Client/ZeroClient.swift new file mode 100644 index 0000000000..a6f30e593e --- /dev/null +++ b/swift/Sources/Zero/Client/ZeroClient.swift @@ -0,0 +1,163 @@ +/// A Zero client: syncs the rows of named queries from zero-cache into a +/// local replica, keeps materialized views of them current, and runs +/// mutators optimistically before the server confirms them +/// (`Zero` in `packages/zero-client/src/client/zero.ts`). +/// +/// `Context` is what queries and mutators are given besides their +/// arguments, such as the logged-in user's auth data. +@MainActor +public final class Zero { + let engine: Engine + public let context: Context + public let connection: Connection + + /// - Parameters: + /// - mutators: Every mutator the client may run. Pending mutations are + /// replayed by name, so each must be registered. + /// - scheduler: The clock and timers; tests pass a manual one. + /// - socketFactory: Opens the sync socket. + public init( + schema: ZeroSchema, context: Context, mutators: [any Mutator] = [], + options: ZeroOptions, scheduler: any Scheduler = SystemScheduler(), + socketFactory: (any SocketFactory)? + ) throws { + var functions: [String: MutatorFunction] = [:] + for m in mutators { + guard functions[m.name] == nil else { + throw ApplicationError("Mutator \"\(m.name)\" is registered twice") + } + functions[m.name] = { tx, args in try m.run(tx, args: args, context: context) } + } + engine = try Engine( + schema: schema, options: options, mutators: functions, scheduler: scheduler, + socketFactory: socketFactory) + self.context = context + connection = Connection(engine) + } + + public var clientID: String { engine.clientID } + public var clientGroupID: String { engine.clientGroupID } + public var userID: String? { engine.userID } + public var schema: ZeroSchema { engine.schema } + public var closed: Bool { engine.closed } + + /// A view of the query's rows that stays current. Named queries are + /// synced from the server; an unnamed one only sees local data. + public func materialize( + _ request: QueryRequest, ttl: TTL = .defaultTTL + ) throws -> MaterializedView { + try materialize(request.query(context), ttl: ttl) + } + + /// A view of an unnamed query, which only sees local data. + public func materialize( + _ query: Query, ttl: TTL = .defaultTTL + ) throws -> MaterializedView { + try materialize(query.any, ttl: ttl) + } + + public func materialize(_ query: AnyQuery, ttl: TTL = .defaultTTL) throws -> MaterializedView { + try engine.loop.call { try engine.context.materialize(query, ttl: ttl) } + } + + /// The query's rows: what the replica holds now (`.unknown`), or once the + /// server has confirmed them on this connection (`.complete`) or any + /// (`.cached`). + public func run( + _ request: QueryRequest, type: RunType = .unknown, ttl: TTL? = nil + ) async throws -> S { + let data = try await run(request.query(context), type: type, ttl: ttl).value + return try S(view: data) + } + + public func run( + _ query: Query, type: RunType = .unknown, ttl: TTL? = nil + ) async throws -> S { + let data = try await run(query.any, type: type, ttl: ttl).value + return try S(view: data) + } + + public func run( + _ query: AnyQuery, type: RunType = .unknown, ttl: TTL? = nil + ) throws + -> Deferred + { + try engine.loop.call { + try engine.context.run(query, type: QueryContext.RunType(rawValue: type.rawValue)!, ttl: ttl) + } + } + + /// Keeps the query's rows synced without a view, until `cleanup`. + public func preload( + _ request: QueryRequest, ttl: TTL = .defaultPreloadTTL + ) -> Preload { + preload(request.query(context), ttl: ttl) + } + + public func preload(_ query: AnyQuery, ttl: TTL = .defaultPreloadTTL) -> Preload { + engine.loop.call { engine.context.preload(query, ttl: ttl) } + } + + /// Runs a mutator on the client now and sends it to the server. + @discardableResult + public func mutate(_ request: MutateRequest) -> MutatorResult { + engine.loop.call { engine.mutate(name: request.name, args: request.args) } + } + + /// Closes the connection. Pending mutations and queries fail, and views + /// stop updating. + public func close() { + engine.loop.call { engine.close() } + } +} + +extension Zero where Context == Void { + public convenience init( + schema: ZeroSchema, mutators: [any Mutator] = [], options: ZeroOptions, + scheduler: any Scheduler = SystemScheduler(), socketFactory: (any SocketFactory)? + ) throws { + try self.init( + schema: schema, context: (), mutators: mutators, options: options, scheduler: scheduler, + socketFactory: socketFactory) + } +} + +/// How long `run` waits (`RunOptions.type`). +public enum RunType: String, Sendable { + case unknown, cached, complete +} + +/// The connection to zero-cache (`Connection` in +/// `packages/zero-client/src/client/connection.ts`). +@MainActor +public final class Connection { + private let engine: Engine + public private(set) var state: ConnectionState + private var listeners: [(id: Int, listener: (ConnectionState) -> Void)] = [] + private var nextID = 0 + + init(_ engine: Engine) { + self.engine = engine + state = ConnectionState(engine.connectionManager.state) + _ = engine.connectionManager.subscribe { [weak self] s in + guard let self else { return } + self.state = ConnectionState(s) + for (_, l) in self.listeners { l(self.state) } + } + } + + /// Calls `listener` on every change of `state`. + @discardableResult + public func subscribe(_ listener: @escaping (ConnectionState) -> Void) -> () -> Void { + let id = nextID + nextID += 1 + listeners.append((id, listener)) + return { [weak self] in self?.listeners.removeAll { $0.id == id } } + } + + /// Resumes connecting from `needsAuth` or `error`. With `auth`, sets the + /// token first; when already connected, the server gets the new token. + public func connect(auth: String? = nil) { + engine.loop.call { engine.connect(auth: auth.map { .some($0) }) } + } +} diff --git a/swift/Sources/Zero/IVM/DeferredInput.swift b/swift/Sources/Zero/IVM/DeferredInput.swift new file mode 100644 index 0000000000..565b7a1836 --- /dev/null +++ b/swift/Sources/Zero/IVM/DeferredInput.swift @@ -0,0 +1,55 @@ +/// A placeholder input for a view whose pipeline is not built yet +/// (`packages/zql/src/ivm/deferred-input.ts`). +/// +/// Until `attach` it behaves like an empty pipeline: `fetch` yields nothing +/// and nothing is pushed. `attach` builds the real pipeline and hydrates the +/// output with its rows as add changes, as if they had just arrived. +public final class DeferredInput: Input { + private let schema: SourceSchema + private let build: () throws -> any Input + private weak var output: (any Output)? + private var input: (any Input)? + public private(set) var destroyed = false + + /// `schema` is what the built pipeline reports; `build` builds it, once. + public init(schema: SourceSchema, build: @escaping () throws -> any Input) { + self.schema = schema + self.build = build + } + + public var attached: Bool { input != nil } + + public func setOutput(_ output: any Output) { + self.output = output + input?.setOutput(output) + } + + public func getSchema() -> SourceSchema { schema } + + public func fetch(_ req: FetchRequest) -> NodeStream { + input?.fetch(req) ?? .empty + } + + public func destroy() { + destroyed = true + input?.destroy() + } + + /// Builds the pipeline and pushes its rows to the output. Leaves this + /// input unattached when building or hydrating throws. + public func attach() throws { + precondition(input == nil, "DeferredInput: pipeline already attached") + precondition(!destroyed, "DeferredInput: cannot attach after destroy") + let input = try build() + guard let output else { + self.input = input + return + } + input.setOutput(output) + for node in input.fetch(FetchRequest()) { + output.push(.add(node), pusher: input) + } + output.reconcile(pusher: input) + self.input = input + } +} diff --git a/swift/Sources/Zero/IVM/MemorySource.swift b/swift/Sources/Zero/IVM/MemorySource.swift index d2c7e7ea42..9ec83472bb 100644 --- a/swift/Sources/Zero/IVM/MemorySource.swift +++ b/swift/Sources/Zero/IVM/MemorySource.swift @@ -394,7 +394,8 @@ public final class MemorySource: Source { /// A pipeline's connection to a `MemorySource`. final class MemorySourceInput: SourceInput { - unowned let source: MemorySource + /// Weak: a view can outlive the client and its sources. + weak var source: MemorySource? let connection: MemorySource.Connection let schema: SourceSchema let fullyAppliedFilters: Bool @@ -410,9 +411,9 @@ final class MemorySourceInput: SourceInput { } func getSchema() -> SourceSchema { schema } - func fetch(_ req: FetchRequest) -> NodeStream { source.fetch(req, connection) } + func fetch(_ req: FetchRequest) -> NodeStream { source?.fetch(req, connection) ?? .empty } func setOutput(_ output: any Output) { connection.output = output } - func destroy() { source.disconnect(self) } + func destroy() { source?.disconnect(self) } } /// A bound in an index scan: a value, or below or above every value. diff --git a/swift/Sources/Zero/IVM/View.swift b/swift/Sources/Zero/IVM/View.swift index 6b03843656..59c66b55a6 100644 --- a/swift/Sources/Zero/IVM/View.swift +++ b/swift/Sources/Zero/IVM/View.swift @@ -319,17 +319,30 @@ public final class ArrayView: Output { private var committedRoot: ViewEntry? private var held = false private var heldResultType: ResultType = .unknown + private var heldError: ErroredQuery? public private(set) var resultType: ResultType = .unknown + /// Why the query failed, when `resultType` is `.error`. + public private(set) var error: ErroredQuery? public var onDestroy: (() -> Void)? /// Hydrates from `input`. `complete` marks the result complete from the /// start. - public init(_ input: any Input, format: Format, complete: Bool = false) { + public convenience init(_ input: any Input, format: Format, complete: Bool = false) { + self.init(input, format: format, error: nil, complete: complete) + } + + /// Hydrates from `input`, starting as failed when `error` is set. + public init(_ input: any Input, format: Format, error: ErroredQuery?, complete: Bool) { self.input = input self.format = format root = ViewEntry(row: [:]) root.relationships[""] = format.singular ? .one(nil) : .many([]) - if complete { resultType = .complete } + if complete { + resultType = .complete + } else if let error { + resultType = .error + self.error = error + } input.setOutput(self) hydrate() } @@ -391,6 +404,12 @@ public final class ArrayView: Output { committedRoot = root held = true heldResultType = resultType + heldError = error + } + + /// The result type and error listeners are told about now. + public var listenerResultType: (ResultType, ErroredQuery?) { + committedRoot != nil ? (heldResultType, heldError) : (resultType, error) } public func releaseData() { committedRoot = nil } @@ -404,8 +423,9 @@ public final class ArrayView: Output { fireListeners() } - public func setResultType(_ type: ResultType) { + public func setResultType(_ type: ResultType, error: ErroredQuery? = nil) { resultType = type + self.error = error if held { dirty = true } else if !dirty { diff --git a/swift/Sources/Zero/JSON/JSONValueCoding.swift b/swift/Sources/Zero/JSON/JSONValueCoding.swift new file mode 100644 index 0000000000..4be1ff416a --- /dev/null +++ b/swift/Sources/Zero/JSON/JSONValueCoding.swift @@ -0,0 +1,465 @@ +/// Encodes `Encodable` values to `JSONValue`, as `JSON.stringify` would see +/// them: optional properties that are nil are left out (JS `undefined`). +public enum JSONValueEncoder { + public static func encode(_ value: T) throws -> JSONValue { + if let v = value as? JSONValue { return v } + let encoder = ValueEncoder(codingPath: []) + try value.encode(to: encoder) + return encoder.storage.value ?? .null + } +} + +/// Decodes `Decodable` values from `JSONValue`. +public enum JSONValueDecoder { + public static func decode(_ type: T.Type, from value: JSONValue) throws -> T { + if let v = value as? T { return v } + return try T(from: ValueDecoder(value, codingPath: [])) + } +} + +/// A value being built: containers write into it after they are handed out. +private final class Slot { + var value: JSONValue? + var object: [String: Slot]? + var array: [Slot]? + + var resolved: JSONValue? { + if let object { return .object(object.compactMapValues(\.resolved)) } + if let array { return .array(array.map { $0.resolved ?? .null }) } + return value + } +} + +private struct AnyKey: CodingKey { + var stringValue: String + var intValue: Int? + init(stringValue: String) { self.stringValue = stringValue } + init(intValue: Int) { + self.stringValue = String(intValue) + self.intValue = intValue + } +} + +private final class ValueEncoder: Encoder { + let codingPath: [any CodingKey] + var userInfo: [CodingUserInfoKey: Any] { [:] } + let slot = Slot() + + init(codingPath: [any CodingKey]) { self.codingPath = codingPath } + + var storage: (value: JSONValue?, Void) { (slot.resolved, ()) } + + func container(keyedBy type: Key.Type) -> KeyedEncodingContainer { + if slot.object == nil { slot.object = [:] } + return KeyedEncodingContainer(KeyedEncoder(slot: slot, codingPath: codingPath)) + } + + func unkeyedContainer() -> any UnkeyedEncodingContainer { + if slot.array == nil { slot.array = [] } + return UnkeyedEncoder(slot: slot, codingPath: codingPath) + } + + func singleValueContainer() -> any SingleValueEncodingContainer { + SingleEncoder(slot: slot, codingPath: codingPath) + } +} + +private func encodeValue(_ value: T, into slot: Slot, path: [any CodingKey]) throws { + if let v = value as? JSONValue { + slot.value = v + return + } + let encoder = ValueEncoder(codingPath: path) + try value.encode(to: encoder) + slot.value = encoder.slot.resolved + slot.object = nil + slot.array = nil +} + +private struct KeyedEncoder: KeyedEncodingContainerProtocol { + let slot: Slot + let codingPath: [any CodingKey] + + private func set(_ key: Key, _ v: JSONValue) { + let s = Slot() + s.value = v + slot.object![key.stringValue] = s + } + + mutating func encodeNil(forKey key: Key) throws { set(key, .null) } + mutating func encode(_ value: Bool, forKey key: Key) throws { set(key, .bool(value)) } + mutating func encode(_ value: String, forKey key: Key) throws { set(key, .string(value)) } + mutating func encode(_ value: Double, forKey key: Key) throws { set(key, .number(value)) } + mutating func encode(_ value: Float, forKey key: Key) throws { set(key, .number(Double(value))) } + mutating func encode(_ value: Int, forKey key: Key) throws { set(key, .number(Double(value))) } + mutating func encode(_ value: Int8, forKey key: Key) throws { set(key, .number(Double(value))) } + mutating func encode(_ value: Int16, forKey key: Key) throws { set(key, .number(Double(value))) } + mutating func encode(_ value: Int32, forKey key: Key) throws { set(key, .number(Double(value))) } + mutating func encode(_ value: Int64, forKey key: Key) throws { set(key, .number(Double(value))) } + mutating func encode(_ value: UInt, forKey key: Key) throws { set(key, .number(Double(value))) } + mutating func encode(_ value: UInt8, forKey key: Key) throws { set(key, .number(Double(value))) } + mutating func encode(_ value: UInt16, forKey key: Key) throws { set(key, .number(Double(value))) } + mutating func encode(_ value: UInt32, forKey key: Key) throws { set(key, .number(Double(value))) } + mutating func encode(_ value: UInt64, forKey key: Key) throws { set(key, .number(Double(value))) } + + mutating func encode(_ value: T, forKey key: Key) throws { + let s = Slot() + try encodeValue(value, into: s, path: codingPath + [key]) + slot.object![key.stringValue] = s + } + + mutating func nestedContainer( + keyedBy keyType: NestedKey.Type, forKey key: Key + ) -> KeyedEncodingContainer { + let s = Slot() + s.object = [:] + slot.object![key.stringValue] = s + return KeyedEncodingContainer(KeyedEncoder(slot: s, codingPath: codingPath + [key])) + } + + mutating func nestedUnkeyedContainer(forKey key: Key) -> any UnkeyedEncodingContainer { + let s = Slot() + s.array = [] + slot.object![key.stringValue] = s + return UnkeyedEncoder(slot: s, codingPath: codingPath + [key]) + } + + mutating func superEncoder() -> any Encoder { superEncoder(forKey: Key(stringValue: "super")!) } + + mutating func superEncoder(forKey key: Key) -> any Encoder { + let encoder = ValueEncoder(codingPath: codingPath + [key]) + slot.object![key.stringValue] = encoder.slot + return encoder + } +} + +private struct UnkeyedEncoder: UnkeyedEncodingContainer { + let slot: Slot + let codingPath: [any CodingKey] + var count: Int { slot.array!.count } + + private func append(_ v: JSONValue) { + let s = Slot() + s.value = v + slot.array!.append(s) + } + + private var key: AnyKey { AnyKey(intValue: count) } + + mutating func encodeNil() throws { append(.null) } + mutating func encode(_ value: Bool) throws { append(.bool(value)) } + mutating func encode(_ value: String) throws { append(.string(value)) } + mutating func encode(_ value: Double) throws { append(.number(value)) } + mutating func encode(_ value: Float) throws { append(.number(Double(value))) } + mutating func encode(_ value: Int) throws { append(.number(Double(value))) } + mutating func encode(_ value: Int8) throws { append(.number(Double(value))) } + mutating func encode(_ value: Int16) throws { append(.number(Double(value))) } + mutating func encode(_ value: Int32) throws { append(.number(Double(value))) } + mutating func encode(_ value: Int64) throws { append(.number(Double(value))) } + mutating func encode(_ value: UInt) throws { append(.number(Double(value))) } + mutating func encode(_ value: UInt8) throws { append(.number(Double(value))) } + mutating func encode(_ value: UInt16) throws { append(.number(Double(value))) } + mutating func encode(_ value: UInt32) throws { append(.number(Double(value))) } + mutating func encode(_ value: UInt64) throws { append(.number(Double(value))) } + + mutating func encode(_ value: T) throws { + let s = Slot() + try encodeValue(value, into: s, path: codingPath + [key]) + slot.array!.append(s) + } + + mutating func nestedContainer( + keyedBy keyType: NestedKey.Type + ) + -> KeyedEncodingContainer + { + let s = Slot() + s.object = [:] + let path = codingPath + [key] + slot.array!.append(s) + return KeyedEncodingContainer(KeyedEncoder(slot: s, codingPath: path)) + } + + mutating func nestedUnkeyedContainer() -> any UnkeyedEncodingContainer { + let s = Slot() + s.array = [] + let path = codingPath + [key] + slot.array!.append(s) + return UnkeyedEncoder(slot: s, codingPath: path) + } + + mutating func superEncoder() -> any Encoder { + let encoder = ValueEncoder(codingPath: codingPath + [key]) + slot.array!.append(encoder.slot) + return encoder + } +} + +private struct SingleEncoder: SingleValueEncodingContainer { + let slot: Slot + let codingPath: [any CodingKey] + + mutating func encodeNil() throws { slot.value = .null } + mutating func encode(_ value: Bool) throws { slot.value = .bool(value) } + mutating func encode(_ value: String) throws { slot.value = .string(value) } + mutating func encode(_ value: Double) throws { slot.value = .number(value) } + mutating func encode(_ value: Float) throws { slot.value = .number(Double(value)) } + mutating func encode(_ value: Int) throws { slot.value = .number(Double(value)) } + mutating func encode(_ value: Int8) throws { slot.value = .number(Double(value)) } + mutating func encode(_ value: Int16) throws { slot.value = .number(Double(value)) } + mutating func encode(_ value: Int32) throws { slot.value = .number(Double(value)) } + mutating func encode(_ value: Int64) throws { slot.value = .number(Double(value)) } + mutating func encode(_ value: UInt) throws { slot.value = .number(Double(value)) } + mutating func encode(_ value: UInt8) throws { slot.value = .number(Double(value)) } + mutating func encode(_ value: UInt16) throws { slot.value = .number(Double(value)) } + mutating func encode(_ value: UInt32) throws { slot.value = .number(Double(value)) } + mutating func encode(_ value: UInt64) throws { slot.value = .number(Double(value)) } + mutating func encode(_ value: T) throws { + try encodeValue(value, into: slot, path: codingPath) + } +} + +private struct ValueDecoder: Decoder { + let value: JSONValue + let codingPath: [any CodingKey] + var userInfo: [CodingUserInfoKey: Any] { [:] } + + init(_ value: JSONValue, codingPath: [any CodingKey]) { + self.value = value + self.codingPath = codingPath + } + + func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer { + guard case .object(let o) = value else { + throw typeMismatch([String: JSONValue].self, value, codingPath) + } + return KeyedDecodingContainer(KeyedDecoder(object: o, codingPath: codingPath)) + } + + func unkeyedContainer() throws -> any UnkeyedDecodingContainer { + guard case .array(let a) = value else { + throw typeMismatch([JSONValue].self, value, codingPath) + } + return UnkeyedDecoder(array: a, codingPath: codingPath) + } + + func singleValueContainer() throws -> any SingleValueDecodingContainer { + SingleDecoder(value: value, codingPath: codingPath) + } +} + +private func typeMismatch( + _ type: Any.Type, _ value: JSONValue, _ path: [any CodingKey] +) + -> Swift.DecodingError +{ + Swift.DecodingError.typeMismatch( + type, .init(codingPath: path, debugDescription: "Expected \(type), found \(value.typeName)")) +} + +private func decodeValue( + _ type: T.Type, _ value: JSONValue, _ path: [any CodingKey] +) + throws -> T +{ + if let v = value as? T { return v } + return try T(from: ValueDecoder(value, codingPath: path)) +} + +private func number( + _ value: JSONValue, _ path: [any CodingKey], _ make: (Double) -> T? +) throws + -> T +{ + guard case .number(let n) = value, let v = make(n) else { + throw typeMismatch(T.self, value, path) + } + return v +} + +private func exactInteger(_ n: Double) -> T? { + guard n.rounded() == n, let v = T(exactly: n) else { return nil } + return v +} + +private struct KeyedDecoder: KeyedDecodingContainerProtocol { + let object: [String: JSONValue] + let codingPath: [any CodingKey] + + var allKeys: [Key] { object.keys.compactMap { Key(stringValue: $0) } } + func contains(_ key: Key) -> Bool { object[key.stringValue] != nil } + + private func get(_ key: Key) throws -> JSONValue { + guard let v = object[key.stringValue] else { + throw Swift.DecodingError.keyNotFound( + key, .init(codingPath: codingPath, debugDescription: "No value for \(key.stringValue)")) + } + return v + } + + func decodeNil(forKey key: Key) throws -> Bool { (object[key.stringValue] ?? .null).isNull } + + func decode(_ type: Bool.Type, forKey key: Key) throws -> Bool { + let v = try get(key) + guard case .bool(let b) = v else { throw typeMismatch(type, v, codingPath + [key]) } + return b + } + + func decode(_ type: String.Type, forKey key: Key) throws -> String { + let v = try get(key) + guard case .string(let s) = v else { throw typeMismatch(type, v, codingPath + [key]) } + return s + } + + func decode(_ type: Double.Type, forKey key: Key) throws -> Double { + try number(get(key), codingPath + [key]) { $0 } + } + func decode(_ type: Float.Type, forKey key: Key) throws -> Float { + try number(get(key), codingPath + [key]) { Float($0) } + } + func decode(_ type: Int.Type, forKey key: Key) throws -> Int { + try number(get(key), codingPath + [key], exactInteger) + } + func decode(_ type: Int8.Type, forKey key: Key) throws -> Int8 { + try number(get(key), codingPath + [key], exactInteger) + } + func decode(_ type: Int16.Type, forKey key: Key) throws -> Int16 { + try number(get(key), codingPath + [key], exactInteger) + } + func decode(_ type: Int32.Type, forKey key: Key) throws -> Int32 { + try number(get(key), codingPath + [key], exactInteger) + } + func decode(_ type: Int64.Type, forKey key: Key) throws -> Int64 { + try number(get(key), codingPath + [key], exactInteger) + } + func decode(_ type: UInt.Type, forKey key: Key) throws -> UInt { + try number(get(key), codingPath + [key], exactInteger) + } + func decode(_ type: UInt8.Type, forKey key: Key) throws -> UInt8 { + try number(get(key), codingPath + [key], exactInteger) + } + func decode(_ type: UInt16.Type, forKey key: Key) throws -> UInt16 { + try number(get(key), codingPath + [key], exactInteger) + } + func decode(_ type: UInt32.Type, forKey key: Key) throws -> UInt32 { + try number(get(key), codingPath + [key], exactInteger) + } + func decode(_ type: UInt64.Type, forKey key: Key) throws -> UInt64 { + try number(get(key), codingPath + [key], exactInteger) + } + + func decode(_ type: T.Type, forKey key: Key) throws -> T { + try decodeValue(type, get(key), codingPath + [key]) + } + + func nestedContainer( + keyedBy type: NestedKey.Type, forKey key: Key + ) throws + -> KeyedDecodingContainer + { + try ValueDecoder(get(key), codingPath: codingPath + [key]).container(keyedBy: type) + } + + func nestedUnkeyedContainer(forKey key: Key) throws -> any UnkeyedDecodingContainer { + try ValueDecoder(get(key), codingPath: codingPath + [key]).unkeyedContainer() + } + + func superDecoder() throws -> any Decoder { + ValueDecoder(object["super"] ?? .null, codingPath: codingPath) + } + + func superDecoder(forKey key: Key) throws -> any Decoder { + ValueDecoder(object[key.stringValue] ?? .null, codingPath: codingPath + [key]) + } +} + +private struct UnkeyedDecoder: UnkeyedDecodingContainer { + let array: [JSONValue] + let codingPath: [any CodingKey] + var currentIndex = 0 + + init(array: [JSONValue], codingPath: [any CodingKey]) { + self.array = array + self.codingPath = codingPath + } + + var count: Int? { array.count } + var isAtEnd: Bool { currentIndex >= array.count } + + private var path: [any CodingKey] { codingPath + [AnyKey(intValue: currentIndex)] } + + private mutating func next() throws -> JSONValue { + guard !isAtEnd else { + throw Swift.DecodingError.valueNotFound( + JSONValue.self, .init(codingPath: path, debugDescription: "Unkeyed container is at end")) + } + defer { currentIndex += 1 } + return array[currentIndex] + } + + mutating func decodeNil() throws -> Bool { + if !isAtEnd, array[currentIndex].isNull { + currentIndex += 1 + return true + } + return false + } + + mutating func decode(_ type: T.Type) throws -> T { + let p = path + return try decodeValue(type, next(), p) + } + + mutating func nestedContainer( + keyedBy type: NestedKey.Type + ) throws + -> KeyedDecodingContainer + { + let p = path + return try ValueDecoder(next(), codingPath: p).container(keyedBy: type) + } + + mutating func nestedUnkeyedContainer() throws -> any UnkeyedDecodingContainer { + let p = path + return try ValueDecoder(next(), codingPath: p).unkeyedContainer() + } + + mutating func superDecoder() throws -> any Decoder { + let p = path + return ValueDecoder(try next(), codingPath: p) + } +} + +private struct SingleDecoder: SingleValueDecodingContainer { + let value: JSONValue + let codingPath: [any CodingKey] + + func decodeNil() -> Bool { value.isNull } + + func decode(_ type: Bool.Type) throws -> Bool { + guard case .bool(let b) = value else { throw typeMismatch(type, value, codingPath) } + return b + } + + func decode(_ type: String.Type) throws -> String { + guard case .string(let s) = value else { throw typeMismatch(type, value, codingPath) } + return s + } + + func decode(_ type: Double.Type) throws -> Double { try number(value, codingPath) { $0 } } + func decode(_ type: Float.Type) throws -> Float { try number(value, codingPath) { Float($0) } } + func decode(_ type: Int.Type) throws -> Int { try number(value, codingPath, exactInteger) } + func decode(_ type: Int8.Type) throws -> Int8 { try number(value, codingPath, exactInteger) } + func decode(_ type: Int16.Type) throws -> Int16 { try number(value, codingPath, exactInteger) } + func decode(_ type: Int32.Type) throws -> Int32 { try number(value, codingPath, exactInteger) } + func decode(_ type: Int64.Type) throws -> Int64 { try number(value, codingPath, exactInteger) } + func decode(_ type: UInt.Type) throws -> UInt { try number(value, codingPath, exactInteger) } + func decode(_ type: UInt8.Type) throws -> UInt8 { try number(value, codingPath, exactInteger) } + func decode(_ type: UInt16.Type) throws -> UInt16 { try number(value, codingPath, exactInteger) } + func decode(_ type: UInt32.Type) throws -> UInt32 { try number(value, codingPath, exactInteger) } + func decode(_ type: UInt64.Type) throws -> UInt64 { try number(value, codingPath, exactInteger) } + + func decode(_ type: T.Type) throws -> T { + try decodeValue(type, value, codingPath) + } +} diff --git a/swift/Sources/Zero/Protocol/ErrorBody.swift b/swift/Sources/Zero/Protocol/ErrorBody.swift index c20c308b85..f5e13ab94b 100644 --- a/swift/Sources/Zero/Protocol/ErrorBody.swift +++ b/swift/Sources/Zero/Protocol/ErrorBody.swift @@ -15,6 +15,16 @@ public enum ErrorBody: Sendable, Hashable, JSONCodable { } } + /// Where the error came from; nil from servers too old to say. + public var origin: ErrorOrigin? { + switch self { + case .basic(let b): b.origin + case .backoff(let b): b.origin + case .pushFailed(let b): b.cause.origin + case .transformFailed(let b): b.cause.origin + } + } + public var message: String { switch self { case .basic(let b): b.message diff --git a/swift/Sources/ZeroTesting/ManualScheduler.swift b/swift/Sources/ZeroTesting/ManualScheduler.swift new file mode 100644 index 0000000000..2c3b3e9acf --- /dev/null +++ b/swift/Sources/ZeroTesting/ManualScheduler.swift @@ -0,0 +1,128 @@ +import Zero + +/// A clock that only moves when told to, like fake timers in JS tests. +/// +/// Timers run in order of due time, then of creation; a repeating timer +/// keeps its place among timers due at the same time (`setInterval` keeps +/// its timer ID). +@MainActor +public final class ManualScheduler: Scheduler { + /// `Date.now()`. + public private(set) var now: Double + /// `performance.now()`: milliseconds since the scheduler was made. + public private(set) var monotonicNow: Double = 0 + + private final class Timer: ScheduledTimer { + let sequence: Int + var due: Double + let interval: Double? + let action: @MainActor () -> Void + var cancelled = false + + init(sequence: Int, due: Double, interval: Double?, action: @escaping @MainActor () -> Void) { + self.sequence = sequence + self.due = due + self.interval = interval + self.action = action + } + + func cancel() { cancelled = true } + } + + private var timers: [Timer] = [] + private var checkpoints: [@MainActor () -> Void] = [] + private var nextSequence = 0 + + public init(now: Double = 0) { + self.now = now + } + + public func schedule( + after ms: Double, _ action: @escaping @MainActor () -> Void + ) + -> any ScheduledTimer + { + add(due: monotonicNow + max(0, ms), interval: nil, action) + } + + public func scheduleRepeating( + every ms: Double, _ action: @escaping @MainActor () -> Void + ) + -> any ScheduledTimer + { + add(due: monotonicNow + max(1, ms), interval: max(1, ms), action) + } + + private func add( + due: Double, interval: Double?, _ action: @escaping @MainActor () -> Void + ) + -> Timer + { + nextSequence += 1 + let timer = Timer(sequence: nextSequence, due: due, interval: interval, action: action) + timers.append(timer) + return timer + } + + /// Runs `action` before the next timer fires, or when the caller next + /// suspends, whichever comes first. + public func checkpoint(_ action: @escaping @MainActor () -> Void) { + checkpoints.append(action) + Task { @MainActor in self.runCheckpoints() } + } + + /// Runs the pending checkpoints, as JS runs microtasks before a timer. + public func runCheckpoints() { + while !checkpoints.isEmpty { + checkpoints.removeFirst()() + } + } + + /// The number of timers that have not fired or been cancelled. + public var pendingTimers: Int { timers.count { !$0.cancelled } } + + private func nextDue(by limit: Double) -> Timer? { + runCheckpoints() + timers.removeAll { $0.cancelled } + var best: Timer? + for t in timers where t.due <= limit { + if let b = best, (b.due, b.sequence) <= (t.due, t.sequence) { continue } + best = t + } + return best + } + + private func fire(_ timer: Timer) { + let elapsed = timer.due - monotonicNow + if elapsed > 0 { + monotonicNow = timer.due + now += elapsed + } + if let interval = timer.interval { + timer.due += interval + } else { + timer.cancelled = true + } + timer.action() + } + + /// Moves the clock forward by `ms`, firing every timer that comes due on + /// the way, including ones those timers schedule. + public func advance(by ms: Double) { + let target = monotonicNow + ms + while let timer = nextDue(by: target) { fire(timer) } + now += target - monotonicNow + monotonicNow = target + } + + /// Fires the timers due now, and the zero-delay timers they schedule, + /// until none is left. + public func runDueTimers(limit: Int = 100_000) { + var count = 0 + while let timer = nextDue(by: monotonicNow) { + fire(timer) + count += 1 + precondition(count < limit, "Timers keep scheduling timers") + } + } +} diff --git a/swift/Sources/ZeroTesting/MockSocket.swift b/swift/Sources/ZeroTesting/MockSocket.swift new file mode 100644 index 0000000000..280db20f87 --- /dev/null +++ b/swift/Sources/ZeroTesting/MockSocket.swift @@ -0,0 +1,60 @@ +import Zero + +/// Opens in-memory sockets and keeps every one, in opening order, so a test +/// can play the server (`MockSocket` in +/// `packages/zero-client/src/client/test-utils.ts`). +@MainActor +public final class MockSocketFactory: SocketFactory { + public private(set) var sockets: [MockSocket] = [] + + public init() {} + + public func open( + url: String, secProtocol: String?, events: any SocketEvents + ) + -> any SocketConnection + { + let socket = MockSocket(url: url, secProtocol: secProtocol, events: events) + sockets.append(socket) + return socket + } +} + +/// A socket the test drives: it records what the client sends and delivers +/// what the test receives on the server's behalf. +@MainActor +public final class MockSocket: SocketConnection { + public let url: String + public let secProtocol: String? + /// The text frames the client sent, oldest first. + public private(set) var messages: [String] = [] + /// Whether the client closed the socket. + public private(set) var closed = false + public private(set) var closeCode: Int? + private weak var events: (any SocketEvents)? + + init(url: String, secProtocol: String?, events: any SocketEvents) { + self.url = url + self.secProtocol = secProtocol + self.events = events + } + + public func send(_ text: String) { messages.append(text) } + + public func close(code: Int?) { + closed = true + closeCode = code + } + + /// Delivers a frame from the server. + public func receive(_ message: SocketMessage) { events?.socketDidReceive(message) } + + public func receive(text: String) { receive(.text(text)) } + + /// The socket closes from the server or network side. + public func serverClose(code: Int = 1006, reason: String = "", wasClean: Bool = false) { + events?.socketDidClose(code: code, reason: reason, wasClean: wasClean) + } + + public func open() { events?.socketDidOpen() } +} diff --git a/swift/Tests/ConformanceTests/ClientApp.swift b/swift/Tests/ConformanceTests/ClientApp.swift new file mode 100644 index 0000000000..19485f9e0a --- /dev/null +++ b/swift/Tests/ConformanceTests/ClientApp.swift @@ -0,0 +1,131 @@ +@testable import Zero + +/// The named queries and mutators of the client scenarios, the same as +/// `packages/zero-conformance/src/client/app.ts`. Arguments stay JSON, as +/// the scenarios give them. +enum ClientApp { + static let schema = ZeroSchema.test + + static func zql(_ table: String) -> AnyQuery { AnyQuery(schema, table) } + + static let queries: [String: @Sendable (JSONValue?) -> AnyQuery] = [ + "issues.all": { _ in zql("issue").orderBy("id", .asc) }, + "issues.open": { _ in + zql("issue") + .where("closed", false) + .related("comments") { $0.orderBy("id", .asc) } + .orderBy("createdAt", .asc) + }, + "issues.byOwner": { args in zql("issue").where("ownerId", args ?? .null).orderBy("id", .asc) }, + "issues.byID": { args in zql("issue").where("id", args ?? .null).related("owner").one() }, + "issues.firstTwo": { _ in zql("issue").orderBy("title", .asc).limit(2) }, + "users.all": { _ in zql("user").orderBy("id", .asc) }, + "issueLabels.all": { _ in + zql("issueLabel").orderBy("issueId", .asc).orderBy("labelId", .asc) + }, + ] + + /// The named query `name` applied to `args` (undefined when nil). + static func request(_ name: String, _ args: JSONValue?) throws -> AnyQuery { + guard let build = queries[name] else { throw Failure("Unknown name \(name)") } + return build(args).nameAndArgs(name, args.map { [$0] } ?? []) + } + + struct Failure: Error, CustomStringConvertible { + let description: String + init(_ description: String) { self.description = description } + } + + /// A mutator over JSON arguments. + struct JSONMutator: Mutator { + let name: String + let body: @MainActor @Sendable (Transaction, JSONValue?) throws -> JSONValue? + + @MainActor + func run(_ tx: Transaction, args: JSONValue?, context: Void) throws -> JSONValue? { + try body(tx, args) + } + } + + /// `args[key] ?? fallback`: JS `??` treats null like a missing key. + static func field(_ args: JSONValue?, _ key: String, _ fallback: JSONValue) -> JSONValue { + guard let v = args?[key], !v.isNull else { return fallback } + return v + } + + static func issueRow(_ args: JSONValue?) -> Row { + var row: Row = [ + "title": field(args, "title", ""), + "description": field(args, "description", ""), + "closed": field(args, "closed", false), + "ownerId": field(args, "ownerId", .null), + "createdAt": field(args, "createdAt", 1), + ] + if let id = args?["id"] { row["id"] = id } + return row + } + + static let mutators: [JSONMutator] = [ + JSONMutator(name: "issue.create") { tx, args in + try tx.mutate.issue.insert(issueRow(args)) + return nil + }, + JSONMutator(name: "issue.upsert") { tx, args in + try tx.mutate.issue.upsert(issueRow(args)) + return nil + }, + JSONMutator(name: "issue.update") { tx, args in + try tx.mutate.issue.update(args?.objectValue ?? [:]) + return nil + }, + JSONMutator(name: "issue.delete") { tx, args in + try tx.mutate.issue.delete(["id": args ?? .null]) + return nil + }, + JSONMutator(name: "issue.close") { tx, args in + try tx.mutate.issue.update(["id": args ?? .null, "closed": true]) + return nil + }, + // Reads through the transaction, so a rebase can change what it writes. + JSONMutator(name: "issue.closeAllOpen") { tx, _ in + let open = try tx.run(zql("issue").where("closed", false)) + for issue in open.entries { + try tx.mutate.issue.update(["id": issue.row["id"] ?? .null, "closed": true]) + } + return nil + }, + // Throws on a rebase that finds the issue already there. + JSONMutator(name: "issue.createUnique") { tx, args in + let existing = try tx.run(zql("issue").where("id", args?["id"] ?? .null).one()) + if !existing.entries.isEmpty { + throw ApplicationError("issue exists", details: ["id": field(args, "id", .null)]) + } + try tx.mutate.issue.insert(issueRow(args)) + return nil + }, + // Returns data to the caller of the optimistic run. + JSONMutator(name: "issue.createReturning") { tx, args in + try tx.mutate.issue.insert(issueRow(args)) + return ["created": field(args, "id", .null)] + }, + JSONMutator(name: "issue.fail") { _, args in + throw Failure(args?.stringValue ?? "undefined") + }, + // Writes and then throws, so nothing it wrote may stay. + JSONMutator(name: "issue.createThenFail") { tx, args in + try tx.mutate.issue.insert(issueRow(args)) + throw ApplicationError("failed after writing") + }, + JSONMutator(name: "comment.add") { tx, args in + var row: Row = [ + "authorId": field(args, "authorId", "u1"), + "text": field(args, "text", ""), + "createdAt": field(args, "createdAt", 1), + ] + if let id = args?["id"] { row["id"] = id } + if let issueID = args?["issueId"] { row["issueId"] = issueID } + try tx.mutate.comment.insert(row) + return nil + }, + ] +} diff --git a/swift/Tests/ConformanceTests/ClientConformanceTests.swift b/swift/Tests/ConformanceTests/ClientConformanceTests.swift new file mode 100644 index 0000000000..004f9d46d1 --- /dev/null +++ b/swift/Tests/ConformanceTests/ClientConformanceTests.swift @@ -0,0 +1,400 @@ +import Foundation +import Testing +import ZeroTesting + +@testable import Zero + +/// Replays the client scenarios (`packages/zero-conformance/src/client/`) +/// and compares what the app and the server observe after every step with +/// what the JS client did. The runner mirrors `harness.ts`. +@Suite(.enabled(if: Vectors.available)) +@MainActor +struct ClientConformanceTests { + @Test func scenarios() throws { + let file = try Vectors.file("client") + let startTime = file["shared"]?["startTime"]?.numberValue ?? 0 + let only = ProcessInfo.processInfo.environment["ZERO_CLIENT_SCENARIO"] + var failed = 0 + var passed = 0 + for scenario in file["cases"]?.arrayValue ?? [] { + let name = scenario["name"]?.stringValue ?? "?" + if let only, !name.contains(only) { continue } + let expected = scenario["expect"]?.arrayValue ?? [] + let runner = try ScenarioRunner(scenario, startTime: startTime) + let actual = runner.run() + if let mismatch = Self.firstMismatch(scenario, expected, actual) { + failed += 1 + Issue.record(Comment(rawValue: "\(name)\n\(mismatch)")) + } else { + passed += 1 + } + } + print("[client scenarios] \(passed) passed, \(failed) failed") + } + + static func firstMismatch( + _ scenario: JSONValue, _ expected: [JSONValue], _ actual: [JSONValue] + ) + -> String? + { + let steps = scenario["steps"]?.arrayValue ?? [] + for i in 0..! + var events: [JSONValue] = [] + var placeholders: [String: String] = [:] + + final class ViewEntryState { + let view: MaterializedView + var data: JSONValue = .null + var resultType = "" + var notifications = 0 + init(_ view: MaterializedView) { self.view = view } + } + + final class Settled { + var value: JSONValue = "pending" + } + + var views: [(String, ViewEntryState)] = [] + var retired: [ViewEntryState] = [] + var mutations: [(String, Settled, Settled)] = [] + var runs: [(String, Settled)] = [] + var preloads: [(String, Settled, Settled, () -> Void)] = [] + + init(_ scenario: JSONValue, startTime: Double) throws { + self.scenario = scenario + scheduler = ManualScheduler(now: startTime) + let o = scenario["options"] ?? [:] + var options = ZeroOptions() + switch o["cacheURL"] { + case .null?: options.cacheURL = nil + case .string(let s)?: options.cacheURL = s + default: options.cacheURL = "http://zero.test" + } + switch o["userID"] { + case .null?: options.userID = nil + case .string(let s)?: options.userID = s + default: options.userID = "u1" + } + options.auth = o["auth"]?.stringValue + if let v = o["pingTimeoutMs"]?.numberValue { options.pingTimeoutMs = v } + if let v = o["disconnectTimeoutMs"]?.numberValue { options.disconnectTimeoutMs = v } + if let v = o["queryChangeThrottleMs"]?.numberValue { options.queryChangeThrottleMs = v } + if let v = o["maxHeaderLength"]?.numberValue { options.maxHeaderLength = Int(v) } + options.mutateURL = o["mutateURL"]?.stringValue + options.mutateHeaders = o["mutateHeaders"]?.objectValue?.compactMapValues(\.stringValue) + options.queryURL = o["queryURL"]?.stringValue + options.queryHeaders = o["queryHeaders"]?.objectValue?.compactMapValues(\.stringValue) + options.storageKey = o["storageKey"]?.stringValue + options.onUpdateNeeded = { [weak self] reason in + self?.events.append(["onUpdateNeeded": reason.json]) + } + options.onClientStateNotFound = { [weak self] in + self?.events.append("onClientStateNotFound") + } + zero = try Zero( + schema: ClientApp.schema, mutators: ClientApp.mutators, options: options, + scheduler: scheduler, socketFactory: factory) + placeholders = [zero.clientID: "$clientID", zero.clientGroupID: "$clientGroupID"] + } + + func normalize(_ v: JSONValue, key: String? = nil) -> JSONValue { + switch v { + case .string(let s): + if key == "requestID" { return "$requestID" } + return .string(placeholders[s] ?? s) + case .array(let a): + return .array(a.map { normalize($0) }) + case .object(let o): + var out: [String: JSONValue] = [:] + for (k, e) in o { out[placeholders[k] ?? k] = normalize(e, key: k) } + return .object(out) + default: + return v + } + } + + func substitute(_ v: JSONValue) -> JSONValue { + switch v { + case .string("$clientID"): + return .string(zero.clientID) + case .array(let a): + return .array(a.map(substitute)) + case .object(let o): + var out: [String: JSONValue] = [:] + for (k, e) in o { out[k == "$clientID" ? zero.clientID : k] = substitute(e) } + return .object(out) + default: + return v + } + } + + func socket(_ index: JSONValue?) throws -> MockSocket { + let sockets = factory.sockets + if let i = index?.numberValue { + guard Int(i) < sockets.count else { throw ClientApp.Failure("No socket") } + return sockets[Int(i)] + } + guard let last = sockets.last else { throw ClientApp.Failure("No socket") } + return last + } + + static func ttl(_ v: JSONValue?) -> TTL? { + switch v { + case .number(let n)?: .milliseconds(n) + case .string(let s)?: .string(s) + default: nil + } + } + + func perform(_ step: JSONValue) throws { + let op = step["op"]?.stringValue ?? "" + switch op { + case "materialize": + let query = try ClientApp.request(step["query"]!.stringValue!, step["args"]) + let view = try zero.materialize(query, ttl: Self.ttl(step["ttl"]) ?? .defaultTTL) + let entry = ViewEntryState(view) + let name = step["view"]!.stringValue! + // A view replaced under its name stays alive, as in JS. + retired += views.filter { $0.0 == name }.map(\.1) + views.removeAll { $0.0 == name } + views.append((name, entry)) + _ = view.addListener { data, resultType, _ in + entry.data = data.json ?? .null + entry.resultType = resultType.rawValue + entry.notifications += 1 + } + case "destroy": + let name = step["view"]!.stringValue! + views.first { $0.0 == name }?.1.view.destroy() + views.removeAll { $0.0 == name } + case "updateTTL": + let name = step["view"]!.stringValue! + if let ttl = Self.ttl(step["ttl"]) { views.first { $0.0 == name }?.1.view.updateTTL(ttl) } + case "preload": + let query = try ClientApp.request(step["query"]!.stringValue!, step["args"]) + let p = zero.preload(query, ttl: Self.ttl(step["ttl"]) ?? .defaultPreloadTTL) + let cached = Settled() + let complete = Settled() + preloads.append((step["preload"]!.stringValue!, cached, complete, p.cleanup)) + p.cached.onSettle { cached.value = Self.settled($0) } + p.complete.onSettle { complete.value = Self.settled($0) } + case "cleanup": + let name = step["preload"]!.stringValue! + if let p = preloads.first(where: { $0.0 == name }) { + zero.engine.loop.dispatch { p.3() } + } + case "run": + let name = step["run"]!.stringValue! + let entry = Settled() + runs.removeAll { $0.0 == name } + runs.append((name, entry)) + let query = try ClientApp.request(step["query"]!.stringValue!, step["args"]) + let type = RunType(rawValue: step["type"]?.stringValue ?? "unknown")! + let result = try zero.run(query, type: type) + result.onSettle { r in + switch r { + case .success(let data): entry.value = ["data": data.json ?? .null] + case .failure(let e as QueryError): entry.value = ["rejected": e.data?.json ?? .null] + case .failure: entry.value = ["rejected": [:]] + } + } + case "mutate": + let name = step["mutator"]!.stringValue! + guard ClientApp.mutators.contains(where: { $0.name == name }) else { + throw ClientApp.Failure("Unknown name \(name)") + } + let result = zero.engine.loop.dispatch { + zero.engine.mutate(name: name, args: step["args"]) + } + let client = Settled() + let server = Settled() + mutations.append((step["mutation"]!.stringValue!, client, server)) + result.client.onSettle { if case .success(let o) = $0 { client.value = o.json } } + result.server.onSettle { if case .success(let o) = $0 { server.value = o.json } } + case "receive": + try socket(step["socket"]).receive( + text: JSONSerializer.stringify(substitute(step["message"]!))) + case "receiveAll": + let s = try socket(step["socket"]) + for m in step["messages"]?.arrayValue ?? [] { + s.receive(text: JSONSerializer.stringify(substitute(m))) + } + case "receiveChunk": + let payload: [UInt8] + if let bytes = step["bytes"]?.arrayValue { + payload = bytes.map { UInt8($0.numberValue!) } + } else if let text = step["text"]?.stringValue { + payload = Array(text.utf8) + } else { + payload = Array(JSONSerializer.stringify(substitute(step["parts"] ?? [])).utf8) + } + try socket(step["socket"]).receive(.binary([PokeChunk.messageType] + payload)) + case "socketClose": + try socket(step["socket"]).serverClose( + code: Int(step["code"]?.numberValue ?? 1006), reason: step["reason"]?.stringValue ?? "", + wasClean: step["wasClean"]?.boolValue ?? false) + case "advance": + scheduler.advance(by: step["ms"]?.numberValue ?? 0) + case "connect": + zero.connection.connect(auth: step["auth"]?.stringValue) + case "close": + zero.close() + default: + throw ClientApp.Failure("Unknown op \(op)") + } + } + + static func settled(_ r: Result) -> JSONValue { + switch r { + case .success: "resolved" + case .failure(let e as QueryError): ["rejected": e.error?.json ?? .null] + case .failure: ["rejected": [:]] + } + } + + /// Settles the work due now (`settle` in `harness.ts`). + func settle() { + zero.engine.loop.dispatch {} + scheduler.runDueTimers() + } + + func run() -> [JSONValue] { + var results: [JSONValue] = [] + for step in scenario["steps"]?.arrayValue ?? [] { + let sockets = factory.sockets + let sentCounts = sockets.map(\.messages.count) + let closedBefore = sockets.map(\.closed) + var result: [String: JSONValue] = [:] + do { + try perform(step) + } catch { + result["threw"] = .string(errorMessage(error)) + } + settle() + + let now = factory.sockets + let opened = now[sockets.count...].map { s -> JSONValue in + [ + "url": socketURL(s.url), + "protocol": normalize((try? SecProtocols.decode(s.secProtocol ?? "")) ?? .null), + ] + } + if !opened.isEmpty { result["opened"] = .array(opened) } + var sent: [JSONValue] = [] + for (i, s) in now.enumerated() { + let from = i < sentCounts.count ? sentCounts[i] : 0 + for m in s.messages[from...] { + sent.append(normalize((try? JSONParser.parse(Array(m.utf8))) ?? .string(m))) + } + } + if !sent.isEmpty { result["sent"] = .array(sent) } + var closed: [JSONValue] = [] + for (i, s) in now.enumerated() where s.closed && !(i < closedBefore.count && closedBefore[i]) + { + closed.append(.number(Double(i))) + } + if !closed.isEmpty { result["closed"] = .array(closed) } + result["state"] = normalize(zero.connection.state.json) + if !views.isEmpty { + var o: [String: JSONValue] = [:] + for (name, v) in views { + o[name] = [ + "data": v.data, "resultType": .string(v.resultType), + "notifications": .number(Double(v.notifications)), + ] + v.notifications = 0 + } + result["views"] = .object(o) + } + if !mutations.isEmpty { + var o: [String: JSONValue] = [:] + for (name, c, s) in mutations { o[name] = ["client": c.value, "server": s.value] } + result["mutations"] = .object(o) + } + if !runs.isEmpty { + var o: [String: JSONValue] = [:] + for (name, r) in runs { o[name] = r.value } + result["runs"] = .object(o) + } + if !preloads.isEmpty { + var o: [String: JSONValue] = [:] + for (name, c, p, _) in preloads { o[name] = ["cached": c.value, "complete": p.value] } + result["preloads"] = .object(o) + } + if !events.isEmpty { + result["events"] = .array(events) + events = [] + } + results.append(.object(result)) + } + zero.close() + return results + } + + /// The connect URL with its query parameters in order (`socketURL`). + func socketURL(_ url: String) -> JSONValue { + let (base, query) = + url.firstIndex(of: "?").map { (String(url[..<$0]), String(url[url.index(after: $0)...])) } + ?? (url, "") + let schemeEnd = base.range(of: "://")!.upperBound + let pathStart = base[schemeEnd...].firstIndex(of: "/") ?? base.endIndex + var pairs: [JSONValue] = [] + for part in query.split(separator: "&") { + let kv = part.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + let k = Self.formDecode(String(kv[0])) + let v = kv.count > 1 ? Self.formDecode(String(kv[1])) : "" + switch k { + case "ts": continue + case "wsid", "profileID": pairs.append([.string(k), .string("$" + k)]) + default: pairs.append([.string(k), .string(placeholders[v] ?? v)]) + } + } + return [ + "origin": .string(String(base[.. String { + var bytes: [UInt8] = [] + var it = Array(s.utf8)[...] + while let b = it.popFirst() { + if b == UInt8(ascii: "+") { + bytes.append(UInt8(ascii: " ")) + } else if b == UInt8(ascii: "%"), it.count >= 2, + let v = UInt8(String(decoding: it.prefix(2), as: UTF8.self), radix: 16) + { + bytes.append(v) + it = it.dropFirst(2) + } else { + bytes.append(b) + } + } + return String(decoding: bytes, as: UTF8.self) + } +} diff --git a/swift/Tests/ConformanceTests/Generated/TestSchema.swift b/swift/Tests/ConformanceTests/Generated/TestSchema.swift new file mode 100644 index 0000000000..36c65420d4 --- /dev/null +++ b/swift/Tests/ConformanceTests/Generated/TestSchema.swift @@ -0,0 +1,507 @@ +// Generated by `zero-swift codegen` from packages/zql/src/query/test/test-schemas.ts. Do not edit. +// swift-format-ignore-file + +import Zero + +struct TestIssue: ZeroRow, Codable, Hashable, Identifiable { + var id: String + var title: String + var description: String + var closed: Bool + var ownerId: String? + var createdAt: Double + + // Relationships, filled when a query asks for them with `related`. + @Indirect var owner: TestUser? + var comments: [TestComment]? + @Indirect var oneComment: TestComment? + var ownerComments: [TestComment]? + var labels: [TestLabel]? + + init( + id: String, + title: String, + description: String, + closed: Bool, + ownerId: String? = nil, + createdAt: Double, + owner: TestUser? = nil, + comments: [TestComment]? = nil, + oneComment: TestComment? = nil, + ownerComments: [TestComment]? = nil, + labels: [TestLabel]? = nil + ) { + self.id = id + self.title = title + self.description = description + self.closed = closed + self.ownerId = ownerId + self.createdAt = createdAt + self.owner = owner + self.comments = comments + self.oneComment = oneComment + self.ownerComments = ownerComments + self.labels = labels + } + + static let tableName = "issue" + + static let columns: KeyPathNames = [ + \TestIssue.id: "id", + \TestIssue.title: "title", + \TestIssue.description: "description", + \TestIssue.closed: "closed", + \TestIssue.ownerId: "ownerId", + \TestIssue.createdAt: "createdAt", + ] + + static let relationships: KeyPathNames = [ + \TestIssue.owner: "owner", + \TestIssue.comments: "comments", + \TestIssue.oneComment: "oneComment", + \TestIssue.ownerComments: "ownerComments", + \TestIssue.labels: "labels", + ] + + init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id") + title = try entry.column("title") + description = try entry.column("description") + closed = try entry.column("closed") + ownerId = try entry.column("ownerId") + createdAt = try entry.column("createdAt") + owner = try entry.relationship("owner") + comments = try entry.relationship("comments") + oneComment = try entry.relationship("oneComment") + ownerComments = try entry.relationship("ownerComments") + labels = try entry.relationship("labels") + } + + var columnValues: Row { + [ + "id": id.zeroValue, + "title": title.zeroValue, + "description": description.zeroValue, + "closed": closed.zeroValue, + "ownerId": ownerId.zeroValue, + "createdAt": createdAt.zeroValue, + ] + } +} + +struct TestUser: ZeroRow, Codable, Hashable, Identifiable { + var id: String + var name: String + var metadata: TestUserMetadata? + + // Relationships, filled when a query asks for them with `related`. + var issues: [TestIssue]? + + init( + id: String, + name: String, + metadata: TestUserMetadata? = nil, + issues: [TestIssue]? = nil + ) { + self.id = id + self.name = name + self.metadata = metadata + self.issues = issues + } + + static let tableName = "user" + + static let columns: KeyPathNames = [ + \TestUser.id: "id", + \TestUser.name: "name", + \TestUser.metadata: "metadata", + ] + + static let relationships: KeyPathNames = [ + \TestUser.issues: "issues", + ] + + init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id") + name = try entry.column("name") + metadata = try entry.column("metadata") + issues = try entry.relationship("issues") + } + + var columnValues: Row { + [ + "id": id.zeroValue, + "name": name.zeroValue, + "metadata": metadata.zeroValue, + ] + } +} + +struct TestComment: ZeroRow, Codable, Hashable, Identifiable { + var id: String + var authorId: String + var issueId: String + var text: String + var createdAt: Double + + // Relationships, filled when a query asks for them with `related`. + @Indirect var issue: TestIssue? + var revisions: [TestRevision]? + @Indirect var author: TestUser? + + init( + id: String, + authorId: String, + issueId: String, + text: String, + createdAt: Double, + issue: TestIssue? = nil, + revisions: [TestRevision]? = nil, + author: TestUser? = nil + ) { + self.id = id + self.authorId = authorId + self.issueId = issueId + self.text = text + self.createdAt = createdAt + self.issue = issue + self.revisions = revisions + self.author = author + } + + static let tableName = "comment" + + static let columns: KeyPathNames = [ + \TestComment.id: "id", + \TestComment.authorId: "authorId", + \TestComment.issueId: "issueId", + \TestComment.text: "text", + \TestComment.createdAt: "createdAt", + ] + + static let relationships: KeyPathNames = [ + \TestComment.issue: "issue", + \TestComment.revisions: "revisions", + \TestComment.author: "author", + ] + + init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id") + authorId = try entry.column("authorId") + issueId = try entry.column("issueId") + text = try entry.column("text") + createdAt = try entry.column("createdAt") + issue = try entry.relationship("issue") + revisions = try entry.relationship("revisions") + author = try entry.relationship("author") + } + + var columnValues: Row { + [ + "id": id.zeroValue, + "authorId": authorId.zeroValue, + "issueId": issueId.zeroValue, + "text": text.zeroValue, + "createdAt": createdAt.zeroValue, + ] + } +} + +struct TestRevision: ZeroRow, Codable, Hashable, Identifiable { + var id: String + var authorId: String + var commentId: String + var text: String + + // Relationships, filled when a query asks for them with `related`. + @Indirect var comment: TestComment? + @Indirect var author: TestUser? + + init( + id: String, + authorId: String, + commentId: String, + text: String, + comment: TestComment? = nil, + author: TestUser? = nil + ) { + self.id = id + self.authorId = authorId + self.commentId = commentId + self.text = text + self.comment = comment + self.author = author + } + + static let tableName = "revision" + + static let columns: KeyPathNames = [ + \TestRevision.id: "id", + \TestRevision.authorId: "authorId", + \TestRevision.commentId: "commentId", + \TestRevision.text: "text", + ] + + static let relationships: KeyPathNames = [ + \TestRevision.comment: "comment", + \TestRevision.author: "author", + ] + + init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id") + authorId = try entry.column("authorId") + commentId = try entry.column("commentId") + text = try entry.column("text") + comment = try entry.relationship("comment") + author = try entry.relationship("author") + } + + var columnValues: Row { + [ + "id": id.zeroValue, + "authorId": authorId.zeroValue, + "commentId": commentId.zeroValue, + "text": text.zeroValue, + ] + } +} + +struct TestLabel: ZeroRow, Codable, Hashable, Identifiable { + var id: String + var name: String + + // Relationships, filled when a query asks for them with `related`. + var issues: [TestIssue]? + + init( + id: String, + name: String, + issues: [TestIssue]? = nil + ) { + self.id = id + self.name = name + self.issues = issues + } + + static let tableName = "label" + + static let columns: KeyPathNames = [ + \TestLabel.id: "id", + \TestLabel.name: "name", + ] + + static let relationships: KeyPathNames = [ + \TestLabel.issues: "issues", + ] + + init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id") + name = try entry.column("name") + issues = try entry.relationship("issues") + } + + var columnValues: Row { + [ + "id": id.zeroValue, + "name": name.zeroValue, + ] + } +} + +struct TestIssueLabel: ZeroRow, Codable, Hashable { + var issueId: String + var labelId: String + + init( + issueId: String, + labelId: String + ) { + self.issueId = issueId + self.labelId = labelId + } + + static let tableName = "issueLabel" + + static let columns: KeyPathNames = [ + \TestIssueLabel.issueId: "issueId", + \TestIssueLabel.labelId: "labelId", + ] + + static let relationships: KeyPathNames = [:] + + init(entry: ViewEntry) throws(ZeroValueError) { + issueId = try entry.column("issueId") + labelId = try entry.column("labelId") + } + + var columnValues: Row { + [ + "issueId": issueId.zeroValue, + "labelId": labelId.zeroValue, + ] + } +} + +struct TestUserMetadata: ZeroValue, Codable, Hashable { + var registrar: TestUserMetadataRegistrar + var email: String + var altContacts: [String]? + + init( + registrar: TestUserMetadataRegistrar, + email: String, + altContacts: [String]? = nil + ) { + self.registrar = registrar + self.email = email + self.altContacts = altContacts + } + + init(zeroValue: JSONValue) throws(ZeroValueError) { + let o = try ZeroObjectReader(zeroValue, "TestUserMetadata") + registrar = try o.field("registrar") + email = try o.field("email") + altContacts = try o.field("altContacts") + } + + var zeroValue: JSONValue { + var o = ZeroObjectWriter() + o.set("registrar", registrar) + o.set("email", email) + o.setIfPresent("altContacts", altContacts) + return o.value + } +} + +enum TestUserMetadataRegistrar: String, ZeroEnum, Codable, CaseIterable { + case github + case google +} + +extension ZeroSchema { + static let test = ZeroSchema( + tables: [ + TableSchema( + name: "issue", serverName: "issues", + columns: [ + ("id", ColumnSchema(.string)), + ("title", ColumnSchema(.string)), + ("description", ColumnSchema(.string)), + ("closed", ColumnSchema(.boolean)), + ("ownerId", ColumnSchema(.string, optional: true, serverName: "owner_id")), + ("createdAt", ColumnSchema(.number)), + ], + primaryKey: ["id"]), + TableSchema( + name: "user", serverName: "users", + columns: [ + ("id", ColumnSchema(.string)), + ("name", ColumnSchema(.string)), + ("metadata", ColumnSchema(.json, optional: true)), + ], + primaryKey: ["id"]), + TableSchema( + name: "comment", serverName: "comments", + columns: [ + ("id", ColumnSchema(.string)), + ("authorId", ColumnSchema(.string)), + ("issueId", ColumnSchema(.string, serverName: "issue_id")), + ("text", ColumnSchema(.string)), + ("createdAt", ColumnSchema(.number)), + ], + primaryKey: ["id"]), + TableSchema( + name: "revision", + columns: [ + ("id", ColumnSchema(.string)), + ("authorId", ColumnSchema(.string)), + ("commentId", ColumnSchema(.string)), + ("text", ColumnSchema(.string)), + ], + primaryKey: ["id"]), + TableSchema( + name: "label", + columns: [ + ("id", ColumnSchema(.string)), + ("name", ColumnSchema(.string)), + ], + primaryKey: ["id"], + uniqueKeys: [["name"]]), + TableSchema( + name: "issueLabel", + columns: [ + ("issueId", ColumnSchema(.string)), + ("labelId", ColumnSchema(.string)), + ], + primaryKey: ["issueId", "labelId"]), + ], + relationships: [ + "issue": [ + "owner": [ + RelationshipHop(sourceField: ["ownerId"], destField: ["id"], destSchema: "user", cardinality: .one), + ], + "comments": [ + RelationshipHop(sourceField: ["id"], destField: ["issueId"], destSchema: "comment", cardinality: .many), + ], + "oneComment": [ + RelationshipHop(sourceField: ["id"], destField: ["issueId"], destSchema: "comment", cardinality: .one), + ], + "ownerComments": [ + RelationshipHop(sourceField: ["ownerId"], destField: ["authorId"], destSchema: "comment", cardinality: .many), + ], + "labels": [ + RelationshipHop(sourceField: ["id"], destField: ["issueId"], destSchema: "issueLabel", cardinality: .many), + RelationshipHop(sourceField: ["labelId"], destField: ["id"], destSchema: "label", cardinality: .many), + ], + ], + "user": [ + "issues": [ + RelationshipHop(sourceField: ["id"], destField: ["ownerId"], destSchema: "issue", cardinality: .many), + ], + ], + "comment": [ + "issue": [ + RelationshipHop(sourceField: ["issueId"], destField: ["id"], destSchema: "issue", cardinality: .one), + ], + "revisions": [ + RelationshipHop(sourceField: ["id"], destField: ["commentId"], destSchema: "revision", cardinality: .many), + ], + "author": [ + RelationshipHop(sourceField: ["authorId"], destField: ["id"], destSchema: "user", cardinality: .one), + ], + ], + "revision": [ + "comment": [ + RelationshipHop(sourceField: ["commentId"], destField: ["id"], destSchema: "comment", cardinality: .one), + ], + "author": [ + RelationshipHop(sourceField: ["authorId"], destField: ["id"], destSchema: "user", cardinality: .one), + ], + ], + "label": [ + "issues": [ + RelationshipHop(sourceField: ["id"], destField: ["labelId"], destSchema: "issueLabel", cardinality: .many), + RelationshipHop(sourceField: ["issueId"], destField: ["id"], destSchema: "issue", cardinality: .many), + ], + ], + ]) +} + +/// The queries of `ZeroSchema.test`, one per table: `testZQL.issue.where(…)`. +struct TestZQL: Sendable { + init() {} + + var issue: Query { Query(AnyQuery(.test, "issue")) } + + var user: Query { Query(AnyQuery(.test, "user")) } + + var comment: Query { Query(AnyQuery(.test, "comment")) } + + var revision: Query { Query(AnyQuery(.test, "revision")) } + + var label: Query { Query(AnyQuery(.test, "label")) } + + var issueLabel: Query { Query(AnyQuery(.test, "issueLabel")) } +} + +let testZQL = TestZQL() diff --git a/swift/Tests/ZeroTests/ClientAPITests.swift b/swift/Tests/ZeroTests/ClientAPITests.swift new file mode 100644 index 0000000000..84d424c606 --- /dev/null +++ b/swift/Tests/ZeroTests/ClientAPITests.swift @@ -0,0 +1,142 @@ +import Testing +import ZeroTesting + +@testable import Zero + +/// The typed definitions through `Zero`: argument encoding on the wire, +/// typed results, and the data a mutator returns. The engine's behavior is +/// covered by the client scenario vectors. +@Suite @MainActor struct ClientAPITests { + struct ByOwner: Codable, Sendable { var ownerId: String } + struct CreateIssue: Codable, Sendable { + var id: String + var title: String + var ownerId: String? + } + struct Auth: Sendable { var userID: String } + + static let byOwner = defineQuery("issues.byOwner") { (args: ByOwner, _: Auth) in + zql.issue.where(\.ownerId, args.ownerId).orderBy(\.id, .asc) + } + static let mine = defineQuery("issues.mine") { (auth: Auth) in + zql.issue.where(\.ownerId, auth.userID).orderBy(\.id, .asc) + } + static let create = defineMutator("issue.create") { (tx, args: CreateIssue, auth: Auth) in + try tx.mutate.insert( + Issue( + id: args.id, title: args.title, description: "", closed: false, + ownerId: args.ownerId ?? auth.userID, createdAt: 1)) + } + struct Created: Encodable { var created: String } + static let createReturning = defineMutator("issue.createReturning") { + (tx, args: CreateIssue, _: Auth) -> Created in + try tx.mutate.issue.insert([ + "id": .string(args.id), "title": .string(args.title), "description": "", + "closed": false, "ownerId": .null, "createdAt": 1, + ]) + return Created(created: args.id) + } + + let scheduler = ManualScheduler(now: 1_700_000_000_000) + let sockets = MockSocketFactory() + + func makeZero() throws -> Zero { + try Zero( + schema: .test, context: Auth(userID: "u2"), mutators: [Self.create, Self.createReturning], + options: ZeroOptions(cacheURL: "http://zero.test", userID: "u2"), scheduler: scheduler, + socketFactory: sockets) + } + + func sent(_ type: String) -> [JSONValue] { + sockets.sockets.flatMap(\.messages).compactMap { text in + let m = try? JSONParser.parse(Array(text.utf8)) + return m?[0]?.stringValue == type ? m?[1] : nil + } + } + + @Test func sendsTheEncodedArgumentsOfAQuery() throws { + let zero = try makeZero() + let request = Self.byOwner(ByOwner(ownerId: "u1")) + #expect(request.args == [["ownerId": "u1"]]) + let views = try [zero.materialize(request), zero.materialize(Self.mine())] + defer { views.forEach { $0.destroy() } } + scheduler.runDueTimers() + + let socket = try #require(sockets.sockets.first) + let protocols = try SecProtocols.decode(#require(socket.secProtocol)) + let patch = protocols["initConnectionMessage"]?[1]?["desiredQueriesPatch"]?.arrayValue ?? [] + #expect( + patch.map { [$0["name"], $0["args"], $0["hash"]] } == [ + [ + "issues.byOwner", [["ownerId": "u1"]], + .string(QueryHash.hashOfNameAndArgs("issues.byOwner", [["ownerId": "u1"]])), + ], + ["issues.mine", [], .string(QueryHash.hashOfNameAndArgs("issues.mine", []))], + ]) + } + + @Test func runsMutatorsWithTypedArgumentsAndContext() async throws { + let zero = try makeZero() + scheduler.runDueTimers() + let socket = try #require(sockets.sockets.first) + socket.receive(text: #"["connected",{"wsid":"w1"}]"#) + scheduler.runDueTimers() + + let a = zero.mutate(Self.create(CreateIssue(id: "i1", title: "mine"))) + zero.mutate(Self.create(CreateIssue(id: "i2", title: "theirs", ownerId: "u1"))) + scheduler.runDueTimers() + + #expect(try await a.client.value == .success(data: nil)) + // The context fills the owner of the first; the second names its own. + #expect(try await zero.run(Self.mine()).map(\.id) == ["i1"]) + #expect(try await zero.run(Self.byOwner(ByOwner(ownerId: "u1"))).map(\.id) == ["i2"]) + #expect( + sent("push").flatMap { $0["mutations"]?.arrayValue ?? [] }.map { [$0["name"], $0["args"]] } + == [ + ["issue.create", [["id": "i1", "title": "mine"]]], + ["issue.create", [["id": "i2", "title": "theirs", "ownerId": "u1"]]], + ]) + } + + @Test func returnsTheEncodedResultOfAMutator() async throws { + let zero = try makeZero() + let result = zero.mutate(Self.createReturning(CreateIssue(id: "i3", title: "t"))) + #expect(try await result.client.value == .success(data: ["created": "i3"])) + #expect(try await zero.run(zql.issue).map(\.title) == ["t"]) + } + + @Test func dropsTheQueryOfAViewThatIsNoLongerReferenced() throws { + var zero: Zero? = try makeZero() + var dropped: MaterializedView? = try zero!.materialize(Self.mine()) + let outliving = try zero!.materialize(zql.issue.related(\.comments).related(\.owner)) + scheduler.runDueTimers() + let socket = try #require(sockets.sockets.first) + socket.receive(text: #"["connected",{"wsid":"w1"}]"#) + scheduler.runDueTimers() + #expect(dropped != nil) + + dropped = nil + scheduler.advance(by: 100) + let patches = sent("changeDesiredQueries").flatMap { + $0["desiredQueriesPatch"]?.arrayValue ?? [] + } + #expect( + patches.map { [$0["op"], $0["hash"]] }.last + == ["del", .string(QueryHash.hashOfNameAndArgs("issues.mine", []))]) + + // A view may outlive its client. + zero = nil + scheduler.advance(by: 100_000) + #expect(outliving.data.json == []) + outliving.destroy() + } + + @Test func rejectsAMutatorNameRegisteredTwice() { + #expect(throws: (any Error).self) { + try Zero( + schema: .test, context: Auth(userID: "u2"), mutators: [Self.create, Self.create], + options: ZeroOptions(cacheURL: "http://zero.test", userID: "u2"), scheduler: scheduler, + socketFactory: sockets) + } + } +} From 7ebad7bc1c5e5df16c32283c0ab9b401b5e035a3 Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 19 Oct 2026 14:55:27 +0000 Subject: [PATCH 012/104] 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 --- packages/zero-conformance/src/client/app.ts | 115 +++---- .../zero-conformance/src/client/harness.ts | 118 ++++--- .../zero-conformance/src/client/scenarios.ts | 2 + .../src/client/scenarios/persistence.ts | 293 ++++++++++++++++++ packages/zero-conformance/vectors/client.json | 19 +- 5 files changed, 456 insertions(+), 91 deletions(-) create mode 100644 packages/zero-conformance/src/client/scenarios/persistence.ts diff --git a/packages/zero-conformance/src/client/app.ts b/packages/zero-conformance/src/client/app.ts index f567110720..807a0ede92 100644 --- a/packages/zero-conformance/src/client/app.ts +++ b/packages/zero-conformance/src/client/app.ts @@ -62,62 +62,64 @@ function issueRow(args: JSONObject) { }; } -export const mutators = defineMutators({ - issue: { - create: defineMutator(async ({tx, args}: {tx: Tx; args: JSONObject}) => { - await tx.mutate.issue.insert(issueRow(args)); - }), - upsert: defineMutator(async ({tx, args}: {tx: Tx; args: JSONObject}) => { - await tx.mutate.issue.upsert(issueRow(args)); - }), - update: defineMutator(async ({tx, args}: {tx: Tx; args: JSONObject}) => { - await tx.mutate.issue.update(args); - }), - delete: defineMutator(async ({tx, args}: {tx: Tx; args: string}) => { - await tx.mutate.issue.delete({id: args}); - }), - close: defineMutator(async ({tx, args}: {tx: Tx; args: string}) => { - await tx.mutate.issue.update({id: args, closed: true}); - }), - // Reads through the transaction, so a rebase can change what it writes. - closeAllOpen: defineMutator(async ({tx}: {tx: Tx}) => { - const open = await tx.run(zql.issue.where('closed', false)); - for (const issue of open) { - await tx.mutate.issue.update({id: issue.id, closed: true}); +const issue = { + create: defineMutator(async ({tx, args}: {tx: Tx; args: JSONObject}) => { + await tx.mutate.issue.insert(issueRow(args)); + }), + upsert: defineMutator(async ({tx, args}: {tx: Tx; args: JSONObject}) => { + await tx.mutate.issue.upsert(issueRow(args)); + }), + update: defineMutator(async ({tx, args}: {tx: Tx; args: JSONObject}) => { + await tx.mutate.issue.update(args); + }), + delete: defineMutator(async ({tx, args}: {tx: Tx; args: string}) => { + await tx.mutate.issue.delete({id: args}); + }), + close: defineMutator(async ({tx, args}: {tx: Tx; args: string}) => { + await tx.mutate.issue.update({id: args, closed: true}); + }), + // Reads through the transaction, so a rebase can change what it writes. + closeAllOpen: defineMutator(async ({tx}: {tx: Tx}) => { + const open = await tx.run(zql.issue.where('closed', false)); + for (const issue of open) { + await tx.mutate.issue.update({id: issue.id, closed: true}); + } + }), + // Throws on a rebase that finds the issue already there. + createUnique: defineMutator( + async ({tx, args}: {tx: Tx; args: JSONObject}) => { + const existing = await tx.run( + zql.issue.where('id', args['id'] as string).one(), + ); + if (existing) { + throw new ApplicationError('issue exists', { + details: {id: args['id'] ?? null}, + }); } - }), - // Throws on a rebase that finds the issue already there. - createUnique: defineMutator( - async ({tx, args}: {tx: Tx; args: JSONObject}) => { - const existing = await tx.run( - zql.issue.where('id', args['id'] as string).one(), - ); - if (existing) { - throw new ApplicationError('issue exists', { - details: {id: args['id'] ?? null}, - }); - } - await tx.mutate.issue.insert(issueRow(args)); - }, - ), - // Returns data to the caller of the optimistic run. - createReturning: defineMutator( - async ({tx, args}: {tx: Tx; args: JSONObject}) => { - await tx.mutate.issue.insert(issueRow(args)); - return {created: args['id'] ?? null}; - }, - ), - fail: defineMutator(({args}: {args: string}) => { - throw new Error(args); - }), - // Writes and then throws, so nothing it wrote may stay. - createThenFail: defineMutator( - async ({tx, args}: {tx: Tx; args: JSONObject}) => { - await tx.mutate.issue.insert(issueRow(args)); - throw new ApplicationError('failed after writing'); - }, - ), - }, + await tx.mutate.issue.insert(issueRow(args)); + }, + ), + // Returns data to the caller of the optimistic run. + createReturning: defineMutator( + async ({tx, args}: {tx: Tx; args: JSONObject}) => { + await tx.mutate.issue.insert(issueRow(args)); + return {created: args['id'] ?? null}; + }, + ), + fail: defineMutator(({args}: {args: string}) => { + throw new Error(args); + }), + // Writes and then throws, so nothing it wrote may stay. + createThenFail: defineMutator( + async ({tx, args}: {tx: Tx; args: JSONObject}) => { + await tx.mutate.issue.insert(issueRow(args)); + throw new ApplicationError('failed after writing'); + }, + ), +}; + +export const mutators = defineMutators({ + issue, comment: { add: defineMutator(async ({tx, args}: {tx: Tx; args: JSONObject}) => { await tx.mutate.comment.insert({ @@ -130,3 +132,6 @@ export const mutators = defineMutators({ }), }, }); + +/** The `issue` mutators alone: another set of mutator names. */ +export const issueMutators = defineMutators({issue}); diff --git a/packages/zero-conformance/src/client/harness.ts b/packages/zero-conformance/src/client/harness.ts index fa6bafddba..332f4ef63d 100644 --- a/packages/zero-conformance/src/client/harness.ts +++ b/packages/zero-conformance/src/client/harness.ts @@ -20,7 +20,7 @@ import { import {decodeSecProtocols} from '../../../zero-protocol/src/connect.ts'; import {POKE_CHUNK_MESSAGE_TYPE} from '../../../zero-protocol/src/poke.ts'; import type {TTL} from '../../../zql/src/query/ttl.ts'; -import {mutators, queries, schema} from './app.ts'; +import {issueMutators, mutators, queries, schema} from './app.ts'; /** `Date.now()` when every scenario starts. */ export const START_TIME = 1_700_000_000_000; @@ -90,7 +90,23 @@ export type Step = | {op: 'advance'; ms: number} /** `zero.connection.connect()`. */ | {op: 'connect'; auth?: string} - | {op: 'close'}; + | {op: 'close'} + /** + * Closes the client and starts a new one on the same storage, as an app + * does when it is relaunched. `advance` moves the clock while no client + * runs. `options` replace the scenario's options; the storage stays the + * scenario's unless they set another `storageKey`. `mutators: 'issues'` + * registers only the `issue` mutators. The old client's views, runs, + * mutations and preloads are dropped, and its IDs become `$clientID.` + * and `$clientGroupID.` for the n-th client, from 0. The step settles + * once the new client has opened its storage. + */ + | { + op: 'restart'; + advance?: number; + options?: ScenarioOptions; + mutators?: 'all' | 'issues'; + }; type Args = ReadonlyJSONValue | undefined; @@ -212,43 +228,50 @@ export async function runScenario(scenario: Scenario): Promise { vi.stubGlobal('WebSocket', RecordingSocket); vi.stubGlobal('CloseEvent', CloseEventPolyfill); - const options = scenario.options ?? {}; const events: ReadonlyJSONValue[] = []; - const z = new TestZero({ - cacheURL: - options.cacheURL === null - ? undefined - : (options.cacheURL ?? 'http://zero.test'), - userID: options.userID === null ? undefined : (options.userID ?? 'u1'), - auth: options.auth, - schema, - mutators, - kvStore: 'mem', - pingTimeoutMs: options.pingTimeoutMs, - disconnectTimeoutMs: options.disconnectTimeoutMs, - queryChangeThrottleMs: options.queryChangeThrottleMs, - maxHeaderLength: options.maxHeaderLength, - mutateURL: options.mutateURL, - mutateHeaders: options.mutateHeaders, - queryURL: options.queryURL, - queryHeaders: options.queryHeaders, - // In-memory stores outlive their Zero instance, keyed by name, so every - // scenario gets a store of its own. The storage key is not sent. - storageKey: `${options.storageKey ?? ''}#scenario-${++scenarioCount}`, - onUpdateNeeded: reason => { - events.push({onUpdateNeeded: json(reason)}); - }, - onClientStateNotFound: () => { - events.push('onClientStateNotFound'); - }, - }); - const {clientID} = z; - const clientGroupID = await z.clientGroupID; + const scenarioKey = ++scenarioCount; + const makeZero = ( + options: ScenarioOptions, + registered: 'all' | 'issues' = 'all', + ) => + new TestZero({ + cacheURL: + options.cacheURL === null + ? undefined + : (options.cacheURL ?? 'http://zero.test'), + userID: options.userID === null ? undefined : (options.userID ?? 'u1'), + auth: options.auth, + schema, + mutators: registered === 'all' ? mutators : issueMutators, + kvStore: 'mem', + pingTimeoutMs: options.pingTimeoutMs, + disconnectTimeoutMs: options.disconnectTimeoutMs, + queryChangeThrottleMs: options.queryChangeThrottleMs, + maxHeaderLength: options.maxHeaderLength, + mutateURL: options.mutateURL, + mutateHeaders: options.mutateHeaders, + queryURL: options.queryURL, + queryHeaders: options.queryHeaders, + // In-memory stores outlive their Zero instance, keyed by name, so every + // scenario gets a store of its own. The storage key is not sent. + storageKey: `${options.storageKey ?? ''}#scenario-${scenarioKey}`, + onUpdateNeeded: reason => { + events.push({onUpdateNeeded: json(reason)}); + }, + onClientStateNotFound: () => { + events.push('onClientStateNotFound'); + }, + }); + let z = makeZero(scenario.options ?? {}); + let {clientID} = z; + let clientGroupID = await z.clientGroupID; + let restarts = 0; const placeholders = new Map([ [clientID, '$clientID'], [clientGroupID, '$clientGroupID'], ]); + const ids = new Map([['$clientID', clientID]]); const normalize = (v: ReadonlyJSONValue, key?: string): ReadonlyJSONValue => { if (typeof v === 'string') { if (key === 'requestID') return '$requestID'; @@ -266,12 +289,12 @@ export async function runScenario(scenario: Scenario): Promise { return v; }; const substitute = (v: ReadonlyJSONValue): ReadonlyJSONValue => { - if (v === '$clientID') return clientID; + if (typeof v === 'string') return ids.get(v) ?? v; if (Array.isArray(v)) return v.map(substitute); if (v !== null && typeof v === 'object') { return Object.fromEntries( Object.entries(v).map(([k, e]) => [ - k === '$clientID' ? clientID : k, + ids.get(k) ?? k, substitute(e as ReadonlyJSONValue), ]), ); @@ -435,6 +458,31 @@ export async function runScenario(scenario: Scenario): Promise { case 'close': void z.close(); return; + case 'restart': { + await z.close(); + await settle(); + if (step.advance !== undefined) { + await vi.advanceTimersByTimeAsync(step.advance); + } + const n = restarts++; + const old = {clientID, clientGroupID}; + z = makeZero(step.options ?? scenario.options ?? {}, step.mutators); + clientID = z.clientID; + clientGroupID = await z.clientGroupID; + placeholders.set(old.clientID, `$clientID.${n}`); + ids.set(`$clientID.${n}`, old.clientID); + if (old.clientGroupID !== clientGroupID) { + placeholders.set(old.clientGroupID, `$clientGroupID.${n}`); + } + placeholders.set(clientID, '$clientID'); + placeholders.set(clientGroupID, '$clientGroupID'); + ids.set('$clientID', clientID); + views.clear(); + mutations.clear(); + runs.clear(); + preloads.clear(); + return; + } } } diff --git a/packages/zero-conformance/src/client/scenarios.ts b/packages/zero-conformance/src/client/scenarios.ts index 2a5de8a55a..030508c224 100644 --- a/packages/zero-conformance/src/client/scenarios.ts +++ b/packages/zero-conformance/src/client/scenarios.ts @@ -2,6 +2,7 @@ import type {Scenario} from './harness.ts'; import {connectionScenarios} from './scenarios/connection.ts'; import {errorScenarios} from './scenarios/errors.ts'; import {mutationScenarios} from './scenarios/mutations.ts'; +import {persistenceScenarios} from './scenarios/persistence.ts'; import {pokeScenarios} from './scenarios/pokes.ts'; import {queryScenarios} from './scenarios/queries.ts'; @@ -11,4 +12,5 @@ export const scenarios: Scenario[] = [ ...pokeScenarios, ...queryScenarios, ...mutationScenarios, + ...persistenceScenarios, ]; diff --git a/packages/zero-conformance/src/client/scenarios/persistence.ts b/packages/zero-conformance/src/client/scenarios/persistence.ts new file mode 100644 index 0000000000..f9e3bb9232 --- /dev/null +++ b/packages/zero-conformance/src/client/scenarios/persistence.ts @@ -0,0 +1,293 @@ +/** + * Persistence: what a client relaunched on the same storage starts from, and + * the lifecycle of the clients that share it. + * + * The JS client persists about a second after a change (Replicache's persist + * scheduler), and the Swift client sooner, so every scenario lets the clock + * run before it restarts: what each client loses in a crash right after a + * change is not part of the contract. + */ +import type {Scenario, Step} from '../harness.ts'; +import { + advance, + connected, + issue, + mutate, + poke, + put, + queryHash, + start, + syncPoke, +} from './helpers.ts'; + +const allHash = queryHash('issues.all'); + +/** Lets the JS client persist what it has. */ +const persisted = advance(2000); + +const synced: Step[] = [ + {op: 'materialize', view: 'v', query: 'issues.all'}, + start, + connected, + ...syncPoke( + 'p1', + null, + '01', + [allHash], + [put('issues', issue('i1')), put('issues', issue('i2'))], + ), + persisted, +]; + +/** Syncs, leaves a mutation pending, then gets `kind` from zero-cache. */ +function serverErrorThenRestart(name: string, kind: string): Scenario { + return { + name, + steps: [ + ...synced, + mutate('m1', 'issue.create', {id: 'i3'}), + persisted, + { + op: 'receive', + message: ['error', {kind, message: 'gone', origin: 'zeroCache'}], + }, + persisted, + {op: 'restart'}, + {op: 'materialize', view: 'v', query: 'issues.all'}, + start, + connected, + ], + }; +} + +export const persistenceScenarios: Scenario[] = [ + { + name: 'persistence: rows, the cookie and got queries survive a restart', + steps: [ + ...synced, + {op: 'restart'}, + // The rows are there before the client connects; the query is cached, + // not complete. + {op: 'materialize', view: 'v', query: 'issues.all'}, + {op: 'run', run: 'cached', query: 'issues.all', type: 'cached'}, + {op: 'run', run: 'complete', query: 'issues.all', type: 'complete'}, + {op: 'preload', preload: 'p', query: 'issues.all'}, + start, + connected, + ...syncPoke('p2', '01', '02', [allHash]), + ], + }, + { + name: 'persistence: a restart before the client connects keeps what it had', + steps: [ + ...synced, + {op: 'restart'}, + persisted, + {op: 'restart'}, + {op: 'materialize', view: 'v', query: 'issues.all'}, + start, + ], + }, + { + name: 'persistence: a pending mutation is pushed again by the next client', + steps: [ + start, + connected, + mutate('m1', 'issue.create', {id: 'i1', title: 'mine'}), + persisted, + {op: 'restart'}, + {op: 'materialize', view: 'v', query: 'issues.all'}, + start, + connected, + ...poke('p1', null, '01', { + lastMutationIDChanges: {'$clientID.0': 1}, + rowsPatch: [put('issues', issue('i1', {title: 'mine'}))], + }), + persisted, + // Confirmed, so not pushed again. + {op: 'restart'}, + {op: 'materialize', view: 'v', query: 'issues.all'}, + start, + connected, + ], + }, + { + name: 'persistence: pending mutations of two clients are pushed in order', + steps: [ + start, + connected, + mutate('m1', 'issue.create', {id: 'i1'}), + persisted, + {op: 'restart'}, + start, + connected, + mutate('m2', 'issue.close', 'i1'), + persisted, + {op: 'restart'}, + {op: 'materialize', view: 'v', query: 'issues.all'}, + start, + connected, + ...poke('p1', null, '01', { + lastMutationIDChanges: {'$clientID.0': 1}, + rowsPatch: [put('issues', issue('i1'))], + }), + ], + }, + { + name: 'persistence: a pending mutation is replayed on what the next client loads', + steps: [ + ...synced, + mutate('m1', 'issue.closeAllOpen'), + persisted, + {op: 'restart'}, + {op: 'materialize', view: 'v', query: 'issues.all'}, + start, + connected, + // i3 arrives before the server runs the mutation, so the replay closes + // it too. + ...poke('p2', '01', '02', { + rowsPatch: [put('issues', issue('i3'))], + }), + ], + }, + { + name: 'persistence: another set of mutators starts a new client group', + steps: [ + ...synced, + {op: 'restart', mutators: 'issues'}, + {op: 'materialize', view: 'v', query: 'issues.all'}, + start, + ], + }, + { + name: 'persistence: the same set of mutators joins the existing client group', + steps: [ + ...synced, + {op: 'restart', mutators: 'issues'}, + start, + persisted, + {op: 'restart'}, + {op: 'materialize', view: 'v', query: 'issues.all'}, + start, + ], + }, + { + name: 'persistence: another user, storage key, or logged-out client starts empty', + steps: [ + ...synced, + {op: 'restart', options: {userID: 'u2'}}, + {op: 'materialize', view: 'v', query: 'issues.all'}, + start, + {op: 'restart', options: {storageKey: 'other'}}, + {op: 'materialize', view: 'v', query: 'issues.all'}, + start, + {op: 'restart', options: {userID: null}}, + {op: 'materialize', view: 'v', query: 'issues.all'}, + start, + // Back to the first user's storage. + {op: 'restart'}, + {op: 'materialize', view: 'v', query: 'issues.all'}, + start, + ], + }, + { + name: 'persistence: another mutate or query URL starts empty', + steps: [ + ...synced, + {op: 'restart', options: {mutateURL: 'http://api.test/mutate'}}, + {op: 'materialize', view: 'v', query: 'issues.all'}, + start, + {op: 'restart', options: {queryURL: 'http://api.test/query'}}, + {op: 'materialize', view: 'v', query: 'issues.all'}, + start, + ], + }, + { + name: 'persistence: a client inactive for a day is deleted and the server told', + // No pings for the hour the scenario stays connected. + options: {pingTimeoutMs: 3_600_000}, + steps: [ + start, + connected, + persisted, + {op: 'restart', advance: 86_400_000}, + start, + connected, + // Client GC runs every five minutes. + advance(300_000), + // Not yet confirmed: sent again with the next connection. + {op: 'socketClose'}, + advance(5000), + connected, + {op: 'receive', message: ['deleteClients', {clientIDs: ['$clientID.0']}]}, + {op: 'socketClose'}, + advance(5000), + connected, + ], + }, + { + name: 'persistence: a client active within the day is kept', + options: {pingTimeoutMs: 3_600_000}, + steps: [ + start, + connected, + persisted, + {op: 'restart', advance: 86_000_000}, + start, + connected, + advance(300_000), + ], + }, + // The server lost the client group: the next client starts a new one, and + // the old group's pending mutations are not pushed (no mutation recovery). + serverErrorThenRestart( + 'persistence: after ClientNotFound the next client starts a new client group', + 'ClientNotFound', + ), + serverErrorThenRestart( + 'persistence: after SchemaVersionNotSupported the next client starts a new client group', + 'SchemaVersionNotSupported', + ), + // The server is behind the client: the store is dropped. + serverErrorThenRestart( + 'persistence: after InvalidConnectionRequestBaseCookie the next client starts from an empty store', + 'InvalidConnectionRequestBaseCookie', + ), + serverErrorThenRestart( + 'persistence: after InvalidConnectionRequestLastMutationID the next client starts from an empty store', + 'InvalidConnectionRequestLastMutationID', + ), + { + name: 'persistence: after a message too large for the server the next client starts a new client group', + // Nothing is sent before the close: the error lists recent messages, and + // the Swift client writes object keys in another order than JS. + steps: [ + start, + connected, + ...poke('p1', null, '01', {rowsPatch: [put('issues', issue('i1'))]}), + persisted, + {op: 'socketClose', code: 1009, reason: 'too big'}, + persisted, + {op: 'restart'}, + {op: 'materialize', view: 'v', query: 'issues.all'}, + start, + ], + }, + { + name: 'persistence: errors that keep the client group', + steps: [ + ...synced, + { + op: 'receive', + message: [ + 'error', + {kind: 'Unauthorized', message: 'expired', origin: 'zeroCache'}, + ], + }, + persisted, + {op: 'restart'}, + {op: 'materialize', view: 'v', query: 'issues.all'}, + start, + ], + }, +]; diff --git a/packages/zero-conformance/vectors/client.json b/packages/zero-conformance/vectors/client.json index dd06202419..1a5733207d 100644 --- a/packages/zero-conformance/vectors/client.json +++ b/packages/zero-conformance/vectors/client.json @@ -125,5 +125,22 @@ {"name":"mutate: pending mutations are rebased on new server data","steps":[{"op":"materialize","view":"v","query":"issues.all"},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"receive","message":["pokeStart",{"pokeID":"p1","baseCookie":null}]},{"op":"receive","message":["pokePart",{"pokeID":"p1","gotQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek"}],"rowsPatch":[{"op":"put","tableName":"issues","value":{"id":"i1","title":"I1","description":"","closed":false,"owner_id":null,"createdAt":1}},{"op":"put","tableName":"issues","value":{"id":"i2","title":"I2","description":"","closed":false,"owner_id":null,"createdAt":1}}]}]},{"op":"receive","message":["pokeEnd",{"pokeID":"p1","cookie":"01"}]},{"op":"mutate","mutation":"m1","mutator":"issue.closeAllOpen"},{"op":"receive","message":["pokeStart",{"pokeID":"p2","baseCookie":"01"}]},{"op":"receive","message":["pokePart",{"pokeID":"p2","gotQueriesPatch":[],"rowsPatch":[{"op":"put","tableName":"issues","value":{"id":"i3","title":"I3","description":"","closed":false,"owner_id":null,"createdAt":1}}]}]},{"op":"receive","message":["pokeEnd",{"pokeID":"p2","cookie":"02"}]},{"op":"receive","message":["pokeStart",{"pokeID":"p3","baseCookie":"02"}]},{"op":"receive","message":["pokePart",{"pokeID":"p3","lastMutationIDChanges":{"$clientID":1},"rowsPatch":[{"op":"put","tableName":"issues","value":{"id":"i1","title":"I1","description":"","closed":true,"owner_id":null,"createdAt":1}},{"op":"put","tableName":"issues","value":{"id":"i2","title":"I2","description":"","closed":true,"owner_id":null,"createdAt":1}}]}]},{"op":"receive","message":["pokeEnd",{"pokeID":"p3","cookie":"03"}]}],"expect":[{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":1}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":2}}},{"sent":[["push",{"timestamp":1700000000000,"clientGroupID":"$clientGroupID","mutations":[{"type":"custom","timestamp":1700000000000,"id":1,"clientID":"$clientID","name":"issue.closeAllOpen","args":[null]}],"pushVersion":1,"requestID":"$requestID"}]],"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":true,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":true,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":1}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":true,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":true,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":true,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":true,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":true,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":true,"ownerId":null,"createdAt":1},{"id":"i3","title":"I3","description":"","closed":true,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":1}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":true,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":true,"ownerId":null,"createdAt":1},{"id":"i3","title":"I3","description":"","closed":true,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":true,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":true,"ownerId":null,"createdAt":1},{"id":"i3","title":"I3","description":"","closed":true,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":true,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":true,"ownerId":null,"createdAt":1},{"id":"i3","title":"I3","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":1}},"mutations":{"m1":{"client":{"type":"success"},"server":{"type":"success"}}}}]}, {"name":"mutate: a rebase where the mutator now throws drops its writes","steps":[{"op":"materialize","view":"v","query":"issues.all"},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"receive","message":["pokeStart",{"pokeID":"p1","baseCookie":null}]},{"op":"receive","message":["pokePart",{"pokeID":"p1","gotQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek"}],"rowsPatch":[]}]},{"op":"receive","message":["pokeEnd",{"pokeID":"p1","cookie":"01"}]},{"op":"mutate","mutation":"m1","mutator":"issue.createUnique","args":{"id":"i1","title":"mine"}},{"op":"receive","message":["pokeStart",{"pokeID":"p2","baseCookie":"01"}]},{"op":"receive","message":["pokePart",{"pokeID":"p2","gotQueriesPatch":[],"rowsPatch":[{"op":"put","tableName":"issues","value":{"id":"i1","title":"theirs","description":"","closed":false,"owner_id":null,"createdAt":1}}]}]},{"op":"receive","message":["pokeEnd",{"pokeID":"p2","cookie":"02"}]},{"op":"receive","message":["pokeStart",{"pokeID":"p3","baseCookie":"02"}]},{"op":"receive","message":["pokePart",{"pokeID":"p3","lastMutationIDChanges":{"$clientID":1}}]},{"op":"receive","message":["pokeEnd",{"pokeID":"p3","cookie":"03"}]}],"expect":[{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":1}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"complete","notifications":1}}},{"sent":[["push",{"timestamp":1700000000000,"clientGroupID":"$clientGroupID","mutations":[{"type":"custom","timestamp":1700000000000,"id":1,"clientID":"$clientID","name":"issue.createUnique","args":[{"id":"i1","title":"mine"}]}],"pushVersion":1,"requestID":"$requestID"}]],"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"mine","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":1}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"mine","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"mine","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"theirs","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":1}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"theirs","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"theirs","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"theirs","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"success"},"server":{"type":"success"}}}}]}, {"name":"mutate: a rebase where the row is gone","steps":[{"op":"materialize","view":"v","query":"issues.all"},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"receive","message":["pokeStart",{"pokeID":"p1","baseCookie":null}]},{"op":"receive","message":["pokePart",{"pokeID":"p1","gotQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek"}],"rowsPatch":[{"op":"put","tableName":"issues","value":{"id":"i1","title":"I1","description":"","closed":false,"owner_id":null,"createdAt":1}}]}]},{"op":"receive","message":["pokeEnd",{"pokeID":"p1","cookie":"01"}]},{"op":"mutate","mutation":"m1","mutator":"issue.update","args":{"id":"i1","title":"edited"}},{"op":"receive","message":["pokeStart",{"pokeID":"p2","baseCookie":"01"}]},{"op":"receive","message":["pokePart",{"pokeID":"p2","gotQueriesPatch":[],"rowsPatch":[{"op":"del","tableName":"issues","id":{"id":"i1"}}]}]},{"op":"receive","message":["pokeEnd",{"pokeID":"p2","cookie":"02"}]},{"op":"receive","message":["pokeStart",{"pokeID":"p3","baseCookie":"02"}]},{"op":"receive","message":["pokePart",{"pokeID":"p3","lastMutationIDChanges":{"$clientID":1}}]},{"op":"receive","message":["pokeEnd",{"pokeID":"p3","cookie":"03"}]}],"expect":[{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":1}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":2}}},{"sent":[["push",{"timestamp":1700000000000,"clientGroupID":"$clientGroupID","mutations":[{"type":"custom","timestamp":1700000000000,"id":1,"clientID":"$clientID","name":"issue.update","args":[{"id":"i1","title":"edited"}]}],"pushVersion":1,"requestID":"$requestID"}]],"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"edited","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":1}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"edited","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"edited","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"complete","notifications":1}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"success"},"server":{"type":"success"}}}}]}, -{"name":"mutate: needs-auth keeps mutations and pushes them after connect()","options":{"auth":"token-1"},"steps":[{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"mutate","mutation":"m1","mutator":"issue.create","args":{"id":"i1"}},{"op":"receive","message":["error",{"kind":"AuthInvalidated","message":"expired","origin":"zeroCache"}]},{"op":"mutate","mutation":"m2","mutator":"issue.create","args":{"id":"i2"}},{"op":"connect","auth":"token-2"},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"advance","ms":10}],"expect":[{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}],"authToken":"token-1"}}],"state":{"name":"connecting"}},{"state":{"name":"connected"}},{"sent":[["push",{"timestamp":1700000000000,"clientGroupID":"$clientGroupID","mutations":[{"type":"custom","timestamp":1700000000000,"id":1,"clientID":"$clientID","name":"issue.create","args":[{"id":"i1"}]}],"pushVersion":1,"requestID":"$requestID"}]],"state":{"name":"connected"},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"closed":[0],"state":{"name":"needs-auth","reason":{"type":"zero-cache","reason":"expired"}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"state":{"name":"needs-auth","reason":{"type":"zero-cache","reason":"expired"}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"},"m2":{"client":{"type":"success"},"server":"pending"}}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}],"authToken":"token-2"}}],"state":{"name":"connecting"},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"},"m2":{"client":{"type":"success"},"server":"pending"}}},{"sent":[["push",{"timestamp":1700000000000,"clientGroupID":"$clientGroupID","mutations":[{"type":"custom","timestamp":1700000000000,"id":1,"clientID":"$clientID","name":"issue.create","args":[{"id":"i1"}]}],"pushVersion":1,"requestID":"$requestID"}],["push",{"timestamp":1700000000000,"clientGroupID":"$clientGroupID","mutations":[{"type":"custom","timestamp":1700000000000,"id":2,"clientID":"$clientID","name":"issue.create","args":[{"id":"i2"}]}],"pushVersion":1,"requestID":"$requestID"}]],"state":{"name":"connected"},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"},"m2":{"client":{"type":"success"},"server":"pending"}}},{"state":{"name":"connected"},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"},"m2":{"client":{"type":"success"},"server":"pending"}}}]} +{"name":"mutate: needs-auth keeps mutations and pushes them after connect()","options":{"auth":"token-1"},"steps":[{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"mutate","mutation":"m1","mutator":"issue.create","args":{"id":"i1"}},{"op":"receive","message":["error",{"kind":"AuthInvalidated","message":"expired","origin":"zeroCache"}]},{"op":"mutate","mutation":"m2","mutator":"issue.create","args":{"id":"i2"}},{"op":"connect","auth":"token-2"},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"advance","ms":10}],"expect":[{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}],"authToken":"token-1"}}],"state":{"name":"connecting"}},{"state":{"name":"connected"}},{"sent":[["push",{"timestamp":1700000000000,"clientGroupID":"$clientGroupID","mutations":[{"type":"custom","timestamp":1700000000000,"id":1,"clientID":"$clientID","name":"issue.create","args":[{"id":"i1"}]}],"pushVersion":1,"requestID":"$requestID"}]],"state":{"name":"connected"},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"closed":[0],"state":{"name":"needs-auth","reason":{"type":"zero-cache","reason":"expired"}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"state":{"name":"needs-auth","reason":{"type":"zero-cache","reason":"expired"}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"},"m2":{"client":{"type":"success"},"server":"pending"}}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}],"authToken":"token-2"}}],"state":{"name":"connecting"},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"},"m2":{"client":{"type":"success"},"server":"pending"}}},{"sent":[["push",{"timestamp":1700000000000,"clientGroupID":"$clientGroupID","mutations":[{"type":"custom","timestamp":1700000000000,"id":1,"clientID":"$clientID","name":"issue.create","args":[{"id":"i1"}]}],"pushVersion":1,"requestID":"$requestID"}],["push",{"timestamp":1700000000000,"clientGroupID":"$clientGroupID","mutations":[{"type":"custom","timestamp":1700000000000,"id":2,"clientID":"$clientID","name":"issue.create","args":[{"id":"i2"}]}],"pushVersion":1,"requestID":"$requestID"}]],"state":{"name":"connected"},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"},"m2":{"client":{"type":"success"},"server":"pending"}}},{"state":{"name":"connected"},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"},"m2":{"client":{"type":"success"},"server":"pending"}}}]}, +{"name":"persistence: rows, the cookie and got queries survive a restart","steps":[{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"receive","message":["pokeStart",{"pokeID":"p1","baseCookie":null}]},{"op":"receive","message":["pokePart",{"pokeID":"p1","gotQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek"}],"rowsPatch":[{"op":"put","tableName":"issues","value":{"id":"i1","title":"I1","description":"","closed":false,"owner_id":null,"createdAt":1}},{"op":"put","tableName":"issues","value":{"id":"i2","title":"I2","description":"","closed":false,"owner_id":null,"createdAt":1}}]}]},{"op":"receive","message":["pokeEnd",{"pokeID":"p1","cookie":"01"}]},{"op":"advance","ms":2000},{"op":"restart"},{"op":"materialize","view":"v","query":"issues.all"},{"op":"run","run":"cached","query":"issues.all","type":"cached"},{"op":"run","run":"complete","query":"issues.all","type":"complete"},{"op":"preload","preload":"p","query":"issues.all"},{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"receive","message":["pokeStart",{"pokeID":"p2","baseCookie":"01"}]},{"op":"receive","message":["pokePart",{"pokeID":"p2","gotQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek"}],"rowsPatch":[]}]},{"op":"receive","message":["pokeEnd",{"pokeID":"p2","cookie":"02"}]}],"expect":[{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":2}}},{"sent":[["changeDesiredQueries",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}]}]],"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie","01"],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"activeClients":["$clientID"]}]}}],"closed":[0],"state":{"name":"connecting"}},{"state":{"name":"connecting"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"cached","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"cached","notifications":0}},"runs":{"cached":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}]}}},{"state":{"name":"connecting"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"cached","notifications":0}},"runs":{"cached":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}]},"complete":"pending"}},{"state":{"name":"connecting"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"cached","notifications":0}},"runs":{"cached":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}]},"complete":"pending"},"preloads":{"p":{"cached":"resolved","complete":"pending"}}},{"state":{"name":"connecting"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"cached","notifications":0}},"runs":{"cached":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}]},"complete":"pending"},"preloads":{"p":{"cached":"resolved","complete":"pending"}}},{"sent":[["changeDesiredQueries",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}]}]],"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"cached","notifications":0}},"runs":{"cached":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}]},"complete":"pending"},"preloads":{"p":{"cached":"resolved","complete":"pending"}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"cached","notifications":0}},"runs":{"cached":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}]},"complete":"pending"},"preloads":{"p":{"cached":"resolved","complete":"pending"}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"cached","notifications":0}},"runs":{"cached":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}]},"complete":"pending"},"preloads":{"p":{"cached":"resolved","complete":"pending"}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":1}},"runs":{"cached":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}]},"complete":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}]}},"preloads":{"p":{"cached":"resolved","complete":"resolved"}}}]}, +{"name":"persistence: a restart before the client connects keeps what it had","steps":[{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"receive","message":["pokeStart",{"pokeID":"p1","baseCookie":null}]},{"op":"receive","message":["pokePart",{"pokeID":"p1","gotQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek"}],"rowsPatch":[{"op":"put","tableName":"issues","value":{"id":"i1","title":"I1","description":"","closed":false,"owner_id":null,"createdAt":1}},{"op":"put","tableName":"issues","value":{"id":"i2","title":"I2","description":"","closed":false,"owner_id":null,"createdAt":1}}]}]},{"op":"receive","message":["pokeEnd",{"pokeID":"p1","cookie":"01"}]},{"op":"advance","ms":2000},{"op":"restart"},{"op":"advance","ms":2000},{"op":"restart"},{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0}],"expect":[{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":2}}},{"sent":[["changeDesiredQueries",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}]}]],"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie","01"],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"activeClients":["$clientID"]}]}}],"closed":[0],"state":{"name":"connecting"}},{"state":{"name":"connecting"}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie","01"],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"activeClients":["$clientID"]}]}}],"closed":[1],"state":{"name":"connecting"}},{"state":{"name":"connecting"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"cached","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"cached","notifications":0}}}]}, +{"name":"persistence: a pending mutation is pushed again by the next client","steps":[{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"mutate","mutation":"m1","mutator":"issue.create","args":{"id":"i1","title":"mine"}},{"op":"advance","ms":2000},{"op":"restart"},{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"receive","message":["pokeStart",{"pokeID":"p1","baseCookie":null}]},{"op":"receive","message":["pokePart",{"pokeID":"p1","lastMutationIDChanges":{"$clientID.0":1},"rowsPatch":[{"op":"put","tableName":"issues","value":{"id":"i1","title":"mine","description":"","closed":false,"owner_id":null,"createdAt":1}}]}]},{"op":"receive","message":["pokeEnd",{"pokeID":"p1","cookie":"01"}]},{"op":"advance","ms":2000},{"op":"restart"},{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]}],"expect":[{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"}},{"state":{"name":"connected"}},{"sent":[["push",{"timestamp":1700000000000,"clientGroupID":"$clientGroupID","mutations":[{"type":"custom","timestamp":1700000000000,"id":1,"clientID":"$clientID","name":"issue.create","args":[{"id":"i1","title":"mine"}]}],"pushVersion":1,"requestID":"$requestID"}]],"state":{"name":"connected"},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"state":{"name":"connected"},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"closed":[0],"state":{"name":"connecting"}},{"state":{"name":"connecting"},"views":{"v":{"data":[{"id":"i1","title":"mine","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"unknown","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[{"id":"i1","title":"mine","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"unknown","notifications":0}}},{"sent":[["changeDesiredQueries",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}]}],["push",{"timestamp":1700000002000,"clientGroupID":"$clientGroupID","mutations":[{"type":"custom","timestamp":1700000000000,"id":1,"clientID":"$clientID.0","name":"issue.create","args":[{"id":"i1","title":"mine"}]}],"pushVersion":1,"requestID":"$requestID"}]],"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"mine","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"mine","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"mine","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"mine","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"unknown","notifications":0}}},{"sent":[["changeDesiredQueries",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}]}]],"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"mine","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"unknown","notifications":0}}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie","01"],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"activeClients":["$clientID"]}]}}],"closed":[1],"state":{"name":"connecting"}},{"state":{"name":"connecting"},"views":{"v":{"data":[{"id":"i1","title":"mine","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"unknown","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[{"id":"i1","title":"mine","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"unknown","notifications":0}}},{"sent":[["changeDesiredQueries",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}]}]],"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"mine","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"unknown","notifications":0}}}]}, +{"name":"persistence: pending mutations of two clients are pushed in order","steps":[{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"mutate","mutation":"m1","mutator":"issue.create","args":{"id":"i1"}},{"op":"advance","ms":2000},{"op":"restart"},{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"mutate","mutation":"m2","mutator":"issue.close","args":"i1"},{"op":"advance","ms":2000},{"op":"restart"},{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"receive","message":["pokeStart",{"pokeID":"p1","baseCookie":null}]},{"op":"receive","message":["pokePart",{"pokeID":"p1","lastMutationIDChanges":{"$clientID.0":1},"rowsPatch":[{"op":"put","tableName":"issues","value":{"id":"i1","title":"I1","description":"","closed":false,"owner_id":null,"createdAt":1}}]}]},{"op":"receive","message":["pokeEnd",{"pokeID":"p1","cookie":"01"}]}],"expect":[{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"}},{"state":{"name":"connected"}},{"sent":[["push",{"timestamp":1700000000000,"clientGroupID":"$clientGroupID","mutations":[{"type":"custom","timestamp":1700000000000,"id":1,"clientID":"$clientID","name":"issue.create","args":[{"id":"i1"}]}],"pushVersion":1,"requestID":"$requestID"}]],"state":{"name":"connected"},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"state":{"name":"connected"},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"closed":[0],"state":{"name":"connecting"}},{"state":{"name":"connecting"}},{"sent":[["push",{"timestamp":1700000002000,"clientGroupID":"$clientGroupID","mutations":[{"type":"custom","timestamp":1700000000000,"id":1,"clientID":"$clientID.0","name":"issue.create","args":[{"id":"i1"}]}],"pushVersion":1,"requestID":"$requestID"}]],"state":{"name":"connected"}},{"sent":[["push",{"timestamp":1700000002000,"clientGroupID":"$clientGroupID","mutations":[{"type":"custom","timestamp":1700000002000,"id":1,"clientID":"$clientID","name":"issue.close","args":["i1"]}],"pushVersion":1,"requestID":"$requestID"}]],"state":{"name":"connected"},"mutations":{"m2":{"client":{"type":"success"},"server":"pending"}}},{"state":{"name":"connected"},"mutations":{"m2":{"client":{"type":"success"},"server":"pending"}}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"closed":[1],"state":{"name":"connecting"}},{"state":{"name":"connecting"},"views":{"v":{"data":[{"id":"i1","title":"","description":"","closed":true,"ownerId":null,"createdAt":1}],"resultType":"unknown","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[{"id":"i1","title":"","description":"","closed":true,"ownerId":null,"createdAt":1}],"resultType":"unknown","notifications":0}}},{"sent":[["changeDesiredQueries",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}]}],["push",{"timestamp":1700000004000,"clientGroupID":"$clientGroupID","mutations":[{"type":"custom","timestamp":1700000000000,"id":1,"clientID":"$clientID.0","name":"issue.create","args":[{"id":"i1"}]}],"pushVersion":1,"requestID":"$requestID"}],["push",{"timestamp":1700000004000,"clientGroupID":"$clientGroupID","mutations":[{"type":"custom","timestamp":1700000002000,"id":1,"clientID":"$clientID.1","name":"issue.close","args":["i1"]}],"pushVersion":1,"requestID":"$requestID"}]],"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"","description":"","closed":true,"ownerId":null,"createdAt":1}],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"","description":"","closed":true,"ownerId":null,"createdAt":1}],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"","description":"","closed":true,"ownerId":null,"createdAt":1}],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":true,"ownerId":null,"createdAt":1}],"resultType":"unknown","notifications":1}}}]}, +{"name":"persistence: a pending mutation is replayed on what the next client loads","steps":[{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"receive","message":["pokeStart",{"pokeID":"p1","baseCookie":null}]},{"op":"receive","message":["pokePart",{"pokeID":"p1","gotQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek"}],"rowsPatch":[{"op":"put","tableName":"issues","value":{"id":"i1","title":"I1","description":"","closed":false,"owner_id":null,"createdAt":1}},{"op":"put","tableName":"issues","value":{"id":"i2","title":"I2","description":"","closed":false,"owner_id":null,"createdAt":1}}]}]},{"op":"receive","message":["pokeEnd",{"pokeID":"p1","cookie":"01"}]},{"op":"advance","ms":2000},{"op":"mutate","mutation":"m1","mutator":"issue.closeAllOpen"},{"op":"advance","ms":2000},{"op":"restart"},{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"receive","message":["pokeStart",{"pokeID":"p2","baseCookie":"01"}]},{"op":"receive","message":["pokePart",{"pokeID":"p2","rowsPatch":[{"op":"put","tableName":"issues","value":{"id":"i3","title":"I3","description":"","closed":false,"owner_id":null,"createdAt":1}}]}]},{"op":"receive","message":["pokeEnd",{"pokeID":"p2","cookie":"02"}]}],"expect":[{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":2}}},{"sent":[["changeDesiredQueries",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}]}]],"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}}},{"sent":[["push",{"timestamp":1700000002000,"clientGroupID":"$clientGroupID","mutations":[{"type":"custom","timestamp":1700000002000,"id":1,"clientID":"$clientID","name":"issue.closeAllOpen","args":[null]}],"pushVersion":1,"requestID":"$requestID"}]],"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":true,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":true,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":1}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":true,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":true,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie","01"],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"activeClients":["$clientID"]}]}}],"closed":[0],"state":{"name":"connecting"}},{"state":{"name":"connecting"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":true,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":true,"ownerId":null,"createdAt":1}],"resultType":"cached","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":true,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":true,"ownerId":null,"createdAt":1}],"resultType":"cached","notifications":0}}},{"sent":[["changeDesiredQueries",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}]}],["push",{"timestamp":1700000004000,"clientGroupID":"$clientGroupID","mutations":[{"type":"custom","timestamp":1700000002000,"id":1,"clientID":"$clientID.0","name":"issue.closeAllOpen","args":[null]}],"pushVersion":1,"requestID":"$requestID"}]],"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":true,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":true,"ownerId":null,"createdAt":1}],"resultType":"cached","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":true,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":true,"ownerId":null,"createdAt":1}],"resultType":"cached","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":true,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":true,"ownerId":null,"createdAt":1}],"resultType":"cached","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":true,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":true,"ownerId":null,"createdAt":1},{"id":"i3","title":"I3","description":"","closed":true,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":2}}}]}, +{"name":"persistence: another set of mutators starts a new client group","steps":[{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"receive","message":["pokeStart",{"pokeID":"p1","baseCookie":null}]},{"op":"receive","message":["pokePart",{"pokeID":"p1","gotQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek"}],"rowsPatch":[{"op":"put","tableName":"issues","value":{"id":"i1","title":"I1","description":"","closed":false,"owner_id":null,"createdAt":1}},{"op":"put","tableName":"issues","value":{"id":"i2","title":"I2","description":"","closed":false,"owner_id":null,"createdAt":1}}]}]},{"op":"receive","message":["pokeEnd",{"pokeID":"p1","cookie":"01"}]},{"op":"advance","ms":2000},{"op":"restart","mutators":"issues"},{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0}],"expect":[{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":2}}},{"sent":[["changeDesiredQueries",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}]}]],"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"closed":[0],"state":{"name":"connecting"}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}}]}, +{"name":"persistence: the same set of mutators joins the existing client group","steps":[{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"receive","message":["pokeStart",{"pokeID":"p1","baseCookie":null}]},{"op":"receive","message":["pokePart",{"pokeID":"p1","gotQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek"}],"rowsPatch":[{"op":"put","tableName":"issues","value":{"id":"i1","title":"I1","description":"","closed":false,"owner_id":null,"createdAt":1}},{"op":"put","tableName":"issues","value":{"id":"i2","title":"I2","description":"","closed":false,"owner_id":null,"createdAt":1}}]}]},{"op":"receive","message":["pokeEnd",{"pokeID":"p1","cookie":"01"}]},{"op":"advance","ms":2000},{"op":"restart","mutators":"issues"},{"op":"advance","ms":0},{"op":"advance","ms":2000},{"op":"restart"},{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0}],"expect":[{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":2}}},{"sent":[["changeDesiredQueries",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}]}]],"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"closed":[0],"state":{"name":"connecting"}},{"state":{"name":"connecting"}},{"state":{"name":"connecting"}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie","01"],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"activeClients":["$clientID"]}]}}],"closed":[1],"state":{"name":"connecting"}},{"state":{"name":"connecting"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"cached","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"cached","notifications":0}}}]}, +{"name":"persistence: another user, storage key, or logged-out client starts empty","steps":[{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"receive","message":["pokeStart",{"pokeID":"p1","baseCookie":null}]},{"op":"receive","message":["pokePart",{"pokeID":"p1","gotQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek"}],"rowsPatch":[{"op":"put","tableName":"issues","value":{"id":"i1","title":"I1","description":"","closed":false,"owner_id":null,"createdAt":1}},{"op":"put","tableName":"issues","value":{"id":"i2","title":"I2","description":"","closed":false,"owner_id":null,"createdAt":1}}]}]},{"op":"receive","message":["pokeEnd",{"pokeID":"p1","cookie":"01"}]},{"op":"advance","ms":2000},{"op":"restart","options":{"userID":"u2"}},{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0},{"op":"restart","options":{"storageKey":"other"}},{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0},{"op":"restart","options":{"userID":null}},{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0},{"op":"restart"},{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0}],"expect":[{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":2}}},{"sent":[["changeDesiredQueries",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}]}]],"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u2"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"closed":[0],"state":{"name":"connecting"}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"closed":[1],"state":{"name":"connecting"}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"closed":[2],"state":{"name":"connecting"}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie","01"],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"activeClients":["$clientID"]}]}}],"closed":[3],"state":{"name":"connecting"}},{"state":{"name":"connecting"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"cached","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"cached","notifications":0}}}]}, +{"name":"persistence: another mutate or query URL starts empty","steps":[{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"receive","message":["pokeStart",{"pokeID":"p1","baseCookie":null}]},{"op":"receive","message":["pokePart",{"pokeID":"p1","gotQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek"}],"rowsPatch":[{"op":"put","tableName":"issues","value":{"id":"i1","title":"I1","description":"","closed":false,"owner_id":null,"createdAt":1}},{"op":"put","tableName":"issues","value":{"id":"i2","title":"I2","description":"","closed":false,"owner_id":null,"createdAt":1}}]}]},{"op":"receive","message":["pokeEnd",{"pokeID":"p1","cookie":"01"}]},{"op":"advance","ms":2000},{"op":"restart","options":{"mutateURL":"http://api.test/mutate"}},{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0},{"op":"restart","options":{"queryURL":"http://api.test/query"}},{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0}],"expect":[{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":2}}},{"sent":[["changeDesiredQueries",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}]}]],"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"userPushURL":"http://api.test/mutate","activeClients":["$clientID"]}]}}],"closed":[0],"state":{"name":"connecting"}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"userQueryURL":"http://api.test/query","activeClients":["$clientID"]}]}}],"closed":[1],"state":{"name":"connecting"}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}}]}, +{"name":"persistence: a client inactive for a day is deleted and the server told","options":{"pingTimeoutMs":3600000},"steps":[{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"advance","ms":2000},{"op":"restart","advance":86400000},{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"advance","ms":300000},{"op":"socketClose"},{"op":"advance","ms":5000},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"receive","message":["deleteClients",{"clientIDs":["$clientID.0"]}]},{"op":"socketClose"},{"op":"advance","ms":5000},{"op":"receive","message":["connected",{"wsid":"w1"}]}],"expect":[{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"}},{"state":{"name":"connected"}},{"state":{"name":"connected"}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"closed":[0],"state":{"name":"connecting"}},{"state":{"name":"connecting"}},{"state":{"name":"connected"}},{"sent":[["deleteClients",{"clientIDs":["$clientID.0"]}]],"state":{"name":"connected"}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"deleted":{"clientIDs":["$clientID.0"]},"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"closed":[1],"state":{"name":"connecting"}},{"state":{"name":"connecting"}},{"state":{"name":"connected"}},{"state":{"name":"connected"}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"closed":[2],"state":{"name":"connecting"}},{"state":{"name":"connecting"}},{"state":{"name":"connected"}}]}, +{"name":"persistence: a client active within the day is kept","options":{"pingTimeoutMs":3600000},"steps":[{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"advance","ms":2000},{"op":"restart","advance":86000000},{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"advance","ms":300000}],"expect":[{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"}},{"state":{"name":"connected"}},{"state":{"name":"connected"}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"closed":[0],"state":{"name":"connecting"}},{"state":{"name":"connecting"}},{"state":{"name":"connected"}},{"state":{"name":"connected"}}]}, +{"name":"persistence: after ClientNotFound the next client starts a new client group","steps":[{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"receive","message":["pokeStart",{"pokeID":"p1","baseCookie":null}]},{"op":"receive","message":["pokePart",{"pokeID":"p1","gotQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek"}],"rowsPatch":[{"op":"put","tableName":"issues","value":{"id":"i1","title":"I1","description":"","closed":false,"owner_id":null,"createdAt":1}},{"op":"put","tableName":"issues","value":{"id":"i2","title":"I2","description":"","closed":false,"owner_id":null,"createdAt":1}}]}]},{"op":"receive","message":["pokeEnd",{"pokeID":"p1","cookie":"01"}]},{"op":"advance","ms":2000},{"op":"mutate","mutation":"m1","mutator":"issue.create","args":{"id":"i3"}},{"op":"advance","ms":2000},{"op":"receive","message":["error",{"kind":"ClientNotFound","message":"gone","origin":"zeroCache"}]},{"op":"advance","ms":2000},{"op":"restart"},{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]}],"expect":[{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":2}}},{"sent":[["changeDesiredQueries",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}]}]],"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}}},{"sent":[["push",{"timestamp":1700000002000,"clientGroupID":"$clientGroupID","mutations":[{"type":"custom","timestamp":1700000002000,"id":1,"clientID":"$clientID","name":"issue.create","args":[{"id":"i3"}]}],"pushVersion":1,"requestID":"$requestID"}]],"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i3","title":"","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":1}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i3","title":"","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"closed":[0],"state":{"name":"error","reason":"gone"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i3","title":"","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"success"},"server":{"type":"error","error":{"type":"zero","message":"gone"}}}},"events":["onClientStateNotFound"]},{"state":{"name":"error","reason":"gone"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i3","title":"","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"success"},"server":{"type":"error","error":{"type":"zero","message":"gone"}}}}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"sent":[["changeDesiredQueries",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}]}]],"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}}]}, +{"name":"persistence: after SchemaVersionNotSupported the next client starts a new client group","steps":[{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"receive","message":["pokeStart",{"pokeID":"p1","baseCookie":null}]},{"op":"receive","message":["pokePart",{"pokeID":"p1","gotQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek"}],"rowsPatch":[{"op":"put","tableName":"issues","value":{"id":"i1","title":"I1","description":"","closed":false,"owner_id":null,"createdAt":1}},{"op":"put","tableName":"issues","value":{"id":"i2","title":"I2","description":"","closed":false,"owner_id":null,"createdAt":1}}]}]},{"op":"receive","message":["pokeEnd",{"pokeID":"p1","cookie":"01"}]},{"op":"advance","ms":2000},{"op":"mutate","mutation":"m1","mutator":"issue.create","args":{"id":"i3"}},{"op":"advance","ms":2000},{"op":"receive","message":["error",{"kind":"SchemaVersionNotSupported","message":"gone","origin":"zeroCache"}]},{"op":"advance","ms":2000},{"op":"restart"},{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]}],"expect":[{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":2}}},{"sent":[["changeDesiredQueries",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}]}]],"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}}},{"sent":[["push",{"timestamp":1700000002000,"clientGroupID":"$clientGroupID","mutations":[{"type":"custom","timestamp":1700000002000,"id":1,"clientID":"$clientID","name":"issue.create","args":[{"id":"i3"}]}],"pushVersion":1,"requestID":"$requestID"}]],"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i3","title":"","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":1}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i3","title":"","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"closed":[0],"state":{"name":"error","reason":"gone"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i3","title":"","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"success"},"server":{"type":"error","error":{"type":"zero","message":"gone"}}}},"events":[{"onUpdateNeeded":{"type":"SchemaVersionNotSupported","message":"gone"}}]},{"state":{"name":"error","reason":"gone"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i3","title":"","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"success"},"server":{"type":"error","error":{"type":"zero","message":"gone"}}}}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"sent":[["changeDesiredQueries",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}]}]],"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}}]}, +{"name":"persistence: after InvalidConnectionRequestBaseCookie the next client starts from an empty store","steps":[{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"receive","message":["pokeStart",{"pokeID":"p1","baseCookie":null}]},{"op":"receive","message":["pokePart",{"pokeID":"p1","gotQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek"}],"rowsPatch":[{"op":"put","tableName":"issues","value":{"id":"i1","title":"I1","description":"","closed":false,"owner_id":null,"createdAt":1}},{"op":"put","tableName":"issues","value":{"id":"i2","title":"I2","description":"","closed":false,"owner_id":null,"createdAt":1}}]}]},{"op":"receive","message":["pokeEnd",{"pokeID":"p1","cookie":"01"}]},{"op":"advance","ms":2000},{"op":"mutate","mutation":"m1","mutator":"issue.create","args":{"id":"i3"}},{"op":"advance","ms":2000},{"op":"receive","message":["error",{"kind":"InvalidConnectionRequestBaseCookie","message":"gone","origin":"zeroCache"}]},{"op":"advance","ms":2000},{"op":"restart"},{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]}],"expect":[{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":2}}},{"sent":[["changeDesiredQueries",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}]}]],"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}}},{"sent":[["push",{"timestamp":1700000002000,"clientGroupID":"$clientGroupID","mutations":[{"type":"custom","timestamp":1700000002000,"id":1,"clientID":"$clientID","name":"issue.create","args":[{"id":"i3"}]}],"pushVersion":1,"requestID":"$requestID"}]],"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i3","title":"","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":1}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i3","title":"","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"closed":[0],"state":{"name":"error","reason":"gone"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i3","title":"","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"success"},"server":{"type":"error","error":{"type":"zero","message":"gone"}}}},"events":["onClientStateNotFound"]},{"state":{"name":"error","reason":"gone"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i3","title":"","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"success"},"server":{"type":"error","error":{"type":"zero","message":"gone"}}}}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"sent":[["changeDesiredQueries",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}]}]],"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}}]}, +{"name":"persistence: after InvalidConnectionRequestLastMutationID the next client starts from an empty store","steps":[{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"receive","message":["pokeStart",{"pokeID":"p1","baseCookie":null}]},{"op":"receive","message":["pokePart",{"pokeID":"p1","gotQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek"}],"rowsPatch":[{"op":"put","tableName":"issues","value":{"id":"i1","title":"I1","description":"","closed":false,"owner_id":null,"createdAt":1}},{"op":"put","tableName":"issues","value":{"id":"i2","title":"I2","description":"","closed":false,"owner_id":null,"createdAt":1}}]}]},{"op":"receive","message":["pokeEnd",{"pokeID":"p1","cookie":"01"}]},{"op":"advance","ms":2000},{"op":"mutate","mutation":"m1","mutator":"issue.create","args":{"id":"i3"}},{"op":"advance","ms":2000},{"op":"receive","message":["error",{"kind":"InvalidConnectionRequestLastMutationID","message":"gone","origin":"zeroCache"}]},{"op":"advance","ms":2000},{"op":"restart"},{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]}],"expect":[{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":2}}},{"sent":[["changeDesiredQueries",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}]}]],"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}}},{"sent":[["push",{"timestamp":1700000002000,"clientGroupID":"$clientGroupID","mutations":[{"type":"custom","timestamp":1700000002000,"id":1,"clientID":"$clientID","name":"issue.create","args":[{"id":"i3"}]}],"pushVersion":1,"requestID":"$requestID"}]],"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i3","title":"","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":1}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i3","title":"","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"closed":[0],"state":{"name":"error","reason":"gone"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i3","title":"","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"success"},"server":{"type":"error","error":{"type":"zero","message":"gone"}}}},"events":["onClientStateNotFound"]},{"state":{"name":"error","reason":"gone"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i3","title":"","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"success"},"server":{"type":"error","error":{"type":"zero","message":"gone"}}}}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"sent":[["changeDesiredQueries",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}]}]],"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}}]}, +{"name":"persistence: after a message too large for the server the next client starts a new client group","steps":[{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"receive","message":["pokeStart",{"pokeID":"p1","baseCookie":null}]},{"op":"receive","message":["pokePart",{"pokeID":"p1","rowsPatch":[{"op":"put","tableName":"issues","value":{"id":"i1","title":"I1","description":"","closed":false,"owner_id":null,"createdAt":1}}]}]},{"op":"receive","message":["pokeEnd",{"pokeID":"p1","cookie":"01"}]},{"op":"advance","ms":2000},{"op":"socketClose","code":1009,"reason":"too big"},{"op":"advance","ms":2000},{"op":"restart"},{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0}],"expect":[{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"}},{"state":{"name":"connected"}},{"state":{"name":"connected"}},{"state":{"name":"connected"}},{"state":{"name":"connected"}},{"state":{"name":"connected"}},{"closed":[0],"state":{"name":"error","reason":"A WebSocket message exceeded the server message size limit. This is usually caused by a mutation's args containing a large value such as a base64-encoded image. Consider uploading large files to object storage and storing only the URL in Zero."},"events":["onClientStateNotFound"]},{"state":{"name":"error","reason":"A WebSocket message exceeded the server message size limit. This is usually caused by a mutation's args containing a large value such as a base64-encoded image. Consider uploading large files to object storage and storing only the URL in Zero."}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}}]}, +{"name":"persistence: errors that keep the client group","steps":[{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"receive","message":["pokeStart",{"pokeID":"p1","baseCookie":null}]},{"op":"receive","message":["pokePart",{"pokeID":"p1","gotQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek"}],"rowsPatch":[{"op":"put","tableName":"issues","value":{"id":"i1","title":"I1","description":"","closed":false,"owner_id":null,"createdAt":1}},{"op":"put","tableName":"issues","value":{"id":"i2","title":"I2","description":"","closed":false,"owner_id":null,"createdAt":1}}]}]},{"op":"receive","message":["pokeEnd",{"pokeID":"p1","cookie":"01"}]},{"op":"advance","ms":2000},{"op":"receive","message":["error",{"kind":"Unauthorized","message":"expired","origin":"zeroCache"}]},{"op":"advance","ms":2000},{"op":"restart"},{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0}],"expect":[{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":2}}},{"sent":[["changeDesiredQueries",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}]}]],"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}}},{"closed":[0],"state":{"name":"needs-auth","reason":{"type":"zero-cache","reason":"expired"}},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}}},{"state":{"name":"needs-auth","reason":{"type":"zero-cache","reason":"expired"}},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie","01"],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"}},{"state":{"name":"connecting"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"cached","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"cached","notifications":0}}}]} ]} From 3974fdf2f375e2250d2c14c5a8f7ff549265c2d7 Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 19 Oct 2026 14:55:27 +0000 Subject: [PATCH 013/104] 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 --- .github/workflows/swift.yml | 2 + swift/DESIGN.md | 48 +- swift/Package.swift | 5 +- swift/Sources/CSQLite/module.modulemap | 5 + swift/Sources/CSQLite/shim.h | 1 + swift/Sources/Zero/Client/Definitions.swift | 63 +- swift/Sources/Zero/Client/Engine.swift | 249 +++++- swift/Sources/Zero/Client/ReplicaStore.swift | 7 +- .../Sources/Zero/Protocol/ClientSchema.swift | 6 + .../Zero/Storage/ReplicaDatabase.swift | 746 ++++++++++++++++++ .../Sources/Zero/Storage/SQLiteDatabase.swift | 152 ++++ .../ClientConformanceTests.swift | 82 +- swift/Tests/ZeroTests/ClientAPITests.swift | 25 +- swift/Tests/ZeroTests/StorageTests.swift | 92 +++ 14 files changed, 1407 insertions(+), 76 deletions(-) create mode 100644 swift/Sources/CSQLite/module.modulemap create mode 100644 swift/Sources/CSQLite/shim.h create mode 100644 swift/Sources/Zero/Storage/ReplicaDatabase.swift create mode 100644 swift/Sources/Zero/Storage/SQLiteDatabase.swift create mode 100644 swift/Tests/ZeroTests/StorageTests.swift diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 8d5c234217..a5883cc098 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -107,6 +107,8 @@ jobs: mkdir -p "$RUNNER_TEMP/swift" tar -xzf "$RUNNER_TEMP/swift.tar.gz" -C "$RUNNER_TEMP/swift" --strip-components=1 echo "$RUNNER_TEMP/swift/usr/bin" >> "$GITHUB_PATH" + - name: Install system libraries + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libsqlite3-dev - name: Build working-directory: swift run: swift build --build-tests diff --git a/swift/DESIGN.md b/swift/DESIGN.md index e18655ec08..d2e2f00166 100644 --- a/swift/DESIGN.md +++ b/swift/DESIGN.md @@ -152,21 +152,43 @@ Rationale: ### Storage -- SQLite (system `libsqlite3`), WAL mode. -- Pending mutations are written durably before they are pushed. Synced-row - writes are coalesced. -- Default location `Application Support/Zero/.sqlite`, configurable (for - example an App Group container). -- iOS Data Protection default `completeUntilFirstUserAuthentication`, - configurable. `complete` would make the database unreadable while the device - is locked and break the background flush. +- SQLite (system `libsqlite3`), WAL mode, `synchronous = NORMAL`. +- One database file per JS database name. The name covers the user, the + storage key, the mutate and query URLs, the client schema's hash, and the + storage version, so a schema change starts a new database as in JS. The file + name is a hash of it plus a Swift layout version. +- The file holds the client groups, the clients with their heartbeats, the + deleted clients the server hasn't confirmed, and per client group the last + server snapshot and the pending mutations. At launch the client joins the + group with its set of mutator names (client group forking is off in Zero) + and replays the pending mutations over the snapshot, as a rebase does. +- Changes are written through. The clients and groups are mirrored in memory, + so every decision is made on the main actor in the JS order. The writes run + in order on a background queue, one transaction per mutation or poke. JS + persists about a second after a change; the Swift client writes right away. +- The client lifecycle follows Replicache: a heartbeat every minute; every five + minutes, clients inactive for a day are deleted (and the server told with + `deleteClients`, and in `initConnection` until it confirms) and client + groups without clients are dropped; five minutes after launch and then every + 12 hours, databases no client used for two days are deleted. A group is + disabled after `ClientNotFound`, `SchemaVersionNotSupported`, or a 1009 + close, and the database dropped after `InvalidConnectionRequestBaseCookie` + or `InvalidConnectionRequestLastMutationID`. +- `ZeroStorage.memory` keeps databases for the life of the process, keyed by + name, like JS `kvStore: 'mem'`. The client scenarios run on both storages. +- Default location `Library/Application Support/Zero` on Apple platforms and + `$XDG_DATA_HOME/zero` elsewhere, configurable (for example an App Group + container). +- iOS Data Protection: files get the app's default class, + `completeUntilFirstUserAuthentication`. `complete` would make the database + unreadable while the device is locked and break the background flush. - No database-level encryption. -- A Swift-side storage version. On a mismatch, drop the store and resync, as JS - does. -- If the store can't be opened, fall back to memory and call - `onStorageFailure`, as JS does. +- If the store can't be opened, the client runs on memory and calls + `onStorageFailure`, as JS does. A failed write stops writing and calls it + too. - One process per store in v1: one `Zero` instance per user and storage key per - process. Concurrent cross-process access (app extensions) is deferred. + process. Concurrent cross-process access (app extensions) is deferred, and so + is refreshing one instance from another's writes. ### Transport diff --git a/swift/Package.swift b/swift/Package.swift index f9d7cb0d78..9c3675ee46 100644 --- a/swift/Package.swift +++ b/swift/Package.swift @@ -14,7 +14,10 @@ let package = Package( .package(url: "https://github.com/apple/swift-nio.git", from: "2.104.0") ], targets: [ - .target(name: "Zero"), + // The system SQLite: in the SDK on Apple platforms, libsqlite3-dev on + // Linux. + .systemLibrary(name: "CSQLite", path: "Sources/CSQLite"), + .target(name: "Zero", dependencies: ["CSQLite"]), .target(name: "ZeroUI", dependencies: ["Zero"]), .target(name: "ZeroTesting", dependencies: ["Zero"]), .testTarget(name: "ZeroTests", dependencies: ["Zero", "ZeroTesting"]), diff --git a/swift/Sources/CSQLite/module.modulemap b/swift/Sources/CSQLite/module.modulemap new file mode 100644 index 0000000000..f6010bd743 --- /dev/null +++ b/swift/Sources/CSQLite/module.modulemap @@ -0,0 +1,5 @@ +module CSQLite [system] { + header "shim.h" + link "sqlite3" + export * +} diff --git a/swift/Sources/CSQLite/shim.h b/swift/Sources/CSQLite/shim.h new file mode 100644 index 0000000000..f52e1f09e6 --- /dev/null +++ b/swift/Sources/CSQLite/shim.h @@ -0,0 +1 @@ +#include diff --git a/swift/Sources/Zero/Client/Definitions.swift b/swift/Sources/Zero/Client/Definitions.swift index 6c7159effb..52b57541fd 100644 --- a/swift/Sources/Zero/Client/Definitions.swift +++ b/swift/Sources/Zero/Client/Definitions.swift @@ -58,10 +58,32 @@ public func defineQuery< QueryDefinition(name: name, body: body) } +/// A query of an app without a context. +public func defineQuery( + _ name: String, _ body: @escaping @Sendable (Args) -> Query +) -> QueryDefinition { + QueryDefinition(name: name) { args, _ in body(args) } +} + +/// A query without arguments that reads the context: +/// `defineQuery("issues.mine") { (_: NoArgs, ctx: Auth) in … }`. public func defineQuery( - _ name: String, _ body: @escaping @Sendable (Context) -> Query + _ name: String, _ body: @escaping @Sendable (NoArgs, Context) -> Query ) -> NoArgsQueryDefinition { - NoArgsQueryDefinition(name: name, body: body) + NoArgsQueryDefinition(name: name) { body(NoArgs(), $0) } +} + +/// A query without arguments of an app without a context. +public func defineQuery( + _ name: String, _ body: @escaping @Sendable () -> Query +) -> NoArgsQueryDefinition { + NoArgsQueryDefinition(name: name) { _ in body() } +} + +/// Stands for the arguments of a query or mutator that takes none, where +/// the definition also takes the context. +public struct NoArgs: Sendable, Hashable { + public init() {} } func encodeArgs(_ args: Args) -> JSONValue { @@ -145,11 +167,44 @@ public func defineMutator( + _ name: String, _ body: @escaping @MainActor @Sendable (Transaction, Args) throws -> Void +) -> MutatorDefinition { + MutatorDefinition(name: name) { tx, args, _ in + try body(tx, args) + return nil + } +} + +/// A mutator of an app without a context that returns a value to the +/// caller of its optimistic run. +public func defineMutator( + _ name: String, _ body: @escaping @MainActor @Sendable (Transaction, Args) throws -> R +) -> MutatorDefinition { + MutatorDefinition(name: name) { tx, args, _ in + try JSONValueEncoder.encode(body(tx, args)) + } +} + +/// A mutator without arguments that reads the context: +/// `defineMutator("issue.closeAll") { (tx, _: NoArgs, ctx: Auth) in … }`. public func defineMutator( - _ name: String, _ body: @escaping @MainActor @Sendable (Transaction, Context) throws -> Void + _ name: String, + _ body: @escaping @MainActor @Sendable (Transaction, NoArgs, Context) throws -> Void ) -> NoArgsMutatorDefinition { NoArgsMutatorDefinition(name: name) { tx, context in - try body(tx, context) + try body(tx, NoArgs(), context) + return nil + } +} + +/// A mutator without arguments of an app without a context. +public func defineMutator( + _ name: String, _ body: @escaping @MainActor @Sendable (Transaction) throws -> Void +) -> NoArgsMutatorDefinition { + NoArgsMutatorDefinition(name: name) { tx, _ in + try body(tx) return nil } } diff --git a/swift/Sources/Zero/Client/Engine.swift b/swift/Sources/Zero/Client/Engine.swift index 6f2ed4b0b0..b9934471e3 100644 --- a/swift/Sources/Zero/Client/Engine.swift +++ b/swift/Sources/Zero/Client/Engine.swift @@ -30,15 +30,21 @@ public struct ZeroOptions: Sendable { public var onUpdateNeeded: (@MainActor @Sendable (UpdateNeededReason) -> Void)? /// The server no longer has the client's state; the local store was reset. public var onClientStateNotFound: (@MainActor @Sendable () -> Void)? + /// Where the replica is kept between launches. + public var storage: ZeroStorage = .sqlite() + /// The storage failed, the first time it does: the client keeps running + /// on memory and stops writing to disk. Defaults to doing nothing. + public var onStorageFailure: (@MainActor @Sendable (StorageFailure) -> Void)? public init( cacheURL: String? = nil, userID: String? = nil, auth: String? = nil, - storageKey: String? = nil + storageKey: String? = nil, storage: ZeroStorage = .sqlite() ) { self.cacheURL = cacheURL self.userID = userID self.auth = auth self.storageKey = storageKey + self.storage = storage } } @@ -76,6 +82,8 @@ final class Engine { isolated deinit { loop.stop() + for t in lifecycleTimers { t.cancel() } + if !repClosed { database.release() } } private let socketFactory: (any SocketFactory)? private let socketOrigin: String? @@ -84,6 +92,8 @@ final class Engine { private let clientToServer: NameMapper let store: ReplicaStore + let database: ReplicaDatabase + private var lifecycleTimers: [any ScheduledTimer] = [] let ivmMain: IVMBranch private(set) var context: QueryContext! let connectionManager: ConnectionManager @@ -96,6 +106,8 @@ final class Engine { private var socket: SocketHandle? private var connectCookie: JSONValue = .null private var initConnectionQueries: OrderedMap? + /// Deleted clients still to tell the server about on this connection. + private var deletedClients: DeleteClientsBody? private var lastMutationIDReceived: Double = 0 private var lastMutationIDsSent: [String: Double] = [:] private var additionalConnectParams: [(String, String)]? @@ -137,11 +149,29 @@ final class Engine { self.loop = EventLoop(scheduler: base) self.scheduler = LoopScheduler(base, loop) self.clientID = randomID() - self.clientGroupID = randomID() - self.profileID = "p" + randomID() self.clientSchema = schema.clientSchema self.clientToServer = NameMapper.clientToServer(schema) - self.store = ReplicaStore(clientID: clientID) + + // Open the database and join a client group (`initClientV6`). + let name = ReplicaDatabase.name( + userID: options.userID, storageKey: options.storageKey ?? "", + mutateURL: options.mutateURL, queryURL: options.queryURL, + schemaHash: clientSchema.hash) + let (database, openFailure) = ReplicaDatabase.open(options.storage, name: name) + self.database = database + let (clientGroupID, group) = database.initClient( + clientID, mutatorNames: mutators.keys.sorted(), now: base.now) + self.clientGroupID = clientGroupID + self.profileID = database.profileID + self.store = ReplicaStore( + clientID: clientID, + snapshot: ReplicaStore.Snapshot( + map: group.entries, cookie: group.cookie, lastMutationIDs: group.lastMutationIDs), + pending: group.mutations.map { + ReplicaStore.PendingMutation( + id: $0.id, clientID: $0.clientID, name: $0.name, args: $0.args, + timestamp: $0.timestamp, writes: []) + }) self.ivmMain = IVMBranch(schema: schema) let loop = self.loop self.connectionManager = ConnectionManager( @@ -182,6 +212,21 @@ final class Engine { }, onPokeError: { [weak self] message in self?.onPokeError(message) }) + // Load the snapshot into the sources and replay the pending mutations + // on it. + ivmMain.apply(KVDiff.between(KVMap(), store.snapshot.map)) + if !store.pending.isEmpty { + let (main, replayed, touched, _) = replay( + store.pending, over: store.snapshot.map, branch: ivmMain.fork()) + ivmMain.apply( + store.advance(to: store.snapshot, main: main, pending: replayed, touched: touched)) + } + database.onWriteFailure = { [weak self] failure in self?.storageFailed(failure) } + if let openFailure { + loop.microtask { [weak self] in self?.storageFailed(openFailure) } + } + startLifecycle() + // The replica is loaded: report its got queries and mutation results, // as the first runs of the store watches do, then build the pipelines. fireWatchers(KVDiff.between(KVMap(), store.main), initial: true) @@ -476,11 +521,12 @@ final class Engine { timestamp: scheduler.monotonicNow, lastMutationID: lastMutationIDReceived, wsid: wsid, profileID: profileID, additionalParams: additionalConnectParams ?? []) addConnectEvent(attempt, "reading deleted clients") + var deleted = deletedClientsBody() addConnectEvent(attempt, "reading desired queries") var queriesPatch: OrderedMap? = queryManager.queriesPatch(store: store) let initConnection = InitConnectionBody( desiredQueriesPatch: queriesPatch!.entries.map(\.value), - clientSchema: connectCookie.isNull ? clientSchema : nil, + clientSchema: connectCookie.isNull ? clientSchema : nil, deleted: deleted, userPushURL: options.mutateURL, userPushHeaders: options.mutateHeaders, userQueryURL: options.queryURL, userQueryHeaders: options.queryHeaders, activeClients: [clientID]) @@ -489,6 +535,8 @@ final class Engine { if secProtocol.utf8.count > options.maxHeaderLength { secProtocol = SecProtocols.encode(initConnection: nil, authToken: authToken) queriesPatch = nil + } else { + deleted = nil } addConnectEvent(attempt, "creating the WebSocket") let handle = SocketHandle(engine: self) @@ -507,6 +555,7 @@ final class Engine { } self.connectCookie = connectCookie self.initConnectionQueries = queriesPatch + self.deletedClients = deleted socket = handle addConnectEvent(attempt, "waiting for the server acknowledgement") armConnectTimeout(attempt, ack: true) @@ -593,7 +642,7 @@ final class Engine { + Self.prettyRecentMessages(recentSentMessages) } disconnect(.client(.invalidMessage, message: message)) - clientGroupDisabled = true + disableClientGroup() loop.microtask { [weak self] in self?.options.onClientStateNotFound?() } } else { disconnect( @@ -654,8 +703,9 @@ final class Engine { } case .pull: abortPingTimeout() - case .deleteClients: - break + case .deleteClients(let body): + database.confirmDeletedClients( + clientIDs: body.clientIDs ?? [], clientGroupIDs: body.clientGroupIDs ?? []) case .pushResponse(let body): try mutationTracker.processPushResponse(body) case .transformError(let errors): @@ -682,11 +732,16 @@ final class Engine { .initConnection( InitConnectionBody( desiredQueriesPatch: patch.entries.map(\.value), - clientSchema: connectCookie.isNull ? clientSchema : nil, + clientSchema: connectCookie.isNull ? clientSchema : nil, deleted: deletedClients, userPushURL: options.mutateURL, userPushHeaders: options.mutateHeaders, userQueryURL: options.queryURL, userQueryHeaders: options.queryHeaders))) + deletedClients = nil } initConnectionQueries = nil + if let deleted = deletedClients { + send(.deleteClients(deleted)) + deletedClients = nil + } connectionManager.connected() if let onConnected = attempt.onConnected { attempt.onConnected = nil @@ -704,15 +759,17 @@ final class Engine { case .versionNotSupported: options.onUpdateNeeded?(UpdateNeededReason(type: body.kind.rawValue, message: body.message)) case .schemaVersionNotSupported: - clientGroupDisabled = true + disableClientGroup() loop.microtask { [weak self] in self?.options.onUpdateNeeded?( UpdateNeededReason(type: "SchemaVersionNotSupported", message: body.message)) } case .clientNotFound: - clientGroupDisabled = true + disableClientGroup() loop.microtask { [weak self] in self?.options.onClientStateNotFound?() } case .invalidConnectionRequestLastMutationID, .invalidConnectionRequestBaseCookie: + // The server is behind the replica: drop it. + database.drop() loop.microtask { [weak self] in self?.options.onClientStateNotFound?() } default: break @@ -762,9 +819,88 @@ final class Engine { repClosed = true pushTimer?.cancel() pushTimer = nil + for t in lifecycleTimers { t.cancel() } + lifecycleTimers = [] + database.release() connectionManager.closed() } + // MARK: - Storage and the client lifecycle + + /// `HEARTBEAT_INTERVAL`. + static let heartbeatIntervalMs: Double = 60_000 + /// `GC_INTERVAL`, of clients and of client groups. + static let gcIntervalMs: Double = 5 * 60_000 + /// `CLIENT_MAX_INACTIVE_TIME`. + static let clientMaxInactiveMs: Double = 24 * 60 * 60_000 + + /// Starts the heartbeat and the garbage collection of clients and client + /// groups. + private func startLifecycle() { + lifecycleTimers = [ + scheduler.scheduleRepeating(every: Self.heartbeatIntervalMs) { [weak self] in + guard let self else { return } + if !self.database.heartbeat(self.clientID, now: self.scheduler.now) { + self.options.onClientStateNotFound?() + } + }, + scheduler.scheduleRepeating(every: Self.gcIntervalMs) { [weak self] in + guard let self else { return } + if let deleted = self.database.collectClients( + keeping: self.clientID, now: self.scheduler.now, maxInactive: Self.clientMaxInactiveMs) + { + // `DeleteClientsManager.onClientsDeleted`. + let ids = deleted.filter { $0.clientGroupID == self.clientGroupID }.map(\.clientID) + self.sendIfConnected(.deleteClients(DeleteClientsBody(clientIDs: ids))) + } + }, + scheduler.scheduleRepeating(every: Self.gcIntervalMs) { [weak self] in + self?.database.collectClientGroups() + }, + scheduler.schedule(after: Self.initialCollectDatabasesDelayMs) { [weak self] in + guard let self else { return } + self.collectDatabases() + self.lifecycleTimers.append( + self.scheduler.scheduleRepeating(every: Self.collectDatabasesIntervalMs) { + [weak self] in self?.collectDatabases() + }) + }, + ] + } + + /// `INITIAL_COLLECT_IDB_DELAY` and `COLLECT_IDB_INTERVAL`. + static let initialCollectDatabasesDelayMs: Double = 5 * 60_000 + static let collectDatabasesIntervalMs: Double = 12 * 60 * 60_000 + + /// Deletes the other databases of the directory that no client has used + /// for twice the time a client may be inactive (`collectIDBDatabases`), + /// such as those of an older schema. + private func collectDatabases() { + database.collectDatabases(now: scheduler.now, maxAge: 2 * Self.clientMaxInactiveMs) + } + + /// The deleted clients of this client group, for `initConnection` + /// (`convertDeletedClientsToBody`). + private func deletedClientsBody() -> DeleteClientsBody? { + let ids = database.deletedClients.filter { $0.clientGroupID == clientGroupID }.map(\.clientID) + return ids.isEmpty ? nil : DeleteClientsBody(clientIDs: ids) + } + + /// The server lost the client group: stop pushing, and keep later clients + /// out of it. + private func disableClientGroup() { + clientGroupDisabled = true + database.disableClientGroup(clientGroupID) + } + + private var storageFailureReported = false + + private func storageFailed(_ failure: StorageFailure) { + guard !storageFailureReported else { return } + storageFailureReported = true + options.onStorageFailure?(failure) + } + // MARK: - Mutations /// `MutatorProxy#onConnectionStateChange`. @@ -816,11 +952,17 @@ final class Engine { client.resolve(Self.outcome(error)) return MutatorResult(client: client, server: server) } - let diff = store.commitMutation( - ReplicaStore.PendingMutation( - id: mutationID, clientID: clientID, name: name, args: args ?? .null, - timestamp: scheduler.monotonicNow, writes: write.writes), - write) + let pending = ReplicaStore.PendingMutation( + id: mutationID, clientID: clientID, name: name, args: args ?? .null, + timestamp: scheduler.monotonicNow, writes: write.writes) + let diff = store.commitMutation(pending, write) + database.apply([ + .addMutation( + group: clientGroupID, + StoredMutation( + clientID: clientID, id: mutationID, name: name, args: pending.args, + timestamp: pending.timestamp)) + ]) context.processChanges(diff) requestPush(now: false) fireWatchers(diff) @@ -870,7 +1012,7 @@ final class Engine { private func invokePush() { guard !pushInFlight, pushRequested, !repClosed, !clientGroupDisabled else { return } pushRequested = false - let pending = store.pending.filter { $0.clientID == clientID } + let pending = store.pending guard !pending.isEmpty else { return } pushInFlight = true connectionReady.onSettle(loop) { [weak self] connected in @@ -947,31 +1089,25 @@ final class Engine { if !pending.isEmpty { // The rebase runs over a fork of the views' branch moved to the new // snapshot. - var branch = ivmMain.fork() + let branch = ivmMain.fork() branch.apply(KVDiff.between(store.main, snapshot.map, keys: touched)) - for var m in pending { - let write = KVWrite(main) - let fork = branch.fork() - let tx = Transaction( - clientID: m.clientID, mutationID: Int(m.id), reason: .rebase, token: token, - write: write, branch: fork, schema: schema) - do { - guard let mutator = mutators[m.name] else { - throw ApplicationError("Unknown mutator: \(m.name)") - } - _ = try mutator(tx, m.args) - main = write.map - branch = fork - m.writes = write.writes - touched.formUnion(write.writes) - } catch { - // The mutation stays pending; its writes are dropped. - m.writes = [] - } - replayed.append(m) - } + let rebased = replay(pending, over: main, branch: branch) + main = rebased.main + replayed = rebased.replayed + touched.formUnion(rebased.touched) } let diff = store.advance(to: snapshot, main: main, pending: replayed, touched: touched) + database.apply([ + .updateSnapshot( + group: clientGroupID, + changes: patchWrite.writes.compactMap { key in + let value = snapshot.map.get(key.string) + // After a clear, only what the poke put back. + return patchWrite.cleared && value == nil ? nil : (key.string, value) + }, + cleared: patchWrite.cleared, cookie: snapshot.cookie, + lastMutationIDs: snapshot.lastMutationIDs) + ]) context.processChanges(diff) fireWatchers(diff) if socketAtStart != nil, socket === socketAtStart { @@ -979,6 +1115,41 @@ final class Engine { } } + /// Runs `pending` again, in order, over `map` and on forks of `branch` + /// (the rebase of `maybeEndPull`). A mutation that throws stays pending + /// without writes. Returns the new main map, the mutations with what they + /// wrote, every key they wrote, and the branch they ran on. + private func replay( + _ pending: [ReplicaStore.PendingMutation], over map: KVMap, branch: IVMBranch + ) -> (main: KVMap, replayed: [ReplicaStore.PendingMutation], touched: Set, IVMBranch) { + var main = map + var branch = branch + var touched = Set() + var replayed: [ReplicaStore.PendingMutation] = [] + for var m in pending { + let write = KVWrite(main) + let fork = branch.fork() + let tx = Transaction( + clientID: m.clientID, mutationID: Int(m.id), reason: .rebase, token: token, + write: write, branch: fork, schema: schema) + do { + guard let mutator = mutators[m.name] else { + throw ApplicationError("Unknown mutator: \(m.name)") + } + _ = try mutator(tx, m.args) + main = write.map + branch = fork + m.writes = write.writes + touched.formUnion(write.writes) + } catch { + // The mutation stays pending; its writes are dropped. + m.writes = [] + } + replayed.append(m) + } + return (main, replayed, touched, branch) + } + /// `JSON.stringify(recentSentMessages, null, 2)`. static func prettyRecentMessages(_ messages: [JSONValue]) -> String { let entries = messages.map { m in diff --git a/swift/Sources/Zero/Client/ReplicaStore.swift b/swift/Sources/Zero/Client/ReplicaStore.swift index 9770b9c6bc..d333bda3bc 100644 --- a/swift/Sources/Zero/Client/ReplicaStore.swift +++ b/swift/Sources/Zero/Client/ReplicaStore.swift @@ -164,7 +164,12 @@ final class ReplicaStore { private(set) var main = KVMap() private(set) var pending: [PendingMutation] = [] - init(clientID: String) { self.clientID = clientID } + init(clientID: String, snapshot: Snapshot = Snapshot(), pending: [PendingMutation] = []) { + self.clientID = clientID + self.snapshot = snapshot + self.main = snapshot.map + self.pending = pending + } var cookie: JSONValue { snapshot.cookie } diff --git a/swift/Sources/Zero/Protocol/ClientSchema.swift b/swift/Sources/Zero/Protocol/ClientSchema.swift index 8f0c6ba080..6781ad72b7 100644 --- a/swift/Sources/Zero/Protocol/ClientSchema.swift +++ b/swift/Sources/Zero/Protocol/ClientSchema.swift @@ -52,6 +52,12 @@ public struct ClientSchema: Sendable, Hashable, JSONCodable { ]) } + /// The hash of the normalized schema that names a client's local + /// database (`clientSchemaFrom`): a schema change starts a new one. + public var hash: String { + String(XXHash32.h64(JSONSerializer.stringify(normalized().json)), radix: 36) + } + /// `normalizeClientSchema`: sorts each primary key, as JS `sort()` does, /// by UTF-16 code units. JS also orders the table and column entries by /// `<`; ``json`` has no order of its own and ``JSONSerializer`` writes keys diff --git a/swift/Sources/Zero/Storage/ReplicaDatabase.swift b/swift/Sources/Zero/Storage/ReplicaDatabase.swift new file mode 100644 index 0000000000..e5e4b5406e --- /dev/null +++ b/swift/Sources/Zero/Storage/ReplicaDatabase.swift @@ -0,0 +1,746 @@ +import Dispatch + +/// Where a client keeps its replica between launches. +public enum ZeroStorage: Sendable, Hashable { + /// A SQLite database in `directory`. The default directory is + /// `Library/Application Support/Zero` in the app's home on Apple platforms, + /// and `$XDG_DATA_HOME/zero` (or `~/.local/share/zero`) elsewhere. + case sqlite(directory: String? = nil) + /// Memory only, kept for the life of the process: a client created later + /// with the same options finds what an earlier one stored, as with JS + /// `kvStore: 'mem'`. + case memory +} + +/// A failure of the local storage, rather than of its data +/// (`StorageFailureError`): the client keeps running on memory. +public struct StorageFailure: Error, Sendable, Hashable, CustomStringConvertible { + public enum Kind: String, Sendable { + /// The device is out of space. + case full + /// The database can't be opened. + case cannotOpen = "cannot-open" + /// A read or write failed. + case ioError = "io-error" + } + + public let kind: Kind + public let message: String + + public var description: String { "\(kind.rawValue): \(message)" } + + init(_ error: any Error) { + if let e = error as? SQLiteError { + kind = e.isFull ? .full : e.isCannotOpen ? .cannotOpen : .ioError + message = e.description + } else if let e = error as? StorageFailure { + self = e + return + } else { + kind = .ioError + message = String(describing: error) + } + } + + init(kind: Kind, message: String) { + self.kind = kind + self.message = message + } +} + +/// A client group: the clients that share a replica because they register +/// the same mutators (`ClientGroup` in +/// `packages/replicache/src/persist/client-groups.ts`). +struct ClientGroupRecord: Sendable, Hashable { + let id: String + let mutatorNames: [String] + /// The server lost the group, so no new client joins it. + var disabled: Bool +} + +/// A client that used the database (`ClientV6`). +struct ClientRecord: Sendable, Hashable { + let id: String + let clientGroupID: String + /// When the client last showed it was running, in ms since 1970. + var heartbeat: Double +} + +/// A deleted client the server may not know about yet +/// (`packages/replicache/src/deleted-clients.ts`). +struct ClientIDPair: Sendable, Hashable { + let clientGroupID: String + let clientID: String + + /// `normalizeDeletedClients`: sorted by group then client, deduplicated. + static func normalize(_ pairs: [ClientIDPair]) -> [ClientIDPair] { + let sorted = pairs.sorted { + let c = StringCompare.utf16($0.clientGroupID, $1.clientGroupID) + return c != 0 ? c < 0 : StringCompare.utf16Less($0.clientID, $1.clientID) + } + var out: [ClientIDPair] = [] + for p in sorted where out.last != p { out.append(p) } + return out + } +} + +/// A mutation that ran on a client and the server has not confirmed. +struct StoredMutation: Sendable, Hashable { + let clientID: String + let id: Double + let name: String + let args: JSONValue + /// `performance.now()` of the client that made it. + let timestamp: Double +} + +/// What a client group keeps: the last server snapshot and the mutations +/// not yet in it. +struct StoredGroup { + var entries = KVMap() + var cookie: JSONValue = .null + var lastMutationIDs: [String: Double] = [:] + var mutations: [StoredMutation] = [] +} + +/// A change to the database, applied in order. +enum StorageOp: Sendable { + case putGroup(ClientGroupRecord) + /// Deletes the group with its snapshot and mutations. + case deleteGroup(String) + case putClient(ClientRecord) + case deleteClients([String]) + case setDeletedClients([ClientIDPair]) + case setProfileID(String) + /// The snapshot after a poke: the keys it changed (nil for deleted), + /// whether it cleared every key first, and its cookie and last mutation + /// IDs. Drops the mutations the IDs confirm. + case updateSnapshot( + group: String, changes: [(String, JSONValue?)], cleared: Bool, cookie: JSONValue, + lastMutationIDs: [String: Double]) + case addMutation(group: String, StoredMutation) +} + +/// The database a client stores its replica in, shared by the clients of +/// one storage name in the process. It keeps the clients and client groups +/// in memory and writes changes through: right away to memory, and in order +/// on a background queue to SQLite. +@MainActor +final class ReplicaDatabase { + /// The JS name of the database (`rep:::`). + let name: String + private(set) var groups: [ClientGroupRecord] = [] + private(set) var clients: [ClientRecord] = [] + private(set) var deletedClients: [ClientIDPair] = [] + private(set) var profileID = "" + + /// The groups' data, for a memory database. + private var memoryGroups: [String: StoredGroup]? + private let sqlite: SQLiteStore? + private let key: String + private var users = 0 + private var dropped = false + /// Set once a write failed; nothing more is written. + private(set) var failure: StorageFailure? + /// Hears about the first write failure. + var onWriteFailure: ((StorageFailure) -> Void)? + + private static var open: [String: ReplicaDatabase] = [:] + /// The profile ID of memory databases, one per process as in JS. + private static var memoryProfileID: String? + + private init(name: String, key: String, sqlite: SQLiteStore?) { + self.name = name + self.key = key + self.sqlite = sqlite + if sqlite == nil { memoryGroups = [:] } + } + + /// Opens the database named `name` in `storage`, or the one already open + /// in this process. A database that can't be opened is replaced by an + /// unshared memory one, and the failure returned. + static func open(_ storage: ZeroStorage, name: String) -> (ReplicaDatabase, StorageFailure?) { + switch storage { + case .memory: + return (shared(key: "memory:" + name, name: name, sqlite: nil), nil) + case .sqlite(let directory): + do { + let dir = try directory ?? defaultDirectory() + try makeDirectories(dir) + let path = dir + "/" + fileName(name) + if let db = open[path] { + db.users += 1 + return (db, nil) + } + let store = try SQLiteStore(path: path, name: name, directory: dir) + let db = ReplicaDatabase(name: name, key: path, sqlite: store) + do { + try store.sync { try db.load(from: $0) } + } catch { + store.close() + throw error + } + db.users = 1 + open[path] = db + return (db, nil) + } catch { + let db = ReplicaDatabase(name: name, key: "", sqlite: nil) + db.profileID = "p" + randomID() + db.users = 1 + return (db, StorageFailure(error)) + } + } + } + + private static func shared(key: String, name: String, sqlite: SQLiteStore?) -> ReplicaDatabase { + if let db = open[key] { + db.users += 1 + return db + } + let db = ReplicaDatabase(name: name, key: key, sqlite: sqlite) + if sqlite == nil { + if memoryProfileID == nil { memoryProfileID = "p" + randomID() } + db.profileID = memoryProfileID! + } + db.users = 1 + open[key] = db + return db + } + + /// Gives up a user's hold. The last user of a SQLite database closes it + /// once its writes are done; memory databases live on. + func release() { + users -= 1 + guard users == 0, let sqlite else { return } + if Self.open[key] === self { Self.open[key] = nil } + sqlite.close() + } + + /// Waits for the writes queued so far. + func flush() { sqlite?.flush() } + + private func load(from db: SQLiteDatabase) throws { + try db.query("SELECT id, mutator_names, disabled FROM client_groups ORDER BY seq") { row in + let names = try JSONParser.parse(row.bytes(1)).arrayValue?.compactMap(\.stringValue) ?? [] + groups.append( + ClientGroupRecord(id: row.text(0), mutatorNames: names, disabled: row.integer(2) != 0)) + } + try db.query("SELECT id, client_group_id, heartbeat FROM clients ORDER BY seq") { row in + clients.append( + ClientRecord(id: row.text(0), clientGroupID: row.text(1), heartbeat: row.real(2))) + } + try db.query("SELECT client_group_id, client_id FROM deleted_clients") { row in + deletedClients.append(ClientIDPair(clientGroupID: row.text(0), clientID: row.text(1))) + } + deletedClients = ClientIDPair.normalize(deletedClients) + profileID = try SQLiteStore.profileID(in: sqlite!.directory) + } + + /// The snapshot and pending mutations of group `id`. + func loadGroup(_ id: String) -> StoredGroup { + if let memoryGroups { return memoryGroups[id] ?? StoredGroup() } + guard let sqlite, failure == nil else { return StoredGroup() } + do { + return try sqlite.sync { try SQLiteStore.loadGroup(id, from: $0) } + } catch { + fail(StorageFailure(error)) + return StoredGroup() + } + } + + /// Applies `ops` to the in-memory state now and queues them for SQLite. + func apply(_ ops: [StorageOp]) { + guard !dropped else { return } + for op in ops { applyToMirror(op) } + if memoryGroups != nil { + for op in ops { applyToMemory(op) } + } else if let sqlite, failure == nil { + sqlite.write(ops) { [weak self] error in self?.fail(StorageFailure(error)) } + } + } + + private func fail(_ f: StorageFailure) { + guard failure == nil else { return } + failure = f + onWriteFailure?(f) + } + + private func applyToMirror(_ op: StorageOp) { + switch op { + case .putGroup(let g): + if let i = groups.firstIndex(where: { $0.id == g.id }) { + groups[i] = g + } else { + groups.append(g) + } + case .deleteGroup(let id): + groups.removeAll { $0.id == id } + case .putClient(let c): + if let i = clients.firstIndex(where: { $0.id == c.id }) { + clients[i] = c + } else { + clients.append(c) + } + case .deleteClients(let ids): + let set = Set(ids) + clients.removeAll { set.contains($0.id) } + case .setDeletedClients(let pairs): + deletedClients = pairs + case .setProfileID(let id): + profileID = id + case .updateSnapshot, .addMutation: + break + } + } + + private func applyToMemory(_ op: StorageOp) { + switch op { + case .putGroup(let g): + if memoryGroups![g.id] == nil { memoryGroups![g.id] = StoredGroup() } + case .deleteGroup(let id): + memoryGroups![id] = nil + case .updateSnapshot(let group, let changes, let cleared, let cookie, let lmids): + var g = memoryGroups![group] ?? StoredGroup() + if cleared { g.entries.removeAll() } + for (k, v) in changes { + if let v { g.entries.set(k, v) } else { g.entries.delete(k) } + } + g.cookie = cookie + g.lastMutationIDs = lmids + g.mutations.removeAll { $0.id <= lmids[$0.clientID] ?? 0 } + memoryGroups![group] = g + case .addMutation(let group, let m): + memoryGroups![group, default: StoredGroup()].mutations.append(m) + case .putClient, .deleteClients, .setDeletedClients, .setProfileID: + break + } + } + + // MARK: - Clients and client groups + + /// Adds client `clientID` to the newest client group that registers the + /// same mutators and isn't disabled, or to a new empty one + /// (`initClientV6` with client group forking off). Returns the group and + /// what it holds. + func initClient( + _ clientID: String, mutatorNames: [String], now: Double + ) -> ( + clientGroupID: String, group: StoredGroup + ) { + let names = Set(mutatorNames) + var ops: [StorageOp] = [] + let groupID: String + var group = StoredGroup() + if let match = groups.first(where: { + !$0.disabled && $0.mutatorNames.count == names.count && names.isSuperset(of: $0.mutatorNames) + }) { + groupID = match.id + group = loadGroup(groupID) + } else { + groupID = randomID() + ops.append( + .putGroup(ClientGroupRecord(id: groupID, mutatorNames: mutatorNames, disabled: false))) + } + ops.append(.putClient(ClientRecord(id: clientID, clientGroupID: groupID, heartbeat: now))) + apply(ops) + return (groupID, group) + } + + /// Records that client `clientID` is running (`writeHeartbeat`). False + /// when the client is gone, collected by another client. + func heartbeat(_ clientID: String, now: Double) -> Bool { + guard var client = clients.first(where: { $0.id == clientID }) else { return false } + client.heartbeat = now + apply([.putClient(client)]) + return true + } + + /// Deletes the clients other than `clientID` that have not run for + /// `maxInactive` ms (`gcClients`). Returns every deleted client the + /// server has not confirmed, or nil when none was deleted now. + func collectClients( + keeping clientID: String, now: Double, maxInactive: Double + ) + -> [ClientIDPair]? + { + let stale = clients.filter { $0.id != clientID && now - $0.heartbeat > maxInactive } + guard !stale.isEmpty else { return nil } + let deleted = ClientIDPair.normalize( + deletedClients + stale.map { ClientIDPair(clientGroupID: $0.clientGroupID, clientID: $0.id) }) + apply([.deleteClients(stale.map(\.id)), .setDeletedClients(deleted)]) + return deleted + } + + /// Deletes the client groups no client is in (`gcClientGroups` without + /// mutation recovery, so their pending mutations go too). + func collectClientGroups() { + let used = Set(clients.map(\.clientGroupID)) + let ops = groups.filter { !used.contains($0.id) }.map { StorageOp.deleteGroup($0.id) } + if !ops.isEmpty { apply(ops) } + } + + /// The server deleted these clients and client groups + /// (`confirmDeletedClients`). + func confirmDeletedClients(clientIDs: [String], clientGroupIDs: [String]) { + let ids = Set(clientIDs) + let groupIDs = Set(clientGroupIDs) + let gone = clients.filter { ids.contains($0.id) || groupIDs.contains($0.clientGroupID) } + let remaining = deletedClients.filter { + !ids.contains($0.clientID) && !groupIDs.contains($0.clientGroupID) + } + apply([.deleteClients(gone.map(\.id)), .setDeletedClients(remaining)]) + } + + /// Keeps new clients out of group `id` (`disableClientGroup`). + func disableClientGroup(_ id: String) { + guard var g = groups.first(where: { $0.id == id }) else { return } + g.disabled = true + apply([.putGroup(g)]) + } + + /// Deletes the database (`dropDatabase`). The next client to open it + /// starts empty; this one's later writes are dropped. + func drop() { + dropped = true + if Self.open[key] === self { Self.open[key] = nil } + sqlite?.drop() + } + + /// Deletes, in the background, the other databases in this database's + /// directory whose clients all last ran more than `maxAge` ago. A + /// database open in this process is kept. + func collectDatabases(now: Double, maxAge: Double) { + guard let sqlite, failure == nil, !dropped else { return } + let keep = Set(Self.open.keys) + sqlite.collectDatabases(keeping: keep, now: now, maxAge: maxAge) + } + + // MARK: - Names and places + + /// The database name of a client (`zero.ts`, `makeIDBName`). + nonisolated static func name( + userID: String?, storageKey: String, mutateURL: String?, queryURL: String?, + schemaHash: String + ) -> String { + // `JSON.stringify` keeps the keys in this order. + let nameKey = + "{\"storageKey\":\(JSONSerializer.stringify(.string(storageKey)))" + + ",\"mutateUrl\":\(JSONSerializer.stringify(.string(mutateURL ?? "")))" + + ",\"queryUrl\":\(JSONSerializer.stringify(.string(queryURL ?? "")))}" + let hashedKey = String(XXHash32.h64(nameKey), radix: 36) + return "rep:zero-\(userID ?? "__anonymous__")-\(hashedKey):\(formatVersion):" + + "\(storageVersion).\(schemaHash)" + } + + /// Replicache's `FormatVersion.Latest`. + nonisolated static let formatVersion = 7 + /// `STORAGE_VERSION` in `packages/zero-client/src/client/storage-version.ts`. + nonisolated static let storageVersion = 52 + /// The version of the SQLite layout. A new one starts a new file, as a new + /// JS storage version starts a new database. + nonisolated static let sqliteLayoutVersion = 1 + + /// The file a database is stored in: names can hold any character. + nonisolated static func fileName(_ name: String) -> String { + "zero-v\(sqliteLayoutVersion)-" + String(XXHash32.h64(name), radix: 36) + ".sqlite" + } +} + +/// A SQLite database file and the serial queue that owns its connection. +final class SQLiteStore: @unchecked Sendable { + private let queue = DispatchQueue(label: "zero.storage") + // Only used on `queue`. + private var db: SQLiteDatabase? + let path: String + let directory: String + + init(path: String, name: String, directory: String) throws { + self.path = path + self.directory = directory + let db = try SQLiteDatabase(path: path) + try db.execute( + """ + PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) WITHOUT ROWID; + CREATE TABLE IF NOT EXISTS client_groups ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + mutator_names TEXT NOT NULL, + disabled INTEGER NOT NULL, + cookie TEXT NOT NULL DEFAULT 'null', + last_mutation_ids TEXT NOT NULL DEFAULT '{}' + ); + CREATE TABLE IF NOT EXISTS clients ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + client_group_id TEXT NOT NULL, + heartbeat REAL NOT NULL + ); + CREATE TABLE IF NOT EXISTS deleted_clients ( + client_group_id TEXT NOT NULL, + client_id TEXT NOT NULL, + PRIMARY KEY (client_group_id, client_id) + ) WITHOUT ROWID; + CREATE TABLE IF NOT EXISTS entries ( + client_group_id TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (client_group_id, key) + ) WITHOUT ROWID; + CREATE TABLE IF NOT EXISTS mutations ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + client_group_id TEXT NOT NULL, + client_id TEXT NOT NULL, + id REAL NOT NULL, + name TEXT NOT NULL, + args TEXT NOT NULL, + timestamp REAL NOT NULL + ); + """) + try db.run( + "INSERT INTO meta (key, value) VALUES ('name', ?) ON CONFLICT (key) DO NOTHING", + [.text(name)]) + self.db = db + } + + /// Runs `body` on the queue and waits for it. + func sync(_ body: (SQLiteDatabase) throws -> T) throws -> T { + try queue.sync { + guard let db else { throw StorageFailure(kind: .ioError, message: "The database is closed") } + return try body(db) + } + } + + func flush() { queue.sync {} } + + /// Applies `ops` in one transaction, after the writes queued before. + func write(_ ops: [StorageOp], onError: @escaping @MainActor @Sendable (any Error) -> Void) { + queue.async { [self] in + guard let db else { return } + do { + try db.transaction { + for op in ops { try Self.apply(op, to: db) } + } + } catch { + let e = error + Task { @MainActor in onError(e) } + } + } + } + + func close() { + queue.sync { + db?.close() + db = nil + } + } + + /// Deletes the file, after the writes queued before. + func drop() { + queue.sync { + db?.close() + db = nil + for suffix in ["", "-wal", "-shm"] { _ = unlink(path + suffix) } + } + } + + /// See `ReplicaDatabase.collectDatabases`. + func collectDatabases(keeping: Set, now: Double, maxAge: Double) { + let directory = self.directory + queue.async { + guard let dir = opendir(directory) else { return } + var files: [String] = [] + while let entry = readdir(dir) { + let name = withUnsafeBytes(of: entry.pointee.d_name) { raw in + String(decoding: raw.prefix { $0 != 0 }, as: UTF8.self) + } + if name.hasPrefix("zero-"), name.hasSuffix(".sqlite") { files.append(name) } + } + closedir(dir) + for name in files { + let path = directory + "/" + name + guard !keeping.contains(path) else { continue } + var recent = false + do { + let db = try SQLiteDatabase(path: path) + defer { db.close() } + try db.query("SELECT max(heartbeat) FROM clients") { row in + recent = now - row.real(0) < maxAge + } + } catch { + // Not a database of ours, or busy: leave it. + continue + } + if !recent { + for suffix in ["", "-wal", "-shm"] { _ = unlink(path + suffix) } + } + } + } + } + + private static func json(_ v: JSONValue) -> SQLiteDatabase.Value { + .text(JSONSerializer.stringify(v)) + } + + private static func apply(_ op: StorageOp, to db: SQLiteDatabase) throws { + switch op { + case .putGroup(let g): + try db.run( + """ + INSERT INTO client_groups (id, mutator_names, disabled) VALUES (?, ?, ?) + ON CONFLICT (id) DO UPDATE SET disabled = excluded.disabled + """, + [ + .text(g.id), json(.array(g.mutatorNames.map { .string($0) })), + .integer(g.disabled ? 1 : 0), + ]) + case .deleteGroup(let id): + try db.run("DELETE FROM client_groups WHERE id = ?", [.text(id)]) + try db.run("DELETE FROM entries WHERE client_group_id = ?", [.text(id)]) + try db.run("DELETE FROM mutations WHERE client_group_id = ?", [.text(id)]) + case .putClient(let c): + try db.run( + """ + INSERT INTO clients (id, client_group_id, heartbeat) VALUES (?, ?, ?) + ON CONFLICT (id) DO UPDATE SET heartbeat = excluded.heartbeat + """, + [.text(c.id), .text(c.clientGroupID), .real(c.heartbeat)]) + case .deleteClients(let ids): + for id in ids { try db.run("DELETE FROM clients WHERE id = ?", [.text(id)]) } + case .setDeletedClients(let pairs): + try db.run("DELETE FROM deleted_clients") + for p in pairs { + try db.run( + "INSERT OR IGNORE INTO deleted_clients (client_group_id, client_id) VALUES (?, ?)", + [.text(p.clientGroupID), .text(p.clientID)]) + } + case .setProfileID: + break + case .updateSnapshot(let group, let changes, let cleared, let cookie, let lmids): + if cleared { + try db.run("DELETE FROM entries WHERE client_group_id = ?", [.text(group)]) + } + for (k, v) in changes { + if let v { + try db.run( + """ + INSERT INTO entries (client_group_id, key, value) VALUES (?, ?, ?) + ON CONFLICT (client_group_id, key) DO UPDATE SET value = excluded.value + """, + [.text(group), .text(k), json(v)]) + } else { + try db.run( + "DELETE FROM entries WHERE client_group_id = ? AND key = ?", [.text(group), .text(k)]) + } + } + try db.run( + "UPDATE client_groups SET cookie = ?, last_mutation_ids = ? WHERE id = ?", + [json(cookie), json(.object(lmids.mapValues { .number($0) })), .text(group)]) + for (clientID, lmid) in lmids { + try db.run( + "DELETE FROM mutations WHERE client_group_id = ? AND client_id = ? AND id <= ?", + [.text(group), .text(clientID), .real(lmid)]) + } + case .addMutation(let group, let m): + try db.run( + """ + INSERT INTO mutations (client_group_id, client_id, id, name, args, timestamp) + VALUES (?, ?, ?, ?, ?, ?) + """, + [ + .text(group), .text(m.clientID), .real(m.id), .text(m.name), json(m.args), + .real(m.timestamp), + ]) + } + } + + static func loadGroup(_ id: String, from db: SQLiteDatabase) throws -> StoredGroup { + var g = StoredGroup() + try db.query( + "SELECT cookie, last_mutation_ids FROM client_groups WHERE id = ?", [.text(id)] + ) { row in + g.cookie = try JSONParser.parse(row.bytes(0)) + for (k, v) in try JSONParser.parse(row.bytes(1)).objectValue ?? [:] { + g.lastMutationIDs[k] = v.numberValue ?? 0 + } + } + try db.query("SELECT key, value FROM entries WHERE client_group_id = ?", [.text(id)]) { row in + g.entries.set(row.text(0), try JSONParser.parse(row.bytes(1))) + } + try db.query( + """ + SELECT client_id, id, name, args, timestamp FROM mutations + WHERE client_group_id = ? ORDER BY seq + """, [.text(id)] + ) { row in + g.mutations.append( + StoredMutation( + clientID: row.text(0), id: row.real(1), name: row.text(2), + args: try JSONParser.parse(row.bytes(3)), timestamp: row.real(4))) + } + return g + } + + /// The profile ID of every database in `directory`, made the first time + /// (JS keeps one per browser profile). + static func profileID(in directory: String) throws -> String { + let path = directory + "/profile-id" + if let f = fopen(path, "r") { + defer { fclose(f) } + var buffer = [CChar](repeating: 0, count: 64) + if fgets(&buffer, 64, f) != nil { + let id = String( + decoding: buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }, as: UTF8.self) + if !id.isEmpty { return id } + } + } + let id = "p" + randomID() + guard let f = fopen(path, "w") else { + throw StorageFailure(kind: .ioError, message: "Cannot write \(path)") + } + fputs(id, f) + fclose(f) + return id + } +} + +#if canImport(Darwin) + import Darwin +#elseif canImport(Glibc) + import Glibc +#elseif canImport(Musl) + import Musl +#endif + +/// The default database directory. +func defaultDirectory() throws -> String { + func env(_ name: String) -> String? { + guard let v = getenv(name) else { return nil } + let s = String(cString: v) + return s.isEmpty ? nil : s + } + #if canImport(Darwin) + guard let home = env("HOME") else { + throw StorageFailure(kind: .cannotOpen, message: "HOME is not set") + } + return home + "/Library/Application Support/Zero" + #else + if let data = env("XDG_DATA_HOME") { return data + "/zero" } + guard let home = env("HOME") else { + throw StorageFailure(kind: .cannotOpen, message: "HOME is not set") + } + return home + "/.local/share/zero" + #endif +} + +/// `mkdir -p`. +func makeDirectories(_ path: String) throws { + var current = path.hasPrefix("/") ? "" : "." + for part in path.split(separator: "/") { + current += "/" + part + if mkdir(current, 0o755) != 0 && errno != EEXIST { + throw StorageFailure(kind: .cannotOpen, message: "Cannot create \(current)") + } + } +} diff --git a/swift/Sources/Zero/Storage/SQLiteDatabase.swift b/swift/Sources/Zero/Storage/SQLiteDatabase.swift new file mode 100644 index 0000000000..50f2ce007d --- /dev/null +++ b/swift/Sources/Zero/Storage/SQLiteDatabase.swift @@ -0,0 +1,152 @@ +import CSQLite + +/// A SQLite failure, with SQLite's result code. +struct SQLiteError: Error, CustomStringConvertible { + let code: Int32 + let message: String + + var description: String { "SQLite error \(code): \(message)" } + + /// The disk or the database file is full. + var isFull: Bool { code & 0xff == SQLITE_FULL } + /// The database file can't be opened or isn't a database. + var isCannotOpen: Bool { + let primary = code & 0xff + return primary == SQLITE_CANTOPEN || primary == SQLITE_NOTADB || primary == SQLITE_CORRUPT + || primary == SQLITE_PERM || primary == SQLITE_READONLY + } +} + +// SQLite copies bound text and blobs before `sqlite3_bind_*` returns. +private let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + +/// A connection to one SQLite database. Not thread-safe: use it from one +/// thread or serial queue at a time. +final class SQLiteDatabase { + private var db: OpaquePointer? + private var statements: [String: OpaquePointer] = [:] + + init(path: String) throws(SQLiteError) { + let flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_NOMUTEX + let rc = sqlite3_open_v2(path, &db, flags, nil) + guard rc == SQLITE_OK else { + let message = db.map { String(cString: sqlite3_errmsg($0)) } ?? "cannot open" + sqlite3_close_v2(db) + db = nil + throw SQLiteError(code: rc, message: message) + } + sqlite3_extended_result_codes(db, 1) + sqlite3_busy_timeout(db, 5000) + } + + deinit { close() } + + func close() { + for (_, s) in statements { sqlite3_finalize(s) } + statements = [:] + if let db { sqlite3_close_v2(db) } + db = nil + } + + private func error(_ rc: Int32) -> SQLiteError { + SQLiteError(code: rc, message: db.map { String(cString: sqlite3_errmsg($0)) } ?? "closed") + } + + /// Runs SQL without parameters or results, such as a schema. + func execute(_ sql: String) throws(SQLiteError) { + let rc = sqlite3_exec(db, sql, nil, nil, nil) + guard rc == SQLITE_OK else { throw error(rc) } + } + + /// A value to bind to a parameter. + enum Value { + case text(String) + case real(Double) + case integer(Int64) + case null + } + + /// One row of a query's result. + struct Row { + fileprivate let statement: OpaquePointer + + func text(_ column: Int32) -> String { + guard let p = sqlite3_column_text(statement, column) else { return "" } + let count = Int(sqlite3_column_bytes(statement, column)) + return String(decoding: UnsafeBufferPointer(start: p, count: count), as: UTF8.self) + } + + /// The column's UTF-8 bytes, without decoding them. + func bytes(_ column: Int32) -> [UInt8] { + guard let p = sqlite3_column_text(statement, column) else { return [] } + let count = Int(sqlite3_column_bytes(statement, column)) + return Array(UnsafeBufferPointer(start: p, count: count)) + } + + func real(_ column: Int32) -> Double { sqlite3_column_double(statement, column) } + func integer(_ column: Int32) -> Int64 { sqlite3_column_int64(statement, column) } + } + + private func prepared(_ sql: String) throws(SQLiteError) -> OpaquePointer { + if let s = statements[sql] { + sqlite3_reset(s) + sqlite3_clear_bindings(s) + return s + } + var s: OpaquePointer? + let rc = sqlite3_prepare_v2(db, sql, -1, &s, nil) + guard rc == SQLITE_OK, let s else { throw error(rc) } + statements[sql] = s + return s + } + + private func bind(_ s: OpaquePointer, _ values: [Value]) throws(SQLiteError) { + for (i, v) in values.enumerated() { + let index = Int32(i + 1) + let rc: Int32 + switch v { + case .text(let t): rc = sqlite3_bind_text(s, index, t, -1, transient) + case .real(let d): rc = sqlite3_bind_double(s, index, d) + case .integer(let n): rc = sqlite3_bind_int64(s, index, n) + case .null: rc = sqlite3_bind_null(s, index) + } + guard rc == SQLITE_OK else { throw error(rc) } + } + } + + /// Runs a statement that returns no rows. + func run(_ sql: String, _ values: [Value] = []) throws(SQLiteError) { + let s = try prepared(sql) + defer { sqlite3_reset(s) } + try bind(s, values) + let rc = sqlite3_step(s) + guard rc == SQLITE_DONE || rc == SQLITE_ROW else { throw error(rc) } + } + + /// Runs a query and calls `body` for each row. + func query( + _ sql: String, _ values: [Value] = [], _ body: (Row) throws -> Void + ) throws { + let s = try prepared(sql) + defer { sqlite3_reset(s) } + try bind(s, values) + while true { + let rc = sqlite3_step(s) + if rc == SQLITE_DONE { return } + guard rc == SQLITE_ROW else { throw error(rc) } + try body(Row(statement: s)) + } + } + + /// Runs `body` in a transaction, rolled back if it throws. + func transaction(_ body: () throws -> Void) throws { + try execute("BEGIN IMMEDIATE") + do { + try body() + try execute("COMMIT") + } catch { + try? execute("ROLLBACK") + throw error + } + } +} diff --git a/swift/Tests/ConformanceTests/ClientConformanceTests.swift b/swift/Tests/ConformanceTests/ClientConformanceTests.swift index 004f9d46d1..e2360616df 100644 --- a/swift/Tests/ConformanceTests/ClientConformanceTests.swift +++ b/swift/Tests/ConformanceTests/ClientConformanceTests.swift @@ -10,7 +10,17 @@ import ZeroTesting @Suite(.enabled(if: Vectors.available)) @MainActor struct ClientConformanceTests { - @Test func scenarios() throws { + @Test func scenariosOnMemory() throws { + try Self.run(storage: .memory) + } + + @Test func scenariosOnSQLite() throws { + let directory = NSTemporaryDirectory() + "zero-client-scenarios-" + UUID().uuidString + defer { try? FileManager.default.removeItem(atPath: directory) } + try Self.run(storage: .sqlite(directory: directory)) + } + + static func run(storage: ZeroStorage) throws { let file = try Vectors.file("client") let startTime = file["shared"]?["startTime"]?.numberValue ?? 0 let only = ProcessInfo.processInfo.environment["ZERO_CLIENT_SCENARIO"] @@ -20,7 +30,7 @@ struct ClientConformanceTests { let name = scenario["name"]?.stringValue ?? "?" if let only, !name.contains(only) { continue } let expected = scenario["expect"]?.arrayValue ?? [] - let runner = try ScenarioRunner(scenario, startTime: startTime) + let runner = try ScenarioRunner(scenario, startTime: startTime, storage: storage) let actual = runner.run() if let mismatch = Self.firstMismatch(scenario, expected, actual) { failed += 1 @@ -29,7 +39,7 @@ struct ClientConformanceTests { passed += 1 } } - print("[client scenarios] \(passed) passed, \(failed) failed") + print("[client scenarios on \(storage)] \(passed) passed, \(failed) failed") } static func firstMismatch( @@ -85,10 +95,26 @@ final class ScenarioRunner { var runs: [(String, Settled)] = [] var preloads: [(String, Settled, Settled, () -> Void)] = [] - init(_ scenario: JSONValue, startTime: Double) throws { + let storage: ZeroStorage + /// The scenario's own storage, as `#scenario-` in `harness.ts`. + let scenarioKey: Int + static var scenarioCount = 0 + var restarts = 0 + /// The IDs placeholders in downstream messages stand for. + var ids: [String: String] = [:] + + init(_ scenario: JSONValue, startTime: Double, storage: ZeroStorage) throws { self.scenario = scenario + self.storage = storage + Self.scenarioCount += 1 + scenarioKey = Self.scenarioCount scheduler = ManualScheduler(now: startTime) - let o = scenario["options"] ?? [:] + zero = try makeZero(scenario["options"] ?? [:]) + placeholders = [zero.clientID: "$clientID", zero.clientGroupID: "$clientGroupID"] + ids = ["$clientID": zero.clientID] + } + + func makeZero(_ o: JSONValue, mutators registered: JSONValue? = nil) throws -> Zero { var options = ZeroOptions() switch o["cacheURL"] { case .null?: options.cacheURL = nil @@ -109,17 +135,45 @@ final class ScenarioRunner { options.mutateHeaders = o["mutateHeaders"]?.objectValue?.compactMapValues(\.stringValue) options.queryURL = o["queryURL"]?.stringValue options.queryHeaders = o["queryHeaders"]?.objectValue?.compactMapValues(\.stringValue) - options.storageKey = o["storageKey"]?.stringValue + options.storageKey = "\(o["storageKey"]?.stringValue ?? "")#scenario-\(scenarioKey)" + options.storage = storage options.onUpdateNeeded = { [weak self] reason in self?.events.append(["onUpdateNeeded": reason.json]) } options.onClientStateNotFound = { [weak self] in self?.events.append("onClientStateNotFound") } - zero = try Zero( - schema: ClientApp.schema, mutators: ClientApp.mutators, options: options, - scheduler: scheduler, socketFactory: factory) - placeholders = [zero.clientID: "$clientID", zero.clientGroupID: "$clientGroupID"] + let mutators = + registered?.stringValue == "issues" + ? ClientApp.mutators.filter { $0.name.hasPrefix("issue.") } : ClientApp.mutators + return try Zero( + schema: ClientApp.schema, mutators: mutators, options: options, scheduler: scheduler, + socketFactory: factory) + } + + /// Closes the client and starts a new one on the same storage + /// (`restart` in `harness.ts`). + func restart(_ step: JSONValue) throws { + zero.close() + settle() + if let ms = step["advance"]?.numberValue { scheduler.advance(by: ms) } + let n = restarts + restarts += 1 + let old = (clientID: zero.clientID, clientGroupID: zero.clientGroupID) + zero = try makeZero(step["options"] ?? scenario["options"] ?? [:], mutators: step["mutators"]) + placeholders[old.clientID] = "$clientID.\(n)" + ids["$clientID.\(n)"] = old.clientID + if old.clientGroupID != zero.clientGroupID { + placeholders[old.clientGroupID] = "$clientGroupID.\(n)" + } + placeholders[zero.clientID] = "$clientID" + placeholders[zero.clientGroupID] = "$clientGroupID" + ids["$clientID"] = zero.clientID + views = [] + retired = [] + mutations = [] + runs = [] + preloads = [] } func normalize(_ v: JSONValue, key: String? = nil) -> JSONValue { @@ -140,13 +194,13 @@ final class ScenarioRunner { func substitute(_ v: JSONValue) -> JSONValue { switch v { - case .string("$clientID"): - return .string(zero.clientID) + case .string(let s): + return .string(ids[s] ?? s) case .array(let a): return .array(a.map(substitute)) case .object(let o): var out: [String: JSONValue] = [:] - for (k, e) in o { out[k == "$clientID" ? zero.clientID : k] = substitute(e) } + for (k, e) in o { out[ids[k] ?? k] = substitute(e) } return .object(out) default: return v @@ -264,6 +318,8 @@ final class ScenarioRunner { zero.connection.connect(auth: step["auth"]?.stringValue) case "close": zero.close() + case "restart": + try restart(step) default: throw ClientApp.Failure("Unknown op \(op)") } diff --git a/swift/Tests/ZeroTests/ClientAPITests.swift b/swift/Tests/ZeroTests/ClientAPITests.swift index 84d424c606..b810491320 100644 --- a/swift/Tests/ZeroTests/ClientAPITests.swift +++ b/swift/Tests/ZeroTests/ClientAPITests.swift @@ -18,7 +18,7 @@ import ZeroTesting static let byOwner = defineQuery("issues.byOwner") { (args: ByOwner, _: Auth) in zql.issue.where(\.ownerId, args.ownerId).orderBy(\.id, .asc) } - static let mine = defineQuery("issues.mine") { (auth: Auth) in + static let mine = defineQuery("issues.mine") { (_: NoArgs, auth: Auth) in zql.issue.where(\.ownerId, auth.userID).orderBy(\.id, .asc) } static let create = defineMutator("issue.create") { (tx, args: CreateIssue, auth: Auth) in @@ -27,6 +27,11 @@ import ZeroTesting id: args.id, title: args.title, description: "", closed: false, ownerId: args.ownerId ?? auth.userID, createdAt: 1)) } + static let closeMine = defineMutator("issue.closeMine") { (tx, _: NoArgs, auth: Auth) in + for issue in try tx.run(zql.issue.where(\.ownerId, auth.userID)) { + try tx.mutate.issue.update(["id": .string(issue.id), "closed": true]) + } + } struct Created: Encodable { var created: String } static let createReturning = defineMutator("issue.createReturning") { (tx, args: CreateIssue, _: Auth) -> Created in @@ -37,13 +42,20 @@ import ZeroTesting return Created(created: args.id) } + /// A store of the test's own, in memory. + static var options: ZeroOptions { + ZeroOptions( + cacheURL: "http://zero.test", userID: "u2", storageKey: randomID(), storage: .memory) + } + let scheduler = ManualScheduler(now: 1_700_000_000_000) let sockets = MockSocketFactory() func makeZero() throws -> Zero { try Zero( - schema: .test, context: Auth(userID: "u2"), mutators: [Self.create, Self.createReturning], - options: ZeroOptions(cacheURL: "http://zero.test", userID: "u2"), scheduler: scheduler, + schema: .test, context: Auth(userID: "u2"), + mutators: [Self.create, Self.createReturning, Self.closeMine], + options: Self.options, scheduler: scheduler, socketFactory: sockets) } @@ -84,17 +96,20 @@ import ZeroTesting let a = zero.mutate(Self.create(CreateIssue(id: "i1", title: "mine"))) zero.mutate(Self.create(CreateIssue(id: "i2", title: "theirs", ownerId: "u1"))) + zero.mutate(Self.closeMine()) scheduler.runDueTimers() #expect(try await a.client.value == .success(data: nil)) // The context fills the owner of the first; the second names its own. - #expect(try await zero.run(Self.mine()).map(\.id) == ["i1"]) + #expect(try await zero.run(Self.mine()).map { [$0.id: $0.closed] } == [["i1": true]]) + #expect(try await zero.run(zql.issue.where(\.closed, false)).map(\.id) == ["i2"]) #expect(try await zero.run(Self.byOwner(ByOwner(ownerId: "u1"))).map(\.id) == ["i2"]) #expect( sent("push").flatMap { $0["mutations"]?.arrayValue ?? [] }.map { [$0["name"], $0["args"]] } == [ ["issue.create", [["id": "i1", "title": "mine"]]], ["issue.create", [["id": "i2", "title": "theirs", "ownerId": "u1"]]], + ["issue.closeMine", [nil]], ]) } @@ -135,7 +150,7 @@ import ZeroTesting #expect(throws: (any Error).self) { try Zero( schema: .test, context: Auth(userID: "u2"), mutators: [Self.create, Self.create], - options: ZeroOptions(cacheURL: "http://zero.test", userID: "u2"), scheduler: scheduler, + options: Self.options, scheduler: scheduler, socketFactory: sockets) } } diff --git a/swift/Tests/ZeroTests/StorageTests.swift b/swift/Tests/ZeroTests/StorageTests.swift new file mode 100644 index 0000000000..912f136c4b --- /dev/null +++ b/swift/Tests/ZeroTests/StorageTests.swift @@ -0,0 +1,92 @@ +import Foundation +import Testing +import ZeroTesting + +@testable import Zero + +/// What the client stores on disk beyond what the client scenarios check: +/// falling back to memory, and collecting old databases. +@Suite @MainActor struct StorageTests { + static let day: Double = 24 * 60 * 60_000 + let directory = NSTemporaryDirectory() + "zero-storage-tests-" + UUID().uuidString + + func makeZero( + _ storageKey: String, at now: Double, storage: ZeroStorage? = nil, + onStorageFailure: (@MainActor @Sendable (StorageFailure) -> Void)? = nil + ) throws -> (Zero, ManualScheduler) { + let scheduler = ManualScheduler(now: now) + var options = ZeroOptions( + userID: "u", storageKey: storageKey, storage: storage ?? .sqlite(directory: directory)) + options.onStorageFailure = onStorageFailure + let zero = try Zero( + schema: .test, mutators: [create], options: options, scheduler: scheduler, + socketFactory: nil) + return (zero, scheduler) + } + + let create = defineMutator("issue.create") { (tx, id: String) in + try tx.mutate.issue.insert([ + "id": .string(id), "title": "", "description": "", "closed": false, "ownerId": .null, + "createdAt": 1, + ]) + } + + func fileName(_ storageKey: String) -> String { + ReplicaDatabase.fileName( + ReplicaDatabase.name( + userID: "u", storageKey: storageKey, mutateURL: nil, queryURL: nil, + schemaHash: ZeroSchema.test.clientSchema.hash)) + } + + func files() throws -> Set { + Set( + try FileManager.default.contentsOfDirectory(atPath: directory).filter { + $0.hasSuffix(".sqlite") + }) + } + + @Test func runsOnMemoryWhenTheDatabaseCannotOpen() async throws { + final class Failures: @unchecked Sendable { var kinds: [StorageFailure.Kind] = [] } + let failures = Failures() + let (zero, scheduler) = try makeZero( + "k", at: 0, storage: .sqlite(directory: "/dev/null/zero"), + onStorageFailure: { failures.kinds.append($0.kind) }) + zero.mutate(create("i1")) + scheduler.runDueTimers() + #expect(failures.kinds == [.cannotOpen]) + #expect(try await zero.run(zql.issue).map(\.id) == ["i1"]) + zero.close() + } + + @Test func keepsMutationsAcrossLaunches() async throws { + defer { try? FileManager.default.removeItem(atPath: directory) } + let (first, _) = try makeZero("k", at: 0) + first.mutate(create("i1")) + first.close() + + let (second, _) = try makeZero("k", at: 1000) + #expect(second.clientGroupID == first.clientGroupID) + #expect(try await second.run(zql.issue).map(\.id) == ["i1"]) + #expect(second.engine.store.pending.map(\.clientID) == [first.clientID]) + second.close() + } + + @Test func collectsDatabasesNoClientUsedForTwoDays() throws { + defer { try? FileManager.default.removeItem(atPath: directory) } + let (old, _) = try makeZero("old", at: 0) + old.close() + let (recent, _) = try makeZero("recent", at: 60 * 60_000) + recent.close() + + // Two days and a minute after the old one last ran. + let (current, scheduler) = try makeZero("current", at: 2 * Self.day + 60_000) + scheduler.advance(by: 5 * 60_000 - 1) + current.engine.database.flush() + #expect(try files() == [fileName("old"), fileName("recent"), fileName("current")]) + + scheduler.advance(by: 1) + current.engine.database.flush() + #expect(try files() == [fileName("recent"), fileName("current")]) + current.close() + } +} From 690870adbef2f72af7ae010f16c8e49991245f9a Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 19 Oct 2026 15:20:05 +0000 Subject: [PATCH 014/104] 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 --- .../zero-conformance/src/client/harness.ts | 39 ++++++++++ .../zero-conformance/src/client/scenarios.ts | 2 + .../src/client/scenarios/visibility.ts | 77 +++++++++++++++++++ packages/zero-conformance/vectors/client.json | 7 +- 4 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 packages/zero-conformance/src/client/scenarios/visibility.ts diff --git a/packages/zero-conformance/src/client/harness.ts b/packages/zero-conformance/src/client/harness.ts index 332f4ef63d..3ed620a26f 100644 --- a/packages/zero-conformance/src/client/harness.ts +++ b/packages/zero-conformance/src/client/harness.ts @@ -41,6 +41,8 @@ export type ScenarioOptions = { queryURL?: string | undefined; queryHeaders?: Record | undefined; storageKey?: string | undefined; + /** Defaults to 5 minutes, the JS default. */ + hiddenTabDisconnectDelay?: number | undefined; }; export type Step = @@ -88,6 +90,11 @@ export type Step = socket?: number; } | {op: 'advance'; ms: number} + /** + * The app goes to the background (`hidden`) or comes back (`visible`), as a + * tab does (`document.visibilityState`). + */ + | {op: 'visibility'; state: 'visible' | 'hidden'} /** `zero.connection.connect()`. */ | {op: 'connect'; auth?: string} | {op: 'close'} @@ -118,6 +125,32 @@ export type Scenario = { type Settled = 'pending' | ReadonlyJSONValue; +/** The document whose visibility the client follows. */ +class FakeDocument { + visibilityState: DocumentVisibilityState = 'visible'; + readonly #listeners = new Set<() => void>(); + + addEventListener( + _type: string, + listener: () => void, + options?: {signal?: AbortSignal}, + ): void { + this.#listeners.add(listener); + options?.signal?.addEventListener('abort', () => + this.#listeners.delete(listener), + ); + } + + removeEventListener(_type: string, listener: () => void): void { + this.#listeners.delete(listener); + } + + setVisibility(state: DocumentVisibilityState): void { + this.visibilityState = state; + for (const listener of [...this.#listeners]) listener(); + } +} + export type StepResult = { /** Thrown synchronously by the step, such as an unknown query. */ threw?: string; @@ -227,6 +260,8 @@ export async function runScenario(scenario: Scenario): Promise { } vi.stubGlobal('WebSocket', RecordingSocket); vi.stubGlobal('CloseEvent', CloseEventPolyfill); + const document = new FakeDocument(); + vi.stubGlobal('document', document); const events: ReadonlyJSONValue[] = []; const scenarioKey = ++scenarioCount; @@ -252,6 +287,7 @@ export async function runScenario(scenario: Scenario): Promise { mutateHeaders: options.mutateHeaders, queryURL: options.queryURL, queryHeaders: options.queryHeaders, + hiddenTabDisconnectDelay: options.hiddenTabDisconnectDelay, // In-memory stores outlive their Zero instance, keyed by name, so every // scenario gets a store of its own. The storage key is not sent. storageKey: `${options.storageKey ?? ''}#scenario-${scenarioKey}`, @@ -450,6 +486,9 @@ export async function runScenario(scenario: Scenario): Promise { case 'advance': await vi.advanceTimersByTimeAsync(step.ms); return; + case 'visibility': + document.setVisibility(step.state); + return; case 'connect': void z.connection.connect( step.auth === undefined ? undefined : {auth: step.auth}, diff --git a/packages/zero-conformance/src/client/scenarios.ts b/packages/zero-conformance/src/client/scenarios.ts index 030508c224..205329866e 100644 --- a/packages/zero-conformance/src/client/scenarios.ts +++ b/packages/zero-conformance/src/client/scenarios.ts @@ -5,6 +5,7 @@ import {mutationScenarios} from './scenarios/mutations.ts'; import {persistenceScenarios} from './scenarios/persistence.ts'; import {pokeScenarios} from './scenarios/pokes.ts'; import {queryScenarios} from './scenarios/queries.ts'; +import {visibilityScenarios} from './scenarios/visibility.ts'; export const scenarios: Scenario[] = [ ...connectionScenarios, @@ -13,4 +14,5 @@ export const scenarios: Scenario[] = [ ...queryScenarios, ...mutationScenarios, ...persistenceScenarios, + ...visibilityScenarios, ]; diff --git a/packages/zero-conformance/src/client/scenarios/visibility.ts b/packages/zero-conformance/src/client/scenarios/visibility.ts new file mode 100644 index 0000000000..d6a659f3d7 --- /dev/null +++ b/packages/zero-conformance/src/client/scenarios/visibility.ts @@ -0,0 +1,77 @@ +/** + * Visibility: the client disconnects once the app has been in the background + * (a hidden tab) for `hiddenTabDisconnectDelay`, and reconnects when it comes + * back. The Swift client reports its app's lifecycle the same way. + */ +import type {Scenario} from '../harness.ts'; +import { + advance, + connected, + issue, + mutate, + put, + queryHash, + start, + syncPoke, +} from './helpers.ts'; + +const allHash = queryHash('issues.all'); +const hidden = {op: 'visibility', state: 'hidden'} as const; +const visible = {op: 'visibility', state: 'visible'} as const; + +export const visibilityScenarios: Scenario[] = [ + { + name: 'visibility: a hidden app disconnects after the delay and reconnects when visible', + options: {hiddenTabDisconnectDelay: 1000}, + steps: [ + {op: 'materialize', view: 'v', query: 'issues.all'}, + start, + connected, + ...syncPoke('p1', null, '01', [allHash], [put('issues', issue('i1'))]), + hidden, + advance(999), + advance(1), + mutate('m1', 'issue.create', {id: 'i2'}), + advance(10_000), + visible, + connected, + ...syncPoke('p2', '01', '02', [allHash]), + ], + }, + { + name: 'visibility: coming back before the delay keeps the connection', + options: {hiddenTabDisconnectDelay: 1000}, + steps: [start, connected, hidden, advance(500), visible, advance(1000)], + }, + { + name: 'visibility: a hidden app does not reconnect after the socket drops', + options: {hiddenTabDisconnectDelay: 0}, + steps: [ + start, + connected, + {op: 'socketClose', code: 1006}, + hidden, + advance(5000), + advance(60_000), + visible, + connected, + ], + }, + { + name: 'visibility: hidden while connecting disconnects once connected', + options: {hiddenTabDisconnectDelay: 0}, + steps: [start, hidden, connected, visible, connected], + }, + { + name: 'visibility: a pending mutation is pushed again after coming back', + options: {hiddenTabDisconnectDelay: 0}, + steps: [ + start, + connected, + mutate('m1', 'issue.create', {id: 'i1'}), + hidden, + visible, + connected, + ], + }, +]; diff --git a/packages/zero-conformance/vectors/client.json b/packages/zero-conformance/vectors/client.json index 1a5733207d..9f34cacc6b 100644 --- a/packages/zero-conformance/vectors/client.json +++ b/packages/zero-conformance/vectors/client.json @@ -142,5 +142,10 @@ {"name":"persistence: after InvalidConnectionRequestBaseCookie the next client starts from an empty store","steps":[{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"receive","message":["pokeStart",{"pokeID":"p1","baseCookie":null}]},{"op":"receive","message":["pokePart",{"pokeID":"p1","gotQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek"}],"rowsPatch":[{"op":"put","tableName":"issues","value":{"id":"i1","title":"I1","description":"","closed":false,"owner_id":null,"createdAt":1}},{"op":"put","tableName":"issues","value":{"id":"i2","title":"I2","description":"","closed":false,"owner_id":null,"createdAt":1}}]}]},{"op":"receive","message":["pokeEnd",{"pokeID":"p1","cookie":"01"}]},{"op":"advance","ms":2000},{"op":"mutate","mutation":"m1","mutator":"issue.create","args":{"id":"i3"}},{"op":"advance","ms":2000},{"op":"receive","message":["error",{"kind":"InvalidConnectionRequestBaseCookie","message":"gone","origin":"zeroCache"}]},{"op":"advance","ms":2000},{"op":"restart"},{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]}],"expect":[{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":2}}},{"sent":[["changeDesiredQueries",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}]}]],"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}}},{"sent":[["push",{"timestamp":1700000002000,"clientGroupID":"$clientGroupID","mutations":[{"type":"custom","timestamp":1700000002000,"id":1,"clientID":"$clientID","name":"issue.create","args":[{"id":"i3"}]}],"pushVersion":1,"requestID":"$requestID"}]],"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i3","title":"","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":1}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i3","title":"","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"closed":[0],"state":{"name":"error","reason":"gone"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i3","title":"","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"success"},"server":{"type":"error","error":{"type":"zero","message":"gone"}}}},"events":["onClientStateNotFound"]},{"state":{"name":"error","reason":"gone"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i3","title":"","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"success"},"server":{"type":"error","error":{"type":"zero","message":"gone"}}}}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"sent":[["changeDesiredQueries",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}]}]],"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}}]}, {"name":"persistence: after InvalidConnectionRequestLastMutationID the next client starts from an empty store","steps":[{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"receive","message":["pokeStart",{"pokeID":"p1","baseCookie":null}]},{"op":"receive","message":["pokePart",{"pokeID":"p1","gotQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek"}],"rowsPatch":[{"op":"put","tableName":"issues","value":{"id":"i1","title":"I1","description":"","closed":false,"owner_id":null,"createdAt":1}},{"op":"put","tableName":"issues","value":{"id":"i2","title":"I2","description":"","closed":false,"owner_id":null,"createdAt":1}}]}]},{"op":"receive","message":["pokeEnd",{"pokeID":"p1","cookie":"01"}]},{"op":"advance","ms":2000},{"op":"mutate","mutation":"m1","mutator":"issue.create","args":{"id":"i3"}},{"op":"advance","ms":2000},{"op":"receive","message":["error",{"kind":"InvalidConnectionRequestLastMutationID","message":"gone","origin":"zeroCache"}]},{"op":"advance","ms":2000},{"op":"restart"},{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]}],"expect":[{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":2}}},{"sent":[["changeDesiredQueries",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}]}]],"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}}},{"sent":[["push",{"timestamp":1700000002000,"clientGroupID":"$clientGroupID","mutations":[{"type":"custom","timestamp":1700000002000,"id":1,"clientID":"$clientID","name":"issue.create","args":[{"id":"i3"}]}],"pushVersion":1,"requestID":"$requestID"}]],"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i3","title":"","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":1}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i3","title":"","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"closed":[0],"state":{"name":"error","reason":"gone"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i3","title":"","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"success"},"server":{"type":"error","error":{"type":"zero","message":"gone"}}}},"events":["onClientStateNotFound"]},{"state":{"name":"error","reason":"gone"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i3","title":"","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"success"},"server":{"type":"error","error":{"type":"zero","message":"gone"}}}}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"sent":[["changeDesiredQueries",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}]}]],"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}}]}, {"name":"persistence: after a message too large for the server the next client starts a new client group","steps":[{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"receive","message":["pokeStart",{"pokeID":"p1","baseCookie":null}]},{"op":"receive","message":["pokePart",{"pokeID":"p1","rowsPatch":[{"op":"put","tableName":"issues","value":{"id":"i1","title":"I1","description":"","closed":false,"owner_id":null,"createdAt":1}}]}]},{"op":"receive","message":["pokeEnd",{"pokeID":"p1","cookie":"01"}]},{"op":"advance","ms":2000},{"op":"socketClose","code":1009,"reason":"too big"},{"op":"advance","ms":2000},{"op":"restart"},{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0}],"expect":[{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"}},{"state":{"name":"connected"}},{"state":{"name":"connected"}},{"state":{"name":"connected"}},{"state":{"name":"connected"}},{"state":{"name":"connected"}},{"closed":[0],"state":{"name":"error","reason":"A WebSocket message exceeded the server message size limit. This is usually caused by a mutation's args containing a large value such as a base64-encoded image. Consider uploading large files to object storage and storing only the URL in Zero."},"events":["onClientStateNotFound"]},{"state":{"name":"error","reason":"A WebSocket message exceeded the server message size limit. This is usually caused by a mutation's args containing a large value such as a base64-encoded image. Consider uploading large files to object storage and storing only the URL in Zero."}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}}]}, -{"name":"persistence: errors that keep the client group","steps":[{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"receive","message":["pokeStart",{"pokeID":"p1","baseCookie":null}]},{"op":"receive","message":["pokePart",{"pokeID":"p1","gotQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek"}],"rowsPatch":[{"op":"put","tableName":"issues","value":{"id":"i1","title":"I1","description":"","closed":false,"owner_id":null,"createdAt":1}},{"op":"put","tableName":"issues","value":{"id":"i2","title":"I2","description":"","closed":false,"owner_id":null,"createdAt":1}}]}]},{"op":"receive","message":["pokeEnd",{"pokeID":"p1","cookie":"01"}]},{"op":"advance","ms":2000},{"op":"receive","message":["error",{"kind":"Unauthorized","message":"expired","origin":"zeroCache"}]},{"op":"advance","ms":2000},{"op":"restart"},{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0}],"expect":[{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":2}}},{"sent":[["changeDesiredQueries",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}]}]],"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}}},{"closed":[0],"state":{"name":"needs-auth","reason":{"type":"zero-cache","reason":"expired"}},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}}},{"state":{"name":"needs-auth","reason":{"type":"zero-cache","reason":"expired"}},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie","01"],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"}},{"state":{"name":"connecting"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"cached","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"cached","notifications":0}}}]} +{"name":"persistence: errors that keep the client group","steps":[{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"receive","message":["pokeStart",{"pokeID":"p1","baseCookie":null}]},{"op":"receive","message":["pokePart",{"pokeID":"p1","gotQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek"}],"rowsPatch":[{"op":"put","tableName":"issues","value":{"id":"i1","title":"I1","description":"","closed":false,"owner_id":null,"createdAt":1}},{"op":"put","tableName":"issues","value":{"id":"i2","title":"I2","description":"","closed":false,"owner_id":null,"createdAt":1}}]}]},{"op":"receive","message":["pokeEnd",{"pokeID":"p1","cookie":"01"}]},{"op":"advance","ms":2000},{"op":"receive","message":["error",{"kind":"Unauthorized","message":"expired","origin":"zeroCache"}]},{"op":"advance","ms":2000},{"op":"restart"},{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0}],"expect":[{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":2}}},{"sent":[["changeDesiredQueries",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}]}]],"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}}},{"closed":[0],"state":{"name":"needs-auth","reason":{"type":"zero-cache","reason":"expired"}},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}}},{"state":{"name":"needs-auth","reason":{"type":"zero-cache","reason":"expired"}},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie","01"],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"}},{"state":{"name":"connecting"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"cached","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1},{"id":"i2","title":"I2","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"cached","notifications":0}}}]}, +{"name":"visibility: a hidden app disconnects after the delay and reconnects when visible","options":{"hiddenTabDisconnectDelay":1000},"steps":[{"op":"materialize","view":"v","query":"issues.all"},{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"receive","message":["pokeStart",{"pokeID":"p1","baseCookie":null}]},{"op":"receive","message":["pokePart",{"pokeID":"p1","gotQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek"}],"rowsPatch":[{"op":"put","tableName":"issues","value":{"id":"i1","title":"I1","description":"","closed":false,"owner_id":null,"createdAt":1}}]}]},{"op":"receive","message":["pokeEnd",{"pokeID":"p1","cookie":"01"}]},{"op":"visibility","state":"hidden"},{"op":"advance","ms":999},{"op":"advance","ms":1},{"op":"mutate","mutation":"m1","mutator":"issue.create","args":{"id":"i2"}},{"op":"advance","ms":10000},{"op":"visibility","state":"visible"},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"receive","message":["pokeStart",{"pokeID":"p2","baseCookie":"01"}]},{"op":"receive","message":["pokePart",{"pokeID":"p2","gotQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek"}],"rowsPatch":[]}]},{"op":"receive","message":["pokeEnd",{"pokeID":"p2","cookie":"02"}]}],"expect":[{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":1}}},{"state":{"name":"connecting"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[],"resultType":"unknown","notifications":0}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":2}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}}},{"sent":[["changeDesiredQueries",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}]}]],"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}}},{"closed":[0],"state":{"name":"disconnected","reason":"Connection closed because tab was hidden"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}}},{"state":{"name":"disconnected","reason":"Connection closed because tab was hidden"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"error","error":{"type":"zero","message":"Connection closed because tab was hidden"}},"server":{"type":"error","error":{"type":"zero","message":"Connection closed because tab was hidden"}}}}},{"state":{"name":"disconnected","reason":"Connection closed because tab was hidden"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"error","error":{"type":"zero","message":"Connection closed because tab was hidden"}},"server":{"type":"error","error":{"type":"zero","message":"Connection closed because tab was hidden"}}}}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie","01"],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[{"op":"put","hash":"1y7zr0i1xir4ek","name":"issues.all","args":[],"ttl":300000}],"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"error","error":{"type":"zero","message":"Connection closed because tab was hidden"}},"server":{"type":"error","error":{"type":"zero","message":"Connection closed because tab was hidden"}}}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"error","error":{"type":"zero","message":"Connection closed because tab was hidden"}},"server":{"type":"error","error":{"type":"zero","message":"Connection closed because tab was hidden"}}}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"error","error":{"type":"zero","message":"Connection closed because tab was hidden"}},"server":{"type":"error","error":{"type":"zero","message":"Connection closed because tab was hidden"}}}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"error","error":{"type":"zero","message":"Connection closed because tab was hidden"}},"server":{"type":"error","error":{"type":"zero","message":"Connection closed because tab was hidden"}}}}},{"state":{"name":"connected"},"views":{"v":{"data":[{"id":"i1","title":"I1","description":"","closed":false,"ownerId":null,"createdAt":1}],"resultType":"complete","notifications":0}},"mutations":{"m1":{"client":{"type":"error","error":{"type":"zero","message":"Connection closed because tab was hidden"}},"server":{"type":"error","error":{"type":"zero","message":"Connection closed because tab was hidden"}}}}}]}, +{"name":"visibility: coming back before the delay keeps the connection","options":{"hiddenTabDisconnectDelay":1000},"steps":[{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"visibility","state":"hidden"},{"op":"advance","ms":500},{"op":"visibility","state":"visible"},{"op":"advance","ms":1000}],"expect":[{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"}},{"state":{"name":"connected"}},{"state":{"name":"connected"}},{"state":{"name":"connected"}},{"state":{"name":"connected"}},{"state":{"name":"connected"}}]}, +{"name":"visibility: a hidden app does not reconnect after the socket drops","options":{"hiddenTabDisconnectDelay":0},"steps":[{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"socketClose","code":1006},{"op":"visibility","state":"hidden"},{"op":"advance","ms":5000},{"op":"advance","ms":60000},{"op":"visibility","state":"visible"},{"op":"receive","message":["connected",{"wsid":"w1"}]}],"expect":[{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"}},{"state":{"name":"connected"}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"closed":[0],"state":{"name":"connecting"}},{"state":{"name":"connecting"}},{"state":{"name":"connecting"}},{"closed":[1],"state":{"name":"disconnected","reason":"Zero was unable to connect for 60 seconds and was disconnected"}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"disconnected","reason":"Zero was unable to connect for 60 seconds and was disconnected"}},{"state":{"name":"connected"}}]}, +{"name":"visibility: hidden while connecting disconnects once connected","options":{"hiddenTabDisconnectDelay":0},"steps":[{"op":"advance","ms":0},{"op":"visibility","state":"hidden"},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"visibility","state":"visible"},{"op":"receive","message":["connected",{"wsid":"w1"}]}],"expect":[{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"}},{"state":{"name":"connecting"}},{"closed":[0],"state":{"name":"disconnected","reason":"Connection closed because tab was hidden"}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"}},{"state":{"name":"connected"}}]}, +{"name":"visibility: a pending mutation is pushed again after coming back","options":{"hiddenTabDisconnectDelay":0},"steps":[{"op":"advance","ms":0},{"op":"receive","message":["connected",{"wsid":"w1"}]},{"op":"mutate","mutation":"m1","mutator":"issue.create","args":{"id":"i1"}},{"op":"visibility","state":"hidden"},{"op":"visibility","state":"visible"},{"op":"receive","message":["connected",{"wsid":"w1"}]}],"expect":[{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"}},{"state":{"name":"connected"}},{"sent":[["push",{"timestamp":1700000000000,"clientGroupID":"$clientGroupID","mutations":[{"type":"custom","timestamp":1700000000000,"id":1,"clientID":"$clientID","name":"issue.create","args":[{"id":"i1"}]}],"pushVersion":1,"requestID":"$requestID"}]],"state":{"name":"connected"},"mutations":{"m1":{"client":{"type":"success"},"server":"pending"}}},{"closed":[0],"state":{"name":"disconnected","reason":"Connection closed because tab was hidden"},"mutations":{"m1":{"client":{"type":"success"},"server":{"type":"error","error":{"type":"zero","message":"Connection closed because tab was hidden"}}}}},{"opened":[{"url":{"origin":"ws://zero.test","path":"/sync/v53/connect","query":[["clientID","$clientID"],["clientGroupID","$clientGroupID"],["userID","u1"],["baseCookie",""],["lmid","0"],["wsid","$wsid"],["profileID","$profileID"]]},"protocol":{"initConnectionMessage":["initConnection",{"desiredQueriesPatch":[],"clientSchema":{"tables":{"comments":{"columns":{"authorId":{"type":"string"},"createdAt":{"type":"number"},"id":{"type":"string"},"issue_id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"issueLabel":{"columns":{"issueId":{"type":"string"},"labelId":{"type":"string"}},"primaryKey":["issueId","labelId"]},"issues":{"columns":{"closed":{"type":"boolean"},"createdAt":{"type":"number"},"description":{"type":"string"},"id":{"type":"string"},"owner_id":{"type":"string"},"title":{"type":"string"}},"primaryKey":["id"]},"label":{"columns":{"id":{"type":"string"},"name":{"type":"string"}},"primaryKey":["id"]},"revision":{"columns":{"authorId":{"type":"string"},"commentId":{"type":"string"},"id":{"type":"string"},"text":{"type":"string"}},"primaryKey":["id"]},"users":{"columns":{"id":{"type":"string"},"metadata":{"type":"json"},"name":{"type":"string"}},"primaryKey":["id"]}}},"activeClients":["$clientID"]}]}}],"state":{"name":"connecting"},"mutations":{"m1":{"client":{"type":"success"},"server":{"type":"error","error":{"type":"zero","message":"Connection closed because tab was hidden"}}}}},{"sent":[["push",{"timestamp":1700000000000,"clientGroupID":"$clientGroupID","mutations":[{"type":"custom","timestamp":1700000000000,"id":1,"clientID":"$clientID","name":"issue.create","args":[{"id":"i1"}]}],"pushVersion":1,"requestID":"$requestID"}]],"state":{"name":"connected"},"mutations":{"m1":{"client":{"type":"success"},"server":{"type":"error","error":{"type":"zero","message":"Connection closed because tab was hidden"}}}}}]} ]} From fcb9aef2a7b1fa3a906f431bd69f76747ccd1693 Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 19 Oct 2026 15:20:06 +0000 Subject: [PATCH 015/104] 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 --- swift/Package.swift | 3 + swift/Sources/Zero/Apple/AppLifecycle.swift | 170 ++++++++++++ .../Zero/Apple/URLSessionTransport.swift | 147 +++++++++++ .../Sources/Zero/Client/ConnectionError.swift | 9 + swift/Sources/Zero/Client/Engine.swift | 245 +++++++++++++++++- swift/Sources/Zero/Client/LiveQuery.swift | 65 +++++ swift/Sources/Zero/Client/Log.swift | 79 ++++++ swift/Sources/Zero/Client/QueryContext.swift | 96 ++++++- swift/Sources/Zero/Client/Socket.swift | 58 +++-- swift/Sources/Zero/Client/Transport.swift | 27 +- swift/Sources/Zero/Client/ViewSnapshot.swift | 65 +++++ swift/Sources/Zero/Client/ZeroClient.swift | 209 +++++++++++++-- swift/Sources/Zero/IVM/View.swift | 10 +- .../ZeroTesting/RecordingLogSink.swift | 20 ++ swift/Sources/ZeroUI/Diffable.swift | 117 +++++++++ swift/Sources/ZeroUI/SwiftUI.swift | 104 ++++++++ .../AppleTests/DiffableAdapterTests.swift | 37 +++ swift/Tests/AppleTests/Notes.swift | 48 ++++ swift/Tests/AppleTests/SwiftUITests.swift | 73 ++++++ .../AppleTests/URLSessionTransportTests.swift | 119 +++++++++ swift/Tests/AppleTests/WebSocketServer.swift | 154 +++++++++++ .../ClientConformanceTests.swift | 4 + swift/Tests/ZeroTests/ClientAPITests.swift | 4 +- swift/Tests/ZeroTests/LifecycleTests.swift | 150 +++++++++++ swift/Tests/ZeroTests/StorageTests.swift | 1 + swift/Tests/ZeroTests/ViewTests.swift | 99 +++++++ 26 files changed, 2045 insertions(+), 68 deletions(-) create mode 100644 swift/Sources/Zero/Apple/AppLifecycle.swift create mode 100644 swift/Sources/Zero/Apple/URLSessionTransport.swift create mode 100644 swift/Sources/Zero/Client/LiveQuery.swift create mode 100644 swift/Sources/Zero/Client/Log.swift create mode 100644 swift/Sources/Zero/Client/ViewSnapshot.swift create mode 100644 swift/Sources/ZeroTesting/RecordingLogSink.swift create mode 100644 swift/Sources/ZeroUI/Diffable.swift create mode 100644 swift/Sources/ZeroUI/SwiftUI.swift create mode 100644 swift/Tests/AppleTests/DiffableAdapterTests.swift create mode 100644 swift/Tests/AppleTests/Notes.swift create mode 100644 swift/Tests/AppleTests/SwiftUITests.swift create mode 100644 swift/Tests/AppleTests/URLSessionTransportTests.swift create mode 100644 swift/Tests/AppleTests/WebSocketServer.swift create mode 100644 swift/Tests/ZeroTests/LifecycleTests.swift create mode 100644 swift/Tests/ZeroTests/ViewTests.swift diff --git a/swift/Package.swift b/swift/Package.swift index 9c3675ee46..9ebc762c3a 100644 --- a/swift/Package.swift +++ b/swift/Package.swift @@ -22,6 +22,9 @@ let package = Package( .target(name: "ZeroTesting", dependencies: ["Zero"]), .testTarget(name: "ZeroTests", dependencies: ["Zero", "ZeroTesting"]), .testTarget(name: "ConformanceTests", dependencies: ["Zero", "ZeroTesting"]), + // The Apple layer: the URLSession transport, SwiftUI, UIKit, and AppKit. + // Empty elsewhere. + .testTarget(name: "AppleTests", dependencies: ["Zero", "ZeroUI", "ZeroTesting"]), .testTarget( name: "E2ETests", dependencies: [ diff --git a/swift/Sources/Zero/Apple/AppLifecycle.swift b/swift/Sources/Zero/Apple/AppLifecycle.swift new file mode 100644 index 0000000000..9b2fbd4919 --- /dev/null +++ b/swift/Sources/Zero/Apple/AppLifecycle.swift @@ -0,0 +1,170 @@ +#if canImport(UIKit) && !os(watchOS) + import UIKit +#elseif canImport(AppKit) + import AppKit +#endif +#if canImport(Network) + import Network +#endif +#if canImport(Darwin) + import Foundation +#endif + +/// What the app's lifecycle does to a client. +@MainActor +protocol LifecycleClient: AnyObject { + func setVisible(_ visible: Bool) + func reconnectNow() + func whenPendingSettled(_ done: @escaping () -> Void) + func flushStorage() +} + +#if canImport(Darwin) + /// Connects a client to the app's lifecycle and the network: + /// + /// - iOS: in the background, the client pushes its pending mutations + /// within a short background task, then disconnects; it reconnects in the + /// foreground. + /// - macOS: the client disconnects when the Mac sleeps and reconnects when + /// it wakes. + /// - Both: when the network is back, the client reconnects without waiting + /// for its backoff, and checks an open connection when the network + /// changes. + @MainActor + final class AppLifecycle { + /// How long the app waits in the background for the server to confirm + /// the pending mutations. + static let backgroundFlushTimeout: Duration = .seconds(10) + + private weak var client: (any LifecycleClient)? + private var observers: [any NSObjectProtocol] = [] + #if canImport(UIKit) && !os(watchOS) + private var flush: BackgroundFlush? + #endif + #if canImport(Network) + private let monitor = NWPathMonitor() + private var lastPath: (status: NWPath.Status, interfaces: [String])? + #endif + + init(_ client: any LifecycleClient) { + self.client = client + #if canImport(UIKit) && !os(watchOS) + let center = NotificationCenter.default + observers.append( + center.addObserver( + forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main + ) { [weak self] _ in MainActor.assumeIsolated { self?.didEnterBackground() } }) + observers.append( + center.addObserver( + forName: UIApplication.willEnterForegroundNotification, object: nil, queue: .main + ) { [weak self] _ in MainActor.assumeIsolated { self?.willEnterForeground() } }) + if UIApplication.shared.applicationState == .background { + client.setVisible(false) + } + #elseif canImport(AppKit) + let center = NSWorkspace.shared.notificationCenter + observers.append( + center.addObserver(forName: NSWorkspace.willSleepNotification, object: nil, queue: .main) + { [weak self] _ in + MainActor.assumeIsolated { + self?.client?.setVisible(false) + self?.client?.flushStorage() + } + }) + observers.append( + center.addObserver(forName: NSWorkspace.didWakeNotification, object: nil, queue: .main) { + [weak self] _ in MainActor.assumeIsolated { self?.client?.setVisible(true) } + }) + #endif + #if canImport(Network) + monitor.pathUpdateHandler = { [weak self] path in + let status = path.status + let interfaces = path.availableInterfaces.map(\.name) + Task { @MainActor in self?.pathChanged(status, interfaces) } + } + monitor.start(queue: DispatchQueue(label: "zero.network")) + #endif + } + + isolated deinit { + stop() + } + + func stop() { + #if canImport(UIKit) && !os(watchOS) + for o in observers { NotificationCenter.default.removeObserver(o) } + #elseif canImport(AppKit) + for o in observers { NSWorkspace.shared.notificationCenter.removeObserver(o) } + #endif + observers = [] + #if canImport(Network) + monitor.cancel() + #endif + } + + #if canImport(Network) + private func pathChanged(_ status: NWPath.Status, _ interfaces: [String]) { + defer { lastPath = (status, interfaces) } + // The first report is the network as it is at launch. + guard let last = lastPath, status == .satisfied else { return } + if last.status != .satisfied || last.interfaces != interfaces { + client?.reconnectNow() + } + } + #endif + + #if canImport(UIKit) && !os(watchOS) + /// Pushes the pending mutations, then disconnects, within a background + /// task. + private func didEnterBackground() { + guard let client else { return } + flush?.end() + let flush = BackgroundFlush(client) + self.flush = flush + flush.task = UIApplication.shared.beginBackgroundTask( + withName: "Zero: push pending mutations" + ) { MainActor.assumeIsolated { flush.finish() } } + client.whenPendingSettled { flush.finish() } + Task { @MainActor in + try? await Task.sleep(for: Self.backgroundFlushTimeout) + flush.finish() + } + } + + private func willEnterForeground() { + flush?.end() + flush = nil + client?.setVisible(true) + } + #endif + } + + #if canImport(UIKit) && !os(watchOS) + /// One trip to the background: ends at the first of the server + /// confirming the pending mutations, the timeout, and the system + /// expiring the background task. + @MainActor + private final class BackgroundFlush { + weak var client: (any LifecycleClient)? + var task = UIBackgroundTaskIdentifier.invalid + private var done = false + + init(_ client: any LifecycleClient) { self.client = client } + + /// Disconnects and ends the background task. + func finish() { + guard !done else { return } + client?.setVisible(false) + client?.flushStorage() + end() + } + + /// Ends the background task, for example when the app is back. + func end() { + done = true + if task != .invalid { UIApplication.shared.endBackgroundTask(task) } + task = .invalid + } + } + #endif +#endif diff --git a/swift/Sources/Zero/Apple/URLSessionTransport.swift b/swift/Sources/Zero/Apple/URLSessionTransport.swift new file mode 100644 index 0000000000..87bfd2587b --- /dev/null +++ b/swift/Sources/Zero/Apple/URLSessionTransport.swift @@ -0,0 +1,147 @@ +#if canImport(Darwin) || ZERO_URLSESSION + import Foundation + #if canImport(FoundationNetworking) + import FoundationNetworking + #endif + import Synchronization + + /// A `Transport` over `URLSessionWebSocketTask`: the client's socket on + /// Apple platforms. + public final class URLSessionTransport: Transport { + private let session: URLSession + private let maximumMessageSize: Int + + /// - Parameters: + /// - configuration: The session's configuration, for example to add + /// headers or to route through a proxy. + /// - maximumMessageSize: The largest frame the socket accepts. A poke + /// can be larger than `URLSessionWebSocketTask`'s 1 MB default. + public init( + configuration: URLSessionConfiguration = .default, maximumMessageSize: Int = 64 << 20 + ) { + session = URLSession(configuration: configuration) + self.maximumMessageSize = maximumMessageSize + } + + deinit { + session.finishTasksAndInvalidate() + } + + public func connect(url: String, secProtocol: String?) async throws -> any Socket { + guard let target = URL(string: url) else { + throw SocketClosed(code: 1006, reason: "Invalid URL: \(url)", wasClean: false) + } + let task = session.webSocketTask(with: target, protocols: secProtocol.map { [$0] } ?? []) + task.maximumMessageSize = maximumMessageSize + let socket = URLSessionSocket(task) + try await socket.open() + return socket + } + } + + /// One `URLSessionWebSocketTask`. Its delegate reports the open and the + /// close. + final class URLSessionSocket: NSObject, Socket, URLSessionWebSocketDelegate { + private struct State { + var opened: CheckedContinuation? + var close: SocketClosed? + } + + let task: URLSessionWebSocketTask + private let state = Mutex(State()) + + init(_ task: URLSessionWebSocketTask) { + self.task = task + } + + /// Starts the task and waits for the handshake. + func open() async throws { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (c: CheckedContinuation) in + state.withLock { $0.opened = c } + task.delegate = self + task.resume() + } + } onCancel: { + task.cancel() + } + } + + private func resumeOpen(_ result: Result) { + let c = state.withLock { s in + defer { s.opened = nil } + return s.opened + } + c?.resume(with: result) + } + + func urlSession( + _ session: URLSession, webSocketTask: URLSessionWebSocketTask, + didOpenWithProtocol protocol: String? + ) { + resumeOpen(.success(())) + } + + func urlSession( + _ session: URLSession, webSocketTask: URLSessionWebSocketTask, + didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data? + ) { + let close = SocketClosed( + code: closeCode.rawValue, reason: reason.map { String(decoding: $0, as: UTF8.self) } ?? "", + wasClean: true) + state.withLock { $0.close = close } + resumeOpen(.failure(close)) + } + + func urlSession( + _ session: URLSession, task: URLSessionTask, didCompleteWithError error: (any Error)? + ) { + let close = state.withLock { s in + if s.close == nil { + s.close = SocketClosed( + code: 1006, reason: error.map { $0.localizedDescription } ?? "", wasClean: false) + } + return s.close! + } + resumeOpen(.failure(close)) + } + + func send(_ message: SocketMessage) async throws { + switch message { + case .text(let text): try await task.send(.string(text)) + case .binary(let bytes): try await task.send(.data(Data(bytes))) + } + } + + func receive() async throws -> SocketMessage? { + do { + switch try await task.receive() { + case .string(let text): return .text(text) + case .data(let data): return .binary([UInt8](data)) + @unknown default: return nil + } + } catch { + throw closeReason(error) + } + } + + /// How the socket closed, once `receive` fails: the close frame when one + /// came, otherwise an abrupt close. + private func closeReason(_ error: any Error) -> SocketClosed { + if let close = state.withLock({ $0.close }) { return close } + if task.closeCode != .invalid { + return SocketClosed( + code: task.closeCode.rawValue, + reason: task.closeReason.map { String(decoding: $0, as: UTF8.self) } ?? "", + wasClean: true) + } + return SocketClosed(code: 1006, reason: error.localizedDescription, wasClean: false) + } + + func close(code: Int, reason: String) async { + task.cancel( + with: URLSessionWebSocketTask.CloseCode(rawValue: code) ?? .normalClosure, + reason: reason.isEmpty ? nil : Data(reason.utf8)) + } + } +#endif diff --git a/swift/Sources/Zero/Client/ConnectionError.swift b/swift/Sources/Zero/Client/ConnectionError.swift index c032f9d07a..fb60155881 100644 --- a/swift/Sources/Zero/Client/ConnectionError.swift +++ b/swift/Sources/Zero/Client/ConnectionError.swift @@ -76,6 +76,15 @@ public enum ConnectionError: Error, Sendable, Hashable, CustomStringConvertible return false } + /// How a rejected mutation describes the client (`getStateDescription`). + var stateDescription: String { + switch clientKind { + case .offline?: "offline" + case .clientClosed?: "closed" + default: "in error state" + } + } + /// `getBackoffParams`. var backoff: BackoffErrorBody? { if isZeroError, case .server(.backoff(let b)) = self { return b } diff --git a/swift/Sources/Zero/Client/Engine.swift b/swift/Sources/Zero/Client/Engine.swift index b9934471e3..3ab8d56ee7 100644 --- a/swift/Sources/Zero/Client/Engine.swift +++ b/swift/Sources/Zero/Client/Engine.swift @@ -35,6 +35,14 @@ public struct ZeroOptions: Sendable { /// The storage failed, the first time it does: the client keeps running /// on memory and stops writing to disk. Defaults to doing nothing. public var onStorageFailure: (@MainActor @Sendable (StorageFailure) -> Void)? + /// The least severe messages logged. + public var logLevel: LogLevel = .warn + /// Where messages are logged. Defaults to `DefaultLogSink`. + public var logSink: (any LogSink)? + /// How long the app is in the background before the client disconnects + /// (`hiddenTabDisconnectDelay`). The Apple layer pushes the pending + /// mutations before it reports the app hidden, so it disconnects at once. + var hiddenDisconnectDelayMs: Double = 0 public init( cacheURL: String? = nil, userID: String? = nil, auth: String? = nil, @@ -79,6 +87,7 @@ final class Engine { let userID: String? let loop: EventLoop let scheduler: LoopScheduler + let log: Log isolated deinit { loop.stop() @@ -131,8 +140,9 @@ final class Engine { init( schema: ZeroSchema, options: ZeroOptions, mutators: [String: MutatorFunction], - scheduler base: any Scheduler, socketFactory: (any SocketFactory)? + scheduler base: any Scheduler, socketFactory: (any SocketFactory)?, visible: Bool = true ) throws { + self.visible = visible if options.userID == "" { throw ConnectionError.client( .internal, @@ -149,6 +159,8 @@ final class Engine { self.loop = EventLoop(scheduler: base) self.scheduler = LoopScheduler(base, loop) self.clientID = randomID() + self.log = Log(level: options.logLevel, sink: options.logSink ?? DefaultLogSink()) + .with("clientID", clientID) self.clientSchema = schema.clientSchema self.clientToServer = NameMapper.clientToServer(schema) @@ -207,7 +219,9 @@ final class Engine { clientID: clientID, schema: schema, scheduler: scheduler, apply: { [weak self] poke in try self?.applyPoke(poke) }, onApplied: { [weak self] poke in - guard let self, let lmid = poke.lastMutationIDChanges[self.clientID] else { return } + guard let self else { return } + defer { self.settlePendingWaiters() } + guard let lmid = poke.lastMutationIDChanges[self.clientID] else { return } try self.mutationTracker.lmidAdvanced(lmid) }, onPokeError: { [weak self] message in self?.onPokeError(message) }) @@ -223,7 +237,10 @@ final class Engine { } database.onWriteFailure = { [weak self] failure in self?.storageFailed(failure) } if let openFailure { - loop.microtask { [weak self] in self?.storageFailed(openFailure) } + log.warn( + "Local store storage failed (\(openFailure.kind.rawValue)) opening \(name); " + + "running this instance in memory: \(openFailure.message)") + loop.microtask { [weak self] in self?.storageFailed(openFailure, opening: true) } } startLifecycle() @@ -300,6 +317,12 @@ final class Engine { let token = nextLoopToken() switch connectionManager.state.status { case .connecting, .disconnected: + guard visible else { + // Wait for the app to come back, or for a state change. + onVisibility(true, token) { [weak self] in self?.beginAttempt() } + onStateChange(token) { [weak self] state in self?.resumeAfterStateChange(state) } + return + } // The app is visible, which wins the race with a state change. loop.microtask { [weak self] in guard let self, self.loopToken == token else { return } @@ -308,6 +331,9 @@ final class Engine { case .connected: waitWhileConnected(token) case .needsAuth, .error: + log.info( + "Run loop paused in \(connectionManager.state.status.rawValue) state. " + + "Call zero.connection.connect() to resume.") waitForConnectRequest(token) case .closed: return @@ -320,6 +346,18 @@ final class Engine { /// (the `catch` of the JS run loop). private func loopFailed(_ error: ConnectionError) { let (transition, reason) = ConnectionTransition.of(error) + if !connectionManager.is(.connected), error.clientKind != .clientClosed { + // Only errors that stop the run loop are errors. Reconnects and auth + // failures are routine. + var level: LogLevel + switch transition { + case .error: level = .error + case .disconnected, .closed: level = .info + case .none, .needsAuth: level = .warn + } + if error.serverKind == .clientNotFound { level = .warn } + log(level, "Failed to connect: \(error.message)") + } var backoffMs = runLoopIntervalMs var sleepMs: Double? switch transition { @@ -343,10 +381,12 @@ final class Engine { } let token = nextLoopToken() if let sleepMs, sleepMs > 0 { - _ = scheduler.schedule(after: sleepMs) { [weak self] in + let timer = scheduler.schedule(after: sleepMs) { [weak self] in guard let self, self.loopToken == token else { return } + self.backoff = nil self.iterate() } + backoff = (token, timer) } else { loop.microtask { [weak self] in guard let self, self.loopToken == token else { return } @@ -408,6 +448,19 @@ final class Engine { guard let self, self.loopToken == token else { return } self.ping(token, end) } + pingNow = { [weak self] in + guard let self, self.loopToken == token else { return } + timer?.cancel() + self.ping(token, end) + } + onVisibility(false, token) { [weak self] in + guard let self else { return } + timer?.cancel() + self.disconnect( + .client(.hidden, message: "Connection closed because tab was hidden")) + end() + self.iterate() + } onStateChange(token) { [weak self] state in end() self?.resumeAfterStateChange(state) @@ -447,6 +500,97 @@ final class Engine { onStateChange(token) { [weak self] _ in self?.iterate() } } + // MARK: - The app's lifecycle + + /// The run loop's backoff before it reconnects. + private var backoff: (token: Int, timer: any ScheduledTimer)? + /// Pings the server now, when the run loop waits while connected. + private var pingNow: () -> Void = {} + /// Trails the app's visibility by `hiddenDisconnectDelayMs` when it goes + /// to the background (`DocumentVisibilityWatcher`). + private(set) var visible = true + private var hiddenTimer: (any ScheduledTimer)? + private var visibilityWaiters: [(visible: Bool, token: Int, waiter: () -> Void)] = [] + private var pendingWaiters: [() -> Void] = [] + + /// The app came to the foreground, or went to the background. In the + /// background the client disconnects and waits to come back. + func setVisible(_ visible: Bool) { + hiddenTimer?.cancel() + hiddenTimer = nil + if visible { + setVisibilityState(true) + } else { + hiddenTimer = scheduler.schedule(after: options.hiddenDisconnectDelayMs) { [weak self] in + self?.hiddenTimer = nil + self?.setVisibilityState(false) + } + } + } + + private func setVisibilityState(_ visible: Bool) { + guard visible != self.visible else { return } + self.visible = visible + let ready = visibilityWaiters.filter { $0.visible == visible } + visibilityWaiters.removeAll { $0.visible == visible } + for w in ready { w.waiter() } + } + + /// Calls `body` in a microtask once the app's visibility is `visible`, + /// unless the run loop's wait `token` is over by then. + private func onVisibility(_ visible: Bool, _ token: Int, _ body: @escaping () -> Void) { + let waiter: () -> Void = { [weak self] in + self?.loop.microtask { + guard let self, self.loopToken == token else { return } + // The wait is over: a state change no longer ends it. + _ = self.nextLoopToken() + body() + } + } + if self.visible == visible { + waiter() + } else { + visibilityWaiters.removeAll { $0.token != loopToken } + visibilityWaiters.append((visible, token, waiter)) + } + } + + /// The network may be back: retry now instead of after the backoff, and + /// check that an open connection still works. + func reconnectNow() { + if let backoff, backoff.token == loopToken { + backoff.timer.cancel() + self.backoff = nil + iterate() + } else if connectionManager.is(.connected) { + pingNow() + } + } + + /// Calls `done` once the server has confirmed every pending mutation, or + /// right away when it can't: the client isn't connected, or the group is + /// disabled. + func whenPendingSettled(_ done: @escaping () -> Void) { + if store.pending.isEmpty || !connectionManager.is(.connected) || clientGroupDisabled + || repClosed + { + done() + return + } + pendingWaiters.append(done) + } + + private func settlePendingWaiters() { + guard !pendingWaiters.isEmpty else { return } + if store.pending.isEmpty || !connectionManager.is(.connected) || clientGroupDisabled + || repClosed + { + let waiters = pendingWaiters + pendingWaiters = [] + for w in waiters { w() } + } + } + // MARK: - Connecting final class ConnectAttempt { @@ -535,6 +679,12 @@ final class Engine { if secProtocol.utf8.count > options.maxHeaderLength { secProtocol = SecProtocols.encode(initConnection: nil, authToken: authToken) queriesPatch = nil + if secProtocol.utf8.count > options.maxHeaderLength { + log.warn( + "Encoded auth token length (\(secProtocol.utf8.count)) exceeds " + + "ZeroOptions.maxHeaderLength (\(options.maxHeaderLength)). This may " + + "cause connection failures.") + } } else { deleted = nil } @@ -630,6 +780,13 @@ final class Engine { ) { guard handle === socket else { return } loop.dispatch { + // 1005 and 1006 are how platforms report a close without a frame. + if code <= 1001 || code == 1005 || code == 1006 { + log.info("Got socket close event: code=\(code) reason=\(reason) wasClean=\(wasClean)") + } else { + log.error( + "Got unexpected socket close event: code=\(code) reason=\(reason) wasClean=\(wasClean)") + } if code == 1009 { var message = "A WebSocket message exceeded the server message size limit. " @@ -643,7 +800,7 @@ final class Engine { } disconnect(.client(.invalidMessage, message: message)) disableClientGroup() - loop.microtask { [weak self] in self?.options.onClientStateNotFound?() } + loop.microtask { [weak self] in self?.clientStateNotFound() } } else { disconnect( wasClean @@ -707,6 +864,9 @@ final class Engine { database.confirmDeletedClients( clientIDs: body.clientIDs ?? [], clientGroupIDs: body.clientGroupIDs ?? []) case .pushResponse(let body): + if case .error = body { + log.error("Received an error response when pushing mutations: \(body.json)") + } try mutationTracker.processPushResponse(body) case .transformError(let errors): queryManager.handleTransformErrors(errors) @@ -752,25 +912,31 @@ final class Engine { private func handleErrorMessage(_ body: ErrorBody) { if body.kind == .mutationRateLimited { lastMutationIDsSent.removeAll() + log.error("\(body.kind.rawValue) Mutation rate limited: \(body.message)") return } - disconnect(.server(body)) + // Being sent elsewhere or later, a lost client group (which recovers), + // and auth failures are routine; every other kind ends the connection. + let error = ConnectionError.server(body) + let routine = error.backoff != nil || body.kind == .clientNotFound || error.isAuthError + log(routine ? .warn : .error, "\(body.kind.rawValue):\n\n\(body.message)") + disconnect(error) switch body.kind { case .versionNotSupported: - options.onUpdateNeeded?(UpdateNeededReason(type: body.kind.rawValue, message: body.message)) + updateNeeded(UpdateNeededReason(type: body.kind.rawValue, message: body.message)) case .schemaVersionNotSupported: disableClientGroup() loop.microtask { [weak self] in - self?.options.onUpdateNeeded?( + self?.updateNeeded( UpdateNeededReason(type: "SchemaVersionNotSupported", message: body.message)) } case .clientNotFound: disableClientGroup() - loop.microtask { [weak self] in self?.options.onClientStateNotFound?() } + loop.microtask { [weak self] in self?.clientStateNotFound() } case .invalidConnectionRequestLastMutationID, .invalidConnectionRequestBaseCookie: // The server is behind the replica: drop it. database.drop() - loop.microtask { [weak self] in self?.options.onClientStateNotFound?() } + loop.microtask { [weak self] in self?.clientStateNotFound() } default: break } @@ -779,10 +945,12 @@ final class Engine { private func onPokeError(_ message: String) { guard !connectionManager.is(.disconnected) else { return } if message.contains("unexpected base cookie") { + log.debug("clearing due to \(message)") disconnect( .client( .unexpectedBaseCookie, message: "Server returned unexpected base cookie during sync")) } else { + log.error("clearing due to unexpected poke error: \(message)") disconnect(.client(.internal, message: "Poke processing error: \(message)")) } } @@ -801,12 +969,35 @@ final class Engine { func connect(auth: String??) { if case .some(let auth) = auth { setAuth(auth) } if case .disconnected(let r) = connectionManager.state, r.clientKind == .noSocketOrigin { + log.error( + "connect() called but the connection is disconnected due to a missing cacheURL. " + + "No reconnect will be attempted.") return } guard connectionManager.isInTerminalState else { return } connectionManager.requestConnect() } + /// What `Zero` does when the client can't sync any longer: by default + /// `ZeroOptions.onClientStateNotFound`. + var onClientStateNotFound: (() -> Void)? + /// What `Zero` does when the app must be updated to sync, besides calling + /// `ZeroOptions.onUpdateNeeded`. + var onUpdateNeeded: ((UpdateNeededReason) -> Void)? + + private func clientStateNotFound() { + if let onClientStateNotFound { + onClientStateNotFound() + } else { + options.onClientStateNotFound?() + } + } + + private func updateNeeded(_ reason: UpdateNeededReason) { + onUpdateNeeded?(reason) + options.onUpdateNeeded?(reason) + } + // MARK: - Close var closed: Bool { repClosed } @@ -841,7 +1032,7 @@ final class Engine { scheduler.scheduleRepeating(every: Self.heartbeatIntervalMs) { [weak self] in guard let self else { return } if !self.database.heartbeat(self.clientID, now: self.scheduler.now) { - self.options.onClientStateNotFound?() + self.clientStateNotFound() } }, scheduler.scheduleRepeating(every: Self.gcIntervalMs) { [weak self] in @@ -895,9 +1086,15 @@ final class Engine { private var storageFailureReported = false - private func storageFailed(_ failure: StorageFailure) { + private func storageFailed(_ failure: StorageFailure, opening: Bool = false) { guard !storageFailureReported else { return } storageFailureReported = true + if !opening { + // A full or failing disk is the device's condition, not a defect. + log.warn( + "Local store storage failed (\(failure.kind.rawValue)); persistence is stopped for " + + "this instance: \(failure.message)") + } options.onStorageFailure?(failure) } @@ -905,6 +1102,7 @@ final class Engine { /// `MutatorProxy#onConnectionStateChange`. private func onConnectionStateChange(_ state: ConnectionManager.State) { + defer { settlePendingWaiters() } switch state { case .disconnected(let r) where r.clientKind == .noSocketOrigin: mutationRejection = nil @@ -921,6 +1119,9 @@ final class Engine { let client = Deferred() let server = Deferred() if let rejection = mutationRejection { + log.warn( + "Mutation \"\(name)\" rejected because Zero is \(rejection.stateDescription). " + + "Details: \(rejection.message).") client.resolve(.zeroError(message: rejection.message)) server.resolve(.zeroError(message: rejection.message)) return MutatorResult(client: client, server: server) @@ -931,10 +1132,14 @@ final class Engine { ) } let (ephemeralID, tracked) = mutationTracker.trackMutation() + let log = self.log tracked.onSettle { result in switch result { case .success(let data): server.resolve(.success(data: data)) - case .failure(let error): server.resolve(Self.outcome(error)) + case .failure(let error): + let outcome = Self.outcome(error) + Self.logFailure(log, name, outcome, on: "server") + server.resolve(outcome) } } let mutationID = store.nextMutationID(for: clientID) @@ -949,7 +1154,9 @@ final class Engine { result = try mutator(tx, args) } catch { mutationTracker.rejectMutation(ephemeralID, error) - client.resolve(Self.outcome(error)) + let outcome = Self.outcome(error) + Self.logFailure(log, name, outcome, on: "client") + client.resolve(outcome) return MutatorResult(client: client, server: server) } let pending = ReplicaStore.PendingMutation( @@ -979,6 +1186,16 @@ final class Engine { return .appError(message: app.message, details: app.details) } + static func logFailure(_ log: Log, _ name: String, _ outcome: MutationOutcome, on origin: String) + { + switch outcome { + case .zeroError(let message): log.error("Mutator \"\(name)\" error on \(origin): \(message)") + case .appError(let message, _): + log.error("Mutator \"\(name)\" app error on \(origin): \(message)") + case .success: break + } + } + /// The store watches: the mutation tracker on `m//`, then the /// query manager on `g/`. A watch hears only about diffs that touch its /// prefix, except for its first run. diff --git a/swift/Sources/Zero/Client/LiveQuery.swift b/swift/Sources/Zero/Client/LiveQuery.swift new file mode 100644 index 0000000000..f5b8667e03 --- /dev/null +++ b/swift/Sources/Zero/Client/LiveQuery.swift @@ -0,0 +1,65 @@ +import Observation + +/// A query's rows as an observable object, typed by the query's result +/// (`useQuery` in `zero-react`). SwiftUI views, and UIKit and AppKit updates, +/// that read `data`, `resultType`, or `error` follow their changes. +/// +/// The query stays synced while the object lives. When `Zero` replaces its +/// client, the object moves to the new one and keeps its identity. +@Observable +@MainActor +public final class LiveQuery { + /// The rows, or the row of a `one()` query. + public private(set) var data: Result + public private(set) var resultType: ResultType + /// The server could not run the query (a `QueryError`), or the rows could + /// not be read as `Result`. `data` keeps the last rows read. + public private(set) var error: (any Error)? + /// The view the object reads. + @ObservationIgnored public let view: MaterializedView + @ObservationIgnored private var removeListener: () -> Void = {} + + init(_ view: MaterializedView) throws { + self.view = view + data = try Result(view: view.data) + resultType = view.resultType + error = view.error.map { QueryError($0) } + removeListener = view.addListener { [weak self] data, type, error in + self?.update(data, type, error) + } + } + + isolated deinit { + removeListener() + } + + private func update(_ value: ViewValue, _ type: ResultType, _ queryError: ErroredQuery?) { + resultType = type + do { + data = try Result(view: value) + error = queryError.map { QueryError($0) } + } catch { + self.error = error + } + } + + /// Raises the query's TTL; a lower one is ignored. + public func updateTTL(_ ttl: TTL) { view.updateTTL(ttl) } +} + +extension Zero { + /// The query's rows as an observable object that stays current. + public func liveQuery( + _ request: QueryRequest, ttl: TTL = .defaultTTL + ) throws -> LiveQuery { + try LiveQuery(materialize(request, ttl: ttl)) + } + + /// The rows of an unnamed query, which only sees local data, as an + /// observable object. + public func liveQuery( + _ query: Query, ttl: TTL = .defaultTTL + ) throws -> LiveQuery { + try LiveQuery(materialize(query, ttl: ttl)) + } +} diff --git a/swift/Sources/Zero/Client/Log.swift b/swift/Sources/Zero/Client/Log.swift new file mode 100644 index 0000000000..462066747b --- /dev/null +++ b/swift/Sources/Zero/Client/Log.swift @@ -0,0 +1,79 @@ +#if canImport(os) + import os +#elseif canImport(Glibc) + import Glibc +#elseif canImport(Musl) + import Musl +#endif + +/// How much the client logs (`LogLevel` in `@rocicorp/logger`). A level +/// includes the levels after it: `.warn` logs warnings and errors. +public enum LogLevel: Int, Sendable, Comparable, CaseIterable, CustomStringConvertible { + case debug, info, warn, error + + public static func < (a: Self, b: Self) -> Bool { a.rawValue < b.rawValue } + + public var description: String { + switch self { + case .debug: "debug" + case .info: "info" + case .warn: "warn" + case .error: "error" + } + } +} + +/// Where the client's log messages go (`LogSink` in `@rocicorp/logger`). +public protocol LogSink: Sendable { + /// - Parameter context: Which client logged the message, such as + /// `clientID`. + func log(level: LogLevel, message: String, context: [String: String]) +} + +/// The default sink: `os.Logger` (subsystem `zero`, category `client`) on +/// Apple platforms, standard error elsewhere. +public struct DefaultLogSink: LogSink { + public init() {} + + public func log(level: LogLevel, message: String, context: [String: String]) { + let prefix = context.sorted { $0.key < $1.key }.map { "\($0.key)=\($0.value)" } + .joined(separator: " ") + let line = prefix.isEmpty ? message : prefix + " " + message + #if canImport(os) + let logger = os.Logger(subsystem: "zero", category: "client") + switch level { + case .debug: logger.debug("\(line, privacy: .public)") + case .info: logger.info("\(line, privacy: .public)") + case .warn: logger.warning("\(line, privacy: .public)") + case .error: logger.error("\(line, privacy: .public)") + } + #else + var text = "\(level) \(line)\n" + text.withUTF8 { buffer in _ = write(2, buffer.baseAddress, buffer.count) } + #endif + } +} + +/// A sink with a level and a context (`LogContext`): the messages below the +/// level are dropped without being built. +struct Log: Sendable { + let level: LogLevel + let sink: any LogSink + var context: [String: String] = [:] + + func with(_ key: String, _ value: String) -> Log { + var copy = self + copy.context[key] = value + return copy + } + + func callAsFunction(_ level: LogLevel, _ message: @autoclosure () -> String) { + guard level >= self.level else { return } + sink.log(level: level, message: message(), context: context) + } + + func debug(_ message: @autoclosure () -> String) { self(.debug, message()) } + func info(_ message: @autoclosure () -> String) { self(.info, message()) } + func warn(_ message: @autoclosure () -> String) { self(.warn, message()) } + func error(_ message: @autoclosure () -> String) { self(.error, message()) } +} diff --git a/swift/Sources/Zero/Client/QueryContext.swift b/swift/Sources/Zero/Client/QueryContext.swift index 3325db97e2..74fbbf09bd 100644 --- a/swift/Sources/Zero/Client/QueryContext.swift +++ b/swift/Sources/Zero/Client/QueryContext.swift @@ -355,16 +355,27 @@ public struct QueryError: Error, Sendable, CustomStringConvertible { /// A view of a query that stays current as data changes (`TypedView`). /// -/// Unlike in JS, dropping the last reference to a view destroys it. +/// Unlike in JS, dropping the last reference to a view destroys it. When +/// `Zero` replaces its client (see `ZeroOptions.onClientStateNotFound`), +/// the view moves to the new client and keeps its identity and listeners. @MainActor public final class MaterializedView { - let view: ArrayView - private let updateTTLImpl: (TTL) -> Void + public typealias Listener = (ViewValue, ResultType, ErroredQuery?) -> Void + + private(set) var view: ArrayView + private var updateTTLImpl: (TTL) -> Void + private var removeForward: () -> Void = {} private var destroyed = false + private var listeners: [(id: Int, listener: Listener)] = [] + private var nextListenerID = 0 + /// What a new client materializes again: set by `Zero`. + var query: AnyQuery? + var ttl: TTL = .defaultTTL init(_ view: ArrayView, updateTTL: @escaping (TTL) -> Void) { self.view = view self.updateTTLImpl = updateTTL + forward() } isolated deinit { @@ -374,27 +385,90 @@ public final class MaterializedView { public var data: ViewValue { view.data } public var resultType: ResultType { view.listenerResultType.0 } public var error: ErroredQuery? { view.listenerResultType.1 } + public var isDestroyed: Bool { destroyed } + + /// The primary key columns of the rows, which make their `RowID`s. + public var primaryKey: [String] { view.input.getSchema().primaryKey } /// Calls `listener` now and whenever the data or result type change. @discardableResult - public func addListener( - _ listener: @escaping (ViewValue, ResultType, ErroredQuery?) -> Void - ) - -> () -> Void - { - view.addListener { [weak view] data, type in - listener(data, type, view?.listenerResultType.1) + public func addListener(_ listener: @escaping Listener) -> () -> Void { + let id = nextListenerID + nextListenerID += 1 + listeners.append((id, listener)) + let (type, error) = view.listenerResultType + listener(view.data, type, error) + return { [weak self] in self?.listeners.removeAll { $0.id == id } } + } + + /// Calls `listener` now and whenever the data or result type change, + /// with how the rows changed since its previous call. + @discardableResult + public func addSnapshotListener(_ listener: @escaping (ViewSnapshot) -> Void) -> () -> Void { + var differ = RowDiffer(primaryKey: primaryKey) + return addListener { data, type, error in listener(differ.next(data, type, error)) } + } + + /// The view's snapshots: the current one, then one per change, until the + /// iterating task is cancelled. The stream keeps the view alive. + public var snapshots: AsyncStream { + let (stream, continuation) = AsyncStream.makeStream() + let subscription = SnapshotSubscription(self) + subscription.remove = addSnapshotListener { continuation.yield($0) } + continuation.onTermination = { _ in + Task { @MainActor in subscription.cancel() } + } + return stream + } + + private func forward() { + removeForward = view.addListener { [weak self] data, type in + guard let self else { return } + let error = self.view.listenerResultType.1 + for (_, listener) in self.listeners { listener(data, type, error) } } } + /// Takes over the pipeline of `other`, a view of the same query on a new + /// client, and tells the listeners about its data. + func rebind(to other: MaterializedView) { + guard !destroyed else { return } + removeForward() + view.destroy() + other.removeForward() + other.destroyed = true + view = other.view + updateTTLImpl = other.updateTTLImpl + forward() + } + /// Stops updating the view and releases its query, which the server /// keeps syncing for the query's TTL. public func destroy() { guard !destroyed else { return } destroyed = true + removeForward() + listeners = [] view.destroy() } /// Raises the query's TTL; a lower one is ignored. - public func updateTTL(_ ttl: TTL) { updateTTLImpl(ttl) } + public func updateTTL(_ ttl: TTL) { + self.ttl = ttl + updateTTLImpl(ttl) + } +} + +/// Keeps a view alive while its snapshot stream is iterated. +@MainActor +private final class SnapshotSubscription { + let view: MaterializedView + var remove: (() -> Void)? + + init(_ view: MaterializedView) { self.view = view } + + func cancel() { + remove?() + remove = nil + } } diff --git a/swift/Sources/Zero/Client/Socket.swift b/swift/Sources/Zero/Client/Socket.swift index 0bb42893e0..a737afb1ab 100644 --- a/swift/Sources/Zero/Client/Socket.swift +++ b/swift/Sources/Zero/Client/Socket.swift @@ -41,72 +41,80 @@ public final class TransportSocketFactory: SocketFactory { } } +/// One socket of a `Transport`. Frames are written in the order they are +/// sent, and the close after them. @MainActor final class TransportConnection: SocketConnection { + private enum Outgoing: Sendable { + case text(String) + case close(Int) + } + private weak var events: (any SocketEvents)? - private var socket: (any Socket)? - private var pending: [String] = [] private var closed = false - private var task: Task? + private let outgoing: AsyncStream.Continuation init(transport: any Transport, url: String, secProtocol: String?, events: any SocketEvents) { self.events = events - task = Task { @MainActor [weak self] in + let (frames, outgoing) = AsyncStream.makeStream() + self.outgoing = outgoing + Task { @MainActor [weak self] in let socket: any Socket do { socket = try await transport.connect(url: url, secProtocol: secProtocol) } catch { - self?.finish(code: 1006, reason: String(describing: error), wasClean: false) + self?.finish(SocketClosed(error)) return } guard let self, !self.closed else { await socket.close(code: 1000, reason: "") return } - self.socket = socket + // One writer keeps the frames in order; each send in a task of its + // own could overtake the one before. + Task { + for await frame in frames { + switch frame { + case .text(let text): + try? await socket.send(.text(text)) + case .close(let code): + await socket.close(code: code, reason: "") + return + } + } + } self.events?.socketDidOpen() - for text in self.pending { self.write(text) } - self.pending = [] do { while let message = try await socket.receive() { if self.closed { return } self.events?.socketDidReceive(message) } - self.finish(code: 1000, reason: "", wasClean: true) + self.finish(SocketClosed(code: 1000, reason: "", wasClean: true)) } catch { - self.finish(code: 1006, reason: String(describing: error), wasClean: false) + self.finish(SocketClosed(error)) } } } func send(_ text: String) { guard !closed else { return } - if socket == nil { - pending.append(text) - } else { - write(text) - } - } - - private func write(_ text: String) { - guard let socket else { return } - Task { try? await socket.send(.text(text)) } + outgoing.yield(.text(text)) } func close(code: Int?) { guard !closed else { return } closed = true events = nil - if let socket { - Task { await socket.close(code: code ?? 1000, reason: "") } - } + outgoing.yield(.close(code ?? 1000)) + outgoing.finish() } - private func finish(code: Int, reason: String, wasClean: Bool) { + private func finish(_ close: SocketClosed) { guard !closed else { return } closed = true + outgoing.finish() let events = self.events self.events = nil - events?.socketDidClose(code: code, reason: reason, wasClean: wasClean) + events?.socketDidClose(code: close.code, reason: close.reason, wasClean: close.wasClean) } } diff --git a/swift/Sources/Zero/Client/Transport.swift b/swift/Sources/Zero/Client/Transport.swift index da16741d0b..b661320370 100644 --- a/swift/Sources/Zero/Client/Transport.swift +++ b/swift/Sources/Zero/Client/Transport.swift @@ -16,11 +16,36 @@ public protocol Transport: Sendable { /// An open WebSocket. public protocol Socket: AnyObject, Sendable { func send(_ message: SocketMessage) async throws - /// The next message, or nil once the socket has closed. + /// The next message, or nil once the socket has closed cleanly. Throws + /// `SocketClosed` to report how it closed otherwise; any other error is + /// an abrupt close. func receive() async throws -> SocketMessage? func close(code: Int, reason: String) async } +/// How a socket closed: the close frame's code and reason, and whether the +/// closing handshake completed (the browser `CloseEvent`). +public struct SocketClosed: Error, Sendable, Hashable { + public var code: Int + public var reason: String + public var wasClean: Bool + + public init(code: Int, reason: String, wasClean: Bool) { + self.code = code + self.reason = reason + self.wasClean = wasClean + } + + /// `error` as a close: itself, or an abrupt close (1006). + public init(_ error: any Error) { + if let closed = error as? SocketClosed { + self = closed + } else { + self.init(code: 1006, reason: String(describing: error), wasClean: false) + } + } +} + /// The query parameters of the sync connect URL, in the order JS sets them /// (`createConnectionURL` in `packages/zero-client/src/client/zero.ts`). public struct ConnectionURL: Sendable, Hashable { diff --git a/swift/Sources/Zero/Client/ViewSnapshot.swift b/swift/Sources/Zero/Client/ViewSnapshot.swift new file mode 100644 index 0000000000..a66c61a896 --- /dev/null +++ b/swift/Sources/Zero/Client/ViewSnapshot.swift @@ -0,0 +1,65 @@ +/// The identity of a row in a view: its primary key values, in key order. +public struct RowID: Hashable, Sendable, CustomStringConvertible { + public let values: [JSONValue] + + public init(_ values: [JSONValue]) { self.values = values } + + init(_ row: Row, primaryKey: [String]) { + values = primaryKey.map { row[$0] ?? .null } + } + + /// A single-column key as JS `String()` prints it, a compound key as a + /// JSON array. + public var description: String { + values.count == 1 ? Keys.jsString(values[0]) : JSONSerializer.stringify(.array(values)) + } +} + +/// A view's rows at one point, and how its top-level rows changed since the +/// snapshot before. +public struct ViewSnapshot: Sendable { + public let data: ViewValue + public let resultType: ResultType + public let error: ErroredQuery? + /// The IDs of the top-level rows, in order. + public let ids: [RowID] + /// From the previous snapshot's `ids` to these. The first snapshot inserts + /// every row. + public let difference: CollectionDifference + /// The rows in both snapshots whose columns or related rows changed. + public let updated: Set +} + +/// Diffs each value of a view against the one before. +struct RowDiffer { + let primaryKey: [String] + private var ids: [RowID] = [] + private var entries: [RowID: ViewEntry] = [:] + + init(primaryKey: [String]) { self.primaryKey = primaryKey } + + mutating func next( + _ data: ViewValue, _ resultType: ResultType, _ error: ErroredQuery? + ) -> ViewSnapshot { + let rows = data.entries + var newIDs: [RowID] = [] + newIDs.reserveCapacity(rows.count) + var newEntries: [RowID: ViewEntry] = [:] + newEntries.reserveCapacity(rows.count) + var updated: Set = [] + for entry in rows { + let id = RowID(entry.row, primaryKey: primaryKey) + newIDs.append(id) + if let old = entries[id], old != entry { updated.insert(id) } + newEntries[id] = entry + } + // Most changes edit rows in place, which leaves the order as it was. + let difference = + newIDs == ids ? CollectionDifference([])! : newIDs.difference(from: ids) + ids = newIDs + entries = newEntries + return ViewSnapshot( + data: data, resultType: resultType, error: error, ids: newIDs, difference: difference, + updated: updated) + } +} diff --git a/swift/Sources/Zero/Client/ZeroClient.swift b/swift/Sources/Zero/Client/ZeroClient.swift index a6f30e593e..e77cc3dce5 100644 --- a/swift/Sources/Zero/Client/ZeroClient.swift +++ b/swift/Sources/Zero/Client/ZeroClient.swift @@ -1,3 +1,5 @@ +import Observation + /// A Zero client: syncs the rows of named queries from zero-cache into a /// local replica, keeps materialized views of them current, and runs /// mutators optimistically before the server confirms them @@ -7,19 +9,34 @@ /// arguments, such as the logged-in user's auth data. @MainActor public final class Zero { - let engine: Engine + private(set) var engine: Engine public let context: Context public let connection: Connection + private let schemaValue: ZeroSchema + private let options: ZeroOptions + private let functions: [String: MutatorFunction] + private let scheduler: any Scheduler + private let socketFactory: (any SocketFactory)? + /// The views and preloads to move to a new client. + private var views: [WeakView] = [] + private var preloads: [PreloadRecord] = [] + #if canImport(Darwin) + private var lifecycle: AppLifecycle? + #endif + /// - Parameters: /// - mutators: Every mutator the client may run. Pending mutations are /// replayed by name, so each must be registered. - /// - scheduler: The clock and timers; tests pass a manual one. - /// - socketFactory: Opens the sync socket. + /// - scheduler: The clock and timers. With the system clock, the client + /// also follows the app's lifecycle and the network; tests pass a + /// manual one. + /// - socketFactory: Opens the sync socket. Defaults to + /// `URLSessionTransport` on Apple platforms. public init( schema: ZeroSchema, context: Context, mutators: [any Mutator] = [], options: ZeroOptions, scheduler: any Scheduler = SystemScheduler(), - socketFactory: (any SocketFactory)? + socketFactory: (any SocketFactory)? = platformSocketFactory() ) throws { var functions: [String: MutatorFunction] = [:] for m in mutators { @@ -32,7 +49,17 @@ public final class Zero { schema: schema, options: options, mutators: functions, scheduler: scheduler, socketFactory: socketFactory) self.context = context + self.schemaValue = schema + self.options = options + self.functions = functions + self.scheduler = scheduler + self.socketFactory = socketFactory connection = Connection(engine) + attach(engine) + #if canImport(Darwin) + // Simulated time drives everything in tests, the app's lifecycle too. + if scheduler is SystemScheduler { lifecycle = AppLifecycle(self) } + #endif } public var clientID: String { engine.clientID } @@ -41,6 +68,47 @@ public final class Zero { public var schema: ZeroSchema { engine.schema } public var closed: Bool { engine.closed } + private func attach(_ engine: Engine) { + if options.onClientStateNotFound == nil { + engine.onClientStateNotFound = { [weak self] in self?.replaceClient() } + } + engine.onUpdateNeeded = { [weak self] reason in self?.connection.updateNeeded = reason } + } + + /// Replaces the client, which can't sync any longer, by a new one on the + /// same store, and moves the views and preloads to it (the + /// `onClientStateNotFound` default; the React provider does the same). + func replaceClient() { + let old = engine + guard !old.closed else { return } + let new: Engine + do { + new = try Engine( + schema: schemaValue, options: options, mutators: functions, scheduler: scheduler, + socketFactory: socketFactory, visible: old.visible) + } catch { + old.log.error("Failed to replace the client: \(error)") + return + } + engine = new + attach(new) + connection.bind(new) + views.removeAll { $0.view?.isDestroyed ?? true } + for case let view? in views.map(\.view) { + guard let query = view.query, + let fresh = try? new.loop.call({ try new.context.materialize(query, ttl: view.ttl) }) + else { continue } + view.rebind(to: fresh) + } + preloads.removeAll { !$0.active } + for record in preloads { + record.cleanupImpl = + new.loop.call { new.context.preload(record.query, ttl: record.ttl) } + .cleanup + } + old.close() + } + /// A view of the query's rows that stays current. Named queries are /// synced from the server; an unnamed one only sees local data. public func materialize( @@ -57,7 +125,14 @@ public final class Zero { } public func materialize(_ query: AnyQuery, ttl: TTL = .defaultTTL) throws -> MaterializedView { - try engine.loop.call { try engine.context.materialize(query, ttl: ttl) } + let view = try engine.loop.call { try engine.context.materialize(query, ttl: ttl) } + view.query = query + view.ttl = ttl + if views.count >= 64, views.count.isMultiple(of: 64) { + views.removeAll { $0.view?.isDestroyed ?? true } + } + views.append(WeakView(view)) + return view } /// The query's rows: what the replica holds now (`.unknown`), or once the @@ -95,7 +170,12 @@ public final class Zero { } public func preload(_ query: AnyQuery, ttl: TTL = .defaultPreloadTTL) -> Preload { - engine.loop.call { engine.context.preload(query, ttl: ttl) } + let preload = engine.loop.call { engine.context.preload(query, ttl: ttl) } + guard query.customQueryID != nil else { return preload } + let record = PreloadRecord(query: query, ttl: ttl, cleanup: preload.cleanup) + preloads.removeAll { !$0.active } + preloads.append(record) + return Preload(cleanup: record.cleanup, cached: preload.cached, complete: preload.complete) } /// Runs a mutator on the client now and sends it to the server. @@ -104,17 +184,83 @@ public final class Zero { engine.loop.call { engine.mutate(name: request.name, args: request.args) } } + /// The app came to the foreground, or went to the background. + func setVisible(_ visible: Bool) { + engine.loop.call { engine.setVisible(visible) } + } + + /// The network may be back: reconnect without waiting for the backoff. + func reconnectNow() { + engine.loop.call { engine.reconnectNow() } + } + + /// Calls `done` once the server has confirmed every pending mutation, + /// or when it can't. + func whenPendingSettled(_ done: @escaping () -> Void) { + engine.loop.call { engine.whenPendingSettled(done) } + } + + /// Waits for the store's queued writes. + func flushStorage() { + engine.database.flush() + } + /// Closes the connection. Pending mutations and queries fail, and views /// stop updating. public func close() { + #if canImport(Darwin) + lifecycle?.stop() + lifecycle = nil + #endif engine.loop.call { engine.close() } } } +extension Zero: LifecycleClient {} + +/// The socket factory a client uses by default: `URLSessionTransport` on +/// Apple platforms, and none elsewhere. +@MainActor +public func platformSocketFactory() -> (any SocketFactory)? { + #if canImport(Darwin) + TransportSocketFactory(URLSessionTransport()) + #else + nil + #endif +} + +@MainActor +private struct WeakView { + weak var view: MaterializedView? + init(_ view: MaterializedView) { self.view = view } +} + +/// A preload that moves to a new client with its `Zero`. +@MainActor +private final class PreloadRecord { + let query: AnyQuery + let ttl: TTL + var cleanupImpl: () -> Void + private(set) var active = true + + init(query: AnyQuery, ttl: TTL, cleanup: @escaping () -> Void) { + self.query = query + self.ttl = ttl + self.cleanupImpl = cleanup + } + + func cleanup() { + guard active else { return } + active = false + cleanupImpl() + } +} + extension Zero where Context == Void { public convenience init( schema: ZeroSchema, mutators: [any Mutator] = [], options: ZeroOptions, - scheduler: any Scheduler = SystemScheduler(), socketFactory: (any SocketFactory)? + scheduler: any Scheduler = SystemScheduler(), + socketFactory: (any SocketFactory)? = platformSocketFactory() ) throws { try self.init( schema: schema, context: (), mutators: mutators, options: options, scheduler: scheduler, @@ -128,24 +274,43 @@ public enum RunType: String, Sendable { } /// The connection to zero-cache (`Connection` in -/// `packages/zero-client/src/client/connection.ts`). +/// `packages/zero-client/src/client/connection.ts`). Observable: SwiftUI +/// views and UIKit and AppKit updates that read `state` follow it. +@Observable @MainActor public final class Connection { - private let engine: Engine + @ObservationIgnored private var engine: Engine public private(set) var state: ConnectionState - private var listeners: [(id: Int, listener: (ConnectionState) -> Void)] = [] - private var nextID = 0 + /// Why the app must be updated to sync, once the server said so + /// (`onUpdateNeeded`). Reconnecting doesn't help: the app should ask the + /// user to update. + public internal(set) var updateNeeded: UpdateNeededReason? + @ObservationIgnored private var listeners: [(id: Int, listener: (ConnectionState) -> Void)] = + [] + @ObservationIgnored private var nextID = 0 + @ObservationIgnored private var unsubscribe: () -> Void = {} init(_ engine: Engine) { self.engine = engine state = ConnectionState(engine.connectionManager.state) - _ = engine.connectionManager.subscribe { [weak self] s in - guard let self else { return } - self.state = ConnectionState(s) - for (_, l) in self.listeners { l(self.state) } + bind(engine) + } + + /// Follows the state of `engine`, a new client. + func bind(_ engine: Engine) { + unsubscribe() + self.engine = engine + unsubscribe = engine.connectionManager.subscribe { [weak self] s in self?.update(s) } + if ConnectionState(engine.connectionManager.state) != state { + update(engine.connectionManager.state) } } + private func update(_ s: ConnectionManager.State) { + state = ConnectionState(s) + for (_, l) in listeners { l(state) } + } + /// Calls `listener` on every change of `state`. @discardableResult public func subscribe(_ listener: @escaping (ConnectionState) -> Void) -> () -> Void { @@ -161,3 +326,17 @@ public final class Connection { engine.loop.call { engine.connect(auth: auth.map { .some($0) }) } } } + +/// A `Zero` of any context, for code that needs only what doesn't depend +/// on it, such as the SwiftUI environment. +@MainActor +public protocol AnyZero: AnyObject, Sendable { + var connection: Connection { get } + var clientID: String { get } + var clientGroupID: String { get } + var userID: String? { get } + var closed: Bool { get } + func close() +} + +extension Zero: AnyZero {} diff --git a/swift/Sources/Zero/IVM/View.swift b/swift/Sources/Zero/IVM/View.swift index 59c66b55a6..c9d532f989 100644 --- a/swift/Sources/Zero/IVM/View.swift +++ b/swift/Sources/Zero/IVM/View.swift @@ -38,8 +38,16 @@ public struct ViewEntry: Sendable { } } +extension ViewEntry: Equatable { + /// Equal rows with equal relationships. How many edges reach the row + /// doesn't matter. + public static func == (a: ViewEntry, b: ViewEntry) -> Bool { + a.row == b.row && a.relationships == b.relationships + } +} + /// A relationship in a view: several rows, or at most one. -public enum ViewValue: Sendable { +public enum ViewValue: Sendable, Equatable { case many([ViewEntry]) case one(ViewEntry?) diff --git a/swift/Sources/ZeroTesting/RecordingLogSink.swift b/swift/Sources/ZeroTesting/RecordingLogSink.swift new file mode 100644 index 0000000000..0069375cf1 --- /dev/null +++ b/swift/Sources/ZeroTesting/RecordingLogSink.swift @@ -0,0 +1,20 @@ +import Synchronization + +/// A log sink that keeps the messages, for tests to check or to keep a test +/// run's output quiet. +public final class RecordingLogSink: LogSink { + public struct Entry: Sendable, Hashable { + public let level: LogLevel + public let message: String + } + + private let recorded = Mutex<[Entry]>([]) + + public init() {} + + public func log(level: LogLevel, message: String, context: [String: String]) { + recorded.withLock { $0.append(Entry(level: level, message: message)) } + } + + public var entries: [Entry] { recorded.withLock { $0 } } +} diff --git a/swift/Sources/ZeroUI/Diffable.swift b/swift/Sources/ZeroUI/Diffable.swift new file mode 100644 index 0000000000..7fdd62bb89 --- /dev/null +++ b/swift/Sources/ZeroUI/Diffable.swift @@ -0,0 +1,117 @@ +#if canImport(UIKit) || canImport(AppKit) + #if canImport(UIKit) + import UIKit + #else + import AppKit + #endif + + /// Feeds a view's rows to a diffable data source: one item per row, keyed + /// by its primary key, in the view's order. Items whose row changed are + /// reconfigured. + /// + /// ```swift + /// let dataSource = UITableViewDiffableDataSource(tableView: tableView) { + /// [unowned self] tableView, indexPath, id in + /// let cell = tableView.dequeueReusableCell(withIdentifier: "issue", for: indexPath) + /// cell.textLabel?.text = adapter.row(for: id)?.title + /// return cell + /// } + /// adapter = DiffableAdapter(try zero.materialize(IssueQueries.open()), + /// section: 0, dataSource: dataSource) + /// ``` + @MainActor + public final class DiffableAdapter { + public typealias Snapshot = NSDiffableDataSourceSnapshot + + /// The view the adapter reads; it stays current while the adapter lives. + public let view: MaterializedView + public let section: Section + /// The rows by ID. A row that can't be read as `Row` is missing. + public private(set) var rows: [RowID: Row] = [:] + private var removeListener: () -> Void = {} + + /// Calls `apply` with the first snapshot now, not animated, and with a + /// snapshot for each change after, animated. + public init( + _ view: MaterializedView, section: Section, + apply: @escaping (_ snapshot: Snapshot, _ animatingDifferences: Bool) -> Void + ) { + self.view = view + self.section = section + var animating = false + removeListener = view.addSnapshotListener { [weak self] snapshot in + guard let self else { return } + self.update(snapshot) + var s = Snapshot() + s.appendSections([section]) + s.appendItems(snapshot.ids, toSection: section) + if !snapshot.updated.isEmpty { s.reconfigureItems(Array(snapshot.updated)) } + apply(s, animating) + animating = true + } + } + + isolated deinit { + removeListener() + } + + public func row(for id: RowID) -> Row? { rows[id] } + + /// Reads the rows that are new or changed. + private func update(_ snapshot: ViewSnapshot) { + var changed = snapshot.updated + for change in snapshot.difference { + switch change { + case .remove(_, let id, _): rows[id] = nil + case .insert(_, let id, _): changed.insert(id) + } + } + for (id, entry) in zip(snapshot.ids, snapshot.data.entries) + where changed.contains(id) || rows[id] == nil { + rows[id] = try? Row(entry: entry) + } + } + } + + #if canImport(UIKit) + extension DiffableAdapter { + public convenience init( + _ view: MaterializedView, section: Section, + dataSource: UITableViewDiffableDataSource + ) { + self.init(view, section: section) { [weak dataSource] snapshot, animating in + dataSource?.apply(snapshot, animatingDifferences: animating) + } + } + + public convenience init( + _ view: MaterializedView, section: Section, + dataSource: UICollectionViewDiffableDataSource + ) { + self.init(view, section: section) { [weak dataSource] snapshot, animating in + dataSource?.apply(snapshot, animatingDifferences: animating) + } + } + } + #else + extension DiffableAdapter { + public convenience init( + _ view: MaterializedView, section: Section, + dataSource: NSTableViewDiffableDataSource + ) { + self.init(view, section: section) { [weak dataSource] snapshot, animating in + dataSource?.apply(snapshot, animatingDifferences: animating) + } + } + + public convenience init( + _ view: MaterializedView, section: Section, + dataSource: NSCollectionViewDiffableDataSource + ) { + self.init(view, section: section) { [weak dataSource] snapshot, animating in + dataSource?.apply(snapshot, animatingDifferences: animating) + } + } + } + #endif +#endif diff --git a/swift/Sources/ZeroUI/SwiftUI.swift b/swift/Sources/ZeroUI/SwiftUI.swift new file mode 100644 index 0000000000..61f1935405 --- /dev/null +++ b/swift/Sources/ZeroUI/SwiftUI.swift @@ -0,0 +1,104 @@ +#if canImport(SwiftUI) + import SwiftUI + + extension EnvironmentValues { + /// The client that `@ZeroQuery` reads from (`ZeroProvider`). Set it + /// with `.zero(_:)`. + public var zero: (any AnyZero)? { + get { self[ZeroKey.self] } + set { self[ZeroKey.self] = newValue } + } + } + + private struct ZeroKey: EnvironmentKey { + static let defaultValue: (any AnyZero)? = nil + } + + extension View { + /// Makes `zero` the client of this view hierarchy's `@ZeroQuery`s. + public func zero(_ zero: some AnyZero) -> some View { + environment(\.zero, zero) + } + } + + /// The rows of a query, kept current (`useQuery` in `zero-react`): + /// + /// ```swift + /// @ZeroQuery(IssueQueries.open()) var issues + /// + /// var body: some View { + /// List(issues) { IssueRow(issue: $0) } + /// .overlay { if $issues.resultType == .unknown { ProgressView() } } + /// } + /// ``` + /// + /// The client comes from the environment (`.zero(_:)`); its context type + /// must be the request's. Until there is one, the value is empty. + @MainActor + @propertyWrapper + public struct ZeroQuery: DynamicProperty + where Result.Row == Row { + @Environment(\.zero) private var zero + @State private var state = QueryState() + private let request: QueryRequest + private let ttl: TTL + + public init(_ request: QueryRequest, ttl: TTL = .defaultTTL) { + self.request = request + self.ttl = ttl + } + + public var wrappedValue: Result { state.live?.data ?? state.empty } + + /// The query's result type and error. + public var projectedValue: QueryState { state } + + public func update() { + state.update(zero as? Zero, request, ttl) + } + } + + /// What `@ZeroQuery` knows about its query besides the rows. + @Observable + @MainActor + public final class QueryState { + /// The live query, once the environment has a client. + public private(set) var live: LiveQuery? + private var materializeError: (any Error)? + @ObservationIgnored private var key: + (zero: ObjectIdentifier, name: String, args: [JSONValue], ttl: TTL)? + @ObservationIgnored let empty: Result + + init() { + // An array of no rows, or no row. + empty = try! Result(view: .many([])) + } + + public var resultType: ResultType { live?.resultType ?? .unknown } + /// The query could not be built or run, or its rows not read. + public var error: (any Error)? { materializeError ?? live?.error } + + func update( + _ zero: Zero?, _ request: QueryRequest, _ ttl: TTL + ) { + guard let zero else { + key = nil + live = nil + return + } + if let key, key.zero == ObjectIdentifier(zero), key.name == request.name, + key.args == request.args, key.ttl == ttl + { + return + } + key = (ObjectIdentifier(zero), request.name, request.args, ttl) + do { + live = try zero.liveQuery(request, ttl: ttl) + materializeError = nil + } catch { + live = nil + materializeError = error + } + } + } +#endif diff --git a/swift/Tests/AppleTests/DiffableAdapterTests.swift b/swift/Tests/AppleTests/DiffableAdapterTests.swift new file mode 100644 index 0000000000..acaf75fbde --- /dev/null +++ b/swift/Tests/AppleTests/DiffableAdapterTests.swift @@ -0,0 +1,37 @@ +#if canImport(UIKit) || canImport(AppKit) + import Testing + import ZeroTesting + import ZeroUI + + @Suite @MainActor struct DiffableAdapterTests { + func makeZero() throws -> Zero { + var options = ZeroOptions(userID: "u", storageKey: randomID(), storage: .memory) + options.logSink = RecordingLogSink() + return try Zero( + schema: .notes, mutators: [Notes.put, Notes.remove], options: options, + scheduler: ManualScheduler(now: 0), socketFactory: nil) + } + + @Test func appliesASnapshotPerChangeKeyedByPrimaryKey() throws { + let zero = try makeZero() + var applied: [(snapshot: DiffableAdapter.Snapshot, animating: Bool)] = [] + let adapter = DiffableAdapter( + try zero.materialize(Notes.all()), section: 0 + ) { applied.append(($0, $1)) } + + zero.mutate(Notes.put(.init(id: "b", text: "2"))) + zero.mutate(Notes.put(.init(id: "a", text: "1"))) + zero.mutate(Notes.put(.init(id: "a", text: "1!"))) + zero.mutate(Notes.remove("b")) + + let a = RowID(["a"]) + let b = RowID(["b"]) + #expect(applied.map(\.snapshot.sectionIdentifiers) == Array(repeating: [0], count: 5)) + #expect(applied.map(\.snapshot.itemIdentifiers) == [[], [b], [a, b], [a, b], [a]]) + #expect(applied.map(\.snapshot.reconfiguredItemIdentifiers) == [[], [], [], [a], []]) + #expect(applied.map(\.animating) == [false, true, true, true, true]) + #expect(adapter.row(for: a) == Note(id: "a", text: "1!")) + #expect(adapter.row(for: b) == nil) + } + } +#endif diff --git a/swift/Tests/AppleTests/Notes.swift b/swift/Tests/AppleTests/Notes.swift new file mode 100644 index 0000000000..f483ab2a79 --- /dev/null +++ b/swift/Tests/AppleTests/Notes.swift @@ -0,0 +1,48 @@ +import Zero + +/// A one-table schema for the Apple layer's tests. +struct Note: ZeroRow, Hashable, Identifiable { + var id: String + var text: String + + init(id: String, text: String) { + self.id = id + self.text = text + } + + static let tableName = "note" + static let columns: KeyPathNames = [\Note.id: "id", \Note.text: "text"] + static let relationships: KeyPathNames = [:] + + init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id") + text = try entry.column("text") + } + + var columnValues: Row { ["id": id.zeroValue, "text": text.zeroValue] } +} + +extension ZeroSchema { + static let notes = ZeroSchema(tables: [ + TableSchema( + name: "note", columns: [("id", ColumnSchema(.string)), ("text", ColumnSchema(.string))], + primaryKey: ["id"]) + ]) +} + +enum Notes { + static let table = Query(AnyQuery(.notes, "note")) + static let all = defineQuery("notes.all") { () in table.orderBy(\.id, .asc) } + + struct Put: Codable, Sendable { + var id: String + var text: String + } + + static let put = defineMutator("note.put") { (tx, note: Put) in + try tx.mutate.upsert(Note(id: note.id, text: note.text)) + } + static let remove = defineMutator("note.remove") { (tx, id: String) in + try tx.mutate.note.delete(["id": .string(id)]) + } +} diff --git a/swift/Tests/AppleTests/SwiftUITests.swift b/swift/Tests/AppleTests/SwiftUITests.swift new file mode 100644 index 0000000000..23fdcd1a92 --- /dev/null +++ b/swift/Tests/AppleTests/SwiftUITests.swift @@ -0,0 +1,73 @@ +#if canImport(SwiftUI) + import Foundation + import SwiftUI + import Testing + import ZeroTesting + import ZeroUI + + #if canImport(UIKit) + import UIKit + #else + import AppKit + #endif + + @Suite @MainActor struct SwiftUITests { + final class Seen { + var texts: [[String]] = [] + } + + struct NotesView: View { + @ZeroQuery(Notes.all()) var notes + let seen: Seen + + var body: some View { + let _ = seen.texts.append(notes.map(\.text)) + Text(notes.map(\.text).joined(separator: ",")) + } + } + + /// Lays the view out and lets SwiftUI process its updates. + func render(_ view: some View) -> AnyObject { + #if canImport(UIKit) + let host = UIHostingController(rootView: view) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 200, height: 200)) + window.rootViewController = host + window.makeKeyAndVisible() + host.view.layoutIfNeeded() + return window + #else + let host = NSHostingView(rootView: view) + host.frame = CGRect(x: 0, y: 0, width: 200, height: 200) + host.layoutSubtreeIfNeeded() + return host + #endif + } + + func settle(_ host: AnyObject) { + RunLoop.main.run(until: Date().addingTimeInterval(0.1)) + #if canImport(UIKit) + (host as? UIWindow)?.layoutIfNeeded() + #else + (host as? NSView)?.layoutSubtreeIfNeeded() + #endif + } + + @Test func zeroQueryFollowsTheRowsOfItsQuery() throws { + var options = ZeroOptions(userID: "u", storageKey: randomID(), storage: .memory) + options.logSink = RecordingLogSink() + let zero = try Zero( + schema: .notes, mutators: [Notes.put], options: options, + scheduler: ManualScheduler(now: 0), socketFactory: nil) + zero.mutate(Notes.put(.init(id: "a", text: "1"))) + + let seen = Seen() + let host = render(NotesView(seen: seen).zero(zero)) + settle(host) + #expect(seen.texts.last == ["1"]) + + zero.mutate(Notes.put(.init(id: "b", text: "2"))) + settle(host) + #expect(seen.texts.last == ["1", "2"]) + } + } +#endif diff --git a/swift/Tests/AppleTests/URLSessionTransportTests.swift b/swift/Tests/AppleTests/URLSessionTransportTests.swift new file mode 100644 index 0000000000..beac08c426 --- /dev/null +++ b/swift/Tests/AppleTests/URLSessionTransportTests.swift @@ -0,0 +1,119 @@ +#if canImport(Network) + import Foundation + import Testing + import ZeroTesting + + @testable import Zero + + /// The URLSession transport against a WebSocket server on the loopback + /// interface. + @Suite(.timeLimit(.minutes(1))) struct URLSessionTransportTests { + @Test func sendsTheProtocolAndExchangesFrames() async throws { + let server = try WebSocketServer() + let port = try await server.start() + defer { server.stop() } + var events = server.events.makeAsyncIterator() + + let socket = try await URLSessionTransport().connect( + url: "ws://127.0.0.1:\(port)/sync/v53/connect?clientID=c", secProtocol: "abc") + guard case .connected(let peer, let protocols)? = await events.next() else { + Issue.record("not connected") + return + } + #expect(protocols == ["abc"]) + + try await socket.send(.text("hello")) + guard case .message(_, let message)? = await events.next() else { + Issue.record("no message") + return + } + #expect(message == .text("hello")) + + peer.send(.text("hi")) + #expect(try await socket.receive() == .text("hi")) + peer.send(.binary([1, 2, 3])) + #expect(try await socket.receive() == .binary([1, 2, 3])) + // Larger than URLSessionWebSocketTask's default limit of 1 MB. + let large = String(repeating: "x", count: 3 << 20) + peer.send(.text(large)) + #expect(try await socket.receive() == .text(large)) + + peer.close(code: 4001, reason: "bye") + await #expect(throws: SocketClosed(code: 4001, reason: "bye", wasClean: true)) { + try await socket.receive() + } + } + + @Test func failsToConnectWhenNothingListens() async throws { + let server = try WebSocketServer() + let port = try await server.start() + server.stop() + try await Task.sleep(for: .milliseconds(100)) + await #expect(throws: SocketClosed.self) { + try await URLSessionTransport().connect(url: "ws://127.0.0.1:\(port)/", secProtocol: nil) + } + } + + /// Records what a socket reports, on the main actor. + @MainActor final class Recorder: SocketEvents { + var opened = false + var received: [SocketMessage] = [] + var closed: SocketClosed? + func socketDidOpen() { opened = true } + func socketDidReceive(_ message: SocketMessage) { received.append(message) } + func socketDidClose(code: Int, reason: String, wasClean: Bool) { + closed = SocketClosed(code: code, reason: reason, wasClean: wasClean) + } + } + + @Test @MainActor func keepsTheOrderOfFramesSentBeforeAndAfterTheOpen() async throws { + let server = try WebSocketServer() + let port = try await server.start() + defer { server.stop() } + var events = server.events.makeAsyncIterator() + + let recorder = Recorder() + let factory = TransportSocketFactory(URLSessionTransport()) + let connection = factory.open( + url: "ws://127.0.0.1:\(port)/", secProtocol: nil, events: recorder) + for i in 0..<200 { connection.send("\(i)") } + guard case .connected(let peer, _)? = await events.next() else { + Issue.record("not connected") + return + } + var texts: [String] = [] + while texts.count < 200, case .message(_, .text(let text))? = await events.next() { + texts.append(text) + } + #expect(texts == (0..<200).map(String.init)) + #expect(recorder.opened) + + peer.close(code: 4003, reason: "done") + while recorder.closed == nil { try await Task.sleep(for: .milliseconds(10)) } + #expect(recorder.closed == SocketClosed(code: 4003, reason: "done", wasClean: true)) + } + + @Test @MainActor func connectsAClientThroughTheDefaultFactory() async throws { + let server = try WebSocketServer() + let port = try await server.start() + defer { server.stop() } + var events = server.events.makeAsyncIterator() + + var options = ZeroOptions( + cacheURL: "http://127.0.0.1:\(port)", userID: "u", storageKey: UUID().uuidString, + storage: .memory) + options.logSink = RecordingLogSink() + let zero = try Zero(schema: ZeroSchema(tables: []), options: options) + defer { zero.close() } + guard case .connected(let peer, let protocols)? = await events.next() else { + Issue.record("not connected") + return + } + let decoded = try SecProtocols.decode(#require(protocols.first)) + #expect(decoded["initConnectionMessage"]?[0] == "initConnection") + + peer.send(.text(#"["connected",{"wsid":"w"}]"#)) + while zero.connection.state != .connected { try await Task.sleep(for: .milliseconds(10)) } + } + } +#endif diff --git a/swift/Tests/AppleTests/WebSocketServer.swift b/swift/Tests/AppleTests/WebSocketServer.swift new file mode 100644 index 0000000000..d6ec862885 --- /dev/null +++ b/swift/Tests/AppleTests/WebSocketServer.swift @@ -0,0 +1,154 @@ +#if canImport(Network) + import Foundation + import Network + import Zero + + /// A value behind a lock. + final class Locked: @unchecked Sendable { + private let lock = NSLock() + private var value: Value + + init(_ value: Value) { self.value = value } + + func withLock(_ body: (inout Value) -> R) -> R { + lock.lock() + defer { lock.unlock() } + return body(&value) + } + } + + /// A WebSocket server on the loopback interface, for the URLSession + /// transport to talk to. + final class WebSocketServer: Sendable { + enum Event: Sendable { + case connected(Peer, protocols: [String]) + case message(Peer, SocketMessage) + case closed(Peer, code: Int) + } + + /// One client connection. + final class Peer: Sendable { + let connection: NWConnection + + init(_ connection: NWConnection) { self.connection = connection } + + func send(_ message: SocketMessage) { + let metadata: NWProtocolWebSocket.Metadata + let data: Data + switch message { + case .text(let text): + metadata = NWProtocolWebSocket.Metadata(opcode: .text) + data = Data(text.utf8) + case .binary(let bytes): + metadata = NWProtocolWebSocket.Metadata(opcode: .binary) + data = Data(bytes) + } + let context = NWConnection.ContentContext(identifier: "frame", metadata: [metadata]) + connection.send( + content: data, contentContext: context, isComplete: true, completion: .idempotent) + } + + /// Sends a close frame with an application code (4000-4999). + func close(code: UInt16, reason: String) { + let metadata = NWProtocolWebSocket.Metadata(opcode: .close) + metadata.closeCode = .privateCode(code) + let context = NWConnection.ContentContext(identifier: "close", metadata: [metadata]) + connection.send( + content: Data(reason.utf8), contentContext: context, isComplete: true, + completion: .idempotent) + } + } + + private let listener: NWListener + private let queue = DispatchQueue(label: "test.websocket") + let events: AsyncStream + private let continuation: AsyncStream.Continuation + private let protocols = Locked<[String]>([]) + + init() throws { + (events, continuation) = AsyncStream.makeStream() + let parameters = NWParameters.tcp + let options = NWProtocolWebSocket.Options() + options.autoReplyPing = true + options.maximumMessageSize = 64 << 20 + let protocols = self.protocols + options.setClientRequestHandler(queue) { subprotocols, _ in + protocols.withLock { $0 = subprotocols } + return NWProtocolWebSocket.Response( + status: .accept, subprotocol: subprotocols.first, additionalHeaders: nil) + } + parameters.defaultProtocolStack.applicationProtocols.insert(options, at: 0) + listener = try NWListener(using: parameters, on: .any) + } + + /// Starts listening; returns the port. + func start() async throws -> UInt16 { + let continuation = self.continuation + let protocols = self.protocols + let queue = self.queue + listener.newConnectionHandler = { connection in + let peer = Peer(connection) + connection.stateUpdateHandler = { state in + if case .ready = state { + continuation.yield(.connected(peer, protocols: protocols.withLock { $0 })) + } + } + connection.start(queue: queue) + Self.receive(peer, continuation) + } + return try await withCheckedThrowingContinuation { + (c: CheckedContinuation) in + let resumed = Locked(false) + let first = { + resumed.withLock { r in + defer { r = true } + return !r + } + } + listener.stateUpdateHandler = { [listener] state in + switch state { + case .ready: if first() { c.resume(returning: listener.port?.rawValue ?? 0) } + case .failed(let error): if first() { c.resume(throwing: error) } + default: break + } + } + listener.start(queue: queue) + } + } + + func stop() { + listener.cancel() + continuation.finish() + } + + private static func receive(_ peer: Peer, _ events: AsyncStream.Continuation) { + peer.connection.receiveMessage { data, context, _, error in + if error != nil { + events.yield(.closed(peer, code: 1006)) + return + } + let metadata = + context?.protocolMetadata(definition: NWProtocolWebSocket.definition) + as? NWProtocolWebSocket.Metadata + switch metadata?.opcode { + case .text?: + events.yield(.message(peer, .text(String(decoding: data ?? Data(), as: UTF8.self)))) + case .binary?: + events.yield(.message(peer, .binary([UInt8](data ?? Data())))) + case .close?: + var code = 1005 + switch metadata?.closeCode { + case .protocolCode(let defined)?: code = Int(defined.rawValue) + case .applicationCode(let c)?, .privateCode(let c)?: code = Int(c) + default: break + } + events.yield(.closed(peer, code: code)) + return + default: + break + } + receive(peer, events) + } + } + } +#endif diff --git a/swift/Tests/ConformanceTests/ClientConformanceTests.swift b/swift/Tests/ConformanceTests/ClientConformanceTests.swift index e2360616df..869745b4b0 100644 --- a/swift/Tests/ConformanceTests/ClientConformanceTests.swift +++ b/swift/Tests/ConformanceTests/ClientConformanceTests.swift @@ -116,6 +116,7 @@ final class ScenarioRunner { func makeZero(_ o: JSONValue, mutators registered: JSONValue? = nil) throws -> Zero { var options = ZeroOptions() + options.logSink = RecordingLogSink() switch o["cacheURL"] { case .null?: options.cacheURL = nil case .string(let s)?: options.cacheURL = s @@ -131,6 +132,7 @@ final class ScenarioRunner { if let v = o["disconnectTimeoutMs"]?.numberValue { options.disconnectTimeoutMs = v } if let v = o["queryChangeThrottleMs"]?.numberValue { options.queryChangeThrottleMs = v } if let v = o["maxHeaderLength"]?.numberValue { options.maxHeaderLength = Int(v) } + options.hiddenDisconnectDelayMs = o["hiddenTabDisconnectDelay"]?.numberValue ?? 300_000 options.mutateURL = o["mutateURL"]?.stringValue options.mutateHeaders = o["mutateHeaders"]?.objectValue?.compactMapValues(\.stringValue) options.queryURL = o["queryURL"]?.stringValue @@ -314,6 +316,8 @@ final class ScenarioRunner { wasClean: step["wasClean"]?.boolValue ?? false) case "advance": scheduler.advance(by: step["ms"]?.numberValue ?? 0) + case "visibility": + zero.setVisible(step["state"] == "visible") case "connect": zero.connection.connect(auth: step["auth"]?.stringValue) case "close": diff --git a/swift/Tests/ZeroTests/ClientAPITests.swift b/swift/Tests/ZeroTests/ClientAPITests.swift index b810491320..3a971c6f6c 100644 --- a/swift/Tests/ZeroTests/ClientAPITests.swift +++ b/swift/Tests/ZeroTests/ClientAPITests.swift @@ -44,8 +44,10 @@ import ZeroTesting /// A store of the test's own, in memory. static var options: ZeroOptions { - ZeroOptions( + var options = ZeroOptions( cacheURL: "http://zero.test", userID: "u2", storageKey: randomID(), storage: .memory) + options.logSink = RecordingLogSink() + return options } let scheduler = ManualScheduler(now: 1_700_000_000_000) diff --git a/swift/Tests/ZeroTests/LifecycleTests.swift b/swift/Tests/ZeroTests/LifecycleTests.swift new file mode 100644 index 0000000000..eb4a8054d2 --- /dev/null +++ b/swift/Tests/ZeroTests/LifecycleTests.swift @@ -0,0 +1,150 @@ +import Testing +import ZeroTesting + +@testable import Zero + +/// What the native client adds to the JS client's recovery: replacing the +/// client in place, reconnecting when the network is back, and waiting for +/// pending mutations before the app is suspended. +@Suite @MainActor struct LifecycleTests { + static let all = defineQuery("issues.all") { () in zql.issue.orderBy(\.id, .asc) } + static let closed = defineQuery("issues.closed") { () in zql.issue.where(\.closed, true) } + static let create = defineMutator("issue.create") { (tx, id: String) in + try tx.mutate.issue.insert([ + "id": .string(id), "title": "", "description": "", "closed": false, "ownerId": .null, + "createdAt": 1, + ]) + } + + let scheduler = ManualScheduler(now: 1_700_000_000_000) + let sockets = MockSocketFactory() + let log = RecordingLogSink() + + func makeZero() throws -> Zero { + var options = ZeroOptions( + cacheURL: "http://zero.test", userID: "u", storageKey: randomID(), storage: .memory) + options.logSink = log + return try Zero( + schema: .test, mutators: [Self.create], options: options, scheduler: scheduler, + socketFactory: sockets) + } + + /// Opens the newest socket and acknowledges it. + func connect() throws -> MockSocket { + scheduler.runDueTimers() + let socket = try #require(sockets.sockets.last) + socket.receive(text: #"["connected",{"wsid":"w"}]"#) + scheduler.runDueTimers() + return socket + } + + func poke( + _ socket: MockSocket, _ id: String, from base: String?, to cookie: String, _ part: String + ) { + let base = base.map { "\"\($0)\"" } ?? "null" + socket.receive(text: #"["pokeStart",{"pokeID":"\#(id)","baseCookie":\#(base)}]"#) + socket.receive(text: #"["pokePart",{"pokeID":"\#(id)",\#(part)}]"#) + socket.receive(text: #"["pokeEnd",{"pokeID":"\#(id)","cookie":"\#(cookie)"}]"#) + scheduler.runDueTimers() + } + + static let hash = QueryHash.hashOfNameAndArgs("issues.all", []) + static func row(_ id: String) -> String { + #"{"op":"put","tableName":"issues","value":{"id":"\#(id)","title":"","description":"","closed":false,"owner_id":null,"createdAt":1}}"# + } + + @Test func replacesAClientTheServerLostAndKeepsItsViews() throws { + let zero = try makeZero() + let view = try zero.materialize(Self.all()) + let live = try zero.liveQuery(Self.all()) + _ = zero.preload(Self.closed()) + let first = try connect() + poke( + first, "p1", from: nil, to: "01", + #""gotQueriesPatch":[{"op":"put","hash":"\#(Self.hash)"}],"rowsPatch":[\#(Self.row("i1"))]"#) + #expect(live.data.map(\.id) == ["i1"]) + let old = (zero.clientID, zero.clientGroupID) + + first.receive( + text: #"["error",{"kind":"ClientNotFound","message":"gone","origin":"zeroCache"}]"#) + scheduler.runDueTimers() + + // A new client in a new group; the old group is disabled, so its rows + // are gone until the new client syncs. + #expect(zero.clientID != old.0) + #expect(zero.clientGroupID != old.1) + #expect(view.data.json == []) + #expect(live.data.isEmpty) + let second = try connect() + #expect(sockets.sockets.count == 2) + let initConnection = try SecProtocols.decode(#require(second.secProtocol))[ + "initConnectionMessage"]?[1] + #expect( + Set(initConnection?["desiredQueriesPatch"]?.arrayValue?.compactMap { $0["name"] } ?? []) + == ["issues.all", "issues.closed"]) + #expect(zero.connection.state == .connected) + + poke( + second, "p1", from: nil, to: "01", + #""gotQueriesPatch":[{"op":"put","hash":"\#(Self.hash)"}],"rowsPatch":[\#(Self.row("i2"))]"#) + #expect(view.data.json?.arrayValue?.map { $0["id"] } == ["i2"]) + #expect(live.data.map(\.id) == ["i2"]) + #expect(view.resultType == .complete) + #expect(log.entries.contains { $0.level == .warn && $0.message == "ClientNotFound:\n\ngone" }) + } + + @Test func reconnectsWithoutTheBackoffWhenTheNetworkIsBack() throws { + let zero = try makeZero() + let first = try connect() + // A dropped connection reconnects at once; a failed attempt backs off. + first.serverClose(code: 1006) + scheduler.runDueTimers() + #expect(sockets.sockets.count == 2) + sockets.sockets[1].serverClose(code: 1006) + scheduler.advance(by: 1000) + #expect(sockets.sockets.count == 2) + + zero.reconnectNow() + scheduler.runDueTimers() + #expect(sockets.sockets.count == 3) + } + + @Test func checksAnOpenConnectionWhenTheNetworkChanges() throws { + let zero = try makeZero() + let socket = try connect() + zero.reconnectNow() + scheduler.runDueTimers() + #expect(socket.messages.last == #"["ping",{}]"#) + } + + @Test func waitsForTheServerToConfirmPendingMutations() throws { + let zero = try makeZero() + let socket = try connect() + zero.mutate(Self.create("i1")) + scheduler.runDueTimers() + var settled = false + zero.whenPendingSettled { settled = true } + scheduler.runDueTimers() + #expect(!settled) + + poke(socket, "p1", from: nil, to: "01", #""lastMutationIDChanges":{"\#(zero.clientID)":1}"#) + #expect(settled) + } + + @Test func disconnectsInTheBackgroundAndReconnectsInTheForeground() throws { + let zero = try makeZero() + let first = try connect() + zero.setVisible(false) + scheduler.runDueTimers() + #expect(first.closed) + #expect( + zero.connection.state == .disconnected(reason: "Connection closed because tab was hidden")) + scheduler.advance(by: 60_000) + #expect(sockets.sockets.count == 1) + + zero.setVisible(true) + _ = try connect() + #expect(sockets.sockets.count == 2) + #expect(zero.connection.state == .connected) + } +} diff --git a/swift/Tests/ZeroTests/StorageTests.swift b/swift/Tests/ZeroTests/StorageTests.swift index 912f136c4b..c7d952bc0d 100644 --- a/swift/Tests/ZeroTests/StorageTests.swift +++ b/swift/Tests/ZeroTests/StorageTests.swift @@ -18,6 +18,7 @@ import ZeroTesting var options = ZeroOptions( userID: "u", storageKey: storageKey, storage: storage ?? .sqlite(directory: directory)) options.onStorageFailure = onStorageFailure + options.logSink = RecordingLogSink() let zero = try Zero( schema: .test, mutators: [create], options: options, scheduler: scheduler, socketFactory: nil) diff --git a/swift/Tests/ZeroTests/ViewTests.swift b/swift/Tests/ZeroTests/ViewTests.swift new file mode 100644 index 0000000000..1d5ec8484c --- /dev/null +++ b/swift/Tests/ZeroTests/ViewTests.swift @@ -0,0 +1,99 @@ +import Observation +import Testing +import ZeroTesting + +@testable import Zero + +/// What views add to the JS views: row diffs between snapshots, and live +/// queries that Observation tracks. +@Suite @MainActor struct ViewTests { + struct Edit: Codable, Sendable { + var id: String + var title: String + } + + static let create = defineMutator("issue.create") { (tx, args: Edit) in + try tx.mutate.issue.insert([ + "id": .string(args.id), "title": .string(args.title), "description": "", "closed": false, + "ownerId": .null, "createdAt": 1, + ]) + } + static let retitle = defineMutator("issue.retitle") { (tx, args: Edit) in + try tx.mutate.issue.update(["id": .string(args.id), "title": .string(args.title)]) + } + static let remove = defineMutator("issue.remove") { (tx, id: String) in + try tx.mutate.issue.delete(["id": .string(id)]) + } + + func makeZero() throws -> Zero { + var options = ZeroOptions(userID: "u", storageKey: randomID(), storage: .memory) + options.logSink = RecordingLogSink() + return try Zero( + schema: .test, mutators: [Self.create, Self.retitle, Self.remove], options: options, + scheduler: ManualScheduler(now: 0), socketFactory: nil) + } + + @Test func diffsTheRowsOfEachSnapshotAgainstThePreviousOne() throws { + let zero = try makeZero() + let view = try zero.materialize(zql.issue.orderBy(\.title, .asc)) + var snapshots: [ViewSnapshot] = [] + _ = view.addSnapshotListener { snapshots.append($0) } + + zero.mutate(Self.create(Edit(id: "b", title: "2"))) + zero.mutate(Self.create(Edit(id: "a", title: "1"))) + zero.mutate(Self.retitle(Edit(id: "b", title: "2!"))) + // Moves `a` after `b`. + zero.mutate(Self.retitle(Edit(id: "a", title: "3"))) + zero.mutate(Self.remove("b")) + + let a = RowID(["a"]) + let b = RowID(["b"]) + #expect(snapshots.map(\.ids) == [[], [b], [a, b], [a, b], [b, a], [a]]) + #expect( + snapshots.map { Array($0.difference) } == [ + [], [.insert(offset: 0, element: b, associatedWith: nil)], + [.insert(offset: 0, element: a, associatedWith: nil)], [], + [ + .remove(offset: 0, element: a, associatedWith: nil), + .insert(offset: 1, element: a, associatedWith: nil), + ], + [.remove(offset: 0, element: b, associatedWith: nil)], + ]) + #expect(snapshots.map(\.updated) == [[], [], [], [b], [a], []]) + #expect( + snapshots.last?.data.json == [.object(Self.rest.merging(["id": "a", "title": "3"]) { $1 })]) + } + + static let rest: [String: JSONValue] = [ + "description": "", "closed": false, "ownerId": nil, "createdAt": 1, + ] + + @Test func streamsSnapshotsUntilTheConsumerStops() async throws { + let zero = try makeZero() + let view = try zero.materialize(zql.issue.orderBy(\.id, .asc)) + var iterator = view.snapshots.makeAsyncIterator() + #expect(await iterator.next()?.ids == []) + zero.mutate(Self.create(Edit(id: "a", title: ""))) + zero.mutate(Self.create(Edit(id: "b", title: ""))) + #expect(await iterator.next()?.ids == [RowID(["a"])]) + #expect(await iterator.next()?.ids == [RowID(["a"]), RowID(["b"])]) + } + + @Test func liveQueriesAreTrackedByObservation() throws { + let zero = try makeZero() + let live = try zero.liveQuery(zql.issue.orderBy(\.id, .asc)) + #expect(live.data.isEmpty) + + final class Flag: @unchecked Sendable { var raised = false } + let changed = Flag() + withObservationTracking { + _ = live.data + } onChange: { + changed.raised = true + } + zero.mutate(Self.create(Edit(id: "a", title: "A"))) + #expect(changed.raised) + #expect(live.data.map(\.title) == ["A"]) + #expect(live.resultType == .unknown) + } +} From a717844a4b8415ff65ebb706cdaee06f174726bd Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 19 Oct 2026 15:22:47 +0000 Subject: [PATCH 016/104] 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 --- swift/Sources/ZeroUI/Diffable.swift | 11 +++++++++-- swift/Sources/ZeroUI/SwiftUI.swift | 3 ++- swift/Tests/AppleTests/DiffableAdapterTests.swift | 4 +++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/swift/Sources/ZeroUI/Diffable.swift b/swift/Sources/ZeroUI/Diffable.swift index 7fdd62bb89..590d26d290 100644 --- a/swift/Sources/ZeroUI/Diffable.swift +++ b/swift/Sources/ZeroUI/Diffable.swift @@ -7,7 +7,7 @@ /// Feeds a view's rows to a diffable data source: one item per row, keyed /// by its primary key, in the view's order. Items whose row changed are - /// reconfigured. + /// reconfigured (reloaded on AppKit). /// /// ```swift /// let dataSource = UITableViewDiffableDataSource(tableView: tableView) { @@ -45,7 +45,14 @@ var s = Snapshot() s.appendSections([section]) s.appendItems(snapshot.ids, toSection: section) - if !snapshot.updated.isEmpty { s.reconfigureItems(Array(snapshot.updated)) } + if !snapshot.updated.isEmpty { + #if canImport(UIKit) + s.reconfigureItems(Array(snapshot.updated)) + #else + // AppKit snapshots can only reload. + s.reloadItems(Array(snapshot.updated)) + #endif + } apply(s, animating) animating = true } diff --git a/swift/Sources/ZeroUI/SwiftUI.swift b/swift/Sources/ZeroUI/SwiftUI.swift index 61f1935405..636287e2b2 100644 --- a/swift/Sources/ZeroUI/SwiftUI.swift +++ b/swift/Sources/ZeroUI/SwiftUI.swift @@ -36,7 +36,8 @@ /// must be the request's. Until there is one, the value is empty. @MainActor @propertyWrapper - public struct ZeroQuery: DynamicProperty + public struct ZeroQuery: + @preconcurrency DynamicProperty where Result.Row == Row { @Environment(\.zero) private var zero @State private var state = QueryState() diff --git a/swift/Tests/AppleTests/DiffableAdapterTests.swift b/swift/Tests/AppleTests/DiffableAdapterTests.swift index acaf75fbde..2789b8acfd 100644 --- a/swift/Tests/AppleTests/DiffableAdapterTests.swift +++ b/swift/Tests/AppleTests/DiffableAdapterTests.swift @@ -28,7 +28,9 @@ let b = RowID(["b"]) #expect(applied.map(\.snapshot.sectionIdentifiers) == Array(repeating: [0], count: 5)) #expect(applied.map(\.snapshot.itemIdentifiers) == [[], [b], [a, b], [a, b], [a]]) - #expect(applied.map(\.snapshot.reconfiguredItemIdentifiers) == [[], [], [], [a], []]) + #if canImport(UIKit) + #expect(applied.map(\.snapshot.reconfiguredItemIdentifiers) == [[], [], [], [a], []]) + #endif #expect(applied.map(\.animating) == [false, true, true, true, true]) #expect(adapter.row(for: a) == Note(id: "a", text: "1!")) #expect(adapter.row(for: b) == nil) From 62e6a405d1b5cce626f30d60c872d10efc62aabe Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 19 Oct 2026 15:22:47 +0000 Subject: [PATCH 017/104] 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 --- packages/zero-swift-cli/src/codegen.test.ts | 11 + swift/Tests/E2ETests/ClientE2ETests.swift | 106 ++++ .../Tests/E2ETests/Generated/TestSchema.swift | 507 ++++++++++++++++++ 3 files changed, 624 insertions(+) create mode 100644 swift/Tests/E2ETests/ClientE2ETests.swift create mode 100644 swift/Tests/E2ETests/Generated/TestSchema.swift diff --git a/packages/zero-swift-cli/src/codegen.test.ts b/packages/zero-swift-cli/src/codegen.test.ts index 299afb12c4..5c3e36f185 100644 --- a/packages/zero-swift-cli/src/codegen.test.ts +++ b/packages/zero-swift-cli/src/codegen.test.ts @@ -31,6 +31,17 @@ const fixtures: {name: string; config: Config}[] = [ access: 'internal', }, }, + { + name: 'zql test schema for the end-to-end tests', + config: { + schema: 'packages/zql/src/query/test/test-schemas.ts', + output: 'swift/Tests/E2ETests/Generated/TestSchema.swift', + schemaName: 'test', + zql: 'testZQL', + typePrefix: 'Test', + access: 'internal', + }, + }, { name: 'codegen fixture schema', config: { diff --git a/swift/Tests/E2ETests/ClientE2ETests.swift b/swift/Tests/E2ETests/ClientE2ETests.swift new file mode 100644 index 0000000000..34eb66cdb1 --- /dev/null +++ b/swift/Tests/E2ETests/ClientE2ETests.swift @@ -0,0 +1,106 @@ +import Foundation +import Testing +import Zero + +/// The app of the end-to-end server (`packages/zero-conformance/src/e2e/app.ts`), +/// defined in Swift as an app would. +enum E2EApp { + static let open = defineQuery("issues.open") { () in + testZQL.issue.where(\.closed, false).related(\.comments).related(\.owner) + .orderBy(\.createdAt, .asc) + } + static let byID = defineQuery("issues.byID") { (id: String) in + testZQL.issue.where(\.id, id).related(\.comments).one() + } + + struct CreateIssue: Codable, Sendable { + var id: String + var title: String + var ownerId: String? + } + + static let create = defineMutator("issue.create") { (tx, args: CreateIssue) in + try tx.mutate.insert( + TestIssue( + id: args.id, title: args.title, description: "", closed: false, ownerId: args.ownerId, + createdAt: 100)) + } + static let close = defineMutator("issue.close") { (tx, id: String) in + try tx.mutate.issue.update(["id": .string(id), "closed": true]) + } +} + +/// The whole client against zero-cache, the API server, and Postgres: sync, +/// optimistic mutations the server confirms, and a relaunch on the same +/// store. +@Suite(.enabled(if: E2E.url != nil), .timeLimit(.minutes(2))) +@MainActor +struct ClientE2ETests { + let directory = NSTemporaryDirectory() + "zero-e2e-" + UUID().uuidString + + func makeZero(storageKey: String) throws -> Zero { + let origin = E2E.url!.replacingOccurrences(of: "ws://", with: "http://") + let options = ZeroOptions( + cacheURL: origin, storageKey: storageKey, storage: .sqlite(directory: directory)) + return try Zero( + schema: .test, mutators: [E2EApp.create, E2EApp.close], options: options, + socketFactory: TransportSocketFactory(NIOTransport())) + } + + /// Waits until `condition` holds. + func eventually( + _ what: String, timeout: Duration = .seconds(30), _ condition: () -> Bool + ) async throws { + let deadline = ContinuousClock.now + timeout + while !condition() { + guard ContinuousClock.now < deadline else { + Issue.record("Timed out waiting for \(what)") + throw CancellationError() + } + try await Task.sleep(for: .milliseconds(20)) + } + } + + @Test func syncsMutatesAndRelaunches() async throws { + defer { try? FileManager.default.removeItem(atPath: directory) } + let key = UUID().uuidString + let zero = try makeZero(storageKey: key) + let open = try zero.liveQuery(E2EApp.open()) + try await eventually("the open issues") { open.resultType == .complete } + #expect(Set(["i1", "i3", "i4"]).isSubset(of: open.data.map(\.id))) + let i1 = try #require(open.data.first { $0.id == "i1" }) + #expect(i1.comments?.map(\.id) == ["c1", "c2"]) + #expect(i1.owner?.name == "Alice") + #expect(open.data.first { $0.id == "i3" }?.title == "Emoji 😀 in titles") + + // Optimistic at once, then confirmed by the server. + let id = "swift-\(UUID().uuidString)" + let created = zero.mutate(E2EApp.create(.init(id: id, title: "From Swift", ownerId: "u2"))) + #expect(open.data.contains { $0.id == id }) + #expect(try await created.client.value == .success(data: nil)) + #expect(try await created.server.value == .success(data: nil)) + let confirmed = try await zero.run(E2EApp.byID(id), type: .complete) + #expect(confirmed?.title == "From Swift") + + // Another client sees it through the server. + let other = try makeZero(storageKey: UUID().uuidString) + let seen = try await other.run(E2EApp.byID(id), type: .complete) + #expect(seen?.ownerId == "u2") + + let closed = zero.mutate(E2EApp.close(id)) + #expect(try await closed.server.value == .success(data: nil)) + #expect(!open.data.contains { $0.id == id }) + let liveOther = try other.liveQuery(E2EApp.byID(id)) + try await eventually("the close to sync") { liveOther.data?.closed == true } + other.close() + + // A relaunch reads the rows from its store before the server answers. + zero.close() + let relaunched = try makeZero(storageKey: key) + defer { relaunched.close() } + #expect(relaunched.clientGroupID == zero.clientGroupID) + let cached = try await relaunched.run(E2EApp.open(), type: .cached) + #expect(Set(["i1", "i3", "i4"]).isSubset(of: cached.map(\.id))) + #expect(!cached.contains { $0.id == id }) + } +} diff --git a/swift/Tests/E2ETests/Generated/TestSchema.swift b/swift/Tests/E2ETests/Generated/TestSchema.swift new file mode 100644 index 0000000000..36c65420d4 --- /dev/null +++ b/swift/Tests/E2ETests/Generated/TestSchema.swift @@ -0,0 +1,507 @@ +// Generated by `zero-swift codegen` from packages/zql/src/query/test/test-schemas.ts. Do not edit. +// swift-format-ignore-file + +import Zero + +struct TestIssue: ZeroRow, Codable, Hashable, Identifiable { + var id: String + var title: String + var description: String + var closed: Bool + var ownerId: String? + var createdAt: Double + + // Relationships, filled when a query asks for them with `related`. + @Indirect var owner: TestUser? + var comments: [TestComment]? + @Indirect var oneComment: TestComment? + var ownerComments: [TestComment]? + var labels: [TestLabel]? + + init( + id: String, + title: String, + description: String, + closed: Bool, + ownerId: String? = nil, + createdAt: Double, + owner: TestUser? = nil, + comments: [TestComment]? = nil, + oneComment: TestComment? = nil, + ownerComments: [TestComment]? = nil, + labels: [TestLabel]? = nil + ) { + self.id = id + self.title = title + self.description = description + self.closed = closed + self.ownerId = ownerId + self.createdAt = createdAt + self.owner = owner + self.comments = comments + self.oneComment = oneComment + self.ownerComments = ownerComments + self.labels = labels + } + + static let tableName = "issue" + + static let columns: KeyPathNames = [ + \TestIssue.id: "id", + \TestIssue.title: "title", + \TestIssue.description: "description", + \TestIssue.closed: "closed", + \TestIssue.ownerId: "ownerId", + \TestIssue.createdAt: "createdAt", + ] + + static let relationships: KeyPathNames = [ + \TestIssue.owner: "owner", + \TestIssue.comments: "comments", + \TestIssue.oneComment: "oneComment", + \TestIssue.ownerComments: "ownerComments", + \TestIssue.labels: "labels", + ] + + init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id") + title = try entry.column("title") + description = try entry.column("description") + closed = try entry.column("closed") + ownerId = try entry.column("ownerId") + createdAt = try entry.column("createdAt") + owner = try entry.relationship("owner") + comments = try entry.relationship("comments") + oneComment = try entry.relationship("oneComment") + ownerComments = try entry.relationship("ownerComments") + labels = try entry.relationship("labels") + } + + var columnValues: Row { + [ + "id": id.zeroValue, + "title": title.zeroValue, + "description": description.zeroValue, + "closed": closed.zeroValue, + "ownerId": ownerId.zeroValue, + "createdAt": createdAt.zeroValue, + ] + } +} + +struct TestUser: ZeroRow, Codable, Hashable, Identifiable { + var id: String + var name: String + var metadata: TestUserMetadata? + + // Relationships, filled when a query asks for them with `related`. + var issues: [TestIssue]? + + init( + id: String, + name: String, + metadata: TestUserMetadata? = nil, + issues: [TestIssue]? = nil + ) { + self.id = id + self.name = name + self.metadata = metadata + self.issues = issues + } + + static let tableName = "user" + + static let columns: KeyPathNames = [ + \TestUser.id: "id", + \TestUser.name: "name", + \TestUser.metadata: "metadata", + ] + + static let relationships: KeyPathNames = [ + \TestUser.issues: "issues", + ] + + init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id") + name = try entry.column("name") + metadata = try entry.column("metadata") + issues = try entry.relationship("issues") + } + + var columnValues: Row { + [ + "id": id.zeroValue, + "name": name.zeroValue, + "metadata": metadata.zeroValue, + ] + } +} + +struct TestComment: ZeroRow, Codable, Hashable, Identifiable { + var id: String + var authorId: String + var issueId: String + var text: String + var createdAt: Double + + // Relationships, filled when a query asks for them with `related`. + @Indirect var issue: TestIssue? + var revisions: [TestRevision]? + @Indirect var author: TestUser? + + init( + id: String, + authorId: String, + issueId: String, + text: String, + createdAt: Double, + issue: TestIssue? = nil, + revisions: [TestRevision]? = nil, + author: TestUser? = nil + ) { + self.id = id + self.authorId = authorId + self.issueId = issueId + self.text = text + self.createdAt = createdAt + self.issue = issue + self.revisions = revisions + self.author = author + } + + static let tableName = "comment" + + static let columns: KeyPathNames = [ + \TestComment.id: "id", + \TestComment.authorId: "authorId", + \TestComment.issueId: "issueId", + \TestComment.text: "text", + \TestComment.createdAt: "createdAt", + ] + + static let relationships: KeyPathNames = [ + \TestComment.issue: "issue", + \TestComment.revisions: "revisions", + \TestComment.author: "author", + ] + + init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id") + authorId = try entry.column("authorId") + issueId = try entry.column("issueId") + text = try entry.column("text") + createdAt = try entry.column("createdAt") + issue = try entry.relationship("issue") + revisions = try entry.relationship("revisions") + author = try entry.relationship("author") + } + + var columnValues: Row { + [ + "id": id.zeroValue, + "authorId": authorId.zeroValue, + "issueId": issueId.zeroValue, + "text": text.zeroValue, + "createdAt": createdAt.zeroValue, + ] + } +} + +struct TestRevision: ZeroRow, Codable, Hashable, Identifiable { + var id: String + var authorId: String + var commentId: String + var text: String + + // Relationships, filled when a query asks for them with `related`. + @Indirect var comment: TestComment? + @Indirect var author: TestUser? + + init( + id: String, + authorId: String, + commentId: String, + text: String, + comment: TestComment? = nil, + author: TestUser? = nil + ) { + self.id = id + self.authorId = authorId + self.commentId = commentId + self.text = text + self.comment = comment + self.author = author + } + + static let tableName = "revision" + + static let columns: KeyPathNames = [ + \TestRevision.id: "id", + \TestRevision.authorId: "authorId", + \TestRevision.commentId: "commentId", + \TestRevision.text: "text", + ] + + static let relationships: KeyPathNames = [ + \TestRevision.comment: "comment", + \TestRevision.author: "author", + ] + + init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id") + authorId = try entry.column("authorId") + commentId = try entry.column("commentId") + text = try entry.column("text") + comment = try entry.relationship("comment") + author = try entry.relationship("author") + } + + var columnValues: Row { + [ + "id": id.zeroValue, + "authorId": authorId.zeroValue, + "commentId": commentId.zeroValue, + "text": text.zeroValue, + ] + } +} + +struct TestLabel: ZeroRow, Codable, Hashable, Identifiable { + var id: String + var name: String + + // Relationships, filled when a query asks for them with `related`. + var issues: [TestIssue]? + + init( + id: String, + name: String, + issues: [TestIssue]? = nil + ) { + self.id = id + self.name = name + self.issues = issues + } + + static let tableName = "label" + + static let columns: KeyPathNames = [ + \TestLabel.id: "id", + \TestLabel.name: "name", + ] + + static let relationships: KeyPathNames = [ + \TestLabel.issues: "issues", + ] + + init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id") + name = try entry.column("name") + issues = try entry.relationship("issues") + } + + var columnValues: Row { + [ + "id": id.zeroValue, + "name": name.zeroValue, + ] + } +} + +struct TestIssueLabel: ZeroRow, Codable, Hashable { + var issueId: String + var labelId: String + + init( + issueId: String, + labelId: String + ) { + self.issueId = issueId + self.labelId = labelId + } + + static let tableName = "issueLabel" + + static let columns: KeyPathNames = [ + \TestIssueLabel.issueId: "issueId", + \TestIssueLabel.labelId: "labelId", + ] + + static let relationships: KeyPathNames = [:] + + init(entry: ViewEntry) throws(ZeroValueError) { + issueId = try entry.column("issueId") + labelId = try entry.column("labelId") + } + + var columnValues: Row { + [ + "issueId": issueId.zeroValue, + "labelId": labelId.zeroValue, + ] + } +} + +struct TestUserMetadata: ZeroValue, Codable, Hashable { + var registrar: TestUserMetadataRegistrar + var email: String + var altContacts: [String]? + + init( + registrar: TestUserMetadataRegistrar, + email: String, + altContacts: [String]? = nil + ) { + self.registrar = registrar + self.email = email + self.altContacts = altContacts + } + + init(zeroValue: JSONValue) throws(ZeroValueError) { + let o = try ZeroObjectReader(zeroValue, "TestUserMetadata") + registrar = try o.field("registrar") + email = try o.field("email") + altContacts = try o.field("altContacts") + } + + var zeroValue: JSONValue { + var o = ZeroObjectWriter() + o.set("registrar", registrar) + o.set("email", email) + o.setIfPresent("altContacts", altContacts) + return o.value + } +} + +enum TestUserMetadataRegistrar: String, ZeroEnum, Codable, CaseIterable { + case github + case google +} + +extension ZeroSchema { + static let test = ZeroSchema( + tables: [ + TableSchema( + name: "issue", serverName: "issues", + columns: [ + ("id", ColumnSchema(.string)), + ("title", ColumnSchema(.string)), + ("description", ColumnSchema(.string)), + ("closed", ColumnSchema(.boolean)), + ("ownerId", ColumnSchema(.string, optional: true, serverName: "owner_id")), + ("createdAt", ColumnSchema(.number)), + ], + primaryKey: ["id"]), + TableSchema( + name: "user", serverName: "users", + columns: [ + ("id", ColumnSchema(.string)), + ("name", ColumnSchema(.string)), + ("metadata", ColumnSchema(.json, optional: true)), + ], + primaryKey: ["id"]), + TableSchema( + name: "comment", serverName: "comments", + columns: [ + ("id", ColumnSchema(.string)), + ("authorId", ColumnSchema(.string)), + ("issueId", ColumnSchema(.string, serverName: "issue_id")), + ("text", ColumnSchema(.string)), + ("createdAt", ColumnSchema(.number)), + ], + primaryKey: ["id"]), + TableSchema( + name: "revision", + columns: [ + ("id", ColumnSchema(.string)), + ("authorId", ColumnSchema(.string)), + ("commentId", ColumnSchema(.string)), + ("text", ColumnSchema(.string)), + ], + primaryKey: ["id"]), + TableSchema( + name: "label", + columns: [ + ("id", ColumnSchema(.string)), + ("name", ColumnSchema(.string)), + ], + primaryKey: ["id"], + uniqueKeys: [["name"]]), + TableSchema( + name: "issueLabel", + columns: [ + ("issueId", ColumnSchema(.string)), + ("labelId", ColumnSchema(.string)), + ], + primaryKey: ["issueId", "labelId"]), + ], + relationships: [ + "issue": [ + "owner": [ + RelationshipHop(sourceField: ["ownerId"], destField: ["id"], destSchema: "user", cardinality: .one), + ], + "comments": [ + RelationshipHop(sourceField: ["id"], destField: ["issueId"], destSchema: "comment", cardinality: .many), + ], + "oneComment": [ + RelationshipHop(sourceField: ["id"], destField: ["issueId"], destSchema: "comment", cardinality: .one), + ], + "ownerComments": [ + RelationshipHop(sourceField: ["ownerId"], destField: ["authorId"], destSchema: "comment", cardinality: .many), + ], + "labels": [ + RelationshipHop(sourceField: ["id"], destField: ["issueId"], destSchema: "issueLabel", cardinality: .many), + RelationshipHop(sourceField: ["labelId"], destField: ["id"], destSchema: "label", cardinality: .many), + ], + ], + "user": [ + "issues": [ + RelationshipHop(sourceField: ["id"], destField: ["ownerId"], destSchema: "issue", cardinality: .many), + ], + ], + "comment": [ + "issue": [ + RelationshipHop(sourceField: ["issueId"], destField: ["id"], destSchema: "issue", cardinality: .one), + ], + "revisions": [ + RelationshipHop(sourceField: ["id"], destField: ["commentId"], destSchema: "revision", cardinality: .many), + ], + "author": [ + RelationshipHop(sourceField: ["authorId"], destField: ["id"], destSchema: "user", cardinality: .one), + ], + ], + "revision": [ + "comment": [ + RelationshipHop(sourceField: ["commentId"], destField: ["id"], destSchema: "comment", cardinality: .one), + ], + "author": [ + RelationshipHop(sourceField: ["authorId"], destField: ["id"], destSchema: "user", cardinality: .one), + ], + ], + "label": [ + "issues": [ + RelationshipHop(sourceField: ["id"], destField: ["labelId"], destSchema: "issueLabel", cardinality: .many), + RelationshipHop(sourceField: ["issueId"], destField: ["id"], destSchema: "issue", cardinality: .many), + ], + ], + ]) +} + +/// The queries of `ZeroSchema.test`, one per table: `testZQL.issue.where(…)`. +struct TestZQL: Sendable { + init() {} + + var issue: Query { Query(AnyQuery(.test, "issue")) } + + var user: Query { Query(AnyQuery(.test, "user")) } + + var comment: Query { Query(AnyQuery(.test, "comment")) } + + var revision: Query { Query(AnyQuery(.test, "revision")) } + + var label: Query { Query(AnyQuery(.test, "label")) } + + var issueLabel: Query { Query(AnyQuery(.test, "issueLabel")) } +} + +let testZQL = TestZQL() From 4c05e7768fa37004044ccbfb839ae76fc8e58dff Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 19 Oct 2026 15:28:08 +0000 Subject: [PATCH 018/104] 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 --- swift/Tests/AppleTests/DiffableAdapterTests.swift | 2 ++ swift/Tests/AppleTests/SwiftUITests.swift | 2 ++ swift/Tests/AppleTests/WebSocketServer.swift | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/swift/Tests/AppleTests/DiffableAdapterTests.swift b/swift/Tests/AppleTests/DiffableAdapterTests.swift index 2789b8acfd..17e82cd5e5 100644 --- a/swift/Tests/AppleTests/DiffableAdapterTests.swift +++ b/swift/Tests/AppleTests/DiffableAdapterTests.swift @@ -3,6 +3,8 @@ import ZeroTesting import ZeroUI + @testable import Zero + @Suite @MainActor struct DiffableAdapterTests { func makeZero() throws -> Zero { var options = ZeroOptions(userID: "u", storageKey: randomID(), storage: .memory) diff --git a/swift/Tests/AppleTests/SwiftUITests.swift b/swift/Tests/AppleTests/SwiftUITests.swift index 23fdcd1a92..8bd8267eb0 100644 --- a/swift/Tests/AppleTests/SwiftUITests.swift +++ b/swift/Tests/AppleTests/SwiftUITests.swift @@ -5,6 +5,8 @@ import ZeroTesting import ZeroUI + @testable import Zero + #if canImport(UIKit) import UIKit #else diff --git a/swift/Tests/AppleTests/WebSocketServer.swift b/swift/Tests/AppleTests/WebSocketServer.swift index d6ec862885..ff6073231b 100644 --- a/swift/Tests/AppleTests/WebSocketServer.swift +++ b/swift/Tests/AppleTests/WebSocketServer.swift @@ -99,7 +99,7 @@ return try await withCheckedThrowingContinuation { (c: CheckedContinuation) in let resumed = Locked(false) - let first = { + let first: @Sendable () -> Bool = { resumed.withLock { r in defer { r = true } return !r From a90092532101150b48601074afb1d695420b7c73 Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 19 Oct 2026 15:44:03 +0000 Subject: [PATCH 019/104] 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 --- packages/zero-swift-cli/src/cli.ts | 52 +++- packages/zero-swift-cli/src/drift.ts | 277 ++++++++++++++++++++ packages/zero-swift-cli/src/env.d.ts | 3 + packages/zero-swift-cli/vitest.config.ts | 24 ++ swift/Sources/Zero/AST/AST.swift | 2 +- swift/Sources/Zero/Client/Definitions.swift | 51 ++++ swift/Sources/Zero/Query/Like.swift | 12 + swift/Sources/Zero/Query/Query.swift | 35 ++- swift/Sources/Zero/Query/ZeroValue.swift | 32 +++ swift/Sources/ZeroTesting/Drift.swift | 220 ++++++++++++++++ 10 files changed, 694 insertions(+), 14 deletions(-) create mode 100644 packages/zero-swift-cli/src/drift.ts create mode 100644 packages/zero-swift-cli/src/env.d.ts create mode 100644 packages/zero-swift-cli/vitest.config.ts create mode 100644 swift/Sources/ZeroTesting/Drift.swift diff --git a/packages/zero-swift-cli/src/cli.ts b/packages/zero-swift-cli/src/cli.ts index 85de5e563b..62b87e249d 100644 --- a/packages/zero-swift-cli/src/cli.ts +++ b/packages/zero-swift-cli/src/cli.ts @@ -7,8 +7,17 @@ * [--config zero-swift.json] [--check] * * Options override the config file, which defaults to `zero-swift.json` when - * present. `--check` exits with an error when the output is out of date - * instead of writing it. + * present. + * + * `zero-swift drift record`: runs the app's TS queries and mutators on the + * samples a module exports and writes what they did, for the Swift drift + * test (see drift.ts). + * + * zero-swift drift record [--samples drift-samples.ts] + * [--out drift-vectors.json] [--check] + * + * `--check` exits with an error when the output is out of date instead of + * writing it. */ import {existsSync, readFileSync, writeFileSync} from 'node:fs'; import {relative, resolve} from 'node:path'; @@ -48,15 +57,30 @@ async function main(argv: string[]): Promise { schema: {type: 'string'}, out: {type: 'string'}, config: {type: 'string'}, + samples: {type: 'string'}, check: {type: 'boolean'}, }, }); - const [command = 'codegen'] = positionals; + const [command = 'codegen', subcommand] = positionals; + const cwd = process.cwd(); + if (command === 'drift' && subcommand === 'record') { + // Loaded here: the recorder runs the app's `@rocicorp/zero`, which + // codegen does not need. + const {recordDriftFile} = await import('./drift.ts'); + const vectors = await recordDriftFile( + resolve(cwd, values.samples ?? 'drift-samples.ts'), + ); + return writeOutput( + resolve(cwd, values.out ?? 'drift-vectors.json'), + vectors, + values.check ?? false, + cwd, + ); + } if (command !== 'codegen') { - process.stderr.write(`Unknown command "${command}"\n`); + process.stderr.write(`Unknown command "${positionals.join(' ')}"\n`); return 2; } - const cwd = process.cwd(); const configPath = values.config ?? (existsSync(resolve(cwd, 'zero-swift.json')) @@ -72,16 +96,26 @@ async function main(argv: string[]): Promise { }; const output = resolve(cwd, config.output ?? 'Schema.swift'); const swift = await codegenFile(config, cwd); + return writeOutput(output, swift, values.check ?? false, cwd); +} + +/** Writes `contents`, or with `check`, fails when the file differs. */ +function writeOutput( + output: string, + contents: string, + check: boolean, + cwd: string, +): number { const current = existsSync(output) ? readFileSync(output, 'utf8') : undefined; - if (values.check) { - if (current !== swift) { + if (check) { + if (current !== contents) { process.stderr.write(`${relative(cwd, output)} is out of date\n`); return 1; } return 0; } - if (current !== swift) { - writeFileSync(output, swift); + if (current !== contents) { + writeFileSync(output, contents); } process.stdout.write(`Wrote ${relative(cwd, output)}\n`); return 0; diff --git a/packages/zero-swift-cli/src/drift.ts b/packages/zero-swift-cli/src/drift.ts new file mode 100644 index 0000000000..fe88c72f5b --- /dev/null +++ b/packages/zero-swift-cli/src/drift.ts @@ -0,0 +1,277 @@ +/** + * `zero-swift drift record`: runs an app's TS queries and mutators on + * samples and records what they did. The Swift drift test + * (`Drift.failures` in ZeroTesting) checks the app's Swift port against the + * record, so a change to either side that the other lacks fails a test. + * + * The samples module exports `schema`, the `queries` and `mutators` + * registries, and `querySamples` and `mutatorSamples`. + */ +import {pathToFileURL} from 'node:url'; +import { + createBuilder, + defineMutator, + defineMutators, + isMutator, + isQuery, + mustGetMutator, + mustGetQuery, + Zero, + type QueryInternals, + type Schema, +} from '@rocicorp/zero'; +import type {ReadonlyJSONValue} from '../../shared/src/json.ts'; +import {hashOfAST} from '../../zero-protocol/src/query-hash.ts'; + +type Row = Record; + +/** What the recorder uses of a `Zero`. */ +type Client = { + mutate(request: never): {client: Promise}; + run(query: never): Promise; + close(): Promise; +}; + +export type QuerySample = { + /** The query's name in the registry, such as `issue.byID`. */ + name: string; + /** Omit for a query without arguments. */ + args?: ReadonlyJSONValue | undefined; + context?: ReadonlyJSONValue | undefined; +}; + +export type MutatorSample = { + /** The mutator's name in the registry, such as `issue.create`. */ + name: string; + /** Omit for a mutator without arguments. */ + args?: ReadonlyJSONValue | undefined; + context?: ReadonlyJSONValue | undefined; + /** The rows in the client before the mutator runs, by table. */ + seed?: Record | undefined; +}; + +export type DriftSamples = { + schema: Schema; + queries: object; + mutators: object; + querySamples: QuerySample[]; + mutatorSamples: MutatorSample[]; +}; + +const seedName = '__drift.seed'; +const offlineWarning = + 'Zero starting up with no server URL. No data will be synced.'; + +/** Records the samples that the module at `path` exports by default. */ +export async function recordDriftFile(path: string): Promise { + const module = (await import(pathToFileURL(path).href)) as { + default?: DriftSamples; + }; + if (!module.default?.querySamples || !module.default.mutatorSamples) { + throw new Error(`${path} does not export drift samples by default`); + } + return recordDrift(module.default); +} + +export async function recordDrift(samples: DriftSamples): Promise { + const {schema, queries, mutators} = samples; + const querySamples = samples.querySamples.map(sample => { + const query = mustGetQuery(queries as never, sample.name) as unknown as { + fn(options: {args: unknown; ctx: unknown}): never; + }; + const {ast} = query.fn({ + args: sample.args, + ctx: sample.context, + }) as unknown as QueryInternals; + return { + name: sample.name, + args: sample.args === undefined ? [] : [sample.args], + context: sample.context ?? null, + ast: json(ast), + hash: hashOfAST(ast), + }; + }); + + const mutatorSamples = []; + // Each sample's client is offline on purpose; Zero warns about that once + // per client. + const {warn} = console; + // oxlint-disable-next-line no-console + console.warn = (...args: unknown[]) => { + if (args[0] !== offlineWarning) warn(...args); + }; + try { + for (const sample of samples.mutatorSamples) { + mutatorSamples.push({ + ...sample, + context: sample.context ?? null, + seed: sample.seed ?? {}, + ...(await runMutator(schema, mutators, sample)), + }); + } + } finally { + // oxlint-disable-next-line no-console + console.warn = warn; + } + + const vectors = { + queries: names(queries, isQuery, q => q.queryName), + mutators: names(mutators, isMutator, m => m.mutatorName), + querySamples, + mutatorSamples, + }; + return JSON.stringify(vectors, null, 2) + '\n'; +} + +/** Puts the sample's rows before the mutator runs. */ +async function seed({ + tx, + args, +}: { + tx: {mutate: Record}>}; + args: Record; +}) { + for (const [table, rows] of Object.entries(args)) { + for (const row of rows) { + await tx.mutate[table].upsert(row); + } + } +} + +/** Runs one mutator in a client of its own, offline. */ +async function runMutator( + schema: Schema, + mutators: object, + sample: MutatorSample, +) { + const registry = defineMutators(mutators as never, { + __drift: {seed: defineMutator(seed as never)}, + }); + const zero = new Zero({ + schema, + mutators: registry, + userID: 'drift', + storageKey: `drift-${Math.random().toString(36).slice(2)}`, + kvStore: 'mem', + context: sample.context, + // The mutators' errors are what the samples record, not news. + logSink: {log() {}}, + } as never) as unknown as Client; + try { + const call = (name: string, args: unknown) => + zero.mutate( + ( + mustGetMutator(registry, name) as unknown as ( + ...a: unknown[] + ) => never + )(...(args === undefined ? [] : [args])), + ); + await call(seedName, sample.seed ?? {}).client; + const before = await rows(zero, schema); + const outcome = await call(sample.name, sample.args).client; + const after = await rows(zero, schema); + return {outcome: json(outcome), changes: diff(schema, before, after)}; + } finally { + await zero.close(); + } +} + +/** Every row of every table, by table and then by primary key. */ +async function rows(zero: Client, schema: Schema) { + const builder = createBuilder(schema) as unknown as Record; + const out = new Map>(); + for (const [table, {primaryKey}] of Object.entries(schema.tables)) { + const data = (await zero.run(builder[table])) as unknown as Row[]; + out.set( + table, + new Map( + data.map(row => [ + JSON.stringify(primaryKey.map(c => row[c] ?? null)), + json(row) as Row, + ]), + ), + ); + } + return out; +} + +/** + * The rows the mutator put and deleted: by table in schema order, then by + * primary key in UTF-8 order. + */ +function diff( + schema: Schema, + before: Map>, + after: Map>, +) { + const changes = []; + for (const [table, {primaryKey}] of Object.entries(schema.tables)) { + const old = before.get(table) ?? new Map(); + const now = after.get(table) ?? new Map(); + const keys = [...new Set([...old.keys(), ...now.keys()])].toSorted((a, b) => + Buffer.compare(Buffer.from(a), Buffer.from(b)), + ); + for (const key of keys) { + const o = old.get(key); + const n = now.get(key); + if (n !== undefined) { + if (o === undefined || !sameJSON(o, n)) { + changes.push({table, op: 'put', row: n}); + } + } else if (o !== undefined) { + changes.push({ + table, + op: 'del', + id: Object.fromEntries(primaryKey.map(c => [c, o[c] ?? null])), + }); + } + } + } + return changes; +} + +function sameJSON(a: ReadonlyJSONValue, b: ReadonlyJSONValue): boolean { + if (a === b) return true; + if (typeof a !== 'object' || typeof b !== 'object' || !a || !b) return false; + if (Array.isArray(a) || Array.isArray(b)) { + return ( + Array.isArray(a) && + Array.isArray(b) && + a.length === b.length && + a.every((v, i) => sameJSON(v, b[i])) + ); + } + const ak = Object.keys(a); + const bo = b as Record; + return ( + ak.length === Object.keys(b).length && + ak.every( + k => + k in bo && sameJSON((a as Record)[k], bo[k]), + ) + ); +} + +/** The names of the registry's leaves, sorted. */ +function names( + registry: object, + isLeaf: (v: unknown) => v is T, + name: (leaf: T) => string, +): string[] { + const out: string[] = []; + const walk = (node: unknown) => { + if (isLeaf(node)) { + out.push(name(node)); + } else if (node && typeof node === 'object') { + for (const [key, child] of Object.entries(node)) { + if (key !== '~') walk(child); + } + } + }; + walk(registry); + return out.sort(); +} + +function json(v: unknown): ReadonlyJSONValue { + return v === undefined ? null : JSON.parse(JSON.stringify(v)); +} diff --git a/packages/zero-swift-cli/src/env.d.ts b/packages/zero-swift-cli/src/env.d.ts new file mode 100644 index 0000000000..b5ce66cbce --- /dev/null +++ b/packages/zero-swift-cli/src/env.d.ts @@ -0,0 +1,3 @@ +interface ImportMeta { + readonly env?: {readonly VITEST?: string | boolean}; +} diff --git a/packages/zero-swift-cli/vitest.config.ts b/packages/zero-swift-cli/vitest.config.ts new file mode 100644 index 0000000000..153e24a5bc --- /dev/null +++ b/packages/zero-swift-cli/vitest.config.ts @@ -0,0 +1,24 @@ +import {fileURLToPath} from 'node:url'; +import {mergeConfig} from 'vitest/config'; +import config from '../shared/src/tool/vitest-config.ts'; + +// The generators write files, so they run in Node. Apps import +// `@rocicorp/zero`, which is its sources here, as in the apps' own tests. +export default mergeConfig(config, { + resolve: { + alias: [ + { + find: /^@rocicorp\/zero$/, + replacement: fileURLToPath( + new URL('../zero/src/zero.ts', import.meta.url), + ), + }, + ], + }, + test: { + name: 'zero-swift-cli', + browser: { + enabled: false, + }, + }, +}); diff --git a/swift/Sources/Zero/AST/AST.swift b/swift/Sources/Zero/AST/AST.swift index 101e237018..521d5825de 100644 --- a/swift/Sources/Zero/AST/AST.swift +++ b/swift/Sources/Zero/AST/AST.swift @@ -44,7 +44,7 @@ public struct AST: Sendable, Hashable { } } - public enum Direction: String, Sendable, Hashable { + public enum Direction: String, Codable, Sendable, Hashable { case asc, desc } diff --git a/swift/Sources/Zero/Client/Definitions.swift b/swift/Sources/Zero/Client/Definitions.swift index 52b57541fd..6f85d3a612 100644 --- a/swift/Sources/Zero/Client/Definitions.swift +++ b/swift/Sources/Zero/Client/Definitions.swift @@ -17,6 +17,39 @@ where Result.Row == Row { } } +/// A named query whose arguments can come as JSON: what the drift harness +/// checks against the TS definition of the same name. +public protocol NamedQuery: Sendable { + associatedtype Context: Sendable + var name: String { get } + /// The query for `args` (none, or one JSON value) read as the + /// definition's argument type, with the arguments it sends for them. + func query(json args: [JSONValue], context: Context) throws -> (AnyQuery, args: [JSONValue]) +} + +extension QueryDefinition: NamedQuery where Args: Decodable { + public func query( + json args: [JSONValue], context: Context + ) throws -> ( + AnyQuery, args: [JSONValue] + ) { + let decoded = try JSONValueDecoder.decode(Args.self, from: args.first ?? .null) + let request = self(decoded) + return (request.query(context), request.args) + } +} + +extension NoArgsQueryDefinition: NamedQuery { + public func query( + json args: [JSONValue], context: Context + ) throws -> ( + AnyQuery, args: [JSONValue] + ) { + let request = self() + return (request.query(context), request.args) + } +} + /// A named query without arguments. public struct NoArgsQueryDefinition: Sendable where Result.Row == Row { @@ -101,6 +134,13 @@ public protocol Mutator: Sendable { /// Runs the mutator in `tx`. `args` is nil when the caller passed none. /// Returns what the caller's `client` result carries. @MainActor func run(_ tx: Transaction, args: JSONValue?, context: Context) throws -> JSONValue? + /// `args` read as the mutator's argument type and encoded again: what + /// the client would send for them. + func reencode(args: JSONValue?) throws -> JSONValue? +} + +extension Mutator { + public func reencode(args: JSONValue?) throws -> JSONValue? { args } } /// A mutator defined in Swift (`defineMutator` in @@ -123,6 +163,10 @@ public struct MutatorDefinition: Mu let decoded = try JSONValueDecoder.decode(Args.self, from: args ?? .null) return try body(tx, decoded, context) } + + public func reencode(args: JSONValue?) throws -> JSONValue? { + encodeArgs(try JSONValueDecoder.decode(Args.self, from: args ?? .null)) + } } /// A mutator without arguments. @@ -146,6 +190,13 @@ public struct MutateRequest: Sendable { /// nil when the mutator takes no arguments (JS `undefined`). public let args: JSONValue? + /// A request with arguments already in their JSON form, for example to + /// replay a mutation by name. Prefer calling the mutator definition. + public init(mutator: any Mutator, args: JSONValue?) { + self.mutator = mutator + self.args = args + } + public var name: String { mutator.name } } diff --git a/swift/Sources/Zero/Query/Like.swift b/swift/Sources/Zero/Query/Like.swift index c8a286064a..b910705379 100644 --- a/swift/Sources/Zero/Query/Like.swift +++ b/swift/Sources/Zero/Query/Like.swift @@ -157,3 +157,15 @@ enum JSCase { return cu } } + +/// Escapes the wildcards of `value` (`%` and `_`) for a `LIKE` pattern, as +/// in `.where { $0.title.ilike("%\(escapeLike(text))%") }` +/// (`packages/zql/src/query/escape-like.ts`). +public func escapeLike(_ value: String) -> String { + var out = "" + for c in value.unicodeScalars { + if c == "%" || c == "_" { out.unicodeScalars.append("\\") } + out.unicodeScalars.append(c) + } + return out +} diff --git a/swift/Sources/Zero/Query/Query.swift b/swift/Sources/Zero/Query/Query.swift index 3b7ad850ca..139bef0d34 100644 --- a/swift/Sources/Zero/Query/Query.swift +++ b/swift/Sources/Zero/Query/Query.swift @@ -130,6 +130,14 @@ public struct Query: Sendable where Result.Ro Self(any.where(Row.column(column), op, value.zeroValue)) } + /// Compares a column with a value that may be nil. nil compares with + /// `op` as JS compares `undefined`, so `.eq` with nil matches no row. + public func `where`( + _ column: KeyPath, _ op: AST.SimpleOperator, _ value: V? + ) -> Self { + Self(any.where(Row.column(column), op, value?.zeroValue ?? .null)) + } + /// `IN` and `NOT IN`. public func `where`( _ column: KeyPath, _ op: AST.SimpleOperator, _ values: [V] @@ -200,6 +208,17 @@ public struct Query: Sendable where Result.Ro Self(any.start(row.columnValues, inclusive: inclusive)) } + /// Starts after the row with these column values, or at it when + /// `inclusive`: a cursor that holds only the columns the query orders by, + /// as in `.start([\.created: page.created, \.id: page.id])`. + public func start( + _ values: [PartialKeyPath: any ZeroValue], inclusive: Bool = false + ) -> Self { + var row: [String: JSONValue] = [:] + for (keyPath, value) in values { row[Row.column(keyPath)] = value.zeroValue } + return Self(any.start(row, inclusive: inclusive)) + } + /// At most one row. public func one() -> Query { Query(any.one()) @@ -325,12 +344,20 @@ public struct RowExpression: Sendable { name, flip: flip, scalar: scalar, sub.map { sub in { sub(Query($0)).any } })) } - public func and(_ conditions: Condition...) -> Condition { - Condition(andConditions(conditions.map(\.ast))) + /// All of `conditions`, leaving out the nil ones; true when there are + /// none. + public func and(_ conditions: Condition?...) -> Condition { and(conditions) } + + public func and(_ conditions: [Condition?]) -> Condition { + Condition(andConditions(conditions.compactMap { $0?.ast })) } - public func or(_ conditions: Condition...) -> Condition { - Condition(orConditions(conditions.map(\.ast))) + /// Any of `conditions`, leaving out the nil ones; false when there are + /// none. + public func or(_ conditions: Condition?...) -> Condition { or(conditions) } + + public func or(_ conditions: [Condition?]) -> Condition { + Condition(orConditions(conditions.compactMap { $0?.ast })) } public func not(_ condition: Condition) -> Condition { !condition } diff --git a/swift/Sources/Zero/Query/ZeroValue.swift b/swift/Sources/Zero/Query/ZeroValue.swift index da56c6916f..47d2ec03e4 100644 --- a/swift/Sources/Zero/Query/ZeroValue.swift +++ b/swift/Sources/Zero/Query/ZeroValue.swift @@ -181,3 +181,35 @@ extension KeyedEncodingContainer { try encodeIfPresent(value.wrappedValue, forKey: key) } } + +/// An argument the TS validator declares `nullable` (`z.nullable(…)`): +/// `nil` encodes as `null`. A plain optional is omitted when `nil`, as an +/// `optional` field is. +@propertyWrapper +public struct Nullable { + public var wrappedValue: Value? + + public init(wrappedValue: Value?) { self.wrappedValue = wrappedValue } +} + +extension Nullable: Sendable where Value: Sendable {} +extension Nullable: Equatable where Value: Equatable {} +extension Nullable: Hashable where Value: Hashable {} + +extension Nullable: Codable where Value: Codable { + public init(from decoder: any Decoder) throws { + let c = try decoder.singleValueContainer() + self.init(wrappedValue: c.decodeNil() ? nil : try c.decode(Value.self)) + } + + public func encode(to encoder: any Encoder) throws { + var c = encoder.singleValueContainer() + if let v = wrappedValue { try c.encode(v) } else { try c.encodeNil() } + } +} + +extension KeyedDecodingContainer { + public func decode(_ type: Nullable.Type, forKey key: Key) throws -> Nullable { + try decodeIfPresent(type, forKey: key) ?? Nullable(wrappedValue: nil) + } +} diff --git a/swift/Sources/ZeroTesting/Drift.swift b/swift/Sources/ZeroTesting/Drift.swift new file mode 100644 index 0000000000..dfd4b8b967 --- /dev/null +++ b/swift/Sources/ZeroTesting/Drift.swift @@ -0,0 +1,220 @@ +#if canImport(Testing) + import Testing +#endif + +/// The drift harness: checks an app's Swift queries and mutators against +/// what their TS definitions did on the same samples +/// (`zero-swift drift record` writes the vectors). +/// +/// - Every name in the TS registries has a Swift definition, and the other +/// way round. +/// - Each query sample's arguments read as the Swift argument type and +/// encode back to the same JSON, and its AST has the same hash. +/// - Each mutator sample's arguments round-trip the same way, and the +/// mutator, run on the sample's rows, has the same outcome and changes the +/// same rows. +@MainActor +public enum Drift { + /// The differences between `vectors` and the Swift definitions; empty when + /// there are none. + /// + /// - Parameters: + /// - vectors: The contents of the `drift-vectors.json` that + /// `zero-swift drift record` wrote. + /// - queries: Every named query of the app. + /// - mutators: Every mutator of the app. + public static func failures( + vectors: String, schema: ZeroSchema, queries: [any NamedQuery], + mutators: [any Mutator] + ) throws -> [String] { + let v = try JSONParser.parse(vectors) + var failures: [String] = [] + + let tsQueries = Set(v["queries"]?.arrayValue?.compactMap(\.stringValue) ?? []) + let tsMutators = Set(v["mutators"]?.arrayValue?.compactMap(\.stringValue) ?? []) + let swiftQueries = Dictionary(queries.map { ($0.name, $0) }) { a, _ in a } + let swiftMutators = Dictionary(mutators.map { ($0.name, $0) }) { a, _ in a } + for name in tsQueries.subtracting(swiftQueries.keys).sorted() { + failures.append("query \(name): no Swift definition") + } + for name in Set(swiftQueries.keys).subtracting(tsQueries).sorted() { + failures.append("query \(name): not in the TS registry") + } + for name in tsMutators.subtracting(swiftMutators.keys).sorted() { + failures.append("mutator \(name): no Swift definition") + } + for name in Set(swiftMutators.keys).subtracting(tsMutators).sorted() { + failures.append("mutator \(name): not in the TS registry") + } + + for (i, sample) in (v["querySamples"]?.arrayValue ?? []).enumerated() { + let name = sample["name"]?.stringValue ?? "" + let label = "query \(name) sample \(i)" + guard let definition = swiftQueries[name] else { continue } + do { + failures += try checkQuery(label, sample, definition) + } catch { + failures.append("\(label): \(error)") + } + } + for (i, sample) in (v["mutatorSamples"]?.arrayValue ?? []).enumerated() { + let name = sample["name"]?.stringValue ?? "" + let label = "mutator \(name) sample \(i)" + guard let mutator = swiftMutators[name] else { continue } + do { + failures += try checkMutator(label, sample, mutator, schema: schema, all: mutators) + } catch { + failures.append("\(label): \(error)") + } + } + return failures + } + + #if canImport(Testing) + /// Records an issue for each difference between `vectors` and the Swift + /// definitions. + public static func expectNoDrift( + vectors: String, schema: ZeroSchema, queries: [any NamedQuery], + mutators: [any Mutator], sourceLocation: SourceLocation = #_sourceLocation + ) { + do { + for failure in try failures( + vectors: vectors, schema: schema, queries: queries, mutators: mutators) + { + Issue.record(Comment(rawValue: failure), sourceLocation: sourceLocation) + } + } catch { + Issue.record(error, sourceLocation: sourceLocation) + } + } + #endif + + private static func context(_ sample: JSONValue) throws -> Context { + try JSONValueDecoder.decode(Context.self, from: sample["context"] ?? .null) + } + + private static func checkQuery( + _ label: String, _ sample: JSONValue, _ definition: any NamedQuery + ) throws -> [String] { + let args = sample["args"]?.arrayValue ?? [] + let (query, sent) = try definition.query(json: args, context: context(sample)) + var failures: [String] = [] + if sent != args { + failures.append( + "\(label): the arguments encode as \(JSONSerializer.stringify(.array(sent))), " + + "not \(JSONSerializer.stringify(.array(args)))") + } + let hash = QueryHash.hashOfAST(query.ast) + if let expected = sample["hash"]?.stringValue, hash != expected { + failures.append( + "\(label): the AST differs.\n TS: " + + JSONSerializer.stringify(sample["ast"] ?? .null) + "\n Swift: " + + JSONSerializer.stringify(query.ast.json)) + } + return failures + } + + private static func checkMutator( + _ label: String, _ sample: JSONValue, _ mutator: any Mutator, schema: ZeroSchema, + all: [any Mutator] + ) throws -> [String] { + var failures: [String] = [] + let args = sample["args"] + let sent = try mutator.reencode(args: args) + if sent != args { + failures.append( + "\(label): the arguments encode as \(sent.map(JSONSerializer.stringify) ?? "undefined"), " + + "not \(args.map(JSONSerializer.stringify) ?? "undefined")") + } + + var options = ZeroOptions(userID: "drift", storageKey: randomStorageKey(), storage: .memory) + options.logSink = RecordingLogSink() + let zero = try Zero( + schema: schema, context: try context(sample) as Context, + mutators: all + [SeedMutator()], options: options, + scheduler: ManualScheduler(now: 0), socketFactory: nil) + defer { zero.close() } + zero.mutate(MutateRequest(mutator: SeedMutator(), args: sample["seed"] ?? [:])) + let before = try rows(zero, schema) + let result = zero.mutate(MutateRequest(mutator: mutator, args: args)) + let after = try rows(zero, schema) + + let outcome = result.client.result.map { r in (try? r.get())?.json ?? .null } ?? .null + if let expected = sample["outcome"], outcome != expected { + failures.append( + "\(label): the outcome is \(JSONSerializer.stringify(outcome)), not " + + JSONSerializer.stringify(expected)) + } + let changes = diff(before, after, schema) + let expected = sample["changes"] ?? [] + if changes != expected { + failures.append( + "\(label): the changes differ.\n TS: \(JSONSerializer.stringify(expected))\n Swift: " + + JSONSerializer.stringify(changes)) + } + return failures + } + + /// Rows by table, then by primary key. + private typealias Tables = [String: [String: Row]] + + /// Every row of every table. + private static func rows(_ zero: Zero, _ schema: ZeroSchema) throws -> Tables { + var out: Tables = [:] + for table in schema.tableNames { + let pk = schema.tables[table]?.primaryKey ?? [] + let data = try zero.run(AnyQuery(schema, table)).result?.get() ?? .many([]) + var byKey: [String: Row] = [:] + for entry in data.entries { + byKey[JSONSerializer.stringify(.array(pk.map { entry.row[$0] ?? .null }))] = entry.row + } + out[table] = byKey + } + return out + } + + /// The changes from `before` to `after`, as `zero-swift drift record` + /// lists them: by table in schema order, then by primary key. + private static func diff( + _ before: Tables, _ after: Tables, _ schema: ZeroSchema + ) -> JSONValue { + var changes: [JSONValue] = [] + for table in schema.tableNames { + let pk = schema.tables[table]?.primaryKey ?? [] + let old = before[table] ?? [:] + let new = after[table] ?? [:] + for key in Set(old.keys).union(new.keys).sorted(by: { StringCompare.utf8($0, $1) < 0 }) { + switch (old[key], new[key]) { + case (let o, let n?) where o != n: + changes.append(["table": .string(table), "op": "put", "row": .object(n)]) + case (let o?, nil): + var id: Row = [:] + for c in pk { id[c] = o[c] ?? .null } + changes.append(["table": .string(table), "op": "del", "id": .object(id)]) + default: + break + } + } + } + return .array(changes) + } + + private static func randomStorageKey() -> String { + "drift-" + String(UInt64.random(in: 0...UInt64.max), radix: 36) + } +} + +/// Puts the sample's rows (`{table: [row]}`) before the mutator runs. +private struct SeedMutator: Mutator { + let name = "__drift.seed" + + @MainActor + func run(_ tx: Transaction, args: JSONValue?, context: Context) throws -> JSONValue? { + for (table, rows) in args?.objectValue ?? [:] { + for row in rows.arrayValue ?? [] { + try tx.mutate[dynamicMember: table].upsert(row.objectValue ?? [:]) + } + } + return nil + } +} From 8294d192945215e9e32d22f6085a05f71ad2f558 Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 19 Oct 2026 15:44:04 +0000 Subject: [PATCH 020/104] 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 --- .github/workflows/swift.yml | 14 +- apps/zbugs-swift/.gitignore | 4 + apps/zbugs-swift/Package.swift | 27 + apps/zbugs-swift/README.md | 26 + apps/zbugs-swift/Sources/ZBugs/Auth.swift | 91 + apps/zbugs-swift/Sources/ZBugs/Mutators.swift | 416 + apps/zbugs-swift/Sources/ZBugs/Queries.swift | 534 + apps/zbugs-swift/Sources/ZBugs/Schema.swift | 884 ++ .../Tests/ZBugsTests/DriftTests.swift | 17 + .../Tests/ZBugsTests/drift-vectors.json | 10151 ++++++++++++++++ apps/zbugs-swift/drift-samples.ts | 480 + apps/zbugs-swift/zero-swift.json | 9 + packages/zero-swift-cli/src/codegen.test.ts | 15 +- packages/zero-swift-cli/src/drift.test.ts | 30 + packages/zero-swift-cli/tsconfig.json | 7 +- 15 files changed, 12697 insertions(+), 8 deletions(-) create mode 100644 apps/zbugs-swift/.gitignore create mode 100644 apps/zbugs-swift/Package.swift create mode 100644 apps/zbugs-swift/README.md create mode 100644 apps/zbugs-swift/Sources/ZBugs/Auth.swift create mode 100644 apps/zbugs-swift/Sources/ZBugs/Mutators.swift create mode 100644 apps/zbugs-swift/Sources/ZBugs/Queries.swift create mode 100644 apps/zbugs-swift/Sources/ZBugs/Schema.swift create mode 100644 apps/zbugs-swift/Tests/ZBugsTests/DriftTests.swift create mode 100644 apps/zbugs-swift/Tests/ZBugsTests/drift-vectors.json create mode 100644 apps/zbugs-swift/drift-samples.ts create mode 100644 apps/zbugs-swift/zero-swift.json create mode 100644 packages/zero-swift-cli/src/drift.test.ts diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index a5883cc098..329e71b658 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -41,8 +41,9 @@ jobs: # writes the seeded fuzz corpora (generated/), which aren't checked in. - name: Check vectors are current run: pnpm --filter zero-conformance run test - # The Swift tests compile the Swift that zero-swift generates. - - name: Check generated Swift is current + # The Swift tests compile the Swift that zero-swift generates, and check + # the Swift ports of apps against the drift vectors it records. + - name: Check generated Swift and drift vectors are current run: pnpm --filter zero-swift run test - name: Check types run: pnpm --filter zero-conformance --filter zero-swift run check-types @@ -76,6 +77,15 @@ jobs: run: swift build --build-tests - name: Test run: swift test --skip-build + # zbugs' Swift port, checked against zbugs' TS queries and mutators on + # the samples in drift-samples.ts (the vectors job checks the record is + # current). + - name: zbugs + working-directory: apps/zbugs-swift + run: | + swift format lint --strict --recursive Sources Tests Package.swift + swift build --build-tests + swift test --skip-build e2e: name: Swift end-to-end (Linux) diff --git a/apps/zbugs-swift/.gitignore b/apps/zbugs-swift/.gitignore new file mode 100644 index 0000000000..77b280c420 --- /dev/null +++ b/apps/zbugs-swift/.gitignore @@ -0,0 +1,4 @@ +.build/ +.swiftpm/ +*.xcodeproj/xcuserdata/ +Package.resolved diff --git a/apps/zbugs-swift/Package.swift b/apps/zbugs-swift/Package.swift new file mode 100644 index 0000000000..0cb144a8ed --- /dev/null +++ b/apps/zbugs-swift/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version: 6.2 +import PackageDescription + +/// zbugs on the Zero Swift client: the schema, queries, and mutators of +/// apps/zbugs/shared, ported to Swift and checked against the TS ones. +let package = Package( + name: "ZBugs", + platforms: [.iOS(.v26), .macOS(.v26)], + products: [ + .library(name: "ZBugs", targets: ["ZBugs"]) + ], + dependencies: [ + .package(name: "Zero", path: "../../swift") + ], + targets: [ + .target(name: "ZBugs", dependencies: [.product(name: "Zero", package: "Zero")]), + .testTarget( + name: "ZBugsTests", + dependencies: [ + "ZBugs", + .product(name: "Zero", package: "Zero"), + .product(name: "ZeroTesting", package: "Zero"), + ], + exclude: ["drift-vectors.json"]), + ], + swiftLanguageModes: [.v6] +) diff --git a/apps/zbugs-swift/README.md b/apps/zbugs-swift/README.md new file mode 100644 index 0000000000..c5526bb80a --- /dev/null +++ b/apps/zbugs-swift/README.md @@ -0,0 +1,26 @@ +# zbugs on the Zero Swift client + +The schema, queries, and mutators of [zbugs](../zbugs) (`apps/zbugs/shared`), +ported to Swift as the `ZBugs` library. + +- `Sources/ZBugs/Schema.swift` is generated from `apps/zbugs/shared/schema.ts` + by `zero-swift codegen` (config in `zero-swift.json`). +- `Queries.swift` and `Mutators.swift` are ports of `queries.ts` and + `mutators.ts` (with `auth.ts` in `Auth.swift`). +- `Tests/ZBugsTests/drift-vectors.json` records what the TS queries and + mutators do on the samples in `drift-samples.ts`. `DriftTests` fails when + the Swift port does something else, or when a query or mutator exists on + only one side. + +After changing a TS query or mutator (or the samples), re-record and port the +change: + +```sh +cd packages/zero-swift-cli +UPDATE_GENERATED=1 pnpm test # rewrites Schema.swift and drift-vectors.json +cd ../../apps/zbugs-swift +swift test +``` + +CI fails while the record is out of date (`pnpm --filter zero-swift run test`) +or the port drifts from it (`swift test` here). diff --git a/apps/zbugs-swift/Sources/ZBugs/Auth.swift b/apps/zbugs-swift/Sources/ZBugs/Auth.swift new file mode 100644 index 0000000000..a30a549dc1 --- /dev/null +++ b/apps/zbugs-swift/Sources/ZBugs/Auth.swift @@ -0,0 +1,91 @@ +import Zero + +/// Who the client acts for: the `sub` and `role` of the zbugs JWT +/// (`AuthData` in apps/zbugs/shared/auth.ts). The context of every query +/// and mutator; nil when logged out. +public struct AuthData: Codable, Sendable, Hashable { + public var sub: String + public var role: UserRole + + public init(sub: String, role: UserRole) { + self.sub = sub + self.role = role + } +} + +public typealias Role = UserRole + +/// apps/zbugs/shared/error.ts. +public enum MutationErrorCode: String, Sendable { + case notLoggedIn = "NOT_LOGGED_IN" + case notAuthorized = "NOT_AUTHORIZED" + case notificationFailed = "NOTIFICATION_FAILED" + case entityNotFound = "ENTITY_NOT_FOUND" +} + +/// An `ApplicationError` with zbugs' details: `{code, id}`. +func mutationError(_ message: String, _ code: MutationErrorCode, _ id: String? = nil) + -> ApplicationError +{ + var details: [String: JSONValue] = ["code": .string(code.rawValue)] + if let id { details["id"] = .string(id) } + return ApplicationError(message, details: .object(details)) +} + +/// `must` in packages/shared: the value, or the error JS throws for a +/// missing one. +func must(_ value: T?) throws -> T { + guard let value else { throw ApplicationError("Unexpected undefined value") } + return value +} + +func assertIsLoggedIn(_ auth: AuthData?) throws -> AuthData { + guard let auth else { + throw mutationError("User must be logged in for this operation", .notLoggedIn) + } + return auth +} + +func isAdmin(_ auth: AuthData?) throws -> Bool { + try assertIsLoggedIn(auth).role == .crew +} + +/// A row with a creator: an issue, a comment, or an emoji. +protocol Created: ZeroRow { + var creatorID: String { get } + var id: String { get } +} + +extension Issue: Created {} +extension Comment: Created {} +extension Emoji: Created {} + +@MainActor +func assertIsCreatorOrAdmin( + _ tx: Transaction, _ auth: AuthData?, _ query: Query, _ id: String +) throws { + let auth = try assertIsLoggedIn(auth) + if try isAdmin(auth) { return } + // By name: a key path through `Created` is not the row's own `\.id`. + let entity = try tx.run(Query(query.any.where("id", .string(id))).one()) + // Security: the same message whether or not the entity exists. + guard let entity, auth.sub == entity.creatorID else { + throw mutationError("Not authorized to access this resource", .notAuthorized, id) + } +} + +@MainActor +func assertUserCanSeeIssue(_ tx: Transaction, _ userID: String, _ issueID: String) throws { + let issue = try must(tx.run(zql.issue.where(\.id, issueID).one())) + let user = try must(tx.run(zql.user.where(\.id, userID).one())) + if issue.visibility != .public && userID != issue.creatorID && user.role != .crew { + throw mutationError( + "User does not have permission to view this issue", .notAuthorized, issueID) + } +} + +@MainActor +func assertUserCanSeeComment(_ tx: Transaction, _ userID: String, _ commentID: String) throws { + let comment = try must(tx.run(zql.comment.where(\.id, commentID).one())) + try assertUserCanSeeIssue(tx, userID, comment.issueID) +} diff --git a/apps/zbugs-swift/Sources/ZBugs/Mutators.swift b/apps/zbugs-swift/Sources/ZBugs/Mutators.swift new file mode 100644 index 0000000000..38da63c834 --- /dev/null +++ b/apps/zbugs-swift/Sources/ZBugs/Mutators.swift @@ -0,0 +1,416 @@ +import Zero + +// The mutators of apps/zbugs/shared/mutators.ts. The drift test checks +// that each changes the same rows as its TS definition, and fails the same +// way. + +public struct CreateIssueArgs: Codable, Sendable, Hashable { + public var id: String + public var title: String + public var description: String? + public var created: Double + public var modified: Double + public var projectID: String + + public init( + id: String, title: String, description: String? = nil, created: Double, modified: Double, + projectID: String + ) { + self.id = id + self.title = title + self.description = description + self.created = created + self.modified = modified + self.projectID = projectID + } +} + +/// The fields of an issue to change; nil leaves a field as it is. +public struct UpdateIssueArgs: Codable, Sendable, Hashable { + public var id: String + public var title: String? + public var description: String? + public var `open`: Bool? + /// `.some(nil)` unassigns the issue. + public var assigneeID: String?? + public var visibility: IssueVisibility? + public var modified: Double + + public init( + id: String, title: String? = nil, description: String? = nil, open: Bool? = nil, + assigneeID: String?? = nil, visibility: IssueVisibility? = nil, modified: Double + ) { + self.id = id + self.title = title + self.description = description + self.open = open + self.assigneeID = assigneeID + self.visibility = visibility + self.modified = modified + } + + enum CodingKeys: String, CodingKey { + case id, title, description, open, assigneeID, visibility, modified + } + + // By hand, since a left-out `assigneeID` and a null one differ. + public init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + title = try c.decodeIfPresent(String.self, forKey: .title) + description = try c.decodeIfPresent(String.self, forKey: .description) + open = try c.decodeIfPresent(Bool.self, forKey: .open) + if c.contains(.assigneeID) { + assigneeID = .some( + try c.decodeNil(forKey: .assigneeID) ? nil : c.decode(String.self, forKey: .assigneeID)) + } + visibility = try c.decodeIfPresent(IssueVisibility.self, forKey: .visibility) + modified = try c.decode(Double.self, forKey: .modified) + } + + public func encode(to encoder: any Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(id, forKey: .id) + try c.encodeIfPresent(title, forKey: .title) + try c.encodeIfPresent(description, forKey: .description) + try c.encodeIfPresent(open, forKey: .open) + if let assigneeID { try c.encode(assigneeID, forKey: .assigneeID) } + try c.encodeIfPresent(visibility, forKey: .visibility) + try c.encode(modified, forKey: .modified) + } +} + +public struct IssueLabelArgs: Codable, Sendable, Hashable { + public var issueID: String + public var labelID: String + /// Ignored: the issue's project is the label's. Still accepted for the + /// clients that send it. + public var projectID: String? + + public init(issueID: String, labelID: String) { + self.issueID = issueID + self.labelID = labelID + } +} + +public struct RemoveLabelArgs: Codable, Sendable, Hashable { + public var issueID: String + public var labelID: String + public init(issueID: String, labelID: String) { + self.issueID = issueID + self.labelID = labelID + } +} + +public enum NotificationType: String, Codable, Sendable, Hashable { + case subscribe, unsubscribe +} + +public struct NotificationUpdateArgs: Codable, Sendable, Hashable { + public var issueID: String + public var subscribed: NotificationType + public var created: Double + public init(issueID: String, subscribed: NotificationType, created: Double) { + self.issueID = issueID + self.subscribed = subscribed + self.created = created + } +} + +public struct AddEmojiArgs: Codable, Sendable, Hashable { + public var id: String + public var unicode: String + public var annotation: String + public var subjectID: String + public var created: Double + public init(id: String, unicode: String, annotation: String, subjectID: String, created: Double) { + self.id = id + self.unicode = unicode + self.annotation = annotation + self.subjectID = subjectID + self.created = created + } +} + +public struct AddCommentArgs: Codable, Sendable, Hashable { + public var id: String + public var issueID: String + public var body: String + public var created: Double + public init(id: String, issueID: String, body: String, created: Double) { + self.id = id + self.issueID = issueID + self.body = body + self.created = created + } +} + +public struct EditCommentArgs: Codable, Sendable, Hashable { + public var id: String + public var body: String + public init(id: String, body: String) { + self.id = id + self.body = body + } +} + +public struct CreateLabelArgs: Codable, Sendable, Hashable { + public var id: String + public var name: String + public var projectID: String + public init(id: String, name: String, projectID: String) { + self.id = id + self.name = name + self.projectID = projectID + } +} + +public struct CreateAndAddLabelArgs: Codable, Sendable, Hashable { + public var issueID: String + public var labelID: String + public var labelName: String + /// Ignored, as in `IssueLabelArgs`. + public var projectID: String? + public init(issueID: String, labelID: String, labelName: String) { + self.issueID = issueID + self.labelID = labelID + self.labelName = labelName + } +} + +public struct ViewStateArgs: Codable, Sendable, Hashable { + public var issueID: String + public var viewed: Double + public init(issueID: String, viewed: Double) { + self.issueID = issueID + self.viewed = viewed + } +} + +public struct UserPrefArgs: Codable, Sendable, Hashable { + public var key: String + public var value: String + public init(key: String, value: String) { + self.key = key + self.value = value + } +} + +public enum Mutators { + public static let createIssue = defineMutator("issue.create") { + (tx, args: CreateIssueArgs, auth: AuthData?) in + let auth = try assertIsLoggedIn(auth) + try tx.mutate.insert( + Issue( + id: args.id, title: args.title, open: true, modified: args.modified, + created: args.created, projectID: args.projectID, creatorID: auth.sub, + description: args.description ?? "", visibility: .public)) + // The creator follows the issue. + try updateIssueNotification( + tx, userID: auth.sub, issueID: args.id, subscribed: .subscribe, created: args.created) + } + + public static let updateIssue = defineMutator("issue.update") { + (tx, change: UpdateIssueArgs, auth: AuthData?) in + // Authorization comes first, so the error does not tell whether a + // private issue exists. + try assertIsCreatorOrAdmin(tx, auth, zql.issue, change.id) + guard let oldIssue = try tx.run(zql.issue.where(\.id, change.id).one()) else { + // An admin skips the creator check; the same error as for anyone else. + throw mutationError("Issue not found or not authorized", .notAuthorized, change.id) + } + try tx.mutate.issue.update(JSONValueEncoder.encode(change).objectValue ?? [:]) + + var previousAssigneeID: String? + if case .some(let assigneeID) = change.assigneeID, assigneeID != oldIssue.assigneeID { + previousAssigneeID = oldIssue.assigneeID + } + // The assignee follows the issue; the one before stops. + if let assigneeID = truthy(change.assigneeID ?? nil) { + try updateIssueNotification( + tx, userID: assigneeID, issueID: change.id, subscribed: .subscribe, + created: change.modified) + } + if let previous = truthy(previousAssigneeID) { + try updateIssueNotification( + tx, userID: previous, issueID: change.id, subscribed: .unsubscribe, + created: change.modified) + } + } + + public static let deleteIssue = defineMutator("issue.delete") { + (tx, id: String, auth: AuthData?) in + try assertIsCreatorOrAdmin(tx, auth, zql.issue, id) + try tx.mutate.issue.delete(["id": .string(id)]) + } + + /// Deletes the issues if the user created every one of them (or is an + /// admin); otherwise none. + public static let deleteIssues = defineMutator("issue.deleteMany") { + (tx, ids: [String], auth: AuthData?) in + _ = try assertIsLoggedIn(auth) + for id in ids { + try assertIsCreatorOrAdmin(tx, auth, zql.issue, id) + } + for id in ids { + try tx.mutate.issue.delete(["id": .string(id)]) + } + } + + public static let addLabel = defineMutator("issue.addLabel") { + (tx, args: IssueLabelArgs, auth: AuthData?) in + try assertIsCreatorOrAdmin(tx, auth, zql.issue, args.issueID) + try tx.mutate.insert( + IssueLabel( + issueID: args.issueID, labelID: args.labelID, + projectID: issueProjectID(tx, args.issueID))) + } + + public static let removeLabel = defineMutator("issue.removeLabel") { + (tx, args: RemoveLabelArgs, auth: AuthData?) in + try assertIsCreatorOrAdmin(tx, auth, zql.issue, args.issueID) + try tx.mutate.issueLabel.delete([ + "issueID": .string(args.issueID), "labelID": .string(args.labelID), + ]) + } + + public static let updateNotification = defineMutator("notification.update") { + (tx, args: NotificationUpdateArgs, auth: AuthData?) in + let auth = try assertIsLoggedIn(auth) + try updateIssueNotification( + tx, userID: auth.sub, issueID: args.issueID, subscribed: args.subscribed, + created: args.created, forceUpdate: true) + } + + public static let addEmojiToIssue = defineMutator("emoji.addToIssue") { + (tx, args: AddEmojiArgs, auth: AuthData?) in + try addEmoji(tx, .issue, args, auth) + } + + public static let addEmojiToComment = defineMutator("emoji.addToComment") { + (tx, args: AddEmojiArgs, auth: AuthData?) in + try addEmoji(tx, .comment, args, auth) + } + + public static let removeEmoji = defineMutator("emoji.remove") { + (tx, id: String, auth: AuthData?) in + try assertIsCreatorOrAdmin(tx, auth, zql.emoji, id) + try tx.mutate.emoji.delete(["id": .string(id)]) + } + + public static let addComment = defineMutator("comment.add") { + (tx, args: AddCommentArgs, auth: AuthData?) in + let creatorID = try assertIsLoggedIn(auth).sub + try assertUserCanSeeIssue(tx, creatorID, args.issueID) + try tx.mutate.insert( + Comment( + id: args.id, issueID: args.issueID, created: args.created, body: args.body, + creatorID: creatorID)) + try updateIssueNotification( + tx, userID: creatorID, issueID: args.issueID, subscribed: .subscribe, + created: args.created) + } + + public static let editComment = defineMutator("comment.edit") { + (tx, args: EditCommentArgs, auth: AuthData?) in + try assertIsCreatorOrAdmin(tx, auth, zql.comment, args.id) + try tx.mutate.comment.update(["id": .string(args.id), "body": .string(args.body)]) + } + + public static let removeComment = defineMutator("comment.remove") { + (tx, id: String, auth: AuthData?) in + try assertIsCreatorOrAdmin(tx, auth, zql.comment, id) + try tx.mutate.comment.delete(["id": .string(id)]) + } + + public static let createLabel = defineMutator("label.create") { + (tx, args: CreateLabelArgs, auth: AuthData?) in + guard try isAdmin(auth) else { + throw mutationError("Only admins can create labels", .notAuthorized, args.id) + } + try tx.mutate.insert(Label(id: args.id, name: args.name, projectID: args.projectID)) + } + + public static let createAndAddLabel = defineMutator("label.createAndAddToIssue") { + (tx, args: CreateAndAddLabelArgs, auth: AuthData?) in + guard try isAdmin(auth) else { + throw mutationError("Only admins can create labels", .notAuthorized, args.labelID) + } + let projectID = try issueProjectID(tx, args.issueID) + try tx.mutate.insert(Label(id: args.labelID, name: args.labelName, projectID: projectID)) + try tx.mutate.insert( + IssueLabel(issueID: args.issueID, labelID: args.labelID, projectID: projectID)) + } + + public static let setViewState = defineMutator("viewState.set") { + (tx, args: ViewStateArgs, auth: AuthData?) in + let userID = try assertIsLoggedIn(auth).sub + try tx.mutate.upsert(ViewState(issueID: args.issueID, userID: userID, viewed: args.viewed)) + } + + public static let setUserPref = defineMutator("userPref.set") { + (tx, args: UserPrefArgs, auth: AuthData?) in + let userID = try assertIsLoggedIn(auth).sub + try tx.mutate.upsert(UserPref(key: args.key, userID: userID, value: args.value)) + } + + /// Every mutator, for the client and the drift test. + public static let all: [any Mutator] = [ + createIssue, updateIssue, deleteIssue, deleteIssues, addLabel, removeLabel, + updateNotification, addEmojiToIssue, addEmojiToComment, removeEmoji, addComment, editComment, + removeComment, createLabel, createAndAddLabel, setViewState, setUserPref, + ] +} + +/// The project of an issue: the issue decides it, never the caller. +@MainActor +func issueProjectID(_ tx: Transaction, _ issueID: String) throws -> String { + guard let issue = try tx.run(zql.issue.where(\.id, issueID).one()) else { + throw mutationError("Issue not found", .entityNotFound, issueID) + } + return issue.projectID +} + +enum EmojiSubject { + case issue, comment +} + +@MainActor +func addEmoji(_ tx: Transaction, _ subject: EmojiSubject, _ args: AddEmojiArgs, _ auth: AuthData?) + throws +{ + let creatorID = try assertIsLoggedIn(auth).sub + switch subject { + case .issue: try assertUserCanSeeIssue(tx, creatorID, args.subjectID) + case .comment: try assertUserCanSeeComment(tx, creatorID, args.subjectID) + } + try tx.mutate.insert( + Emoji( + id: args.id, value: args.unicode, annotation: args.annotation, subjectID: args.subjectID, + creatorID: creatorID, created: args.created)) + // An emoji on the issue itself follows the issue. + if subject == .issue { + try updateIssueNotification( + tx, userID: creatorID, issueID: args.subjectID, subscribed: .subscribe, + created: args.created) + } +} + +/// Subscribes to an issue, unless the user chose before and `forceUpdate` +/// is false, or unsubscribes from it. +@MainActor +func updateIssueNotification( + _ tx: Transaction, userID: String, issueID: String, subscribed: NotificationType, + created: Double, forceUpdate: Bool = false +) throws { + try assertUserCanSeeIssue(tx, userID, issueID) + let existing = try tx.run( + zql.issueNotifications.where(\.userID, userID).where(\.issueID, issueID).one()) + if subscribed == .subscribe && (existing == nil || forceUpdate) { + try tx.mutate.upsert( + IssueNotifications(userID: userID, issueID: issueID, subscribed: true, created: created)) + } else if subscribed == .unsubscribe { + try tx.mutate.upsert( + IssueNotifications(userID: userID, issueID: issueID, subscribed: false, created: created)) + } +} diff --git a/apps/zbugs-swift/Sources/ZBugs/Queries.swift b/apps/zbugs-swift/Sources/ZBugs/Queries.swift new file mode 100644 index 0000000000..11ce8245da --- /dev/null +++ b/apps/zbugs-swift/Sources/ZBugs/Queries.swift @@ -0,0 +1,534 @@ +import Zero + +// The queries of apps/zbugs/shared/queries.ts. The drift test checks that +// each builds the same AST as its TS definition. + +public let zeroProjectName = "Zero" + +public enum SortField: String, Codable, Sendable, Hashable { + case modified, created +} + +/// The filters and sort of an issue list (`listContextParams`). +public struct ListContextParams: Codable, Sendable, Hashable { + @Nullable public var open: Bool? + public var projectName: String? + @Nullable public var assignee: String? + @Nullable public var creator: String? + @Nullable public var labels: [String]? + @Nullable public var textFilter: String? + public var sortField: SortField + public var sortDirection: AST.Direction + + public init( + open: Bool?, projectName: String? = nil, assignee: String?, creator: String?, + labels: [String]?, textFilter: String?, sortField: SortField, sortDirection: AST.Direction + ) { + self.open = open + self.projectName = projectName + self.assignee = assignee + self.creator = creator + self.labels = labels + self.textFilter = textFilter + self.sortField = sortField + self.sortDirection = sortDirection + } +} + +/// Where an issue list starts: the issue's sort keys (`issueRowSortSchema`). +public struct IssueRowSort: Codable, Sendable, Hashable { + public var id: String + public var created: Double + public var modified: Double + + public init(id: String, created: Double, modified: Double) { + self.id = id + self.created = created + self.modified = modified + } +} + +public enum PageDirection: String, Codable, Sendable, Hashable { + case forward, backward +} + +public enum IDField: String, Codable, Sendable, Hashable { + case shortID, id +} + +/// An issue's `id` or its `shortID`. +public enum IssueKey: Codable, Sendable, Hashable { + case string(String) + case number(Double) + + public init(from decoder: any Decoder) throws { + let c = try decoder.singleValueContainer() + if let s = try? c.decode(String.self) { + self = .string(s) + } else { + self = .number(try c.decode(Double.self)) + } + } + + public func encode(to encoder: any Encoder) throws { + var c = encoder.singleValueContainer() + switch self { + case .string(let s): try c.encode(s) + case .number(let n): try c.encode(n) + } + } + + var json: JSONValue { + switch self { + case .string(let s): .string(s) + case .number(let n): .number(n) + } + } +} + +public struct ProjectArgs: Codable, Sendable, Hashable { + public var projectName: String + public init(projectName: String) { self.projectName = projectName } +} + +public struct LabelsOrderByNameArgs: Codable, Sendable, Hashable { + public var projectName: String + public var orderBy: AST.Direction + public init(projectName: String, orderBy: AST.Direction) { + self.projectName = projectName + self.orderBy = orderBy + } +} + +public struct UsersForProjectArgs: Codable, Sendable, Hashable { + public enum Filter: String, Codable, Sendable, Hashable { + case crew, creators, assignees + } + + public var disabled: Bool + public var login: String? + public var projectName: String + public var filter: Filter? + + public init(disabled: Bool, login: String? = nil, projectName: String, filter: Filter? = nil) { + self.disabled = disabled + self.login = login + self.projectName = projectName + self.filter = filter + } +} + +public struct CommentsPageArgs: Codable, Sendable, Hashable { + public struct Cursor: Codable, Sendable, Hashable { + public var id: String + public var created: Double + public init(id: String, created: Double) { + self.id = id + self.created = created + } + } + + public var issueID: String + public var limit: Int + @Nullable public var start: Cursor? + public var dir: PageDirection + public var inclusive: Bool + + public init(issueID: String, limit: Int, start: Cursor?, dir: PageDirection, inclusive: Bool) { + self.issueID = issueID + self.limit = limit + self.start = start + self.dir = dir + self.inclusive = inclusive + } +} + +public struct IssueDetailArgs: Codable, Sendable, Hashable { + public var idField: IDField + public var id: IssueKey + public init(idField: IDField, id: IssueKey) { + self.idField = idField + self.id = id + } +} + +public struct IssueListV2Args: Codable, Sendable, Hashable { + public var listContext: ListContextParams + @Nullable public var limit: Int? + @Nullable public var start: IssueRowSort? + public var dir: PageDirection + public var inclusive: Bool? + + public init( + listContext: ListContextParams, limit: Int?, start: IssueRowSort?, dir: PageDirection, + inclusive: Bool? = nil + ) { + self.listContext = listContext + self.limit = limit + self.start = start + self.dir = dir + self.inclusive = inclusive + } +} + +public struct ListIssueByIDArgs: Codable, Sendable, Hashable { + public var listContext: ListContextParams + public var idField: IDField + public var idValue: IssueKey + public init(listContext: ListContextParams, idField: IDField, idValue: IssueKey) { + self.listContext = listContext + self.idField = idField + self.idValue = idValue + } +} + +public struct PrevNextArgs: Codable, Sendable, Hashable { + public enum Direction: String, Codable, Sendable, Hashable { + case next, prev + } + + @Nullable public var listContext: ListContextParams? + @Nullable public var issue: IssueRowSort? + public var dir: Direction + + public init(listContext: ListContextParams?, issue: IssueRowSort?, dir: Direction) { + self.listContext = listContext + self.issue = issue + self.dir = dir + } +} + +public struct IssueListArgs: Codable, Sendable, Hashable { + public var listContext: ListContextParams + public var limit: Int + public init(listContext: ListContextParams, limit: Int) { + self.listContext = listContext + self.limit = limit + } +} + +public struct UserPickerArgs: Codable, Sendable, Hashable { + public enum Filter: String, Codable, Sendable, Hashable { + case crew, creators + } + + public var disabled: Bool + @Nullable public var login: String? + @Nullable public var filter: Filter? + + public init(disabled: Bool, login: String?, filter: Filter?) { + self.disabled = disabled + self.login = login + self.filter = filter + } +} + +public enum Queries { + public static let allLabels = defineQuery("allLabels") { (_: NoArgs, _: AuthData?) in + zql.label + } + + public static let allUsers = defineQuery("allUsers") { (_: NoArgs, _: AuthData?) in + zql.user + } + + public static let allProjects = defineQuery("allProjects") { (_: NoArgs, _: AuthData?) in + zql.project + } + + public static let user = defineQuery("user") { (userID: String, _: AuthData?) in + zql.user.where(\.id, userID).one() + } + + public static let crewUser = defineQuery("crewUser") { (userID: String, _: AuthData?) in + zql.user.where(\.id, userID).where(\.role, .crew).one() + } + + public static let labels = defineQuery("labels") { (args: ProjectArgs, _: AuthData?) in + labelsQuery(args.projectName, nil) + } + + public static let labelsOrderByName = defineQuery("labelsOrderByName") { + (args: LabelsOrderByNameArgs, _: AuthData?) in + labelsQuery(args.projectName, args.orderBy) + } + + public static let issuePreloadV2 = defineQuery("issuePreloadV2") { + (args: ProjectArgs, auth: AuthData?) in + applyIssuePermissions( + zql.issue + .whereExists(\.project, scalar: true) { + $0.where(\.lowerCaseName, args.projectName.lowercased()) + } + .related(\.labels) + .related(\.viewState) { q in + truthy(auth?.sub).map { q.where(\.userID, $0) } ?? alwaysFalse(q) + } + .related(\.creator) + .related(\.assignee) + .related(\.emoji) { $0.related(\.creator) } + .related(\.comments) { comments in + comments + .related(\.creator) + .related(\.emoji) { $0.related(\.creator) } + .limit(10) + .orderBy(\.created, .desc) + } + .orderBy(\.modified, .desc) + .orderBy(\.id, .desc) + .limit(1000), + auth?.role) + } + + public static let userPref = defineQuery("userPref") { (key: String, auth: AuthData?) in + zql.userPref.where(\.key, key).where(\.userID, .eq, auth?.sub).one() + } + + public static let usersForProject = defineQuery("usersForProject") { + (args: UsersForProjectArgs, _: AuthData?) in + var q = zql.user + if args.disabled, let login = truthy(args.login) { + q = q.where(\.login, login) + } else if let filter = args.filter { + let inProject = { (issues: Query) in + issues.whereExists(\.project, scalar: true) { + $0.where(\.lowerCaseName, args.projectName.lowercased()) + } + } + switch filter { + case .crew: q = q.where(crew) + case .creators: q = q.whereExists(\.createdIssues, inProject) + case .assignees: q = q.whereExists(\.assignedIssues, inProject) + } + } + return q + } + + /// A page of an issue's comments, ordered by (created, id); a backward + /// page flips the order. `start` is the cursor row. + public static let commentsPage = defineQuery("commentsPage") { + (args: CommentsPageArgs, _: AuthData?) in + let dir: AST.Direction = args.dir == .forward ? .asc : .desc + var q = zql.comment + .related(\.creator) + .related(\.emoji) { $0.related(\.creator) } + .where(\.issueID, args.issueID) + .orderBy(\.created, dir) + .orderBy(\.id, dir) + if let start = args.start { + q = q.start([\.id: start.id, \.created: start.created], inclusive: args.inclusive) + } + return q.limit(args.limit) + } + + public static let comment = defineQuery("comment") { (id: String, _: AuthData?) in + zql.comment + .related(\.creator) + .related(\.emoji) { $0.related(\.creator) } + .where(\.id, id) + .one() + } + + public static let issueDetail = defineQuery("issueDetail") { + (args: IssueDetailArgs, auth: AuthData?) in + applyIssuePermissions( + zql.issue + .where(args.idField.rawValue, args.id.json) + .related(\.project) + .related(\.emoji) { $0.related(\.creator) } + .related(\.creator) + .related(\.assignee) + .related(\.labels) + .related(\.notificationState) { q in + truthy(auth?.sub).map { q.where(\.userID, $0) } ?? alwaysFalse(q) + } + .related(\.viewState) { q in + (truthy(auth?.sub).map { q.where(\.userID, $0) } ?? alwaysFalse(q)).one() + } + // The most recent comments, so the comments list renders locally on + // navigation. + .related(\.comments) { comments in + comments + .related(\.creator) + .related(\.emoji) { $0.related(\.creator) } + .limit(50) + .orderBy(\.created, .desc) + .orderBy(\.id, .desc) + } + .one(), + auth?.role) + } + + public static let issueListV2 = defineQuery("issueListV2") { + (args: IssueListV2Args, auth: AuthData?) in + buildListQuery( + ListQueryArgs( + listContext: args.listContext, userID: auth?.sub, role: auth?.role, limit: args.limit, + start: args.start, dir: args.dir, inclusive: args.inclusive ?? false)) + } + + /// One issue, for the order to start a list at. + public static let listIssueByID = defineQuery("listIssueByID") { + (args: ListIssueByIDArgs, auth: AuthData?) in + buildListQuery(ListQueryArgs(listContext: args.listContext, role: auth?.role)) + .where(args.idField.rawValue, args.idValue.json) + .one() + } + + public static let emojiChange = defineQuery("emojiChange") { (subjectID: String, _: AuthData?) in + zql.emoji + .where(\.subjectID, subjectID) + .related(\.creator) { $0.one() } + } + + /// Deprecated in zbugs. + public static let prevNext = defineQuery("prevNext") { (args: PrevNextArgs, auth: AuthData?) in + buildListQuery( + ListQueryArgs( + listContext: args.listContext, role: auth?.role, start: args.issue, + dir: args.dir == .next ? .forward : .backward) + ).one() + } + + /// Deprecated in zbugs. + public static let issueList = defineQuery("issueList") { (args: IssueListArgs, auth: AuthData?) in + buildListQuery( + ListQueryArgs( + listContext: args.listContext, userID: auth?.sub, role: auth?.role, limit: args.limit)) + } + + /// Deprecated in zbugs. + public static let userPicker = defineQuery("userPicker") { (args: UserPickerArgs, _: AuthData?) in + var q = zql.user + if args.disabled, let login = truthy(args.login) { + q = q.where(\.login, login) + } else if let filter = args.filter { + switch filter { + case .crew: q = q.where(crew) + case .creators: q = q.whereExists(\.createdIssues) + } + } + return q + } + + /// Every query, for the server's registry and the drift test. + public static let all: [any NamedQuery] = [ + allLabels, allUsers, allProjects, user, crewUser, labels, labelsOrderByName, issuePreloadV2, + userPref, usersForProject, commentsPage, comment, issueDetail, issueListV2, listIssueByID, + emojiChange, prevNext, issueList, userPicker, + ] +} + +/// The crew, without the bots. +private func crew(_ user: RowExpression) -> Condition { + user.and(user.role == .crew, user.not(user.login.like("rocibot%"))) +} + +func applyIssuePermissions(_ q: Query, _ role: Role?) + -> Query +where Result.Row == Issue { + role == .crew ? q : q.where(\.visibility, .public) +} + +func labelsQuery(_ projectName: String, _ orderBy: AST.Direction?) -> Query { + var q = zql.label.whereExists(\.project, scalar: true) { + $0.where(\.lowerCaseName, projectName.lowercased()) + } + if let orderBy { + q = q.orderBy(\.name, orderBy) + } + return q +} + +/// Matches no row. +func alwaysFalse(_ q: Query) -> Query where S.Row == R { + q.where { $0.or() } +} + +/// The string when JS would take it as true: neither nil nor empty. +func truthy(_ s: String?) -> String? { + s.flatMap { $0.isEmpty ? nil : $0 } +} + +struct ListQueryArgs { + var issueQuery = zql.issue + var listContext: ListContextParams? + var userID: String? + var role: Role? + var limit: Int? + var start: IssueRowSort? + var dir = PageDirection.forward + var inclusive = false +} + +func buildListQuery(_ args: ListQueryArgs) -> Query { + var q = args.issueQuery + .related(\.viewState) { q in + (truthy(args.userID).map { q.where(\.userID, $0) } ?? alwaysFalse(q)).one() + } + .related(\.labels) + // The issue page cannot render until the creator is present; loading it + // here lets a click from the list render locally. + .related(\.creator) + + guard let listContext = args.listContext else { + return alwaysFalse(q) + } + let projectName = (listContext.projectName ?? zeroProjectName).lowercased() + q = q.whereExists(\.project, scalar: true) { $0.where(\.lowerCaseName, projectName) } + + let dir: AST.Direction = + args.dir == .forward + ? listContext.sortDirection : listContext.sortDirection == .asc ? .desc : .asc + let sortField: KeyPath = + listContext.sortField == .modified ? \.modified : \.created + q = q.orderBy(sortField, dir).orderBy(\.id, dir) + + if let start = args.start { + q = q.start( + [\.id: start.id, \.created: start.created, \.modified: start.modified], + inclusive: args.inclusive) + } + if let limit = args.limit, limit != 0 { + q = q.limit(limit) + } + + let text = truthy(listContext.textFilter).map { "%\(escapeLike($0))%" } + q = q.where { issue in + issue.and( + [ + listContext.open.map { issue.open == $0 }, + text.map { text in + issue.or( + issue.title.ilike(text), issue.description.ilike(text), + issue.exists(\.comments) { $0.where(\.body, .ilike, text) }) + }, + ] + + (listContext.labels ?? []).map { label in + // `label`'s unique key is (projectID, name); the scalar `project` + // gate pins projectID before the `name` gate runs. + issue.exists(\.issueLabels) { + $0.whereExists(\.label, scalar: true) { + $0.where(\.name, label).whereExists(\.project, scalar: true) { + $0.where(\.lowerCaseName, projectName) + } + } + } + }) + } + + if let creator = truthy(listContext.creator) { + q = q.whereExists(\.creator, scalar: true) { $0.where(\.login, creator) } + } + if let assignee = truthy(listContext.assignee) { + q = q.whereExists(\.assignee, scalar: true) { $0.where(\.login, assignee) } + } + return applyIssuePermissions(q, args.role) +} + +extension Query { + /// `where(column, value)` by the column's name, as JS writes it. + func `where`(_ column: String, _ value: JSONValue) -> Self { + Self(any.where(column, value)) + } +} diff --git a/apps/zbugs-swift/Sources/ZBugs/Schema.swift b/apps/zbugs-swift/Sources/ZBugs/Schema.swift new file mode 100644 index 0000000000..d64e586ada --- /dev/null +++ b/apps/zbugs-swift/Sources/ZBugs/Schema.swift @@ -0,0 +1,884 @@ +// Generated by `zero-swift codegen` from ../zbugs/shared/schema.ts. Do not edit. +// swift-format-ignore-file + +import Zero + +public struct User: ZeroRow, Codable, Hashable, Identifiable { + public var id: String + public var login: String + public var name: String? + public var avatar: String + public var role: UserRole + + // Relationships, filled when a query asks for them with `related`. + public var createdIssues: [Issue]? + public var assignedIssues: [Issue]? + + public init( + id: String, + login: String, + name: String? = nil, + avatar: String, + role: UserRole, + createdIssues: [Issue]? = nil, + assignedIssues: [Issue]? = nil + ) { + self.id = id + self.login = login + self.name = name + self.avatar = avatar + self.role = role + self.createdIssues = createdIssues + self.assignedIssues = assignedIssues + } + + public static let tableName = "user" + + public static let columns: KeyPathNames = [ + \User.id: "id", + \User.login: "login", + \User.name: "name", + \User.avatar: "avatar", + \User.role: "role", + ] + + public static let relationships: KeyPathNames = [ + \User.createdIssues: "createdIssues", + \User.assignedIssues: "assignedIssues", + ] + + public init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id") + login = try entry.column("login") + name = try entry.column("name") + avatar = try entry.column("avatar") + role = try entry.column("role") + createdIssues = try entry.relationship("createdIssues") + assignedIssues = try entry.relationship("assignedIssues") + } + + public var columnValues: Row { + [ + "id": id.zeroValue, + "login": login.zeroValue, + "name": name.zeroValue, + "avatar": avatar.zeroValue, + "role": role.zeroValue, + ] + } +} + +public struct Project: ZeroRow, Codable, Hashable, Identifiable { + public var id: String + public var name: String + public var lowerCaseName: String + public var issueCountEstimate: Int? + public var supportsSearch: Bool + public var markURL: String? + public var logoURL: String? + + // Relationships, filled when a query asks for them with `related`. + public var issues: [Issue]? + public var labels: [Label]? + + public init( + id: String, + name: String, + lowerCaseName: String, + issueCountEstimate: Int? = nil, + supportsSearch: Bool, + markURL: String? = nil, + logoURL: String? = nil, + issues: [Issue]? = nil, + labels: [Label]? = nil + ) { + self.id = id + self.name = name + self.lowerCaseName = lowerCaseName + self.issueCountEstimate = issueCountEstimate + self.supportsSearch = supportsSearch + self.markURL = markURL + self.logoURL = logoURL + self.issues = issues + self.labels = labels + } + + public static let tableName = "project" + + public static let columns: KeyPathNames = [ + \Project.id: "id", + \Project.name: "name", + \Project.lowerCaseName: "lowerCaseName", + \Project.issueCountEstimate: "issueCountEstimate", + \Project.supportsSearch: "supportsSearch", + \Project.markURL: "markURL", + \Project.logoURL: "logoURL", + ] + + public static let relationships: KeyPathNames = [ + \Project.issues: "issues", + \Project.labels: "labels", + ] + + public init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id") + name = try entry.column("name") + lowerCaseName = try entry.column("lowerCaseName") + issueCountEstimate = try entry.column("issueCountEstimate") + supportsSearch = try entry.column("supportsSearch") + markURL = try entry.column("markURL") + logoURL = try entry.column("logoURL") + issues = try entry.relationship("issues") + labels = try entry.relationship("labels") + } + + public var columnValues: Row { + [ + "id": id.zeroValue, + "name": name.zeroValue, + "lowerCaseName": lowerCaseName.zeroValue, + "issueCountEstimate": issueCountEstimate.zeroValue, + "supportsSearch": supportsSearch.zeroValue, + "markURL": markURL.zeroValue, + "logoURL": logoURL.zeroValue, + ] + } +} + +public struct Issue: ZeroRow, Codable, Hashable, Identifiable { + public var id: String + public var shortID: Int? + public var title: String + public var `open`: Bool + public var modified: Double + public var created: Double + public var projectID: String + public var creatorID: String + public var assigneeID: String? + public var description: String + public var visibility: IssueVisibility + + // Relationships, filled when a query asks for them with `related`. + @Indirect public var project: Project? + public var issueLabels: [IssueLabel]? + public var labels: [Label]? + public var comments: [Comment]? + @Indirect public var creator: User? + @Indirect public var assignee: User? + public var viewState: [ViewState]? + public var emoji: [Emoji]? + @Indirect public var notificationState: IssueNotifications? + + public init( + id: String, + shortID: Int? = nil, + title: String, + `open`: Bool, + modified: Double, + created: Double, + projectID: String, + creatorID: String, + assigneeID: String? = nil, + description: String, + visibility: IssueVisibility, + project: Project? = nil, + issueLabels: [IssueLabel]? = nil, + labels: [Label]? = nil, + comments: [Comment]? = nil, + creator: User? = nil, + assignee: User? = nil, + viewState: [ViewState]? = nil, + emoji: [Emoji]? = nil, + notificationState: IssueNotifications? = nil + ) { + self.id = id + self.shortID = shortID + self.title = title + self.`open` = `open` + self.modified = modified + self.created = created + self.projectID = projectID + self.creatorID = creatorID + self.assigneeID = assigneeID + self.description = description + self.visibility = visibility + self.project = project + self.issueLabels = issueLabels + self.labels = labels + self.comments = comments + self.creator = creator + self.assignee = assignee + self.viewState = viewState + self.emoji = emoji + self.notificationState = notificationState + } + + public static let tableName = "issue" + + public static let columns: KeyPathNames = [ + \Issue.id: "id", + \Issue.shortID: "shortID", + \Issue.title: "title", + \Issue.`open`: "open", + \Issue.modified: "modified", + \Issue.created: "created", + \Issue.projectID: "projectID", + \Issue.creatorID: "creatorID", + \Issue.assigneeID: "assigneeID", + \Issue.description: "description", + \Issue.visibility: "visibility", + ] + + public static let relationships: KeyPathNames = [ + \Issue.project: "project", + \Issue.issueLabels: "issueLabels", + \Issue.labels: "labels", + \Issue.comments: "comments", + \Issue.creator: "creator", + \Issue.assignee: "assignee", + \Issue.viewState: "viewState", + \Issue.emoji: "emoji", + \Issue.notificationState: "notificationState", + ] + + public init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id") + shortID = try entry.column("shortID") + title = try entry.column("title") + `open` = try entry.column("open") + modified = try entry.column("modified") + created = try entry.column("created") + projectID = try entry.column("projectID") + creatorID = try entry.column("creatorID") + assigneeID = try entry.column("assigneeID") + description = try entry.column("description") + visibility = try entry.column("visibility") + project = try entry.relationship("project") + issueLabels = try entry.relationship("issueLabels") + labels = try entry.relationship("labels") + comments = try entry.relationship("comments") + creator = try entry.relationship("creator") + assignee = try entry.relationship("assignee") + viewState = try entry.relationship("viewState") + emoji = try entry.relationship("emoji") + notificationState = try entry.relationship("notificationState") + } + + public var columnValues: Row { + [ + "id": id.zeroValue, + "shortID": shortID.zeroValue, + "title": title.zeroValue, + "open": `open`.zeroValue, + "modified": modified.zeroValue, + "created": created.zeroValue, + "projectID": projectID.zeroValue, + "creatorID": creatorID.zeroValue, + "assigneeID": assigneeID.zeroValue, + "description": description.zeroValue, + "visibility": visibility.zeroValue, + ] + } +} + +public struct Comment: ZeroRow, Codable, Hashable, Identifiable { + public var id: String + public var issueID: String + public var created: Double + public var body: String + public var creatorID: String + + // Relationships, filled when a query asks for them with `related`. + @Indirect public var creator: User? + public var emoji: [Emoji]? + @Indirect public var issue: Issue? + + public init( + id: String, + issueID: String, + created: Double, + body: String, + creatorID: String, + creator: User? = nil, + emoji: [Emoji]? = nil, + issue: Issue? = nil + ) { + self.id = id + self.issueID = issueID + self.created = created + self.body = body + self.creatorID = creatorID + self.creator = creator + self.emoji = emoji + self.issue = issue + } + + public static let tableName = "comment" + + public static let columns: KeyPathNames = [ + \Comment.id: "id", + \Comment.issueID: "issueID", + \Comment.created: "created", + \Comment.body: "body", + \Comment.creatorID: "creatorID", + ] + + public static let relationships: KeyPathNames = [ + \Comment.creator: "creator", + \Comment.emoji: "emoji", + \Comment.issue: "issue", + ] + + public init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id") + issueID = try entry.column("issueID") + created = try entry.column("created") + body = try entry.column("body") + creatorID = try entry.column("creatorID") + creator = try entry.relationship("creator") + emoji = try entry.relationship("emoji") + issue = try entry.relationship("issue") + } + + public var columnValues: Row { + [ + "id": id.zeroValue, + "issueID": issueID.zeroValue, + "created": created.zeroValue, + "body": body.zeroValue, + "creatorID": creatorID.zeroValue, + ] + } +} + +public struct Label: ZeroRow, Codable, Hashable, Identifiable { + public var id: String + public var name: String + public var projectID: String + + // Relationships, filled when a query asks for them with `related`. + @Indirect public var project: Project? + + public init( + id: String, + name: String, + projectID: String, + project: Project? = nil + ) { + self.id = id + self.name = name + self.projectID = projectID + self.project = project + } + + public static let tableName = "label" + + public static let columns: KeyPathNames
/`. A single-column key is the value /// as JS `String()` prints it; a compound key is the decimal `h128` of the /// JSON array of its values. - public static func primaryKeyString( + package static func primaryKeyString( table: String, primaryKey: [String], row: Row ) throws(InvalidPrimaryKeyValue) -> String { func scalar(_ column: String) throws(InvalidPrimaryKeyValue) -> JSONValue { @@ -47,15 +47,15 @@ public enum Keys { } } - public static func desiredQueriesKey(clientID: String, hash: String) -> String { + package static func desiredQueriesKey(clientID: String, hash: String) -> String { desiredQueriesPrefix + clientID + "/" + hash } - public static func gotQueriesKey(hash: String) -> String { + package static func gotQueriesKey(hash: String) -> String { gotQueriesPrefix + hash } - public static func mutationResponseKey(clientID: String, id: Double) -> String { + package static func mutationResponseKey(clientID: String, id: Double) -> String { mutationsPrefix + clientID + "/" + JSNumber.format(id) } } diff --git a/swift/Sources/Zero/Client/Transport.swift b/swift/Sources/Zero/Client/Transport.swift index b661320370..3286cf709c 100644 --- a/swift/Sources/Zero/Client/Transport.swift +++ b/swift/Sources/Zero/Client/Transport.swift @@ -48,22 +48,22 @@ public struct SocketClosed: Error, Sendable, Hashable { /// The query parameters of the sync connect URL, in the order JS sets them /// (`createConnectionURL` in `packages/zero-client/src/client/zero.ts`). -public struct ConnectionURL: Sendable, Hashable { - public var socketOrigin: String - public var clientID: String - public var clientGroupID: String - public var userID: String? +package struct ConnectionURL: Sendable, Hashable { + package var socketOrigin: String + package var clientID: String + package var clientGroupID: String + package var userID: String? /// The cookie of the client's last server snapshot, nil for none. - public var baseCookie: String? + package var baseCookie: String? /// The `ts` parameter, `performance.now()` in JS. - public var timestamp: Double - public var lastMutationID: Double - public var wsid: String - public var profileID: String - public var debugPerf: Bool - public var additionalParams: [(String, String)] + package var timestamp: Double + package var lastMutationID: Double + package var wsid: String + package var profileID: String + package var debugPerf: Bool + package var additionalParams: [(String, String)] - public init( + package init( socketOrigin: String, clientID: String, clientGroupID: String, userID: String? = nil, baseCookie: String? = nil, timestamp: Double, lastMutationID: Double, wsid: String, profileID: String, debugPerf: Bool = false, additionalParams: [(String, String)] = [] @@ -81,10 +81,10 @@ public struct ConnectionURL: Sendable, Hashable { self.additionalParams = additionalParams } - public static func == (a: ConnectionURL, b: ConnectionURL) -> Bool { a.string == b.string } - public func hash(into hasher: inout Hasher) { hasher.combine(string) } + package static func == (a: ConnectionURL, b: ConnectionURL) -> Bool { a.string == b.string } + package func hash(into hasher: inout Hasher) { hasher.combine(string) } - public var string: String { + package var string: String { var params: [(String, String)] = [ ("clientID", clientID), ("clientGroupID", clientGroupID), ] diff --git a/swift/Sources/Zero/Hash/QueryHash.swift b/swift/Sources/Zero/Hash/QueryHash.swift index 966403e2b8..5e8ef49b14 100644 --- a/swift/Sources/Zero/Hash/QueryHash.swift +++ b/swift/Sources/Zero/Hash/QueryHash.swift @@ -3,7 +3,7 @@ /// The JS implementation folds an AST (or a name and args) into two xxHash32 /// lanes word by word. These digests are the query IDs the client registers, /// so the Swift client reproduces them exactly. -public enum QueryHash { +package enum QueryHash { struct State { var h1: UInt32 = XXHash32.prime5 var h2: UInt32 = XXHash32.prime1 @@ -77,19 +77,19 @@ public enum QueryHash { static let strMark: UInt32 = 0x4000_0000 /// `hashOfAST`: the hash of `ast` after normalizing it. - public static func hashOfAST(_ ast: AST) -> String { + package static func hashOfAST(_ ast: AST) -> String { hashOfNormalizedAST(ast.normalized()) } /// The hash of an AST that is already normalized. - public static func hashOfNormalizedAST(_ ast: AST) -> String { + package static func hashOfNormalizedAST(_ ast: AST) -> String { var s = State() s.visitAST(ast) return s.finalize() } /// `hashOfNameAndArgs`: the identity of a named (custom) query. - public static func hashOfNameAndArgs(_ name: String, _ args: [JSONValue]) -> String { + package static func hashOfNameAndArgs(_ name: String, _ args: [JSONValue]) -> String { var s = State() s.mix(tagNameArgs) s.mixString(name) @@ -98,7 +98,7 @@ public enum QueryHash { } /// `hashOfQueryInternals`: the identity of a query as the client sees it. - public static func hashOfQueryInternals( + package static func hashOfQueryInternals( ast: AST, format: JSONValue, system: AST.System, diff --git a/swift/Sources/Zero/Hash/XXHash32.swift b/swift/Sources/Zero/Hash/XXHash32.swift index f89e759589..9afea73b09 100644 --- a/swift/Sources/Zero/Hash/XXHash32.swift +++ b/swift/Sources/Zero/Hash/XXHash32.swift @@ -1,21 +1,21 @@ /// xxHash32 over UTF-8 bytes, and the wider `h64`/`h128` built from it /// (`packages/shared/src/hash.ts`). -public enum XXHash32 { - public static let prime1: UInt32 = 2_654_435_761 - public static let prime2: UInt32 = 2_246_822_519 - public static let prime3: UInt32 = 3_266_489_917 - public static let prime4: UInt32 = 668_265_263 - public static let prime5: UInt32 = 374_761_393 +package enum XXHash32 { + package static let prime1: UInt32 = 2_654_435_761 + package static let prime2: UInt32 = 2_246_822_519 + package static let prime3: UInt32 = 3_266_489_917 + package static let prime4: UInt32 = 668_265_263 + package static let prime5: UInt32 = 374_761_393 @inlinable - public static func round(_ acc: UInt32, _ word: UInt32) -> UInt32 { + package static func round(_ acc: UInt32, _ word: UInt32) -> UInt32 { var acc = acc &+ word &* prime2 acc = (acc << 13) | (acc >> 19) return acc &* prime1 } @inlinable - public static func avalanche(_ acc: UInt32) -> UInt32 { + package static func avalanche(_ acc: UInt32) -> UInt32 { var acc = acc acc ^= acc >> 15 acc = acc &* prime2 @@ -26,7 +26,7 @@ public enum XXHash32 { } /// Reference xxHash32 of `bytes` with `seed`. - public static func hash(_ bytes: C, seed: UInt32 = 0) -> UInt32 + package static func hash(_ bytes: C, seed: UInt32 = 0) -> UInt32 where C.Element == UInt8, C.Index == Int { let len = bytes.count var offset = bytes.startIndex @@ -74,13 +74,13 @@ public enum XXHash32 { } /// `h32(s)`: xxHash32 of the UTF-8 bytes of `s`, seed 0. - public static func h32(_ s: String) -> UInt32 { + package static func h32(_ s: String) -> UInt32 { var s = s return s.withUTF8 { hash($0, seed: 0) } } /// `h64(s)`: seed-0 digest in the high word, seed-1 in the low word. - public static func h64(_ s: String) -> UInt64 { + package static func h64(_ s: String) -> UInt64 { var s = s return s.withUTF8 { buf in UInt64(hash(buf, seed: 0)) << 32 | UInt64(hash(buf, seed: 1)) @@ -88,7 +88,7 @@ public enum XXHash32 { } /// `h128(s)`: digests for seeds 0...3, highest-order word first. - public static func h128(_ s: String) -> UInt128 { + package static func h128(_ s: String) -> UInt128 { var s = s return s.withUTF8 { buf in var r: UInt128 = 0 diff --git a/swift/Sources/Zero/IVM/Change.swift b/swift/Sources/Zero/IVM/Change.swift index fbd017b108..cddf66d951 100644 --- a/swift/Sources/Zero/IVM/Change.swift +++ b/swift/Sources/Zero/IVM/Change.swift @@ -1,12 +1,12 @@ /// A row flowing through a pipeline, plus its relationships /// (`packages/zql/src/ivm/data.ts`). Relationships are generated lazily as /// they are read. -public struct Node { - public var row: Row - public var relationships: Relationships +package struct Node { + package var row: Row + package var relationships: Relationships @inlinable - public init(row: Row, relationships: Relationships = Relationships()) { + package init(row: Row, relationships: Relationships = Relationships()) { self.row = row self.relationships = relationships } @@ -22,21 +22,21 @@ public struct Node { /// A node's relationships, in insertion order like a JS object's keys. Nodes /// carry a handful at most, so lookups are linear. -public struct Relationships: Sequence { - public typealias Stream = () -> NodeStream +package struct Relationships: Sequence { + package typealias Stream = () -> NodeStream @usableFromInline var entries: [(name: String, stream: Stream)] @inlinable - public init() { entries = [] } + package init() { entries = [] } @inlinable - public var isEmpty: Bool { entries.isEmpty } + package var isEmpty: Bool { entries.isEmpty } @inlinable - public var names: [String] { entries.map(\.name) } + package var names: [String] { entries.map(\.name) } @inlinable - public subscript(name: String) -> Stream? { + package subscript(name: String) -> Stream? { get { for entry in entries where entry.name == name { return entry.stream } return nil @@ -56,20 +56,20 @@ public struct Relationships: Sequence { /// `{...self, ...other}`: `other` wins, keys keep their first position. @inlinable - public func merging(_ other: Relationships) -> Relationships { + package func merging(_ other: Relationships) -> Relationships { var result = self for entry in other.entries { result[entry.name] = entry.stream } return result } @inlinable - public func makeIterator() -> IndexingIterator<[(name: String, stream: Stream)]> { + package func makeIterator() -> IndexingIterator<[(name: String, stream: Stream)]> { entries.makeIterator() } } /// The kinds of change (`change-type-enum.ts`). -public enum ChangeType: Int, Sendable, Hashable { +package enum ChangeType: Int, Sendable, Hashable { case add = 0 case remove = 1 case edit = 2 @@ -77,26 +77,26 @@ public enum ChangeType: Int, Sendable, Hashable { } /// A change to a child relationship of a node. -public struct ChildChange { - public var relationshipName: String - public var change: Change +package struct ChildChange { + package var relationshipName: String + package var change: Change @inlinable - public init(relationshipName: String, change: Change) { + package init(relationshipName: String, change: Change) { self.relationshipName = relationshipName self.change = change } } /// A change pushed through a pipeline (`packages/zql/src/ivm/change.ts`). -public indirect enum Change { +package indirect enum Change { case add(Node) case remove(Node) case child(Node, ChildChange) case edit(Node, oldNode: Node) @inlinable - public var type: ChangeType { + package var type: ChangeType { switch self { case .add: .add case .remove: .remove @@ -107,7 +107,7 @@ public indirect enum Change { /// The node, or the new node of an edit. @inlinable - public var node: Node { + package var node: Node { switch self { case .add(let n), .remove(let n), .child(let n, _), .edit(let n, _): n } @@ -115,13 +115,13 @@ public indirect enum Change { } /// A change to a source's rows (`packages/zql/src/ivm/source.ts`). -public enum SourceChange: Sendable { +package enum SourceChange: Sendable { case add(Row) case remove(Row) case edit(Row, oldRow: Row) @inlinable - public var type: ChangeType { + package var type: ChangeType { switch self { case .add: .add case .remove: .remove @@ -131,7 +131,7 @@ public enum SourceChange: Sendable { /// The row, or the new row of an edit. @inlinable - public var row: Row { + package var row: Row { switch self { case .add(let r), .remove(let r), .edit(let r, _): r } diff --git a/swift/Sources/Zero/IVM/Constraint.swift b/swift/Sources/Zero/IVM/Constraint.swift index 9eb6be0f34..af709954ad 100644 --- a/swift/Sources/Zero/IVM/Constraint.swift +++ b/swift/Sources/Zero/IVM/Constraint.swift @@ -2,28 +2,28 @@ /// /// Keys keep insertion order, like the JS object: a `MemorySource` builds /// its index sort from it. A value is `nil` where JS has `undefined`. -public struct Constraint { - public var entries: [(key: String, value: Value?)] +package struct Constraint { + package var entries: [(key: String, value: Value?)] @inlinable - public init(_ entries: [(key: String, value: Value?)] = []) { + package init(_ entries: [(key: String, value: Value?)] = []) { self.entries = entries } @inlinable - public var keys: [String] { entries.map(\.key) } + package var keys: [String] { entries.map(\.key) } @inlinable - public var count: Int { entries.count } + package var count: Int { entries.count } @inlinable - public func has(_ key: String) -> Bool { + package func has(_ key: String) -> Bool { entries.contains { $0.key == key } } /// The value for `key`; `nil` when absent or `undefined`. @inlinable - public subscript(key: String) -> Value? { + package subscript(key: String) -> Value? { get { for e in entries where e.key == key { return e.value } return nil @@ -31,7 +31,7 @@ public struct Constraint { } @inlinable - public mutating func set(_ key: String, _ value: Value?) { + package mutating func set(_ key: String, _ value: Value?) { if let i = entries.firstIndex(where: { $0.key == key }) { entries[i].value = value } else { @@ -41,7 +41,7 @@ public struct Constraint { /// `{...self, ...other}`. @inlinable - public func merging(_ other: Constraint) -> Constraint { + package func merging(_ other: Constraint) -> Constraint { var result = self for e in other.entries { result.set(e.key, e.value) } return result @@ -49,7 +49,7 @@ public struct Constraint { /// `constraintMatchesRow`. @inlinable - public func matches(_ row: Row) -> Bool { + package func matches(_ row: Row) -> Bool { for e in entries where !ValueCompare.equal(row[e.key], e.value) { return false } @@ -57,7 +57,7 @@ public struct Constraint { } /// `constraintsAreCompatible`: no shared key has different values. - public func isCompatible(with other: Constraint) -> Bool { + package func isCompatible(with other: Constraint) -> Bool { for e in entries where other.has(e.key) && !ValueCompare.equal(e.value, other[e.key]) { return false } @@ -65,7 +65,7 @@ public struct Constraint { } /// `constraintMatchesPrimaryKey`. - public func matchesPrimaryKey(_ primaryKey: [String]) -> Bool { + package func matchesPrimaryKey(_ primaryKey: [String]) -> Bool { keyMatchesPrimaryKey(keys, primaryKey) } diff --git a/swift/Sources/Zero/IVM/Data.swift b/swift/Sources/Zero/IVM/Data.swift index 7447acd36d..976c2bebd7 100644 --- a/swift/Sources/Zero/IVM/Data.swift +++ b/swift/Sources/Zero/IVM/Data.swift @@ -1,13 +1,13 @@ /// Value comparison for IVM (`packages/zql/src/ivm/data.ts`). -public enum ValueCompare { - public struct IncomparableError: Error, CustomStringConvertible { - public let description: String +package enum ValueCompare { + package struct IncomparableError: Error, CustomStringConvertible { + package let description: String } /// `compareValues(a, b)`: strings by UTF-8 bytes, numbers numerically, /// `false < true`, and `null` before everything. Values of different /// non-null types, and arrays or objects, can't be compared: JS throws. - public static func compareChecked(_ a: Value, _ b: Value) throws(IncomparableError) -> Int { + package static func compareChecked(_ a: Value, _ b: Value) throws(IncomparableError) -> Int { switch (a, b) { case (.string(let x), .string(let y)): return StringCompare.utf8(x, y) @@ -34,7 +34,7 @@ public enum ValueCompare { /// type, then by JSON encoding. Pipelines use this so a bad `orderBy` (on /// a json column) degrades instead of trapping. @inlinable - public static func compare(_ a: Value, _ b: Value) -> Int { + package static func compare(_ a: Value, _ b: Value) -> Int { switch (a, b) { case (.string(let x), .string(let y)): return StringCompare.utf8(x, y) @@ -74,7 +74,7 @@ public enum ValueCompare { /// SQL joins). Arrays and objects are never equal: JS compares them by /// reference. @inlinable - public static func equal(_ a: Value, _ b: Value) -> Bool { + package static func equal(_ a: Value, _ b: Value) -> Bool { switch (a, b) { case (.null, _), (_, .null): return false case (.string(let x), .string(let y)): return StringCompare.equal(x, y) @@ -86,13 +86,13 @@ public enum ValueCompare { /// `compareValues` on possibly missing values: `undefined` reads as `null`. @inlinable - public static func compare(_ a: Value?, _ b: Value?) -> Int { + package static func compare(_ a: Value?, _ b: Value?) -> Int { compare(a ?? .null, b ?? .null) } /// `valuesEqual` on possibly missing values: `undefined` equals nothing. @inlinable - public static func equal(_ a: Value?, _ b: Value?) -> Bool { + package static func equal(_ a: Value?, _ b: Value?) -> Bool { guard let a, let b else { return false } return equal(a, b) } @@ -100,7 +100,7 @@ public enum ValueCompare { /// JS `===` on possibly missing values. Strings compare by code unit; /// arrays and objects are never identical, since rows are decoded fresh. @inlinable - public static func identical(_ a: Value?, _ b: Value?) -> Bool { + package static func identical(_ a: Value?, _ b: Value?) -> Bool { switch (a, b) { case (nil, nil): return true case (nil, _), (_, nil): return false @@ -114,16 +114,16 @@ public enum ValueCompare { /// A row's value for `column`; a missing column reads as `null`. @inlinable - public static func value(_ row: Row, _ column: String) -> Value { + package static func value(_ row: Row, _ column: String) -> Value { row[column] ?? .null } } /// Orders rows (`Comparator` in `data.ts`). -public typealias Comparator = (Row, Row) -> Int +package typealias Comparator = (Row, Row) -> Int /// `makeComparator(order, reverse)`. -public func makeComparator(_ order: AST.Ordering, reverse: Bool = false) -> Comparator { +package func makeComparator(_ order: AST.Ordering, reverse: Bool = false) -> Comparator { if order.count == 1 && order[0].direction == .asc && !reverse { let field = order[0].field return { a, b in ValueCompare.compare(a[field], b[field]) } diff --git a/swift/Sources/Zero/IVM/DeferredInput.swift b/swift/Sources/Zero/IVM/DeferredInput.swift index 565b7a1836..4bd0e0ab79 100644 --- a/swift/Sources/Zero/IVM/DeferredInput.swift +++ b/swift/Sources/Zero/IVM/DeferredInput.swift @@ -4,40 +4,40 @@ /// Until `attach` it behaves like an empty pipeline: `fetch` yields nothing /// and nothing is pushed. `attach` builds the real pipeline and hydrates the /// output with its rows as add changes, as if they had just arrived. -public final class DeferredInput: Input { +package final class DeferredInput: Input { private let schema: SourceSchema private let build: () throws -> any Input private weak var output: (any Output)? private var input: (any Input)? - public private(set) var destroyed = false + package private(set) var destroyed = false /// `schema` is what the built pipeline reports; `build` builds it, once. - public init(schema: SourceSchema, build: @escaping () throws -> any Input) { + package init(schema: SourceSchema, build: @escaping () throws -> any Input) { self.schema = schema self.build = build } - public var attached: Bool { input != nil } + package var attached: Bool { input != nil } - public func setOutput(_ output: any Output) { + package func setOutput(_ output: any Output) { self.output = output input?.setOutput(output) } - public func getSchema() -> SourceSchema { schema } + package func getSchema() -> SourceSchema { schema } - public func fetch(_ req: FetchRequest) -> NodeStream { + package func fetch(_ req: FetchRequest) -> NodeStream { input?.fetch(req) ?? .empty } - public func destroy() { + package func destroy() { destroyed = true input?.destroy() } /// Builds the pipeline and pushes its rows to the output. Leaves this /// input unattached when building or hydrating throws. - public func attach() throws { + package func attach() throws { precondition(input == nil, "DeferredInput: pipeline already attached") precondition(!destroyed, "DeferredInput: cannot attach after destroy") let input = try build() diff --git a/swift/Sources/Zero/IVM/MemorySource.swift b/swift/Sources/Zero/IVM/MemorySource.swift index 9ec83472bb..1f431a3f35 100644 --- a/swift/Sources/Zero/IVM/MemorySource.swift +++ b/swift/Sources/Zero/IVM/MemorySource.swift @@ -1,6 +1,6 @@ /// A source of rows held in memory, kept in sorted order /// (`packages/zql/src/ivm/memory-source.ts`). -public final class MemorySource: Source { +package final class MemorySource: Source { struct Overlay { var epoch: Int var change: SourceChange @@ -48,7 +48,7 @@ public final class MemorySource: Source { var pushEpoch = 0 private let schema: TableSchema - public convenience init(_ table: TableSchema) { + package convenience init(_ table: TableSchema) { self.init(table: table, primaryIndexData: nil) } @@ -65,16 +65,16 @@ public final class MemorySource: Source { indexOrder.append(primaryIndexKey) } - public var tableSchema: TableSchema { schema } + package var tableSchema: TableSchema { schema } /// A copy whose primary index shares nodes with this one until either /// changes. - public func fork() -> MemorySource { + package func fork() -> MemorySource { MemorySource(table: schema, primaryIndexData: primaryIndex.data) } /// The rows, in primary key order. - public var data: BTreeSet { primaryIndex.data } + package var data: BTreeSet { primaryIndex.data } static func indexKey(_ sort: AST.Ordering) -> String { JSONSerializer.stringify( @@ -83,7 +83,7 @@ public final class MemorySource: Source { var primaryIndex: Index { indexes[primaryIndexKey]! } - public func connect( + package func connect( sort: AST.Ordering?, filters: AST.Condition?, splitEditKeys: Set? ) throws(PipelineError) -> any SourceInput { let transformed = transformFilters(filters) @@ -131,7 +131,7 @@ public final class MemorySource: Source { } /// For tests: the keys of the indexes built so far. - public var indexKeys: [String] { indexOrder } + package var indexKeys: [String] { indexOrder } func fetch(_ req: FetchRequest, _ conn: Connection) -> NodeStream { NodeStream { [self] in AnyIterator(self.fetchIterator(req, conn)) } @@ -280,7 +280,7 @@ public final class MemorySource: Source { /// Pushes an add for each of `rows`. When nothing is connected and the /// source is empty, builds the primary index in one pass instead. - public func pushAdds(_ rows: [Row]) { + package func pushAdds(_ rows: [Row]) { let index = primaryIndex let canBulkLoad = connections.isEmpty && indexes.count == 1 && index.data.isEmpty if !canBulkLoad { @@ -295,7 +295,7 @@ public final class MemorySource: Source { indexes[primaryIndexKey]!.data = BTreeSet(sorted: sorted, compare) } - public func push(_ change: SourceChange) { + package func push(_ change: SourceChange) { genPushAndWriteWithSplitEdit(change) } diff --git a/swift/Sources/Zero/IVM/Operator.swift b/swift/Sources/Zero/IVM/Operator.swift index 4aa43df02b..fbc1463d0f 100644 --- a/swift/Sources/Zero/IVM/Operator.swift +++ b/swift/Sources/Zero/IVM/Operator.swift @@ -1,17 +1,17 @@ /// The schema of an operator's output (`packages/zql/src/ivm/schema.ts`). -public final class SourceSchema { - public let tableName: String - public let columns: [String: ColumnSchema] - public let primaryKey: [String] +package final class SourceSchema { + package let tableName: String + package let columns: [String: ColumnSchema] + package let primaryKey: [String] /// Relationship schemas, in the order they were added. - public let relationships: [(name: String, schema: SourceSchema)] - public let isHidden: Bool - public let system: AST.System - public let compareRows: Comparator + package let relationships: [(name: String, schema: SourceSchema)] + package let isHidden: Bool + package let system: AST.System + package let compareRows: Comparator /// `nil` for an unordered input. - public let sort: AST.Ordering? + package let sort: AST.Ordering? - public init( + package init( tableName: String, columns: [String: ColumnSchema], primaryKey: [String], @@ -31,12 +31,12 @@ public final class SourceSchema { self.sort = sort } - public func relationship(_ name: String) -> SourceSchema? { + package func relationship(_ name: String) -> SourceSchema? { for r in relationships where r.name == name { return r.schema } return nil } - public var relationshipNames: [String] { relationships.map(\.name) } + package var relationshipNames: [String] { relationships.map(\.name) } /// A copy with the given fields replaced. func with( @@ -63,32 +63,32 @@ public final class SourceSchema { } /// Where a fetch starts (`Start` in `operator.ts`). -public struct Start { - public enum Basis: Sendable { case at, after } - public var row: Row - public var basis: Basis +package struct Start { + package enum Basis: Sendable { case at, after } + package var row: Row + package var basis: Basis - public init(row: Row, basis: Basis) { + package init(row: Row, basis: Basis) { self.row = row self.basis = basis } } /// `FetchRequest` in `operator.ts`. -public struct FetchRequest { - public var constraint: Constraint? +package struct FetchRequest { + package var constraint: Constraint? /// Multi-row IN clauses, all ANDed together and with `constraint`. - public var multiConstraints: [[Constraint]]? + package var multiConstraints: [[Constraint]]? /// If supplied, `start.row` must have previously been output by fetch or /// push. - public var start: Start? + package var start: Start? /// Whether to fetch in reverse order of the schema's sort. - public var reverse: Bool + package var reverse: Bool /// A predicate without subqueries for the source to apply. Only /// `FilterStart` introduces one; pass-through operators keep it. - public var filter: AST.Condition? + package var filter: AST.Condition? - public init( + package init( constraint: Constraint? = nil, multiConstraints: [[Constraint]]? = nil, start: Start? = nil, @@ -104,14 +104,14 @@ public struct FetchRequest { } /// `InputBase` in `operator.ts`. -public protocol InputBase: AnyObject { +package protocol InputBase: AnyObject { func getSchema() -> SourceSchema func destroy() } /// An operator's input: something that can be fetched from and pushes to an /// output. -public protocol Input: InputBase { +package protocol Input: InputBase { func setOutput(_ output: any Output) func fetch(_ req: FetchRequest) -> NodeStream } @@ -121,14 +121,14 @@ public protocol Input: InputBase { /// Pushing is two-phase. `push` propagates a change; once every change of a /// source push has propagated, `reconcile` lets bounded operators (`Take`) /// refill and push the rows that refill them. -public protocol Output: AnyObject { +package protocol Output: AnyObject { func push(_ change: Change, pusher: any InputBase) /// Phase two. Sinks can ignore it; operators must forward it. func reconcile(pusher: any InputBase) } extension Output { - public func reconcile(pusher: any InputBase) {} + package func reconcile(pusher: any InputBase) {} } /// An operator in a pipeline: an input to the next and an output of the @@ -137,7 +137,7 @@ extension Output { /// Operators hold their inputs strongly and their outputs weakly, so the /// view at the end of a pipeline owns it. A push that reaches a released /// output is dropped. -public protocol Operator: Input, Output {} +package protocol Operator: Input, Output {} /// Routes an input's pushes to closures, as JS operators do with object /// literals. The operator owns it: inputs hold their outputs weakly. @@ -155,22 +155,22 @@ final class ClosureOutput: Output { } /// The `where` sub-graph's input (`FilterInput` in `filter-operators.ts`). -public protocol FilterInput: InputBase { +package protocol FilterInput: InputBase { func setFilterOutput(_ output: any FilterOutput) } /// The `where` sub-graph's output. Filter operators have no `fetch`; a fetch /// through the sub-graph asks `filter` whether each node passes. -public protocol FilterOutput: Output { +package protocol FilterOutput: Output { func beginFilter() func filter(_ node: Node) -> Bool func endFilter() } -public protocol FilterOperator: FilterInput, FilterOutput {} +package protocol FilterOperator: FilterInput, FilterOutput {} /// A source of rows for pipelines (`Source` in `source.ts`). -public protocol Source: AnyObject { +package protocol Source: AnyObject { var tableSchema: TableSchema { get } /// Connects a pipeline. `sort` is `nil` for an unordered connection. func connect( @@ -180,7 +180,7 @@ public protocol Source: AnyObject { } /// A source's connection to one pipeline. -public protocol SourceInput: Input { +package protocol SourceInput: Input { /// Whether the source applies all of the connection's filters, so no /// `Filter` operators are needed above it. var fullyAppliedFilters: Bool { get } diff --git a/swift/Sources/Zero/IVM/Stream.swift b/swift/Sources/Zero/IVM/Stream.swift index 6b8bd9dd21..6ed05f2c3a 100644 --- a/swift/Sources/Zero/IVM/Stream.swift +++ b/swift/Sources/Zero/IVM/Stream.swift @@ -7,34 +7,35 @@ /// /// The JS `'yield'` tokens, which only hand control back to a scheduler, /// have no counterpart: the Swift engine hydrates synchronously. -public struct IVMStream: Sequence { +@usableFromInline +package struct IVMStream: Sequence { @usableFromInline let make: () -> AnyIterator /// A stream whose iterator `make` builds on the first `next()`. @inlinable - public init(_ make: @escaping () -> AnyIterator) { + package init(_ make: @escaping () -> AnyIterator) { self.make = make } /// A stream that yields `elements`. @inlinable - public init(_ elements: [Element]) { + package init(_ elements: [Element]) { self.make = { AnyIterator(elements.makeIterator()) } } @inlinable - public static var empty: IVMStream { + package static var empty: IVMStream { IVMStream { AnyIterator { nil } } } @inlinable - public func makeIterator() -> LazyIterator { + package func makeIterator() -> LazyIterator { LazyIterator(make) } /// Maps each element; `f` runs as elements are pulled. @inlinable - public func mapLazy(_ f: @escaping (Element) -> T) -> IVMStream { + package func mapLazy(_ f: @escaping (Element) -> T) -> IVMStream { let source = self return IVMStream { var it = source.makeIterator() @@ -44,7 +45,8 @@ public struct IVMStream: Sequence { } /// Starts the underlying iterator on the first `next()`. -public struct LazyIterator: IteratorProtocol { +@usableFromInline +package struct LazyIterator: IteratorProtocol { @usableFromInline var make: (() -> AnyIterator)? @usableFromInline var iterator: AnyIterator? @@ -54,7 +56,7 @@ public struct LazyIterator: IteratorProtocol { } @inlinable - public mutating func next() -> Element? { + package mutating func next() -> Element? { if let make { iterator = make() self.make = nil @@ -66,7 +68,7 @@ public struct LazyIterator: IteratorProtocol { /// Builds an iterator from a closure over local state; the closure runs /// when the first element is requested. @inlinable -func lazyStream(_ body: @escaping () -> () -> Element?) -> IVMStream { +package func lazyStream(_ body: @escaping () -> () -> Element?) -> IVMStream { IVMStream { AnyIterator(body()) } } @@ -86,4 +88,4 @@ extension Sequence { } } -public typealias NodeStream = IVMStream +package typealias NodeStream = IVMStream diff --git a/swift/Sources/Zero/IVM/View.swift b/swift/Sources/Zero/IVM/View.swift index 0d22367541..405d15ef7f 100644 --- a/swift/Sources/Zero/IVM/View.swift +++ b/swift/Sources/Zero/IVM/View.swift @@ -1,10 +1,10 @@ /// The shape of a query result: whether it is a single row, and the /// formats of its relationships (`packages/zero-types/src/format.ts`). -public struct Format: Sendable, Hashable { - public var singular: Bool - public var relationships: [String: Format] +package struct Format: Sendable, Hashable { + package var singular: Bool + package var relationships: [String: Format] - public init(singular: Bool = false, relationships: [String: Format] = [:]) { + package init(singular: Bool = false, relationships: [String: Format] = [:]) { self.singular = singular self.relationships = relationships } @@ -338,8 +338,8 @@ public enum ResultType: String, Sendable, Hashable { /// /// Listeners are notified on `flush`, which the engine calls when a /// transaction commits. -public final class ArrayView: Output { - public typealias Listener = (ViewValue, ResultType) -> Void +package final class ArrayView: Output { + package typealias Listener = (ViewValue, ResultType) -> Void let input: any Input let format: Format @@ -352,19 +352,19 @@ public final class ArrayView: Output { private var held = false private var heldResultType: ResultType = .unknown private var heldError: ErroredQuery? - public private(set) var resultType: ResultType = .unknown + package private(set) var resultType: ResultType = .unknown /// Why the query failed, when `resultType` is `.error`. - public private(set) var error: ErroredQuery? - public var onDestroy: (() -> Void)? + package private(set) var error: ErroredQuery? + package var onDestroy: (() -> Void)? /// Hydrates from `input`. `complete` marks the result complete from the /// start. - public convenience init(_ input: any Input, format: Format, complete: Bool = false) { + package convenience init(_ input: any Input, format: Format, complete: Bool = false) { self.init(input, format: format, error: nil, complete: complete) } /// Hydrates from `input`, starting as failed when `error` is set. - public init(_ input: any Input, format: Format, error: ErroredQuery?, complete: Bool) { + package init(_ input: any Input, format: Format, error: ErroredQuery?, complete: Bool) { self.input = input self.format = format root = ViewEntry(row: [:]) @@ -380,7 +380,7 @@ public final class ArrayView: Output { } /// The current rows. - public var data: ViewValue { + package var data: ViewValue { (committedRoot ?? root).relationships[""]! } @@ -394,7 +394,7 @@ public final class ArrayView: Output { /// Adds a listener, calls it with the current data, and returns a /// function that removes it. @discardableResult - public func addListener(_ listener: @escaping Listener) -> () -> Void { + package func addListener(_ listener: @escaping Listener) -> () -> Void { let id = nextListenerID nextListenerID += 1 listeners.append((id, listener)) @@ -413,7 +413,7 @@ public final class ArrayView: Output { for (_, listener) in listeners { listener(data, type) } } - public func destroy() { onDestroy?() } + package func destroy() { onDestroy?() } private func hydrate() { dirty = true @@ -421,7 +421,7 @@ public final class ArrayView: Output { flush() } - public func push(_ change: Change, pusher: any InputBase) { + package func push(_ change: Change, pusher: any InputBase) { if ViewApply.apply(&root, change, getSchema(), "", format) { dirty = true } @@ -429,7 +429,7 @@ public final class ArrayView: Output { /// Keeps exposing the current snapshot until `releaseData` or the next /// `flush`. - public func holdData() { + package func holdData() { committedRoot = root held = true heldResultType = resultType @@ -437,14 +437,14 @@ public final class ArrayView: Output { } /// The result type and error listeners are told about now. - public var listenerResultType: (ResultType, ErroredQuery?) { + package var listenerResultType: (ResultType, ErroredQuery?) { committedRoot != nil ? (heldResultType, heldError) : (resultType, error) } - public func releaseData() { committedRoot = nil } + package func releaseData() { committedRoot = nil } /// Notifies listeners if the view changed since the last flush. - public func flush() { + package func flush() { committedRoot = nil held = false if !dirty { return } @@ -452,7 +452,7 @@ public final class ArrayView: Output { fireListeners() } - public func setResultType(_ type: ResultType, error: ErroredQuery? = nil) { + package func setResultType(_ type: ResultType, error: ErroredQuery? = nil) { resultType = type self.error = error if held { @@ -463,17 +463,17 @@ public final class ArrayView: Output { } /// The server-confirmed result of an earlier sync is in the store. - public func markCached() { + package func markCached() { if resultType == .unknown { setResultType(.cached) } } - public func unmarkCached() { + package func unmarkCached() { if resultType == .cached { setResultType(.unknown) } } } extension Format: JSONCodable { - public init(json: JSONValue) throws(DecodingError) { + package init(json: JSONValue) throws(DecodingError) { let o = try JSONObjectReader(json) let relationships = try o.optional("relationships") { (v) throws(DecodingError) in try JSONDecode.record(v) { (f) throws(DecodingError) in try Format(json: f) } @@ -482,7 +482,7 @@ extension Format: JSONCodable { singular: try o.optionalBool("singular") ?? false, relationships: relationships ?? [:]) } - public var json: JSONValue { + package var json: JSONValue { var rels: [String: JSONValue] = [:] for (k, v) in relationships { rels[k] = v.json } return ["singular": .bool(singular), "relationships": .object(rels)] diff --git a/swift/Sources/Zero/JSON/JSNumber.swift b/swift/Sources/Zero/JSON/JSNumber.swift index 175c0a2766..8efb789505 100644 --- a/swift/Sources/Zero/JSON/JSNumber.swift +++ b/swift/Sources/Zero/JSON/JSNumber.swift @@ -1,12 +1,12 @@ /// JS number semantics the client has to reproduce exactly: `Number#toString` /// (also what `JSON.stringify` writes) and `Number(string)`. -public enum JSNumber { +package enum JSNumber { /// `String(n)` in JS, i.e. ECMAScript `Number::toString(n, 10)`. /// /// Uses the shortest round-tripping digits (which Swift's `description` /// also produces) laid out with the JS rules: plain notation for decimal /// exponents in `-6...21`, exponential otherwise. - public static func format(_ value: Double) -> String { + package static func format(_ value: Double) -> String { if value.isNaN { return "NaN" } if value == 0 { return "0" } if value.isInfinite { return value < 0 ? "-Infinity" : "Infinity" } @@ -73,7 +73,7 @@ public enum JSNumber { /// `Number(string)` in JS (ECMAScript `StringToNumber`): trims JS /// whitespace, maps the empty string to 0, and accepts decimal, `Infinity`, /// and `0x`/`0o`/`0b` literals. Anything else is `NaN`. - public static func parse(_ string: String) -> Double { + package static func parse(_ string: String) -> Double { let scalars = Array(string.unicodeScalars) var start = 0 var end = scalars.count @@ -152,13 +152,13 @@ public enum JSNumber { /// `(n | 0) === n` in JS: whether `n` is exactly an int32. `-0` counts, /// because `-0 === 0`. @inlinable - public static func isInt32(_ n: Double) -> Bool { + package static func isInt32(_ n: Double) -> Bool { n >= -2_147_483_648 && n <= 2_147_483_647 && n == n.rounded(.towardZero) } /// `ToInt32(n)` in JS, for an `n` that `isInt32` accepted. @inlinable - public static func int32Bits(_ n: Double) -> UInt32 { + package static func int32Bits(_ n: Double) -> UInt32 { UInt32(bitPattern: Int32(n)) } } diff --git a/swift/Sources/Zero/JSON/JSONDecoding.swift b/swift/Sources/Zero/JSON/JSONDecoding.swift index cea3b11b09..a1b8330baa 100644 --- a/swift/Sources/Zero/JSON/JSONDecoding.swift +++ b/swift/Sources/Zero/JSON/JSONDecoding.swift @@ -1,15 +1,15 @@ /// Errors from decoding a `JSONValue` into a typed protocol value. The path /// points at the offending value, like valita's error paths. -public struct DecodingError: Error, CustomStringConvertible, Equatable { - public var path: [String] - public var message: String +package struct DecodingError: Error, CustomStringConvertible, Equatable { + package var path: [String] + package var message: String - public init(_ message: String, path: [String] = []) { + package init(_ message: String, path: [String] = []) { self.message = message self.path = path } - public var description: String { + package var description: String { path.isEmpty ? message : "\(message) at \(path.joined(separator: "."))" } @@ -21,17 +21,17 @@ public struct DecodingError: Error, CustomStringConvertible, Equatable { /// Helpers for hand-written decoders that follow valita's rules: optional /// fields may be absent but not `null` (unless declared nullable), unknown /// keys are ignored (`passthrough` mode). -public struct JSONObjectReader { - public let object: [String: JSONValue] +package struct JSONObjectReader { + package let object: [String: JSONValue] - public init(_ value: JSONValue, _ what: String = "object") throws(DecodingError) { + package init(_ value: JSONValue, _ what: String = "object") throws(DecodingError) { guard case .object(let o) = value else { throw DecodingError("Expected \(what), got \(value.typeName)") } object = o } - public func required( + package func required( _ key: String, _ decode: (JSONValue) throws(DecodingError) -> T ) throws(DecodingError) -> T { guard let v = object[key] else { throw DecodingError("Missing property", path: [key]) } @@ -42,7 +42,7 @@ public struct JSONObjectReader { } } - public func optional( + package func optional( _ key: String, _ decode: (JSONValue) throws(DecodingError) -> T ) throws(DecodingError) -> T? { guard let v = object[key] else { return nil } @@ -53,32 +53,32 @@ public struct JSONObjectReader { } } - public func string(_ key: String) throws(DecodingError) -> String { + package func string(_ key: String) throws(DecodingError) -> String { try required(key, JSONDecode.string) } - public func optionalString(_ key: String) throws(DecodingError) -> String? { + package func optionalString(_ key: String) throws(DecodingError) -> String? { try optional(key, JSONDecode.string) } - public func number(_ key: String) throws(DecodingError) -> Double { + package func number(_ key: String) throws(DecodingError) -> Double { try required(key, JSONDecode.number) } - public func optionalNumber(_ key: String) throws(DecodingError) -> Double? { + package func optionalNumber(_ key: String) throws(DecodingError) -> Double? { try optional(key, JSONDecode.number) } - public func bool(_ key: String) throws(DecodingError) -> Bool { + package func bool(_ key: String) throws(DecodingError) -> Bool { try required(key, JSONDecode.bool) } - public func optionalBool(_ key: String) throws(DecodingError) -> Bool? { + package func optionalBool(_ key: String) throws(DecodingError) -> Bool? { try optional(key, JSONDecode.bool) } /// `v.literal(expected)`. - public func literal(_ key: String, _ expected: String) throws(DecodingError) { + package func literal(_ key: String, _ expected: String) throws(DecodingError) { let s = try string(key) if s != expected { throw DecodingError("Expected literal \"\(expected)\", got \"\(s)\"", path: [key]) @@ -86,36 +86,36 @@ public struct JSONObjectReader { } } -public enum JSONDecode { - public static func string(_ v: JSONValue) throws(DecodingError) -> String { +package enum JSONDecode { + package static func string(_ v: JSONValue) throws(DecodingError) -> String { guard case .string(let s) = v else { throw DecodingError("Expected string, got \(v.typeName)") } return s } - public static func number(_ v: JSONValue) throws(DecodingError) -> Double { + package static func number(_ v: JSONValue) throws(DecodingError) -> Double { guard case .number(let n) = v else { throw DecodingError("Expected number, got \(v.typeName)") } return n } - public static func bool(_ v: JSONValue) throws(DecodingError) -> Bool { + package static func bool(_ v: JSONValue) throws(DecodingError) -> Bool { guard case .bool(let b) = v else { throw DecodingError("Expected boolean, got \(v.typeName)") } return b } - public static func object(_ v: JSONValue) throws(DecodingError) -> [String: JSONValue] { + package static func object(_ v: JSONValue) throws(DecodingError) -> [String: JSONValue] { guard case .object(let o) = v else { throw DecodingError("Expected object, got \(v.typeName)") } return o } - public static func array(_ v: JSONValue) throws(DecodingError) -> [JSONValue] { + package static func array(_ v: JSONValue) throws(DecodingError) -> [JSONValue] { guard case .array(let a) = v else { throw DecodingError("Expected array, got \(v.typeName)") } return a } /// Any JSON value (`jsonSchema`). - public static func json(_ v: JSONValue) throws(DecodingError) -> JSONValue { v } + package static func json(_ v: JSONValue) throws(DecodingError) -> JSONValue { v } - public static func array( + package static func array( _ v: JSONValue, _ element: (JSONValue) throws(DecodingError) -> T ) throws(DecodingError) -> [T] { let a = try array(v) @@ -132,7 +132,7 @@ public enum JSONDecode { } /// `v.record(element)`. - public static func record( + package static func record( _ v: JSONValue, _ element: (JSONValue) throws(DecodingError) -> T ) throws(DecodingError) -> [String: T] { let o = try object(v) @@ -149,14 +149,14 @@ public enum JSONDecode { } /// A non-empty array of strings (`CompoundKey`, `PrimaryKey`). - public static func compoundKey(_ v: JSONValue) throws(DecodingError) -> [String] { + package static func compoundKey(_ v: JSONValue) throws(DecodingError) -> [String] { let k = try array(v, string) if k.isEmpty { throw DecodingError("Expected non-empty array") } return k } /// A string, number, or boolean (`primaryKeyValueSchema`). - public static func primaryKeyValue(_ v: JSONValue) throws(DecodingError) -> JSONValue { + package static func primaryKeyValue(_ v: JSONValue) throws(DecodingError) -> JSONValue { switch v { case .string, .number, .bool: return v default: throw DecodingError("Expected string, number, or boolean, got \(v.typeName)") @@ -164,7 +164,7 @@ public enum JSONDecode { } /// A tuple message `[tag, body]`. - public static func tagged(_ v: JSONValue) throws(DecodingError) -> (String, JSONValue) { + package static func tagged(_ v: JSONValue) throws(DecodingError) -> (String, JSONValue) { guard case .array(let a) = v, a.count == 2, case .string(let tag) = a[0] else { throw DecodingError("Expected [tag, body] tuple") } @@ -173,36 +173,36 @@ public enum JSONDecode { } /// Builds a JSON object, leaving out `nil` fields. -public struct JSONObjectBuilder { - public var object: [String: JSONValue] = [:] +package struct JSONObjectBuilder { + package var object: [String: JSONValue] = [:] - public init() {} + package init() {} - public mutating func set(_ key: String, _ value: JSONValue) { + package mutating func set(_ key: String, _ value: JSONValue) { object[key] = value } - public mutating func set(_ key: String, _ value: JSONValue?) { + package mutating func set(_ key: String, _ value: JSONValue?) { if let value { object[key] = value } } - public mutating func set(_ key: String, _ value: String?) { + package mutating func set(_ key: String, _ value: String?) { if let value { object[key] = .string(value) } } - public mutating func set(_ key: String, _ value: Double?) { + package mutating func set(_ key: String, _ value: Double?) { if let value { object[key] = .number(value) } } - public mutating func set(_ key: String, _ value: Bool?) { + package mutating func set(_ key: String, _ value: Bool?) { if let value { object[key] = .bool(value) } } - public var value: JSONValue { .object(object) } + package var value: JSONValue { .object(object) } } /// A value with a JSON representation following the `zero-protocol` schema. -public protocol JSONCodable: Sendable { +package protocol JSONCodable: Sendable { init(json: JSONValue) throws(DecodingError) var json: JSONValue { get } } diff --git a/swift/Sources/Zero/JSON/JSONParser.swift b/swift/Sources/Zero/JSON/JSONParser.swift index a75f8a6845..abc7b998a3 100644 --- a/swift/Sources/Zero/JSON/JSONParser.swift +++ b/swift/Sources/Zero/JSON/JSONParser.swift @@ -2,17 +2,17 @@ /// /// Differences from JS are limited to what a Swift `String` can represent: an /// escaped lone surrogate (`"\ud800"`) becomes U+FFFD. -public struct JSONParser { - public struct Error: Swift.Error, CustomStringConvertible, Equatable { - public let message: String - public let offset: Int - public var description: String { "\(message) at offset \(offset)" } +package struct JSONParser { + package struct Error: Swift.Error, CustomStringConvertible, Equatable { + package let message: String + package let offset: Int + package var description: String { "\(message) at offset \(offset)" } } /// Nesting deeper than this is rejected instead of overflowing the stack. - public static let maxDepth = 5_000 + package static let maxDepth = 5_000 - public static func parse(_ text: String) throws(Error) -> JSONValue { + package static func parse(_ text: String) throws(Error) -> JSONValue { var text = text let result = text.withUTF8 { buffer in Result { () throws(Error) in try parse(buffer) } @@ -20,14 +20,14 @@ public struct JSONParser { return try result.get() } - public static func parse(_ bytes: [UInt8]) throws(Error) -> JSONValue { + package static func parse(_ bytes: [UInt8]) throws(Error) -> JSONValue { let result = bytes.withUnsafeBufferPointer { buffer in Result { () throws(Error) in try parse(buffer) } } return try result.get() } - public static func parse(_ buffer: UnsafeBufferPointer) throws(Error) -> JSONValue { + package static func parse(_ buffer: UnsafeBufferPointer) throws(Error) -> JSONValue { var p = JSONParser(buffer) p.skipWhitespace() let value = try p.parseValue(depth: 0) diff --git a/swift/Sources/Zero/JSON/JSONSerializer.swift b/swift/Sources/Zero/JSON/JSONSerializer.swift index 94d243dc1b..3aa0ee8399 100644 --- a/swift/Sources/Zero/JSON/JSONSerializer.swift +++ b/swift/Sources/Zero/JSON/JSONSerializer.swift @@ -4,18 +4,18 @@ /// Object keys are written sorted by UTF-16 code unit (`Object.keys().sort()` /// order), since `JSONValue` objects are unordered. The output is therefore /// deterministic. -public enum JSONSerializer { - public static func stringify(_ value: JSONValue) -> String { +package enum JSONSerializer { + package static func stringify(_ value: JSONValue) -> String { var out = "" write(value, into: &out) return out } - public static func data(_ value: JSONValue) -> [UInt8] { + package static func data(_ value: JSONValue) -> [UInt8] { Array(stringify(value).utf8) } - public static func write(_ value: JSONValue, into out: inout String) { + package static func write(_ value: JSONValue, into out: inout String) { switch value { case .null: out += "null" @@ -48,7 +48,7 @@ public enum JSONSerializer { static let hex: [Character] = Array("0123456789abcdef") - public static func writeString(_ s: String, into out: inout String) { + package static func writeString(_ s: String, into out: inout String) { out += "\"" var needsEscape = false for u in s.utf8 where u < 0x20 || u == 0x22 || u == 0x5C { diff --git a/swift/Sources/Zero/JSON/JSONValue.swift b/swift/Sources/Zero/JSON/JSONValue.swift index 3bc528260a..e5d2817f4a 100644 --- a/swift/Sources/Zero/JSON/JSONValue.swift +++ b/swift/Sources/Zero/JSON/JSONValue.swift @@ -20,7 +20,7 @@ public enum JSONValue: Sendable { public typealias Row = [String: JSONValue] /// A value in a row or a query literal. -public typealias Value = JSONValue +package typealias Value = JSONValue extension JSONValue: Hashable { public static func == (lhs: JSONValue, rhs: JSONValue) -> Bool { diff --git a/swift/Sources/Zero/Protocol/ClientSchema.swift b/swift/Sources/Zero/Protocol/ClientSchema.swift index 6781ad72b7..82957556a7 100644 --- a/swift/Sources/Zero/Protocol/ClientSchema.swift +++ b/swift/Sources/Zero/Protocol/ClientSchema.swift @@ -1,30 +1,30 @@ /// The schema a client was built with, sent so zero-cache can check it /// against the replica (`clientSchemaSchema` in /// `packages/zero-protocol/src/client-schema.ts`). -public struct ClientSchema: Sendable, Hashable, JSONCodable { +package struct ClientSchema: Sendable, Hashable, JSONCodable { /// `valueTypeSchema`. - public enum ValueType: String, Sendable, Hashable, CaseIterable { + package enum ValueType: String, Sendable, Hashable, CaseIterable { case string, number, boolean, null, json } /// `tableSchemaSchema`. Each column maps to its `{type}`. - public struct Table: Sendable, Hashable { - public var columns: [String: ValueType] - public var primaryKey: [String] + package struct Table: Sendable, Hashable { + package var columns: [String: ValueType] + package var primaryKey: [String] - public init(columns: [String: ValueType], primaryKey: [String]) { + package init(columns: [String: ValueType], primaryKey: [String]) { self.columns = columns self.primaryKey = primaryKey } } - public var tables: [String: Table] + package var tables: [String: Table] - public init(tables: [String: Table]) { + package init(tables: [String: Table]) { self.tables = tables } - public init(json: JSONValue) throws(DecodingError) { + package init(json: JSONValue) throws(DecodingError) { let r = try JSONObjectReader(json, "client schema") tables = try r.required("tables") { (v) throws(DecodingError) in try JSONDecode.record(v) { (t) throws(DecodingError) -> Table in @@ -40,7 +40,7 @@ public struct ClientSchema: Sendable, Hashable, JSONCodable { } } - public var json: JSONValue { + package var json: JSONValue { .object([ "tables": .object( tables.mapValues { t in @@ -54,7 +54,7 @@ public struct ClientSchema: Sendable, Hashable, JSONCodable { /// The hash of the normalized schema that names a client's local /// database (`clientSchemaFrom`): a schema change starts a new one. - public var hash: String { + package var hash: String { String(XXHash32.h64(JSONSerializer.stringify(normalized().json)), radix: 36) } @@ -62,7 +62,7 @@ public struct ClientSchema: Sendable, Hashable, JSONCodable { /// by UTF-16 code units. JS also orders the table and column entries by /// `<`; ``json`` has no order of its own and ``JSONSerializer`` writes keys /// in that same order. - public func normalized() -> ClientSchema { + package func normalized() -> ClientSchema { ClientSchema( tables: tables.mapValues { t in Table(columns: t.columns, primaryKey: t.primaryKey.sorted(by: StringCompare.utf16Less)) diff --git a/swift/Sources/Zero/Protocol/Connect.swift b/swift/Sources/Zero/Protocol/Connect.swift index d575aff7b6..892fd09008 100644 --- a/swift/Sources/Zero/Protocol/Connect.swift +++ b/swift/Sources/Zero/Protocol/Connect.swift @@ -1,21 +1,21 @@ /// The body of the `connected` message zero-cache sends once a connection /// is set up (`connectedBodySchema` in `packages/zero-protocol/src/connect.ts`). -public struct ConnectedBody: Sendable, Hashable, JSONCodable { - public var wsid: String - public var timestamp: Double? +package struct ConnectedBody: Sendable, Hashable, JSONCodable { + package var wsid: String + package var timestamp: Double? - public init(wsid: String, timestamp: Double? = nil) { + package init(wsid: String, timestamp: Double? = nil) { self.wsid = wsid self.timestamp = timestamp } - public init(json: JSONValue) throws(DecodingError) { + package init(json: JSONValue) throws(DecodingError) { let r = try JSONObjectReader(json, "connected body") wsid = try r.string("wsid") timestamp = try r.optionalNumber("timestamp") } - public var json: JSONValue { + package var json: JSONValue { var o = JSONObjectBuilder() o.set("wsid", wsid) o.set("timestamp", timestamp) @@ -26,25 +26,25 @@ public struct ConnectedBody: Sendable, Hashable, JSONCodable { /// The body of the first message a client sends, usually in the /// `Sec-WebSocket-Protocol` header (`initConnectionBodySchema` in /// `packages/zero-protocol/src/connect.ts`). -public struct InitConnectionBody: Sendable, Hashable, JSONCodable { - public var desiredQueriesPatch: UpQueriesPatch +package struct InitConnectionBody: Sendable, Hashable, JSONCodable { + package var desiredQueriesPatch: UpQueriesPatch /// Sent only when the client has no server snapshot yet: the schema can be /// large, and zero-cache keeps it for the client group. - public var clientSchema: ClientSchema? - public var deleted: DeleteClientsBody? + package var clientSchema: ClientSchema? + package var deleted: DeleteClientsBody? /// Configures the push endpoint of the API server. - public var userPushURL: String? - public var userPushHeaders: [String: String]? + package var userPushURL: String? + package var userPushHeaders: [String: String]? /// Configures the query endpoint of the API server. - public var userQueryURL: String? - public var userQueryHeaders: [String: String]? + package var userQueryURL: String? + package var userQueryHeaders: [String: String]? /// The client IDs that are running in the client group, so zero-cache can /// inactivate the queries of the others. - public var activeClients: [String]? + package var activeClients: [String]? /// A W3C traceparent header for distributed tracing. - public var traceparent: String? + package var traceparent: String? - public init( + package init( desiredQueriesPatch: UpQueriesPatch, clientSchema: ClientSchema? = nil, deleted: DeleteClientsBody? = nil, userPushURL: String? = nil, userPushHeaders: [String: String]? = nil, userQueryURL: String? = nil, @@ -62,7 +62,7 @@ public struct InitConnectionBody: Sendable, Hashable, JSONCodable { self.traceparent = traceparent } - public init(json: JSONValue) throws(DecodingError) { + package init(json: JSONValue) throws(DecodingError) { let r = try JSONObjectReader(json, "initConnection body") desiredQueriesPatch = try r.required("desiredQueriesPatch", JSONDecode.upQueriesPatch) clientSchema = try r.optional("clientSchema", ClientSchema.init(json:)) @@ -75,7 +75,7 @@ public struct InitConnectionBody: Sendable, Hashable, JSONCodable { traceparent = try r.optionalString("traceparent") } - public var json: JSONValue { + package var json: JSONValue { var o = JSONObjectBuilder() o.set("desiredQueriesPatch", .array(desiredQueriesPatch.map(\.json))) o.set("clientSchema", clientSchema?.json) @@ -95,8 +95,8 @@ public struct InitConnectionBody: Sendable, Hashable, JSONCodable { /// `decodeSecProtocols` in `packages/zero-protocol/src/connect.ts`): the /// UTF-8 of `JSON.stringify({initConnectionMessage, authToken})`, base64 /// encoded, then URI encoded. -public enum SecProtocols { - public static func encode(initConnection: InitConnectionBody?, authToken: String?) -> String { +package enum SecProtocols { + package static func encode(initConnection: InitConnectionBody?, authToken: String?) -> String { var o = JSONObjectBuilder() o.set( "initConnectionMessage", initConnection.map { JSONValue.message("initConnection", $0.json) }) @@ -118,7 +118,7 @@ public enum SecProtocols { /// Decodes a protocol header value into the JSON it carries, as /// `JSON.parse` returns it: `decodeSecProtocols` does not validate it. - public static func decode(_ secProtocol: String) throws(DecodingError) -> JSONValue { + package static func decode(_ secProtocol: String) throws(DecodingError) -> JSONValue { let base64 = try URIComponent.decode(secProtocol) guard let bytes = Base64.forgivingDecode(base64) else { throw DecodingError("The string to be decoded is not correctly encoded") diff --git a/swift/Sources/Zero/Protocol/Downstream.swift b/swift/Sources/Zero/Protocol/Downstream.swift index 1a92e275d5..9942a47c7d 100644 --- a/swift/Sources/Zero/Protocol/Downstream.swift +++ b/swift/Sources/Zero/Protocol/Downstream.swift @@ -5,7 +5,7 @@ /// know as invalid and disconnects (`#onMessage` in /// `packages/zero-client/src/client/zero.ts`, which parses in `passthrough` /// mode). -public enum Downstream: Sendable, Hashable, JSONCodable { +package enum Downstream: Sendable, Hashable, JSONCodable { case connected(ConnectedBody) case error(ErrorBody) case pong @@ -19,7 +19,7 @@ public enum Downstream: Sendable, Hashable, JSONCodable { /// Custom queries the API server failed to transform. case transformError([ErroredQuery]) - public var tag: String { + package var tag: String { switch self { case .connected: "connected" case .error: "error" @@ -35,7 +35,7 @@ public enum Downstream: Sendable, Hashable, JSONCodable { } } - public init(json: JSONValue) throws(DecodingError) { + package init(json: JSONValue) throws(DecodingError) { guard case .array(let a) = json else { throw DecodingError("Expected array") } guard a.count == 2, case .string(let tag) = a[0] else { throw DecodingError("Expected [tag, body] tuple") @@ -67,11 +67,11 @@ public enum Downstream: Sendable, Hashable, JSONCodable { } /// Parses message text, as the client receives it. - public init(text: String) throws(DecodingError) { + package init(text: String) throws(DecodingError) { try self.init(json: try JSONValue.parseMessage(text)) } - public var json: JSONValue { + package var json: JSONValue { switch self { case .connected(let b): .message(tag, b.json) case .error(let b): .message(tag, b.json) @@ -88,5 +88,5 @@ public enum Downstream: Sendable, Hashable, JSONCodable { } /// The message text to send. - public var text: String { JSONSerializer.stringify(json) } + package var text: String { JSONSerializer.stringify(json) } } diff --git a/swift/Sources/Zero/Protocol/ErrorBody.swift b/swift/Sources/Zero/Protocol/ErrorBody.swift index f5e13ab94b..5d29223458 100644 --- a/swift/Sources/Zero/Protocol/ErrorBody.swift +++ b/swift/Sources/Zero/Protocol/ErrorBody.swift @@ -1,12 +1,12 @@ /// The body of an `error` message (`errorBodySchema` in /// `packages/zero-protocol/src/error.ts`). -public enum ErrorBody: Sendable, Hashable, JSONCodable { +package enum ErrorBody: Sendable, Hashable, JSONCodable { case basic(BasicErrorBody) case backoff(BackoffErrorBody) case pushFailed(PushFailedBody) case transformFailed(TransformFailedBody) - public var kind: ErrorKind { + package var kind: ErrorKind { switch self { case .basic(let b): b.kind case .backoff(let b): b.kind @@ -16,7 +16,7 @@ public enum ErrorBody: Sendable, Hashable, JSONCodable { } /// Where the error came from; nil from servers too old to say. - public var origin: ErrorOrigin? { + package var origin: ErrorOrigin? { switch self { case .basic(let b): b.origin case .backoff(let b): b.origin @@ -25,7 +25,7 @@ public enum ErrorBody: Sendable, Hashable, JSONCodable { } } - public var message: String { + package var message: String { switch self { case .basic(let b): b.message case .backoff(let b): b.message @@ -34,7 +34,7 @@ public enum ErrorBody: Sendable, Hashable, JSONCodable { } } - public init(json: JSONValue) throws(DecodingError) { + package init(json: JSONValue) throws(DecodingError) { let r = try JSONObjectReader(json, "error body") let kind = try r.literalUnion("kind", ErrorKind.self) if ErrorKind.basic.contains(kind) { @@ -76,7 +76,7 @@ public enum ErrorBody: Sendable, Hashable, JSONCodable { } } - public var json: JSONValue { + package var json: JSONValue { var o = JSONObjectBuilder() o.set("kind", .string(kind.rawValue)) o.set("message", message) @@ -102,12 +102,12 @@ public enum ErrorBody: Sendable, Hashable, JSONCodable { } /// `basicErrorBodySchema`. `origin` is `server` or `zeroCache` when set. -public struct BasicErrorBody: Sendable, Hashable { - public var kind: ErrorKind - public var message: String - public var origin: ErrorOrigin? +package struct BasicErrorBody: Sendable, Hashable { + package var kind: ErrorKind + package var message: String + package var origin: ErrorOrigin? - public init(kind: ErrorKind, message: String, origin: ErrorOrigin? = nil) { + package init(kind: ErrorKind, message: String, origin: ErrorOrigin? = nil) { self.kind = kind self.message = message self.origin = origin @@ -116,16 +116,16 @@ public struct BasicErrorBody: Sendable, Hashable { /// `backoffBodySchema`: zero-cache asks the client to reconnect later. /// `origin` is `zeroCache` when set. -public struct BackoffErrorBody: Sendable, Hashable { - public var kind: ErrorKind - public var message: String - public var minBackoffMs: Double? - public var maxBackoffMs: Double? +package struct BackoffErrorBody: Sendable, Hashable { + package var kind: ErrorKind + package var message: String + package var minBackoffMs: Double? + package var maxBackoffMs: Double? /// Query parameters to send in the next reconnect only. - public var reconnectParams: [String: String]? - public var origin: ErrorOrigin? + package var reconnectParams: [String: String]? + package var origin: ErrorOrigin? - public init( + package init( kind: ErrorKind, message: String, minBackoffMs: Double? = nil, maxBackoffMs: Double? = nil, reconnectParams: [String: String]? = nil, origin: ErrorOrigin? = nil ) { @@ -141,7 +141,7 @@ public struct BackoffErrorBody: Sendable, Hashable { /// Why a push or transform failed: the `origin` and `reason` of /// `pushFailedBodySchema` and `transformFailedBodySchema`, whose three /// variants each allow different combinations. -public enum FailureCause: Sendable, Hashable { +package enum FailureCause: Sendable, Hashable { /// `origin: 'server'`: the API server reported the failure. case server(reason: ErrorReason) /// `origin: 'zeroCache', reason: 'http'`: the API server answered with a @@ -150,12 +150,12 @@ public enum FailureCause: Sendable, Hashable { /// `origin: 'zeroCache'` with reason `timeout`, `parse`, or `internal`. case zeroCache(reason: ErrorReason) - public var origin: ErrorOrigin { + package var origin: ErrorOrigin { if case .server = self { return .server } return .zeroCache } - public var reason: ErrorReason { + package var reason: ErrorReason { switch self { case .server(let reason), .zeroCache(let reason): reason case .http: .http @@ -192,17 +192,17 @@ public enum FailureCause: Sendable, Hashable { /// `pushFailedBodySchema`: the API server or zero-cache failed to process /// a push. `mutationIDs` may be a subset of the pushed mutations. -public struct PushFailedBody: Sendable, Hashable { - public var message: String - public var mutationIDs: [MutationID] - public var details: JSONValue? - public var cause: FailureCause +package struct PushFailedBody: Sendable, Hashable { + package var message: String + package var mutationIDs: [MutationID] + package var details: JSONValue? + package var cause: FailureCause static let serverReasons: Set = [ .database, .parse, .outOfOrderMutation, .unsupportedPushVersion, .internal, ] - public init( + package init( message: String, mutationIDs: [MutationID], details: JSONValue? = nil, cause: FailureCause ) { self.message = message @@ -214,15 +214,15 @@ public struct PushFailedBody: Sendable, Hashable { /// `transformFailedBodySchema`: the API server or zero-cache failed to /// transform custom queries. -public struct TransformFailedBody: Sendable, Hashable { - public var message: String - public var queryIDs: [String] - public var details: JSONValue? - public var cause: FailureCause +package struct TransformFailedBody: Sendable, Hashable { + package var message: String + package var queryIDs: [String] + package var details: JSONValue? + package var cause: FailureCause static let serverReasons: Set = [.database, .parse, .internal] - public init( + package init( message: String, queryIDs: [String], details: JSONValue? = nil, cause: FailureCause ) { self.message = message diff --git a/swift/Sources/Zero/Protocol/Inspect.swift b/swift/Sources/Zero/Protocol/Inspect.swift index 9c43673076..06e91b5b02 100644 --- a/swift/Sources/Zero/Protocol/Inspect.swift +++ b/swift/Sources/Zero/Protocol/Inspect.swift @@ -3,17 +3,17 @@ /// /// It is kept as JSON, checked and stripped like valita does: the Swift /// client has no inspector yet. -public struct InspectUpBody: Sendable, Hashable, JSONCodable { - public let json: JSONValue +package struct InspectUpBody: Sendable, Hashable, JSONCodable { + package let json: JSONValue - public init(json: JSONValue) throws(DecodingError) { + package init(json: JSONValue) throws(DecodingError) { self.json = try InspectSchemas.up.parse(json) } /// The request ID the response echoes. - public var id: String { json["id"]?.stringValue ?? "" } + package var id: String { json["id"]?.stringValue ?? "" } /// `queries`, `metrics`, `version`, `authenticate`, or `analyze-query`. - public var op: String { json["op"]?.stringValue ?? "" } + package var op: String { json["op"]?.stringValue ?? "" } } /// The body of an `inspect` response from zero-cache @@ -21,20 +21,20 @@ public struct InspectUpBody: Sendable, Hashable, JSONCodable { /// /// It is kept as JSON, checked and stripped like valita does: the Swift /// client has no inspector yet. -public struct InspectDownBody: Sendable, Hashable, JSONCodable { - public let json: JSONValue +package struct InspectDownBody: Sendable, Hashable, JSONCodable { + package let json: JSONValue - public init(json: JSONValue) throws(DecodingError) { + package init(json: JSONValue) throws(DecodingError) { self.json = try InspectSchemas.down.parse(json) } /// The ID of the request this answers. - public var id: String { json["id"]?.stringValue ?? "" } + package var id: String { json["id"]?.stringValue ?? "" } /// `queries`, `metrics`, `version`, `authenticated`, `analyze-query`, or /// `error`. - public var op: String { json["op"]?.stringValue ?? "" } + package var op: String { json["op"]?.stringValue ?? "" } /// The response, whose shape depends on ``op``. - public var value: JSONValue { json["value"] ?? .null } + package var value: JSONValue { json["value"] ?? .null } } /// The inspect schemas of `packages/zero-protocol/src/inspect-up.ts`, diff --git a/swift/Sources/Zero/Protocol/Messages.swift b/swift/Sources/Zero/Protocol/Messages.swift index 90e9c2f30a..65b94287ad 100644 --- a/swift/Sources/Zero/Protocol/Messages.swift +++ b/swift/Sources/Zero/Protocol/Messages.swift @@ -1,22 +1,22 @@ /// The body of a `changeDesiredQueries` message /// (`packages/zero-protocol/src/change-desired-queries.ts`). -public struct ChangeDesiredQueriesBody: Sendable, Hashable, JSONCodable { - public var desiredQueriesPatch: UpQueriesPatch +package struct ChangeDesiredQueriesBody: Sendable, Hashable, JSONCodable { + package var desiredQueriesPatch: UpQueriesPatch /// A W3C traceparent header for distributed tracing. - public var traceparent: String? + package var traceparent: String? - public init(desiredQueriesPatch: UpQueriesPatch, traceparent: String? = nil) { + package init(desiredQueriesPatch: UpQueriesPatch, traceparent: String? = nil) { self.desiredQueriesPatch = desiredQueriesPatch self.traceparent = traceparent } - public init(json: JSONValue) throws(DecodingError) { + package init(json: JSONValue) throws(DecodingError) { let r = try JSONObjectReader(json, "changeDesiredQueries body") desiredQueriesPatch = try r.required("desiredQueriesPatch", JSONDecode.upQueriesPatch) traceparent = try r.optionalString("traceparent") } - public var json: JSONValue { + package var json: JSONValue { var o = JSONObjectBuilder() o.set("desiredQueriesPatch", .array(desiredQueriesPatch.map(\.json))) o.set("traceparent", traceparent) @@ -26,25 +26,25 @@ public struct ChangeDesiredQueriesBody: Sendable, Hashable, JSONCodable { /// The body of a `pull` request (`pullRequestBodySchema` in /// `packages/zero-protocol/src/pull.ts`). -public struct PullRequestBody: Sendable, Hashable, JSONCodable { - public var clientGroupID: String - public var cookie: Version? - public var requestID: String +package struct PullRequestBody: Sendable, Hashable, JSONCodable { + package var clientGroupID: String + package var cookie: Version? + package var requestID: String - public init(clientGroupID: String, cookie: Version?, requestID: String) { + package init(clientGroupID: String, cookie: Version?, requestID: String) { self.clientGroupID = clientGroupID self.cookie = cookie self.requestID = requestID } - public init(json: JSONValue) throws(DecodingError) { + package init(json: JSONValue) throws(DecodingError) { let r = try JSONObjectReader(json, "pull request body") clientGroupID = try r.string("clientGroupID") cookie = try r.required("cookie", NullableVersion.decode) requestID = try r.string("requestID") } - public var json: JSONValue { + package var json: JSONValue { [ "clientGroupID": .string(clientGroupID), "cookie": NullableVersion.json(cookie), "requestID": .string(requestID), @@ -55,26 +55,26 @@ public struct PullRequestBody: Sendable, Hashable, JSONCodable { /// The body of a `pull` response (`pullResponseBodySchema` in /// `packages/zero-protocol/src/pull.ts`). Pull is only used to recover /// mutations, so it carries no patch. -public struct PullResponseBody: Sendable, Hashable, JSONCodable { - public var cookie: Version +package struct PullResponseBody: Sendable, Hashable, JSONCodable { + package var cookie: Version /// The `requestID` of the pull request this answers. - public var requestID: String - public var lastMutationIDChanges: [String: Double] + package var requestID: String + package var lastMutationIDChanges: [String: Double] - public init(cookie: Version, requestID: String, lastMutationIDChanges: [String: Double]) { + package init(cookie: Version, requestID: String, lastMutationIDChanges: [String: Double]) { self.cookie = cookie self.requestID = requestID self.lastMutationIDChanges = lastMutationIDChanges } - public init(json: JSONValue) throws(DecodingError) { + package init(json: JSONValue) throws(DecodingError) { let r = try JSONObjectReader(json, "pull response body") cookie = try r.string("cookie") requestID = try r.string("requestID") lastMutationIDChanges = try r.required("lastMutationIDChanges", JSONDecode.numberRecord) } - public var json: JSONValue { + package var json: JSONValue { [ "cookie": .string(cookie), "requestID": .string(requestID), "lastMutationIDChanges": .numberRecord(lastMutationIDChanges), @@ -84,22 +84,22 @@ public struct PullResponseBody: Sendable, Hashable, JSONCodable { /// The body of a `deleteClients` message, in either direction /// (`packages/zero-protocol/src/delete-clients.ts`). -public struct DeleteClientsBody: Sendable, Hashable, JSONCodable { - public var clientIDs: [String]? - public var clientGroupIDs: [String]? +package struct DeleteClientsBody: Sendable, Hashable, JSONCodable { + package var clientIDs: [String]? + package var clientGroupIDs: [String]? - public init(clientIDs: [String]? = nil, clientGroupIDs: [String]? = nil) { + package init(clientIDs: [String]? = nil, clientGroupIDs: [String]? = nil) { self.clientIDs = clientIDs self.clientGroupIDs = clientGroupIDs } - public init(json: JSONValue) throws(DecodingError) { + package init(json: JSONValue) throws(DecodingError) { let r = try JSONObjectReader(json, "deleteClients body") clientIDs = try r.optional("clientIDs", JSONDecode.strings) clientGroupIDs = try r.optional("clientGroupIDs", JSONDecode.strings) } - public var json: JSONValue { + package var json: JSONValue { var o = JSONObjectBuilder() o.set("clientIDs", clientIDs.map(JSONValue.strings)) o.set("clientGroupIDs", clientGroupIDs.map(JSONValue.strings)) @@ -109,18 +109,18 @@ public struct DeleteClientsBody: Sendable, Hashable, JSONCodable { /// The body of an `updateAuth` message /// (`packages/zero-protocol/src/update-auth.ts`). -public struct UpdateAuthBody: Sendable, Hashable, JSONCodable { - public var auth: String +package struct UpdateAuthBody: Sendable, Hashable, JSONCodable { + package var auth: String - public init(auth: String) { + package init(auth: String) { self.auth = auth } - public init(json: JSONValue) throws(DecodingError) { + package init(json: JSONValue) throws(DecodingError) { auth = try JSONObjectReader(json, "updateAuth body").string("auth") } - public var json: JSONValue { ["auth": .string(auth)] } + package var json: JSONValue { ["auth": .string(auth)] } } /// A custom query the API server failed to transform (`erroredQuerySchema` @@ -144,7 +144,7 @@ public enum ErroredQuery: Sendable, Hashable, JSONCodable { } } - public init(json: JSONValue) throws(DecodingError) { + package init(json: JSONValue) throws(DecodingError) { let r = try JSONObjectReader(json, "errored query") switch try r.string("error") { case "app": @@ -160,7 +160,7 @@ public enum ErroredQuery: Sendable, Hashable, JSONCodable { } } - public var json: JSONValue { + package var json: JSONValue { var o = JSONObjectBuilder() switch self { case .app(let id, let name, let message, let details): diff --git a/swift/Sources/Zero/Protocol/Mutation.swift b/swift/Sources/Zero/Protocol/Mutation.swift index 47690d5e6a..18677139fa 100644 --- a/swift/Sources/Zero/Protocol/Mutation.swift +++ b/swift/Sources/Zero/Protocol/Mutation.swift @@ -1,10 +1,10 @@ /// The name of every CRUD mutation (`CRUD_MUTATION_NAME` in /// `packages/zero-protocol/src/mutation.ts`). -public let crudMutationName = "_zero_crud" +package let crudMutationName = "_zero_crud" /// One operation of a CRUD mutation (`crudOpSchema` in /// `packages/zero-protocol/src/mutation.ts`). -public enum CRUDOp: Sendable, Hashable, JSONCodable { +package enum CRUDOp: Sendable, Hashable, JSONCodable { case insert(tableName: String, primaryKey: [String], value: Row) case upsert(tableName: String, primaryKey: [String], value: Row) /// `value` is a partial row with at least the primary key columns. @@ -12,7 +12,7 @@ public enum CRUDOp: Sendable, Hashable, JSONCodable { /// `value` holds the primary key columns. case delete(tableName: String, primaryKey: [String], value: PrimaryKeyValueRecord) - public init(json: JSONValue) throws(DecodingError) { + package init(json: JSONValue) throws(DecodingError) { let r = try JSONObjectReader(json, "CRUD op") let op = try r.string("op") switch op { @@ -42,7 +42,7 @@ public enum CRUDOp: Sendable, Hashable, JSONCodable { } } - public var json: JSONValue { + package var json: JSONValue { let (op, tableName, primaryKey, value): (String, String, [String], Row) = switch self { case .insert(let t, let pk, let v): ("insert", t, pk, v) @@ -59,13 +59,13 @@ public enum CRUDOp: Sendable, Hashable, JSONCodable { /// A mutation of the legacy CRUD mutators (`crudMutationSchema`). Its name is /// always ``crudMutationName`` and its args are `[{ops}]`. -public struct CRUDMutation: Sendable, Hashable { - public var id: Double - public var clientID: String - public var ops: [CRUDOp] - public var timestamp: Double +package struct CRUDMutation: Sendable, Hashable { + package var id: Double + package var clientID: String + package var ops: [CRUDOp] + package var timestamp: Double - public init(id: Double, clientID: String, ops: [CRUDOp], timestamp: Double) { + package init(id: Double, clientID: String, ops: [CRUDOp], timestamp: Double) { self.id = id self.clientID = clientID self.ops = ops @@ -74,14 +74,14 @@ public struct CRUDMutation: Sendable, Hashable { } /// A mutation of a custom mutator (`customMutationSchema`). -public struct CustomMutation: Sendable, Hashable { - public var id: Double - public var clientID: String - public var name: String - public var args: [JSONValue] - public var timestamp: Double +package struct CustomMutation: Sendable, Hashable { + package var id: Double + package var clientID: String + package var name: String + package var args: [JSONValue] + package var timestamp: Double - public init(id: Double, clientID: String, name: String, args: [JSONValue], timestamp: Double) { + package init(id: Double, clientID: String, name: String, args: [JSONValue], timestamp: Double) { self.id = id self.clientID = clientID self.name = name @@ -91,25 +91,25 @@ public struct CustomMutation: Sendable, Hashable { } /// A mutation as pushed to zero-cache (`mutationSchema`). -public enum Mutation: Sendable, Hashable, JSONCodable { +package enum Mutation: Sendable, Hashable, JSONCodable { case crud(CRUDMutation) case custom(CustomMutation) - public var id: Double { + package var id: Double { switch self { case .crud(let m): m.id case .custom(let m): m.id } } - public var clientID: String { + package var clientID: String { switch self { case .crud(let m): m.clientID case .custom(let m): m.clientID } } - public init(json: JSONValue) throws(DecodingError) { + package init(json: JSONValue) throws(DecodingError) { let r = try JSONObjectReader(json, "mutation") switch try r.literalUnion("type", MutationType.self) { case .crud: @@ -140,7 +140,7 @@ public enum Mutation: Sendable, Hashable, JSONCodable { } } - public var json: JSONValue { + package var json: JSONValue { switch self { case .crud(let m): return [ @@ -161,21 +161,21 @@ public enum Mutation: Sendable, Hashable, JSONCodable { /// A failed mutation's result (`mutationErrorSchema` in /// `packages/zero-protocol/src/mutation.ts`). The fields are kept as sent: /// see ``MutationResult`` for which results are errors. -public struct MutationError: Sendable, Hashable { +package struct MutationError: Sendable, Hashable { /// `"app"` for an error the mutator threw; `"oooMutation"` or /// `"alreadyProcessed"` for one from zero. - public var error: JSONValue - public var message: JSONValue? - public var details: JSONValue? + package var error: JSONValue + package var message: JSONValue? + package var details: JSONValue? - public init(error: JSONValue, message: JSONValue? = nil, details: JSONValue? = nil) { + package init(error: JSONValue, message: JSONValue? = nil, details: JSONValue? = nil) { self.error = error self.message = message self.details = details } /// An application error (`appErrorSchema`). - public static func app(message: String? = nil, details: JSONValue? = nil) -> MutationError { + package static func app(message: String? = nil, details: JSONValue? = nil) -> MutationError { MutationError(error: "app", message: message.map { .string($0) }, details: details) } } @@ -192,12 +192,12 @@ public struct MutationError: Sendable, Hashable { /// field's matcher returns a value, as `jsonSchema` always does. So a result /// with `data` is OK even if it has an `error`, and any other result with an /// `error`, whatever its value, is an error. -public enum MutationResult: Sendable, Hashable, JSONCodable { +package enum MutationResult: Sendable, Hashable, JSONCodable { /// `mutationOkSchema`, with the data the mutator returned, if any. case ok(data: JSONValue?) case error(MutationError) - public init(json: JSONValue) throws(DecodingError) { + package init(json: JSONValue) throws(DecodingError) { let r = try JSONObjectReader(json, "mutation result") if let data = r.object["data"] { self = .ok(data: data) @@ -209,7 +209,7 @@ public enum MutationResult: Sendable, Hashable, JSONCodable { } } - public var json: JSONValue { + package var json: JSONValue { var o = JSONObjectBuilder() switch self { case .ok(let data): @@ -224,33 +224,33 @@ public enum MutationResult: Sendable, Hashable, JSONCodable { } /// `mutationResponseSchema`. -public struct MutationResponse: Sendable, Hashable, JSONCodable { - public var id: MutationID - public var result: MutationResult +package struct MutationResponse: Sendable, Hashable, JSONCodable { + package var id: MutationID + package var result: MutationResult - public init(id: MutationID, result: MutationResult) { + package init(id: MutationID, result: MutationResult) { self.id = id self.result = result } - public init(json: JSONValue) throws(DecodingError) { + package init(json: JSONValue) throws(DecodingError) { let r = try JSONObjectReader(json, "mutation response") id = try r.required("id", MutationID.init(json:)) result = try r.required("result", MutationResult.init(json:)) } - public var json: JSONValue { + package var json: JSONValue { ["id": id.json, "result": result.json] } } /// A change to the mutation results zero-cache tracks for a client /// (`patchOpSchema` in `packages/zero-protocol/src/mutations-patch.ts`). -public enum MutationPatchOp: Sendable, Hashable, JSONCodable { +package enum MutationPatchOp: Sendable, Hashable, JSONCodable { case put(MutationResponse) case del(MutationID) - public init(json: JSONValue) throws(DecodingError) { + package init(json: JSONValue) throws(DecodingError) { let r = try JSONObjectReader(json, "mutations patch op") switch try r.string("op") { case "put": self = .put(try r.required("mutation", MutationResponse.init(json:))) @@ -259,7 +259,7 @@ public enum MutationPatchOp: Sendable, Hashable, JSONCodable { } } - public var json: JSONValue { + package var json: JSONValue { switch self { case .put(let m): ["op": "put", "mutation": m.json] case .del(let id): ["op": "del", "id": id.json] @@ -268,4 +268,4 @@ public enum MutationPatchOp: Sendable, Hashable, JSONCodable { } /// `mutationsPatchSchema`. -public typealias MutationsPatch = [MutationPatchOp] +package typealias MutationsPatch = [MutationPatchOp] diff --git a/swift/Sources/Zero/Protocol/Poke.swift b/swift/Sources/Zero/Protocol/Poke.swift index 3f16dd6ce6..6232ee4143 100644 --- a/swift/Sources/Zero/Protocol/Poke.swift +++ b/swift/Sources/Zero/Protocol/Poke.swift @@ -1,9 +1,9 @@ /// The range of schema versions the server supports. -public struct SchemaVersions: Sendable, Hashable { - public var minSupportedVersion: Double - public var maxSupportedVersion: Double +package struct SchemaVersions: Sendable, Hashable { + package var minSupportedVersion: Double + package var maxSupportedVersion: Double - public init(minSupportedVersion: Double, maxSupportedVersion: Double) { + package init(minSupportedVersion: Double, maxSupportedVersion: Double) { self.minSupportedVersion = minSupportedVersion self.maxSupportedVersion = maxSupportedVersion } @@ -11,16 +11,16 @@ public struct SchemaVersions: Sendable, Hashable { /// Starts a poke (`pokeStartBodySchema` in /// `packages/zero-protocol/src/poke.ts`). -public struct PokeStartBody: Sendable, Hashable, JSONCodable { - public var pokeID: String +package struct PokeStartBody: Sendable, Hashable, JSONCodable { + package var pokeID: String /// The cookie the poke applies to; `nil` for a client that has never /// pulled, as Replicache starts clients with a `null` cookie. - public var baseCookie: Version? + package var baseCookie: Version? /// Always set when the poke has a `rowsPatch`. - public var schemaVersions: SchemaVersions? - public var timestamp: Double? + package var schemaVersions: SchemaVersions? + package var timestamp: Double? - public init( + package init( pokeID: String, baseCookie: Version?, schemaVersions: SchemaVersions? = nil, timestamp: Double? = nil ) { @@ -30,7 +30,7 @@ public struct PokeStartBody: Sendable, Hashable, JSONCodable { self.timestamp = timestamp } - public init(json: JSONValue) throws(DecodingError) { + package init(json: JSONValue) throws(DecodingError) { let r = try JSONObjectReader(json, "pokeStart body") pokeID = try r.string("pokeID") baseCookie = try r.required("baseCookie", NullableVersion.decode) @@ -43,7 +43,7 @@ public struct PokeStartBody: Sendable, Hashable, JSONCodable { timestamp = try r.optionalNumber("timestamp") } - public var json: JSONValue { + package var json: JSONValue { var o = JSONObjectBuilder() o.set("pokeID", pokeID) o.set("baseCookie", NullableVersion.json(baseCookie)) @@ -62,18 +62,18 @@ public struct PokeStartBody: Sendable, Hashable, JSONCodable { /// One part of a poke (`pokePartBodySchema` in /// `packages/zero-protocol/src/poke.ts`). -public struct PokePartBody: Sendable, Hashable, JSONCodable { - public var pokeID: String +package struct PokePartBody: Sendable, Hashable, JSONCodable { + package var pokeID: String /// The new last mutation ID of each client that changed. - public var lastMutationIDChanges: [String: Double]? + package var lastMutationIDChanges: [String: Double]? /// Changes to each client's desired queries, by client ID. - public var desiredQueriesPatches: [String: QueriesPatch]? + package var desiredQueriesPatches: [String: QueriesPatch]? /// Changes to the queries whose rows `rowsPatch` syncs. - public var gotQueriesPatch: QueriesPatch? - public var rowsPatch: RowsPatch? - public var mutationsPatch: MutationsPatch? + package var gotQueriesPatch: QueriesPatch? + package var rowsPatch: RowsPatch? + package var mutationsPatch: MutationsPatch? - public init( + package init( pokeID: String, lastMutationIDChanges: [String: Double]? = nil, desiredQueriesPatches: [String: QueriesPatch]? = nil, gotQueriesPatch: QueriesPatch? = nil, rowsPatch: RowsPatch? = nil, mutationsPatch: MutationsPatch? = nil @@ -86,7 +86,7 @@ public struct PokePartBody: Sendable, Hashable, JSONCodable { self.mutationsPatch = mutationsPatch } - public init(json: JSONValue) throws(DecodingError) { + package init(json: JSONValue) throws(DecodingError) { let r = try JSONObjectReader(json, "pokePart body") lastMutationIDChanges = try r.optional("lastMutationIDChanges", JSONDecode.numberRecord) desiredQueriesPatches = try r.optional("desiredQueriesPatches") { (v) throws(DecodingError) in @@ -102,7 +102,7 @@ public struct PokePartBody: Sendable, Hashable, JSONCodable { pokeID = try r.string("pokeID") } - public var json: JSONValue { + package var json: JSONValue { var o = JSONObjectBuilder() o.set("lastMutationIDChanges", lastMutationIDChanges.map(JSONValue.numberRecord)) o.set( @@ -117,28 +117,28 @@ public struct PokePartBody: Sendable, Hashable, JSONCodable { } /// Ends a poke (`pokeEndBodySchema` in `packages/zero-protocol/src/poke.ts`). -public struct PokeEndBody: Sendable, Hashable, JSONCodable { - public var pokeID: String +package struct PokeEndBody: Sendable, Hashable, JSONCodable { + package var pokeID: String /// The cookie after the poke. Ignore it, and it may be empty, if /// `cancel` is set. - public var cookie: Version + package var cookie: Version /// Discard the poke without applying it. - public var cancel: Bool? + package var cancel: Bool? - public init(pokeID: String, cookie: Version, cancel: Bool? = nil) { + package init(pokeID: String, cookie: Version, cancel: Bool? = nil) { self.pokeID = pokeID self.cookie = cookie self.cancel = cancel } - public init(json: JSONValue) throws(DecodingError) { + package init(json: JSONValue) throws(DecodingError) { let r = try JSONObjectReader(json, "pokeEnd body") pokeID = try r.string("pokeID") cookie = try r.string("cookie") cancel = try r.optionalBool("cancel") } - public var json: JSONValue { + package var json: JSONValue { var o = JSONObjectBuilder() o.set("pokeID", pokeID) o.set("cookie", cookie) @@ -153,16 +153,16 @@ public struct PokeEndBody: Sendable, Hashable, JSONCodable { /// `pokePart` messages: each is a ``messageType`` byte and then the next /// bytes of the UTF-8 JSON of a `PokePartBody[]`. A chunk can end inside a /// UTF-8 sequence. -public enum PokeChunk { +package enum PokeChunk { /// `POKE_CHUNK_MESSAGE_TYPE`. - public static let messageType: UInt8 = 0x00 + package static let messageType: UInt8 = 0x00 /// `POKE_CHUNK_PROTOCOL_VERSION`. - public static let protocolVersion = 52 + package static let protocolVersion = 52 /// The payload of a binary message. Throws for a message of another type, /// which the client treats as an invalid message /// (`#onMessage` in `packages/zero-client/src/client/zero.ts`). - public static func payload( + package static func payload( ofBinaryMessage message: [UInt8] ) throws(DecodingError) -> ArraySlice @@ -180,7 +180,7 @@ public enum PokeChunk { /// (`decodePokeChunks` in `packages/zero-client/src/client/zero-poke-handler.ts`): /// strict UTF-8, a leading BOM dropped as `TextDecoder` does, then /// `pokePartsSchema`. - public static func decode( + package static func decode( _ chunks: some Sequence> ) throws(DecodingError) -> [PokePartBody] diff --git a/swift/Sources/Zero/Protocol/Primitives.swift b/swift/Sources/Zero/Protocol/Primitives.swift index 6b34cebae5..3dffe3726a 100644 --- a/swift/Sources/Zero/Protocol/Primitives.swift +++ b/swift/Sources/Zero/Protocol/Primitives.swift @@ -1,30 +1,30 @@ /// A cookie: the version of the client view a client has /// (`packages/zero-protocol/src/version.ts`). Where the protocol allows a /// `NullableVersion`, `nil` is the state before the first pull. -public typealias Version = String +package typealias Version = String /// The values of a row's primary key columns, by column /// (`primaryKeyValueRecordSchema` in `packages/zero-protocol/src/primary-key.ts`). /// Each value is a string, number, or boolean. -public typealias PrimaryKeyValueRecord = [String: JSONValue] +package typealias PrimaryKeyValueRecord = [String: JSONValue] /// `packages/zero-protocol/src/mutation-id.ts`. -public struct MutationID: Sendable, Hashable, JSONCodable { - public var id: Double - public var clientID: String +package struct MutationID: Sendable, Hashable, JSONCodable { + package var id: Double + package var clientID: String - public init(id: Double, clientID: String) { + package init(id: Double, clientID: String) { self.id = id self.clientID = clientID } - public init(json: JSONValue) throws(DecodingError) { + package init(json: JSONValue) throws(DecodingError) { let r = try JSONObjectReader(json, "mutation ID") id = try r.number("id") clientID = try r.string("clientID") } - public var json: JSONValue { + package var json: JSONValue { ["id": .number(id), "clientID": .string(clientID)] } } diff --git a/swift/Sources/Zero/Protocol/ProtocolEnums.swift b/swift/Sources/Zero/Protocol/ProtocolEnums.swift index bd33fdbc3a..94e2aab85f 100644 --- a/swift/Sources/Zero/Protocol/ProtocolEnums.swift +++ b/swift/Sources/Zero/Protocol/ProtocolEnums.swift @@ -1,6 +1,6 @@ /// The kind of a protocol error /// (`packages/zero-protocol/src/error-kind-enum.ts`). -public enum ErrorKind: String, Sendable, Hashable, CaseIterable { +package enum ErrorKind: String, Sendable, Hashable, CaseIterable { case authInvalidated = "AuthInvalidated" case clientNotFound = "ClientNotFound" case invalidConnectionRequest = "InvalidConnectionRequest" @@ -23,7 +23,7 @@ public enum ErrorKind: String, Sendable, Hashable, CaseIterable { /// The kinds of a basic error body (`basicErrorKindSchema` in /// `packages/zero-protocol/src/error.ts`). - public static let basic: Set = [ + package static let basic: Set = [ .authInvalidated, .clientNotFound, .invalidConnectionRequest, .invalidConnectionRequestBaseCookie, .invalidConnectionRequestLastMutationID, .invalidConnectionRequestClientDeleted, .invalidMessage, .invalidPush, @@ -33,12 +33,12 @@ public enum ErrorKind: String, Sendable, Hashable, CaseIterable { /// The kinds whose body carries backoff parameters /// (`backoffErrorKindSchema`). - public static let backoff: Set = [.rebalance, .rehome, .serverOverloaded] + package static let backoff: Set = [.rebalance, .rehome, .serverOverloaded] } /// Where an error came from /// (`packages/zero-protocol/src/error-origin-enum.ts`). -public enum ErrorOrigin: String, Sendable, Hashable, CaseIterable { +package enum ErrorOrigin: String, Sendable, Hashable, CaseIterable { case client case server case zeroCache @@ -46,7 +46,7 @@ public enum ErrorOrigin: String, Sendable, Hashable, CaseIterable { /// Why a push or transform failed /// (`packages/zero-protocol/src/error-reason-enum.ts`). -public enum ErrorReason: String, Sendable, Hashable, CaseIterable { +package enum ErrorReason: String, Sendable, Hashable, CaseIterable { case database case parse case outOfOrderMutation = "oooMutation" @@ -57,7 +57,7 @@ public enum ErrorReason: String, Sendable, Hashable, CaseIterable { } /// `packages/zero-protocol/src/mutation-type-enum.ts`. -public enum MutationType: String, Sendable, Hashable, CaseIterable { +package enum MutationType: String, Sendable, Hashable, CaseIterable { case crud case custom } diff --git a/swift/Sources/Zero/Protocol/Push.swift b/swift/Sources/Zero/Protocol/Push.swift index 2e92704ae5..e99f68c4a4 100644 --- a/swift/Sources/Zero/Protocol/Push.swift +++ b/swift/Sources/Zero/Protocol/Push.swift @@ -1,21 +1,21 @@ /// The body of a `push` message (`pushBodySchema` in /// `packages/zero-protocol/src/push.ts`). -public struct PushBody: Sendable, Hashable, JSONCodable { - public var clientGroupID: String - public var mutations: [Mutation] - public var pushVersion: Double +package struct PushBody: Sendable, Hashable, JSONCodable { + package var clientGroupID: String + package var mutations: [Mutation] + package var pushVersion: Double /// The schema version of CRUD mutations. Custom mutations leave schema /// versioning to the API server. - public var schemaVersion: Double? - public var timestamp: Double - public var requestID: String + package var schemaVersion: Double? + package var timestamp: Double + package var requestID: String /// Deprecated: auth is set for the client group by `initConnection` and /// `updateAuth`. - public var auth: String? + package var auth: String? /// A W3C traceparent header for distributed tracing. - public var traceparent: String? + package var traceparent: String? - public init( + package init( clientGroupID: String, mutations: [Mutation], pushVersion: Double, schemaVersion: Double? = nil, timestamp: Double, requestID: String, auth: String? = nil, traceparent: String? = nil @@ -30,7 +30,7 @@ public struct PushBody: Sendable, Hashable, JSONCodable { self.traceparent = traceparent } - public init(json: JSONValue) throws(DecodingError) { + package init(json: JSONValue) throws(DecodingError) { let r = try JSONObjectReader(json, "push body") clientGroupID = try r.string("clientGroupID") mutations = try r.required("mutations") { (v) throws(DecodingError) in @@ -44,7 +44,7 @@ public struct PushBody: Sendable, Hashable, JSONCodable { traceparent = try r.optionalString("traceparent") } - public var json: JSONValue { + package var json: JSONValue { var o = JSONObjectBuilder() o.set("clientGroupID", clientGroupID) o.set("mutations", .array(mutations.map(\.json))) @@ -61,20 +61,20 @@ public struct PushBody: Sendable, Hashable, JSONCodable { /// The deprecated error responses to a push (`pushErrorSchema` in /// `packages/zero-protocol/src/push.ts`). zero-cache now reports push /// failures with `error` messages. -public enum PushError: Sendable, Hashable, JSONCodable { +package enum PushError: Sendable, Hashable, JSONCodable { case unsupportedPushVersion(mutationIDs: [MutationID]?) case unsupportedSchemaVersion(mutationIDs: [MutationID]?) case http(status: Double, details: String, mutationIDs: [MutationID]?) case zeroPusher(details: String, mutationIDs: [MutationID]?) - public var mutationIDs: [MutationID]? { + package var mutationIDs: [MutationID]? { switch self { case .unsupportedPushVersion(let ids), .unsupportedSchemaVersion(let ids): ids case .http(_, _, let ids), .zeroPusher(_, let ids): ids } } - public init(json: JSONValue) throws(DecodingError) { + package init(json: JSONValue) throws(DecodingError) { let r = try JSONObjectReader(json, "push error") let error = try r.string("error") let ids = { () throws(DecodingError) in @@ -95,7 +95,7 @@ public enum PushError: Sendable, Hashable, JSONCodable { } } - public var json: JSONValue { + package var json: JSONValue { var o = JSONObjectBuilder() switch self { case .unsupportedPushVersion: o.set("error", "unsupportedPushVersion") @@ -118,11 +118,11 @@ public enum PushError: Sendable, Hashable, JSONCodable { /// /// valita tries the OK variant first, and it has no discriminator: a body /// with a valid `mutations` array is OK even if it also has an `error` key. -public enum PushResponseBody: Sendable, Hashable, JSONCodable { +package enum PushResponseBody: Sendable, Hashable, JSONCodable { case ok(mutations: [MutationResponse]) case error(PushError) - public init(json: JSONValue) throws(DecodingError) { + package init(json: JSONValue) throws(DecodingError) { let r = try JSONObjectReader(json, "pushResponse body") do { self = .ok( @@ -134,7 +134,7 @@ public enum PushResponseBody: Sendable, Hashable, JSONCodable { } } - public var json: JSONValue { + package var json: JSONValue { switch self { case .ok(let mutations): ["mutations": .array(mutations.map(\.json))] case .error(let e): e.json diff --git a/swift/Sources/Zero/Protocol/QueriesPatch.swift b/swift/Sources/Zero/Protocol/QueriesPatch.swift index 19094fbd59..9aea32bd25 100644 --- a/swift/Sources/Zero/Protocol/QueriesPatch.swift +++ b/swift/Sources/Zero/Protocol/QueriesPatch.swift @@ -1,12 +1,12 @@ /// A change to a set of queries as zero-cache sends it: a client's desired /// queries or the queries whose rows are synced /// (`patchOpSchema` in `packages/zero-protocol/src/queries-patch.ts`). -public enum QueriesPatchOp: Sendable, Hashable, JSONCodable { +package enum QueriesPatchOp: Sendable, Hashable, JSONCodable { case put(hash: String, ttl: Double?) case del(hash: String) case clear - public init(json: JSONValue) throws(DecodingError) { + package init(json: JSONValue) throws(DecodingError) { let r = try JSONObjectReader(json, "queries patch op") switch try r.string("op") { case "put": self = .put(hash: try r.string("hash"), ttl: try r.optionalNumber("ttl")) @@ -16,7 +16,7 @@ public enum QueriesPatchOp: Sendable, Hashable, JSONCodable { } } - public var json: JSONValue { + package var json: JSONValue { switch self { case .put(let hash, let ttl): var o = JSONObjectBuilder() @@ -31,18 +31,18 @@ public enum QueriesPatchOp: Sendable, Hashable, JSONCodable { } /// `queriesPatchSchema`. -public typealias QueriesPatch = [QueriesPatchOp] +package typealias QueriesPatch = [QueriesPatchOp] /// A query a client wants synced (`upPutOpSchema`). A client query carries its /// `ast`; a custom query its `name` and `args`. -public struct UpQueriesPut: Sendable, Hashable { - public var hash: String - public var ttl: Double? - public var ast: AST? - public var name: String? - public var args: [JSONValue]? +package struct UpQueriesPut: Sendable, Hashable { + package var hash: String + package var ttl: Double? + package var ast: AST? + package var name: String? + package var args: [JSONValue]? - public init( + package init( hash: String, ttl: Double? = nil, ast: AST? = nil, name: String? = nil, args: [JSONValue]? = nil ) { @@ -56,12 +56,12 @@ public struct UpQueriesPut: Sendable, Hashable { /// A change to a client's desired queries, as the client sends it /// (`upPatchOpSchema` in `packages/zero-protocol/src/queries-patch.ts`). -public enum UpQueriesPatchOp: Sendable, Hashable, JSONCodable { +package enum UpQueriesPatchOp: Sendable, Hashable, JSONCodable { case put(UpQueriesPut) case del(hash: String) case clear - public init(json: JSONValue) throws(DecodingError) { + package init(json: JSONValue) throws(DecodingError) { let r = try JSONObjectReader(json, "queries patch op") switch try r.string("op") { case "put": @@ -78,7 +78,7 @@ public enum UpQueriesPatchOp: Sendable, Hashable, JSONCodable { } } - public var json: JSONValue { + package var json: JSONValue { switch self { case .put(let put): var o = JSONObjectBuilder() @@ -96,7 +96,7 @@ public enum UpQueriesPatchOp: Sendable, Hashable, JSONCodable { } /// `upQueriesPatchSchema`. -public typealias UpQueriesPatch = [UpQueriesPatchOp] +package typealias UpQueriesPatch = [UpQueriesPatchOp] extension JSONDecode { static func queriesPatch(_ v: JSONValue) throws(DecodingError) -> QueriesPatch { diff --git a/swift/Sources/Zero/Protocol/RowsPatch.swift b/swift/Sources/Zero/Protocol/RowsPatch.swift index cfdd47ce6b..9c791de6ee 100644 --- a/swift/Sources/Zero/Protocol/RowsPatch.swift +++ b/swift/Sources/Zero/Protocol/RowsPatch.swift @@ -1,6 +1,6 @@ /// A change to the synced rows (`rowPatchOpSchema` in /// `packages/zero-protocol/src/row-patch.ts`). -public enum RowPatchOp: Sendable, Hashable, JSONCodable { +package enum RowPatchOp: Sendable, Hashable, JSONCodable { /// Inserts or replaces the row. case put(tableName: String, value: Row) /// Merges `merge` into the row with primary key `id`. `merge` is a @@ -14,7 +14,7 @@ public enum RowPatchOp: Sendable, Hashable, JSONCodable { /// Deletes every row. case clear - public init(json: JSONValue) throws(DecodingError) { + package init(json: JSONValue) throws(DecodingError) { let r = try JSONObjectReader(json, "row patch op") switch try r.string("op") { case "put": @@ -35,7 +35,7 @@ public enum RowPatchOp: Sendable, Hashable, JSONCodable { } } - public var json: JSONValue { + package var json: JSONValue { switch self { case .put(let tableName, let value): return ["op": "put", "tableName": .string(tableName), "value": .object(value)] @@ -56,7 +56,7 @@ public enum RowPatchOp: Sendable, Hashable, JSONCodable { } /// `rowsPatchSchema`. -public typealias RowsPatch = [RowPatchOp] +package typealias RowsPatch = [RowPatchOp] extension JSONDecode { /// `rowSchema` (`packages/zero-protocol/src/data.ts`): a record of JSON diff --git a/swift/Sources/Zero/Protocol/Upstream.swift b/swift/Sources/Zero/Protocol/Upstream.swift index 774d2d93f1..eb1db2df74 100644 --- a/swift/Sources/Zero/Protocol/Upstream.swift +++ b/swift/Sources/Zero/Protocol/Upstream.swift @@ -1,6 +1,6 @@ /// A message from the client to zero-cache (`upstreamSchema` in /// `packages/zero-protocol/src/up.ts`). -public enum Upstream: Sendable, Hashable, JSONCodable { +package enum Upstream: Sendable, Hashable, JSONCodable { case initConnection(InitConnectionBody) case ping case deleteClients(DeleteClientsBody) @@ -19,7 +19,7 @@ public enum Upstream: Sendable, Hashable, JSONCodable { /// messages parse (`v.UnrecognizedTuple`). case unrecognized([JSONValue]) - public var tag: String { + package var tag: String { switch self { case .initConnection: "initConnection" case .ping: "ping" @@ -35,7 +35,7 @@ public enum Upstream: Sendable, Hashable, JSONCodable { } } - public init(json: JSONValue) throws(DecodingError) { + package init(json: JSONValue) throws(DecodingError) { guard case .array(let a) = json else { throw DecodingError("Expected array") } guard case .string(let tag)? = a.first else { throw DecodingError("Expected string", path: ["0"]) @@ -69,11 +69,11 @@ public enum Upstream: Sendable, Hashable, JSONCodable { } /// Parses message text, as zero-cache receives it. - public init(text: String) throws(DecodingError) { + package init(text: String) throws(DecodingError) { try self.init(json: try JSONValue.parseMessage(text)) } - public var json: JSONValue { + package var json: JSONValue { switch self { case .initConnection(let b): .message(tag, b.json) case .ping: .message(tag, [:]) @@ -90,5 +90,5 @@ public enum Upstream: Sendable, Hashable, JSONCodable { } /// The message text to send. - public var text: String { JSONSerializer.stringify(json) } + package var text: String { JSONSerializer.stringify(json) } } diff --git a/swift/Sources/Zero/Query/AnyQuery.swift b/swift/Sources/Zero/Query/AnyQuery.swift index e25a3fd475..b876cd78b2 100644 --- a/swift/Sources/Zero/Query/AnyQuery.swift +++ b/swift/Sources/Zero/Query/AnyQuery.swift @@ -1,6 +1,6 @@ /// The prefix of the aliases the builder gives `exists` subqueries /// (`SUBQ_PREFIX` in `packages/zero-protocol/src/ast.ts`). -public let subqueryAliasPrefix = "zsubq_" +package let subqueryAliasPrefix = "zsubq_" /// The name and arguments of the named query a query was built by. public struct CustomQueryID: Sendable, Hashable { @@ -44,7 +44,7 @@ public struct AnyQuery: Sendable { public let schema: ZeroSchema public let table: String public let ast: AST - public let format: Format + package let format: Format public let system: AST.System public let customQueryID: CustomQueryID? /// The two-hop relationship this query is the destination of, if any. diff --git a/swift/Sources/Zero/Query/Like.swift b/swift/Sources/Zero/Query/Like.swift index b910705379..cd35024be7 100644 --- a/swift/Sources/Zero/Query/Like.swift +++ b/swift/Sources/Zero/Query/Like.swift @@ -5,9 +5,9 @@ /// flag. That regexp works on UTF-16 code units: `_` matches one code unit /// (half of a surrogate pair), and `i` folds case per code unit through /// simple uppercasing. This reproduces both paths. -public struct LikePattern: Sendable { - public struct InvalidPattern: Error, CustomStringConvertible { - public let description: String +package struct LikePattern: Sendable { + package struct InvalidPattern: Error, CustomStringConvertible { + package let description: String } enum Token: Sendable, Equatable { @@ -24,7 +24,7 @@ public struct LikePattern: Sendable { let matcher: Matcher - public init(_ pattern: String, caseInsensitive: Bool) throws(InvalidPattern) { + package init(_ pattern: String, caseInsensitive: Bool) throws(InvalidPattern) { let units = Array(pattern.utf16) let hasSpecial = units.contains { $0 == 0x5F || $0 == 0x25 || $0 == 0x5C } if !hasSpecial { @@ -52,7 +52,7 @@ public struct LikePattern: Sendable { matcher = .wildcard(tokens, caseInsensitive: caseInsensitive) } - public func matches(_ input: String) -> Bool { + package func matches(_ input: String) -> Bool { switch matcher { case .exact(let p): // Code unit equality: Swift's `==` would equate canonically diff --git a/swift/Sources/Zero/Query/Predicate.swift b/swift/Sources/Zero/Query/Predicate.swift index 9d9794ba38..78aed687ed 100644 --- a/swift/Sources/Zero/Query/Predicate.swift +++ b/swift/Sources/Zero/Query/Predicate.swift @@ -1,10 +1,10 @@ /// Row predicates from conditions (`packages/zql/src/builder/filter.ts`). -public typealias RowPredicate = (Row) -> Bool +package typealias RowPredicate = (Row) -> Bool /// An error building a pipeline, where JS throws. -public struct PipelineError: Error, CustomStringConvertible { - public let description: String - public init(_ description: String) { self.description = description } +package struct PipelineError: Error, CustomStringConvertible { + package let description: String + package init(_ description: String) { self.description = description } } /// `createPredicate(condition)` for a condition without subqueries. @@ -175,7 +175,7 @@ func transformFilters( } /// `simplifyCondition` (`packages/zql/src/query/expression.ts`). -public func simplifyCondition(_ c: AST.Condition) -> AST.Condition { +package func simplifyCondition(_ c: AST.Condition) -> AST.Condition { switch c { case .simple, .correlatedSubquery: return c @@ -204,9 +204,9 @@ func flatten(_ isAnd: Bool, _ conditions: [AST.Condition]) -> [AST.Condition] { extension AST.Condition { /// `TRUE`: an empty AND. - public static var trueCondition: AST.Condition { .and([]) } + package static var trueCondition: AST.Condition { .and([]) } /// `FALSE`: an empty OR. - public static var falseCondition: AST.Condition { .or([]) } + package static var falseCondition: AST.Condition { .or([]) } var isAlwaysTrue: Bool { if case .and(let c) = self { c.isEmpty } else { false } diff --git a/swift/Sources/Zero/Query/Query.swift b/swift/Sources/Zero/Query/Query.swift index b5a9f92311..855b6c5b2d 100644 --- a/swift/Sources/Zero/Query/Query.swift +++ b/swift/Sources/Zero/Query/Query.swift @@ -125,7 +125,7 @@ public struct Query: Sendable where Result.Ro } public var ast: AST { any.ast } - public var format: Format { any.format } + package var format: Format { any.format } public var hash: String { any.hash } /// Compares a column with a value. Comparing with nil uses `IS`, since diff --git a/swift/Sources/Zero/Query/TTL.swift b/swift/Sources/Zero/Query/TTL.swift index 7438e6468e..5344839029 100644 --- a/swift/Sources/Zero/Query/TTL.swift +++ b/swift/Sources/Zero/Query/TTL.swift @@ -98,7 +98,7 @@ extension TTL: ExpressibleByIntegerLiteral { extension TTL { /// The JSON form JS would hold: a string or a number. - public var json: JSONValue { + package var json: JSONValue { switch self { case .none: "none" case .forever: "forever" @@ -107,7 +107,7 @@ extension TTL { } } - public init(json: JSONValue) throws(DecodingError) { + package init(json: JSONValue) throws(DecodingError) { switch json { case .number(let n): self = .milliseconds(n) case .string(let s): self = TTL(stringLiteral: s) diff --git a/swift/Sources/Zero/Schema/NameMapper.swift b/swift/Sources/Zero/Schema/NameMapper.swift index 08f33306df..f3b9097a51 100644 --- a/swift/Sources/Zero/Schema/NameMapper.swift +++ b/swift/Sources/Zero/Schema/NameMapper.swift @@ -1,11 +1,11 @@ /// An unknown table or column met while mapping names. -public struct NameMappingError: Error, Sendable, Hashable, CustomStringConvertible { - public var description: String +package struct NameMappingError: Error, Sendable, Hashable, CustomStringConvertible { + package var description: String } /// Maps table and column names between the client and the server /// (`NameMapper` in `packages/zero-types/src/name-mapper.ts`). -public struct NameMapper: Sendable { +package struct NameMapper: Sendable { struct Destination: Sendable { var tableName: String var columns: [String: String] @@ -15,12 +15,12 @@ public struct NameMapper: Sendable { let tables: [String: Destination] /// `clientToServer`. - public static func clientToServer(_ schema: ZeroSchema) -> NameMapper { + package static func clientToServer(_ schema: ZeroSchema) -> NameMapper { NameMapper(schema, fromClient: true) } /// `serverToClient`. - public static func serverToClient(_ schema: ZeroSchema) -> NameMapper { + package static func serverToClient(_ schema: ZeroSchema) -> NameMapper { NameMapper(schema, fromClient: false) } @@ -52,13 +52,13 @@ public struct NameMapper: Sendable { return table } - public func tableName(_ src: String) throws(NameMappingError) -> String { + package func tableName(_ src: String) throws(NameMappingError) -> String { try table(src).tableName } - public func tableNameIfKnown(_ src: String) -> String? { tables[src]?.tableName } + package func tableNameIfKnown(_ src: String) -> String? { tables[src]?.tableName } - public func columnName(_ table: String, _ src: String) throws(NameMappingError) -> String { + package func columnName(_ table: String, _ src: String) throws(NameMappingError) -> String { guard let dst = try self.table(table).columns[src] else { throw NameMappingError(description: "unknown column \"\(src)\" of \"\(table)\" table") } @@ -66,7 +66,7 @@ public struct NameMapper: Sendable { } /// Renames the known columns of `row`; others keep their names. - public func row(_ table: String, _ row: Row) throws(NameMappingError) -> Row { + package func row(_ table: String, _ row: Row) throws(NameMappingError) -> Row { let dest = try self.table(table) if dest.allColumnsSame { return row } var out: Row = [:] @@ -74,14 +74,14 @@ public struct NameMapper: Sendable { return out } - public func columns(_ table: String, _ columns: [String]) throws(NameMappingError) -> [String] { + package func columns(_ table: String, _ columns: [String]) throws(NameMappingError) -> [String] { let dest = try self.table(table) if dest.allColumnsSame { return columns } return columns.map { dest.columns[$0] ?? $0 } } /// `mapAST`: the AST with its table and column names mapped. - public func map(_ ast: AST) throws(NameMappingError) -> AST { + package func map(_ ast: AST) throws(NameMappingError) -> AST { var out = ast out.table = try tableName(ast.table) out.where = try ast.where.map { (w) throws(NameMappingError) in try map(w, table: ast.table) } @@ -111,7 +111,7 @@ public struct NameMapper: Sendable { } /// `mapCondition`. Only the left side of a comparison names a column. - public func map( + package func map( _ condition: AST.Condition, table: String ) throws(NameMappingError) -> AST.Condition @@ -154,7 +154,7 @@ public struct NameMapper: Sendable { extension ZeroSchema { /// The schema zero-cache checks the replica against, under server names /// (`clientSchemaFrom` in `packages/zero-schema/src/builder/schema-builder.ts`). - public var clientSchema: ClientSchema { + package var clientSchema: ClientSchema { var tables: [String: ClientSchema.Table] = [:] for (name, table) in self.tables { var columns: [String: ClientSchema.ValueType] = [:] diff --git a/swift/Sources/Zero/Schema/Schema.swift b/swift/Sources/Zero/Schema/Schema.swift index b40f3c3ed9..044102b8dd 100644 --- a/swift/Sources/Zero/Schema/Schema.swift +++ b/swift/Sources/Zero/Schema/Schema.swift @@ -96,7 +96,7 @@ public struct RelationshipHop: Sendable, Hashable { public typealias Relationship = [RelationshipHop] extension ZeroSchema: JSONCodable { - public init(json: JSONValue) throws(DecodingError) { + package init(json: JSONValue) throws(DecodingError) { let o = try JSONObjectReader(json) let tablesJSON = try o.required("tables", JSONDecode.object) var tables: [TableSchema] = [] @@ -118,7 +118,7 @@ extension ZeroSchema: JSONCodable { self.init(tables: tables, relationships: relationships) } - public var json: JSONValue { + package var json: JSONValue { var tables: [String: JSONValue] = [:] for (name, table) in self.tables { tables[name] = table.json } var rels: [String: JSONValue] = [:] @@ -140,7 +140,7 @@ extension TableSchema: JSONCodable { /// Decodes a table. JSON objects lose their key order in `JSONValue`, so /// columns are ordered by an optional `columnOrder` array, else by name. - public init(json: JSONValue) throws(DecodingError) { + package init(json: JSONValue) throws(DecodingError) { let o = try JSONObjectReader(json) let name = try o.optionalString("name") ?? "" let serverName = try o.optionalString("serverName") @@ -178,7 +178,7 @@ extension TableSchema: JSONCodable { uniqueKeys: uniqueKeys) } - public var json: JSONValue { + package var json: JSONValue { var columns: [String: JSONValue] = [:] for (name, column) in self.columns { var c: [String: JSONValue] = ["type": .string(column.type.rawValue)] @@ -199,7 +199,7 @@ extension TableSchema: JSONCodable { } extension RelationshipHop: JSONCodable { - public init(json: JSONValue) throws(DecodingError) { + package init(json: JSONValue) throws(DecodingError) { let o = try JSONObjectReader(json) let cardinality = try o.string("cardinality") self.init( @@ -213,7 +213,7 @@ extension RelationshipHop: JSONCodable { cardinality: cardinality == "one" ? .one : .many) } - public var json: JSONValue { + package var json: JSONValue { [ "sourceField": sourceField.json, "destField": destField.json, diff --git a/swift/Sources/Zero/Util/BTree.swift b/swift/Sources/Zero/Util/BTree.swift index b60bb4eea7..c91e98bdc5 100644 --- a/swift/Sources/Zero/Util/BTree.swift +++ b/swift/Sources/Zero/Util/BTree.swift @@ -3,8 +3,9 @@ /// Nodes are shared between copies and copied on write, so copying a tree /// is O(1) (`MemorySource.fork`) and an iterator keeps reading the tree as /// it was when the iterator was created. -public struct BTreeSet { - public typealias Compare = (Element, Element) -> Int +@usableFromInline +package struct BTreeSet { + @usableFromInline package typealias Compare = (Element, Element) -> Int @usableFromInline final class Node { // Unchecked: the tree is a value type that copies shared nodes before @@ -29,12 +30,12 @@ public struct BTreeSet { @usableFromInline var root: Node @usableFromInline var _count: Int - public let compare: Compare + @usableFromInline package let compare: Compare - public var count: Int { _count } + @usableFromInline package var count: Int { _count } @inlinable - public init(_ compare: @escaping Compare) { + package init(_ compare: @escaping Compare) { self.compare = compare root = Node(keys: []) _count = 0 @@ -42,7 +43,7 @@ public struct BTreeSet { /// Builds a tree from elements already sorted by `compare`, in O(n). @inlinable - public init(sorted elements: S, _ compare: @escaping Compare) + package init(sorted elements: S, _ compare: @escaping Compare) where S.Element == Element { self.compare = compare let max = Self.maxNodeSize @@ -73,11 +74,11 @@ public struct BTreeSet { _count = n } - public var isEmpty: Bool { _count == 0 } + @usableFromInline package var isEmpty: Bool { _count == 0 } /// The stored element equal to `element`. @inlinable - public func get(_ element: Element) -> Element? { + package func get(_ element: Element) -> Element? { var node = root while !node.isLeaf { let i = lowerBound(node.keys) { compare($0, element) } @@ -90,11 +91,11 @@ public struct BTreeSet { } @inlinable - public func contains(_ element: Element) -> Bool { get(element) != nil } + package func contains(_ element: Element) -> Bool { get(element) != nil } /// Adds `element`, replacing an equal one. Returns whether it was new. @inlinable @discardableResult - public mutating func add(_ element: Element) -> Bool { + package mutating func add(_ element: Element) -> Bool { if !isKnownUniquelyReferenced(&root) { root = root.copy() } let (inserted, split) = Self.insert(element, into: root, compare) if let split { @@ -108,7 +109,7 @@ public struct BTreeSet { /// Removes the element equal to `element`. Returns whether there was one. @inlinable @discardableResult - public mutating func delete(_ element: Element) -> Bool { + package mutating func delete(_ element: Element) -> Bool { if !isKnownUniquelyReferenced(&root) { root = root.copy() } let removed = Self.remove(element, from: root, compare) while !root.isLeaf && root.children.count == 1 { root = root.children[0] } @@ -119,18 +120,18 @@ public struct BTreeSet { /// The elements, ascending. @inlinable - public func values() -> ForwardIterator { values(from: nil) } + package func values() -> ForwardIterator { values(from: nil) } /// The elements not less than `probe`, ascending. `probe(e)` compares `e` /// to the bound: negative when `e` is before it. @inlinable - public func values(from probe: ((Element) -> Int)?) -> ForwardIterator { + package func values(from probe: ((Element) -> Int)?) -> ForwardIterator { ForwardIterator(root: root, probe: probe) } /// The elements not greater than `probe`, descending. @inlinable - public func valuesReversed(from probe: ((Element) -> Int)?) -> ReverseIterator { + package func valuesReversed(from probe: ((Element) -> Int)?) -> ReverseIterator { ReverseIterator(root: root, probe: probe) } @@ -214,7 +215,7 @@ public struct BTreeSet { // MARK: Iteration - public struct ForwardIterator: IteratorProtocol, Sequence { + @usableFromInline package struct ForwardIterator: IteratorProtocol, Sequence { @usableFromInline var path: [(Node, Int)] = [] @usableFromInline var leaf: Node? /// The leaf's keys, read once per leaf. @@ -236,7 +237,7 @@ public struct BTreeSet { } @inlinable - public mutating func next() -> Element? { + package mutating func next() -> Element? { if index < keys.count { index += 1 return keys[index - 1] @@ -267,7 +268,7 @@ public struct BTreeSet { } } - public struct ReverseIterator: IteratorProtocol, Sequence { + @usableFromInline package struct ReverseIterator: IteratorProtocol, Sequence { @usableFromInline var path: [(Node, Int)] = [] @usableFromInline var leaf: Node? @usableFromInline var index = 0 @@ -293,7 +294,7 @@ public struct BTreeSet { } @inlinable - public mutating func next() -> Element? { + package mutating func next() -> Element? { while let l = leaf { if index >= 0 { index -= 1 diff --git a/swift/Sources/Zero/Util/JSSort.swift b/swift/Sources/Zero/Util/JSSort.swift index 2649abc9bf..4fb9b20077 100644 --- a/swift/Sources/Zero/Util/JSSort.swift +++ b/swift/Sources/Zero/Util/JSSort.swift @@ -7,14 +7,14 @@ /// it if strictly descending) and binary-insertion-sorts the rest; that is /// reproduced here exactly. Longer arrays use the same insertion sort, which /// matches V8 for consistent comparators (it is stable). -public enum JSSort { - public static func sorted(_ array: [T], by cmp: (T, T) -> Int) -> [T] { +package enum JSSort { + package static func sorted(_ array: [T], by cmp: (T, T) -> Int) -> [T] { var a = array sort(&a, by: cmp) return a } - public static func sort(_ a: inout [T], by cmp: (T, T) -> Int) { + package static func sort(_ a: inout [T], by cmp: (T, T) -> Int) { let n = a.count if n < 2 { return } // CountAndMakeRun diff --git a/swift/Sources/Zero/Util/StringCompare.swift b/swift/Sources/Zero/Util/StringCompare.swift index 8e3ccc90d6..6a3555fba9 100644 --- a/swift/Sources/Zero/Util/StringCompare.swift +++ b/swift/Sources/Zero/Util/StringCompare.swift @@ -7,10 +7,10 @@ /// /// Both differ from Swift's `String <`, which compares by Unicode canonical /// equivalence. -public enum StringCompare { +package enum StringCompare { /// `compareUTF8(a, b)`: negative, zero, or positive. @inlinable - public static func utf8(_ a: String, _ b: String) -> Int { + package static func utf8(_ a: String, _ b: String) -> Int { // No `a == b` shortcut: Swift's `==` equates canonically equivalent // strings ("é" and "e\u{301}"), which are distinct in Zero. let contiguous = a.utf8.withContiguousStorageIfAvailable { a in @@ -31,7 +31,7 @@ public enum StringCompare { } @inlinable - public static func utf8Less(_ a: String, _ b: String) -> Bool { utf8(a, b) < 0 } + package static func utf8Less(_ a: String, _ b: String) -> Bool { utf8(a, b) < 0 } /// Byte order: negative, zero, or positive. @inlinable @@ -48,7 +48,7 @@ public enum StringCompare { /// JS `a < b ? -1 : a > b ? 1 : 0` for strings. @inlinable - public static func utf16(_ a: String, _ b: String) -> Int { + package static func utf16(_ a: String, _ b: String) -> Int { var ai = a.utf16.makeIterator() var bi = b.utf16.makeIterator() while true { @@ -63,11 +63,11 @@ public enum StringCompare { } @inlinable - public static func utf16Less(_ a: String, _ b: String) -> Bool { utf16(a, b) < 0 } + package static func utf16Less(_ a: String, _ b: String) -> Bool { utf16(a, b) < 0 } /// Code unit equality, as JS `===` compares strings. @inlinable - public static func equal(_ a: String, _ b: String) -> Bool { + package static func equal(_ a: String, _ b: String) -> Bool { let contiguous = a.utf8.withContiguousStorageIfAvailable { a in b.utf8.withContiguousStorageIfAvailable { b in a.count == b.count && compare(a, b) == 0 @@ -80,20 +80,20 @@ public enum StringCompare { /// A string key compared by code units, like a JS `Map` key. Swift's own /// `String` equality treats canonically equivalent strings as equal. -public struct JSKey: Hashable, Sendable, CustomStringConvertible { - public let string: String +package struct JSKey: Hashable, Sendable, CustomStringConvertible { + package let string: String @inlinable - public init(_ string: String) { self.string = string } + package init(_ string: String) { self.string = string } @inlinable - public static func == (a: JSKey, b: JSKey) -> Bool { StringCompare.equal(a.string, b.string) } + package static func == (a: JSKey, b: JSKey) -> Bool { StringCompare.equal(a.string, b.string) } @inlinable - public func hash(into hasher: inout Hasher) { + package func hash(into hasher: inout Hasher) { var s = string s.withUTF8 { hasher.combine(bytes: UnsafeRawBufferPointer($0)) } } - public var description: String { string } + package var description: String { string } } From e1541c314d0dd3ccf09e8ac96a37049e8d7c4ed8 Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 19 Oct 2026 17:02:49 +0000 Subject: [PATCH 031/104] 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 --- .github/workflows/swift-macos.yml | 2 + .github/workflows/swift.yml | 4 + .oxfmtrc.json | 4 +- apps/zbugs-swift/App/project.yml | 8 +- swift/DESIGN.md | 20 ++- swift/README.md | 96 +++++++++++ swift/Sources/Zero/Client/Log.swift | 6 +- swift/Sources/Zero/Client/ZeroClient.swift | 4 + .../Sources/Zero/Zero.docc/GettingStarted.md | 135 +++++++++++++++ swift/Sources/Zero/Zero.docc/JSParity.md | 115 +++++++++++++ swift/Sources/Zero/Zero.docc/Zero.md | 158 ++++++++++++++++++ swift/Sources/ZeroTesting/Drift.swift | 1 + .../ZeroTesting.docc/ZeroTesting.md | 65 +++++++ swift/Sources/ZeroUI/ZeroUI.docc/ZeroUI.md | 56 +++++++ swift/scripts/docs.sh | 35 ++++ 15 files changed, 696 insertions(+), 13 deletions(-) create mode 100644 swift/README.md create mode 100644 swift/Sources/Zero/Zero.docc/GettingStarted.md create mode 100644 swift/Sources/Zero/Zero.docc/JSParity.md create mode 100644 swift/Sources/Zero/Zero.docc/Zero.md create mode 100644 swift/Sources/ZeroTesting/ZeroTesting.docc/ZeroTesting.md create mode 100644 swift/Sources/ZeroUI/ZeroUI.docc/ZeroUI.md create mode 100755 swift/scripts/docs.sh diff --git a/.github/workflows/swift-macos.yml b/.github/workflows/swift-macos.yml index 7de2e602c9..4269b0a3fb 100644 --- a/.github/workflows/swift-macos.yml +++ b/.github/workflows/swift-macos.yml @@ -42,6 +42,8 @@ jobs: - run: swift --version - name: Build and test (macOS) run: swift test + - name: Docs + run: scripts/docs.sh - name: Test (iOS Simulator) run: | device="$(xcrun simctl list devices available -j | diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index fb36ebd6be..0e10aa41ec 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -77,6 +77,10 @@ jobs: run: swift build --build-tests - name: Test run: swift test --skip-build + # Broken symbol links and undocumented parameters fail. ZeroUI is + # Apple-only; swift-macos.yml checks it. + - name: Docs + run: scripts/docs.sh Zero ZeroTesting # zbugs' Swift port, checked against zbugs' TS queries and mutators on # the samples in drift-samples.ts (the vectors job checks the record is # current). diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 6bc75a1069..474a7e16f6 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -33,6 +33,8 @@ "apps/zbugs/db/migrations/", "**/__snapshots__/**", "packages/zero-client/src/client/zero-stress-queries-test.ts", - "packages/zql-benchmarks/src/planner-cost.bench.ts" + "packages/zql-benchmarks/src/planner-cost.bench.ts", + "swift/Sources/*/*.docc", + "**/drift-vectors.json" ] } diff --git a/apps/zbugs-swift/App/project.yml b/apps/zbugs-swift/App/project.yml index 48383fb4c9..84d74ab7ea 100644 --- a/apps/zbugs-swift/App/project.yml +++ b/apps/zbugs-swift/App/project.yml @@ -5,8 +5,8 @@ name: ZBugs options: bundleIdPrefix: dev.rocicorp deploymentTarget: - iOS: "26.0" - macOS: "26.0" + iOS: '26.0' + macOS: '26.0' packages: ZBugs: path: .. @@ -27,13 +27,13 @@ targets: # Not ZBugs, the package module it imports. PRODUCT_MODULE_NAME: ZBugsApp PRODUCT_BUNDLE_IDENTIFIER: dev.rocicorp.zbugs - SWIFT_VERSION: "6.0" + SWIFT_VERSION: '6.0' GENERATE_INFOPLIST_FILE: YES INFOPLIST_FILE: Info.plist INFOPLIST_KEY_CFBundleDisplayName: Zero Bugs INFOPLIST_KEY_UILaunchScreen_Generation: YES INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES INFOPLIST_KEY_LSApplicationCategoryType: public.app-category.developer-tools - TARGETED_DEVICE_FAMILY: "1,2" + TARGETED_DEVICE_FAMILY: '1,2' CODE_SIGN_STYLE: Automatic ENABLE_USER_SCRIPT_SANDBOXING: YES diff --git a/swift/DESIGN.md b/swift/DESIGN.md index 7d8a99d592..ac14c17724 100644 --- a/swift/DESIGN.md +++ b/swift/DESIGN.md @@ -53,12 +53,16 @@ be lowered later with `@available` shims. ``` swift/ Swift package (source of truth) Package.swift - Sources/Zero/ core engine, platform-agnostic - Sources/ZeroUI/ SwiftUI, UIKit, AppKit helpers; URLSession transport + README.md the mirror's front page + Sources/Zero/ core engine; on Apple platforms, the URLSession + transport and app lifecycle too + Sources/Zero/Zero.docc/ DocC: getting started, the JS parity matrix + Sources/ZeroUI/ SwiftUI, UIKit, AppKit helpers Sources/ZeroTesting/ drift-harness replay API and test helpers Sources/ZeroNIOTesting/ SwiftNIO WebSocket transport for Linux end-to-end tests Sources/ZeroBenchmarks/ benchmarks (BENCHMARKS.md) Tests/... + scripts/docs.sh builds the DocC catalogs, failing on warnings VERSION packages/zero-conformance/ private JS package: fixtures, vector generators, JS runners packages/zero-swift-cli/ npm package `zero-swift`: `codegen`, `drift record` @@ -83,7 +87,9 @@ apps/zbugs-swift/ dogfood app (iOS + macOS) `@rocicorp` scope) with a peer dependency on `@rocicorp/zero`. It is versioned with the Swift releases. - License: Apache-2.0, like this repository. -- Docs: DocC, plus a JS→Swift parity matrix. +- Docs: a DocC catalog per library product, with a getting-started article + and a JS→Swift parity matrix (`Zero.docc/JSParity.md`). Oxfmt skips the + catalogs; it would turn DocC's ` ``Symbol`` ` links into code voice. ### Versioning and releases @@ -481,12 +487,14 @@ the Zero convention of passing IDs and timestamps as arguments. - **Linux, every PR.** Swift build, unit tests, and conformance vectors; the vector and drift-vector staleness checks; `apps/zbugs-swift`'s drift test; - an end-to-end run against a real `zero-cache` and Postgres; and - `apps/zbugs-swift` against zbugs' backend (`server/server.ts`). + an end-to-end run against a real `zero-cache` and Postgres; + `apps/zbugs-swift` against zbugs' backend (`server/server.ts`); and the + `Zero` and `ZeroTesting` docs, where a broken symbol link or an + undocumented parameter fails. - **macOS, path-filtered to `swift/`, `packages/zero-conformance/`, and `apps/zbugs-swift/`.** The Apple layer (URLSession transport against a local mock WebSocket server, SwiftUI, UIKit, and AppKit helpers), iOS simulator - tests, and the zbugs app built for iOS and macOS. + tests, all three DocC catalogs, and the zbugs app built for iOS and macOS. - **Benchmarks.** Ports of the key JS benchmarks (`zero-client/src/client/zero.bench.ts`, the shared B-tree and hash benchmarks) plus Swift benchmarks for query hydration over N rows, poke diff --git a/swift/README.md b/swift/README.md new file mode 100644 index 0000000000..ee3e645f08 --- /dev/null +++ b/swift/README.md @@ -0,0 +1,96 @@ +# Zero for Swift + +A native [Zero](https://zero.rocicorp.dev) client for iOS and macOS. Your +app queries a local replica of the server's data, and the replica stays in +sync. Mutations apply to the replica right away and run again on your +server, whose result wins. + +It's a port of the JS client (`@rocicorp/zero`) and behaves the same. +Conformance vectors generated from the JS client test it, and so do +end-to-end tests against `zero-cache` and the zbugs app. + +> [!NOTE] +> Before 1.0, minor versions may change the API. + +## Requirements + +- Swift 6.2 +- iOS 26 or macOS 26. The `Zero` module also builds on Linux. +- A `zero-cache` that speaks sync protocol 53 (Zero 1.12 or later) + +## Install + +```swift +// Package.swift +dependencies: [ + .package(url: "https://github.com/wevm/zero-swift", from: "0.1.0"), +], +targets: [ + .target(name: "App", dependencies: [ + .product(name: "Zero", package: "zero-swift"), + .product(name: "ZeroUI", package: "zero-swift"), // SwiftUI, UIKit, AppKit + ]), + .testTarget(name: "AppTests", dependencies: [ + .product(name: "ZeroTesting", package: "zero-swift"), + ]), +] +``` + +The `zero-swift` CLI generates Swift from your TypeScript schema, and +records the vectors the drift harness checks your Swift queries and +mutators against: + +```sh +npm install --save-dev zero-swift +npx zero-swift codegen +npx zero-swift drift record +``` + +## Example + +```swift +import SwiftUI +import ZeroUI + +let zero = try Zero( + schema: .app, context: auth, mutators: [IssueMutators.create], + options: ZeroOptions(cacheURL: "https://zero.example.com", userID: auth?.sub, auth: token)) + +struct IssueList: View { + @ZeroQuery(IssueQueries.open()) var issues + + var body: some View { + List(issues) { Text($0.title) } + } +} + +zero.mutate(IssueMutators.create(.init(id: newID(), title: "Crash on launch", created: now))) +``` + +## Documentation + +The DocC catalogs in `Sources/*/*.docc` cover the following. Open them in +Xcode with Product › Build Documentation. + +- [Getting started](Sources/Zero/Zero.docc/GettingStarted.md): schema + generation, porting queries and mutators, creating a client. +- [Coming from the JS client](Sources/Zero/Zero.docc/JSParity.md): what + each JS option, method, and React hook is called in Swift, and what isn't + ported. +- [`ZeroUI`](Sources/ZeroUI/ZeroUI.docc/ZeroUI.md): SwiftUI, UIKit, and + AppKit. +- [`ZeroTesting`](Sources/ZeroTesting/ZeroTesting.docc/ZeroTesting.md): the + drift harness and test doubles. + +[`DESIGN.md`](DESIGN.md) explains how the port is built and tested, and +[`BENCHMARKS.md`](BENCHMARKS.md) compares it with the JS client. + +## Compatibility + +| Zero for Swift | Sync protocol | `zero-cache` | Conformance vectors from | +| -------------- | ------------- | ------------ | ------------------------ | +| 0.1 | 53 | 1.12+ | Zero 1.12.0 | + +## License + +Apache-2.0 diff --git a/swift/Sources/Zero/Client/Log.swift b/swift/Sources/Zero/Client/Log.swift index 462066747b..bc4c3ee610 100644 --- a/swift/Sources/Zero/Client/Log.swift +++ b/swift/Sources/Zero/Client/Log.swift @@ -25,8 +25,10 @@ public enum LogLevel: Int, Sendable, Comparable, CaseIterable, CustomStringConve /// Where the client's log messages go (`LogSink` in `@rocicorp/logger`). public protocol LogSink: Sendable { - /// - Parameter context: Which client logged the message, such as - /// `clientID`. + /// - Parameters: + /// - level: How severe the message is. + /// - message: The message, without the context. + /// - context: Which client logged the message, such as `clientID`. func log(level: LogLevel, message: String, context: [String: String]) } diff --git a/swift/Sources/Zero/Client/ZeroClient.swift b/swift/Sources/Zero/Client/ZeroClient.swift index e77cc3dce5..765a86f2a7 100644 --- a/swift/Sources/Zero/Client/ZeroClient.swift +++ b/swift/Sources/Zero/Client/ZeroClient.swift @@ -26,8 +26,12 @@ public final class Zero { #endif /// - Parameters: + /// - schema: The generated schema, such as `.app`. + /// - context: What queries and mutators are given besides their + /// arguments, such as the logged-in user's auth data. /// - mutators: Every mutator the client may run. Pending mutations are /// replayed by name, so each must be registered. + /// - options: Where to sync from, and how. /// - scheduler: The clock and timers. With the system clock, the client /// also follows the app's lifecycle and the network; tests pass a /// manual one. diff --git a/swift/Sources/Zero/Zero.docc/GettingStarted.md b/swift/Sources/Zero/Zero.docc/GettingStarted.md new file mode 100644 index 0000000000..ce4f5c354f --- /dev/null +++ b/swift/Sources/Zero/Zero.docc/GettingStarted.md @@ -0,0 +1,135 @@ +# Getting started + +Generate your schema, port the queries and mutators your app uses, and +create a client. + +## Add the package + +The package is published from this repository's `swift/` directory to +`github.com/wevm/zero-swift`: + +```swift +.package(url: "https://github.com/wevm/zero-swift", from: "0.1.0"), +``` + +Depend on `Zero`, and on `ZeroUI` for SwiftUI, UIKit, and AppKit. It needs +Swift 6.2 and iOS 26 or macOS 26; the core also builds on Linux. + +## Generate the schema + +Your TypeScript schema stays the source of truth. The `zero-swift` CLI +generates Swift from it: a ``ZeroSchema``, a row struct for each table, and +a `zql` builder with a ``Query`` for each table. + +```json +{ + "schema": "../web/shared/schema.ts", + "output": "Sources/App/Schema.swift", + "schemaName": "app", + "tables": { + "issue": {"columns": {"shortID": {"type": "int"}}} + } +} +``` + +```sh +npx zero-swift codegen # reads zero-swift.json +npx zero-swift codegen --check # in CI: fails when Schema.swift is stale +``` + +`number()` columns are `Double`; give a column the type `int` to make it an +`Int`, which must then hold integers. `json()` columns get a generated `Codable` type when `T` is a +plain object or literal union. + +## Port your queries and mutators + +Write the queries and mutators your app runs in Swift, named as in your +TypeScript registries. The server runs the TypeScript ones; the client runs +the Swift ones to show results before the server answers. + +```swift +enum IssueQueries { + static let open = defineQuery("issues.open") { (_: NoArgs, auth: AuthData?) in + zql.issue.where(\.open, true).orderBy(\.modified, .desc).limit(50) + } + static let byID = defineQuery("issues.byID") { (id: String, auth: AuthData?) in + zql.issue.where(\.id, id).related(\.comments).one() + } +} + +enum IssueMutators { + struct Create: Codable, Sendable { + var id: String + var title: String + var created: Double + } + + static let create = defineMutator("issue.create") { (tx, args: Create, auth: AuthData?) in + guard let auth else { throw ApplicationError("Not logged in") } + try tx.mutate.issue.insert( + Issue(id: args.id, title: args.title, creatorID: auth.sub, created: args.created)) + } +} +``` + +Mutators are plain `throws` functions: they read and write through the +``Transaction`` synchronously. Pass IDs and timestamps in as arguments +rather than making them up inside, so the client and the server write the +same rows. + +The drift harness checks the ports: `zero-swift drift record` runs your +TypeScript queries and mutators on sample arguments and writes what they +did, and `Drift.expectNoDrift` in `ZeroTesting` fails a test where the Swift +ones do something else, or where a query or mutator exists on one side only. + +## Create a client + +```swift +var options = ZeroOptions(cacheURL: "https://zero.example.com", userID: auth?.sub, auth: token) +options.mutateURL = "https://api.example.com/mutate" // or zero-cache's configured URL +options.queryURL = "https://api.example.com/query" + +let zero = try Zero( + schema: .app, context: auth, mutators: [IssueMutators.create], options: options) +``` + +Register every mutator the app may run: pending mutations are stored by +name and run again when the server's state arrives, including after a +relaunch. The client keeps its replica in SQLite (``ZeroStorage``) and +connects with `URLSession`. + +## Read rows + +``LiveQuery`` is an observable object whose `data` stays current: + +```swift +let open = try zero.liveQuery(IssueQueries.open()) +open.data // [Issue] +open.resultType // .unknown until the server has confirmed the rows, then .complete +``` + +In SwiftUI, `@ZeroQuery` from `ZeroUI` does the same for a view. For UIKit +and AppKit, `DiffableAdapter` feeds a diffable data source. + +`zero.materialize(_:ttl:)` returns the untyped ``MaterializedView``, whose +``ViewSnapshot``s say which rows were inserted, removed, or changed. +`zero.run(_:type:ttl:)` reads rows once, and `zero.preload(_:ttl:)` syncs +rows without a view. + +## Change rows + +```swift +let result = zero.mutate(IssueMutators.create(.init(id: newID(), title: "Crash", created: now))) +try await result.client.value // the optimistic run +try await result.server.value // the server's verdict +``` + +The client applies the mutator at once; views update before `mutate` +returns. The server runs its own mutator; when its result arrives, the +client replaces its optimistic changes with the server's. + +## Follow the connection + +``Connection`` is observable. Its ``ConnectionState`` is `.needsAuth` when +the server rejects the token: get a new one and call +``Connection/connect(auth:)``. diff --git a/swift/Sources/Zero/Zero.docc/JSParity.md b/swift/Sources/Zero/Zero.docc/JSParity.md new file mode 100644 index 0000000000..98091018ff --- /dev/null +++ b/swift/Sources/Zero/Zero.docc/JSParity.md @@ -0,0 +1,115 @@ +# Coming from the JS client + +What each part of `@rocicorp/zero` is called in Swift, and what isn't +ported. + +## Overview + +The Swift client behaves as the JS client does. Conformance vectors +generated from the JS client check it, and so do end-to-end tests against +`zero-cache`. Names follow JS where Swift allows. Swift differs in three +ways: queries and mutators are written per app instead of shared from +TypeScript, everything runs synchronously on the main actor, and some +web-only options have no counterpart. + +## Options + +`ZeroOptions` keeps the JS option names. `schema`, `mutators`, and `context` +are arguments of +``Zero/init(schema:context:mutators:options:scheduler:socketFactory:)``. + +| JS option | Swift | +| ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `cacheURL` (`server`, deprecated) | `cacheURL` | +| `auth`, `userID`, `storageKey` | the same | +| `logLevel`, `logSink` | `logLevel`, `logSink` (a ``LogSink``) | +| `mutateURL`, `mutateHeaders` | the same | +| `queryURL` (`getQueriesURL`, deprecated), `queryHeaders` | `queryURL`, `queryHeaders` | +| `onUpdateNeeded` | `onUpdateNeeded`, and ``Connection/updateNeeded`` | +| `onClientStateNotFound`, `onStorageFailure` | the same | +| `disconnectTimeoutMs`, `pingTimeoutMs`, `maxHeaderLength`, `queryChangeThrottleMs` | the same | +| `kvStore` (`'idb'` or `'mem'`) | `storage`: `.sqlite()` or `.memory` | +| `hiddenTabDisconnectDelay` | none: the client pushes pending mutations and disconnects when the app goes to the background, and reconnects when it comes back | +| `onOnlineChange` (deprecated) | ``Connection/subscribe(_:)`` | +| `getTraceparent`, `slowMaterializeThreshold`, `batchViewUpdates`, `maxRecentQueries` (deprecated) | not ported | + +## The client + +| JS | Swift | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `new Zero(options)` | `try Zero(schema:context:mutators:options:)` | +| `zero.clientID`, `zero.userID`, `zero.schema`, `zero.context`, `zero.closed` | the same | +| `await zero.clientGroupID` | `zero.clientGroupID`, known at once | +| `zero.query.issue`, `zql.issue` | the generated `zql.issue` | +| `zero.materialize(query)` | `zero.materialize(_:ttl:)` for a ``MaterializedView``, `zero.liveQuery(_:ttl:)` for a typed ``LiveQuery`` | +| `zero.run(query, {type})` | `try await zero.run(_:type:ttl:)` | +| `zero.preload(query)` | `zero.preload(_:ttl:)`, a ``Preload`` with `cleanup`, `cached`, and `complete` | +| `zero.mutate(request)` | `zero.mutate(_:)`, a ``MutatorResult`` whose `client` and `server` are ``Deferred`` outcomes | +| `zero.connection.state`, `.subscribe`, `.connect({auth})` | ``Connection/state``, ``Connection/subscribe(_:)``, ``Connection/connect(auth:)`` | +| `zero.close()` | `zero.close()` | +| `zero.mutate.issue.insert(...)` (CRUD mutators) | not ported: write a mutator | +| `zero.mutateBatch`, `zero.online`, `zero.onOnline`, `zero.inspector`, `zero.delete()`, `dropAllDatabases`, `zero.server`, `zero.storageKey`, `zero.idbName`, `zero.kvStore`, `zero.schemaVersion` | not ported | +| `zero.version` | ``protocolVersion``, the sync protocol version | + +When the server no longer has the client's state, JS asks the app to +reload. Swift replaces the client inside the same `Zero` instead: views, +live queries, and preloads move to the new client and keep their identity. + +## Queries + +| JS | Swift | +| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| `defineQueries({issues: {open: defineQuery(({args, ctx}) => ...)}})` | `defineQuery("issues.open") { (args: Args, ctx: Context) in ... }`, named by its registry path | +| `.where('title', 'LIKE', '%x%')` | `.where(\.title, .like, "%x%")` | +| `.where('open', true)` | `.where(\.open, true)` | +| `.where(({cmp, and, or, not, exists}) => ...)` | `.where { $0.open == true && $0.exists(\.labels) }` (``RowExpression``) | +| `.whereExists('labels', q => ...)` | `.whereExists(\.labels) { $0.where(...) }` | +| `.related('comments', q => q.limit(10))` | `.related(\.comments) { $0.limit(10) }` | +| `.orderBy('created', 'desc')` | `.orderBy(\.created, .desc)` | +| `.limit(n)`, `.start(row)`, `.one()` | the same | +| `escapeLike(text)` | ``escapeLike(_:)`` | + +Columns and relationships are key paths of the generated row types. Rows +are structs; a relationship the query didn't ask for with `related` is nil. + +Comparing with nil differs by form. The two-argument +`where(\.ownerID, nil)` uses `IS`, since `= NULL` matches nothing. The +three-argument form compares as JS compares `undefined`, so +`where(\.ownerID, .eq, nil)` matches no row. + +## Mutators + +| JS | Swift | +| ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `defineMutators({issue: {create: defineMutator(async ({tx, args, ctx}) => ...)}})` | `defineMutator("issue.create") { (tx, args: Args, ctx: Context) in ... }`, a plain `throws` function | +| `await tx.mutate.issue.insert(row)` | `try tx.mutate.issue.insert(row)` | +| `await tx.run(query)` | `try tx.run(query)` | +| `tx.location`, `tx.reason`, `tx.clientID`, `tx.mutationID` | the same | +| `throw new ApplicationError(message, {details})` | `throw ApplicationError(message, details:)` | + +## React and SolidJS + +`ZeroUI` covers the same ground for SwiftUI, UIKit, and AppKit. + +| JS | Swift | +| ----------------------------------------- | ----------------------------------------------------------------------- | +| ``, `useZero()` | `.zero(zero)` on a view, `@Environment(\.zero)` | +| `useQuery(query)` | `@ZeroQuery(query) var rows`, with `$rows.resultType` and `$rows.error` | +| `useConnectionState()` | `zero.connection.state`, which is observable | +| `useSuspenseQuery`, `useZeroOnline` | not ported | +| none | `DiffableAdapter`, for diffable data sources | + +## Behavior to know + +- **The main actor.** Queries, views, and mutators run synchronously on the + main actor. Views and live queries are up to date when `mutate` returns. + Only the network and storage are asynchronous. +- **Nulls in columns that can't be null.** When the server sends null for + a column the schema says isn't optional, a `String`, `Double`, `Int`, or + `Bool` property reads as `""`, `0`, or `false`. JS hands the null + through. An enum or JSON column still fails to read. +- **JSON objects.** `JSONValue` objects are dictionaries, so JSON columns + don't keep their key order. +- **Storage.** The replica is a SQLite database per user and URL, not + IndexedDB. One process uses it at a time; there are no tabs to share it + with. diff --git a/swift/Sources/Zero/Zero.docc/Zero.md b/swift/Sources/Zero/Zero.docc/Zero.md new file mode 100644 index 0000000000..51aaa9cba5 --- /dev/null +++ b/swift/Sources/Zero/Zero.docc/Zero.md @@ -0,0 +1,158 @@ +# ``Zero`` + +A native client for Zero: query a local replica of your server's data that +stays in sync, and change it optimistically. + +## Overview + +Zero keeps the rows your queries need on the device. Queries run against the +local replica and their results stay current as the server's data changes; +mutators change the replica at once and run again on your server, whose +result wins. This package is the Swift port of the JS client +(`@rocicorp/zero`), and behaves the same: it is tested against vectors +generated from the JS client, and against a real `zero-cache`. + +Your TypeScript schema stays the source of truth. `zero-swift codegen` +generates the Swift row types and schema from it; you write the queries and +mutators your app uses in Swift, under the same names as in TypeScript, and +the drift harness (`ZeroTesting`) checks that they do what the TypeScript +ones do. + +```swift +let zero = try Zero( + schema: .app, context: auth, mutators: [IssueMutators.create], + options: ZeroOptions(cacheURL: "https://zero.example.com", userID: auth?.sub, auth: token)) + +let issues = try zero.liveQuery(IssueQueries.open()) // observable, stays current +zero.mutate(IssueMutators.create(.init(id: newID(), title: "Crash on launch"))) +``` + +The client runs on the main actor: queries, views, and mutators are +synchronous, and only the network and storage are asynchronous. On Apple +platforms it connects with `URLSession`, persists to SQLite, and follows the +app's lifecycle and the network on its own; `ZeroUI` adds SwiftUI, UIKit, +and AppKit integration. + +## Topics + +### Essentials + +- +- +- ``Zero/Zero`` +- ``ZeroOptions`` +- ``AnyZero`` + +### Defining queries and mutators + +Define them with `defineQuery(_:_:)` and `defineMutator(_:_:)`, named as in +your TypeScript registries. + +- ``QueryDefinition`` +- ``NoArgsQueryDefinition`` +- ``QueryRequest`` +- ``NamedQuery`` +- ``MutatorDefinition`` +- ``NoArgsMutatorDefinition`` +- ``MutateRequest`` +- ``Mutator`` +- ``NoArgs`` +- ``Transaction`` +- ``TransactionMutate`` +- ``TableMutator`` +- ``ApplicationError`` + +### Building queries + +- ``Query`` +- ``RowExpression`` +- ``Column`` +- ``Condition`` +- ``StaticParameter`` +- ``escapeLike(_:)`` +- ``AnyQuery`` +- ``ExpressionBuilder`` +- ``AST`` +- ``CustomQueryID`` + +### Reading rows + +- ``LiveQuery`` +- ``QueryResult`` +- ``MaterializedView`` +- ``ViewSnapshot`` +- ``ViewValue`` +- ``ViewEntry`` +- ``RowID`` +- ``ResultType`` +- ``QueryError`` +- ``ErroredQuery`` +- ``RunType`` +- ``Preload`` +- ``TTL`` + +### Mutation results + +- ``MutatorResult`` +- ``MutationOutcome`` +- ``Deferred`` + +### The connection + +- ``Connection`` +- ``ConnectionState`` +- ``ConnectionStatus`` +- ``NeedsAuthReason`` +- ``UpdateNeededReason`` +- ``protocolVersion`` + +### The schema and generated code + +These are what `zero-swift codegen` writes; apps rarely use them directly. + +- ``ZeroSchema`` +- ``TableSchema`` +- ``ColumnSchema`` +- ``ValueType`` +- ``RelationshipHop`` +- ``Relationship`` +- ``Cardinality`` +- ``ZeroRow`` +- ``ZeroValue`` +- ``ZeroEnum`` +- ``Nullable`` +- ``Indirect`` +- ``KeyPathNames`` +- ``ZeroValueError`` +- ``ZeroObjectReader`` +- ``ZeroObjectWriter`` + +### JSON + +- ``JSONValue`` +- ``Row`` +- ``JSONValueEncoder`` +- ``JSONValueDecoder`` + +### Storage and logging + +- ``ZeroStorage`` +- ``StorageFailure`` +- ``LogLevel`` +- ``LogSink`` +- ``DefaultLogSink`` + +### Transports and scheduling + +- ``Transport`` +- ``Socket`` +- ``SocketMessage`` +- ``SocketClosed`` +- ``TransportSocketFactory`` +- ``SocketFactory`` +- ``SocketConnection`` +- ``SocketEvents`` +- ``platformSocketFactory()`` +- ``Scheduler`` +- ``ScheduledTimer`` +- ``SystemScheduler`` diff --git a/swift/Sources/ZeroTesting/Drift.swift b/swift/Sources/ZeroTesting/Drift.swift index dfd4b8b967..24fe38c0a2 100644 --- a/swift/Sources/ZeroTesting/Drift.swift +++ b/swift/Sources/ZeroTesting/Drift.swift @@ -21,6 +21,7 @@ public enum Drift { /// - Parameters: /// - vectors: The contents of the `drift-vectors.json` that /// `zero-swift drift record` wrote. + /// - schema: The app's schema, which the mutator samples' rows are in. /// - queries: Every named query of the app. /// - mutators: Every mutator of the app. public static func failures( diff --git a/swift/Sources/ZeroTesting/ZeroTesting.docc/ZeroTesting.md b/swift/Sources/ZeroTesting/ZeroTesting.docc/ZeroTesting.md new file mode 100644 index 0000000000..8c6c504738 --- /dev/null +++ b/swift/Sources/ZeroTesting/ZeroTesting.docc/ZeroTesting.md @@ -0,0 +1,65 @@ +# ``ZeroTesting`` + +Check your Swift queries and mutators against the TypeScript ones, and +test code that uses a client without a server. + +## Overview + +### The drift harness + +You write the Swift queries and mutators by hand, so they can drift from +the TypeScript ones the server runs. `zero-swift drift record` runs the +TypeScript definitions on sample arguments and contexts and writes a +vectors file. `Drift` checks the Swift definitions against those vectors: + +```swift +@Test @MainActor func matchesTheTSQueriesAndMutators() throws { + let vectors = try String(contentsOf: vectorsURL, encoding: .utf8) + Drift.expectNoDrift( + vectors: vectors, schema: .app, queries: Queries.all, mutators: Mutators.all) +} +``` + +The test fails when: + +- a name is in one registry but not the other; +- a sample's arguments don't round-trip through the Swift argument type; +- a query builds a different AST; +- a mutator, run on the sample's rows, changes different rows or ends + differently. + +### A client without a server + +`ManualScheduler` is a clock that moves only when the test advances it. +`MockSocketFactory` hands the client sockets that the test drives as the +server: + +```swift +let scheduler = ManualScheduler() +let sockets = MockSocketFactory() +let zero = try Zero( + schema: .app, context: auth, options: ZeroOptions(cacheURL: "http://zero.test"), + scheduler: scheduler, socketFactory: sockets) + +scheduler.runDueTimers() // the client opens its socket +let socket = try #require(sockets.sockets.first) +socket.receive(text: #"["connected",{"wsid":"w1"}]"#) +#expect(zero.connection.state == .connected) +``` + +`RecordingLogSink` keeps the client's log messages for a test to check. + +`ZeroTesting` re-exports `Zero`. + +## Topics + +### Drift + +- ``Drift`` + +### Test doubles + +- ``ManualScheduler`` +- ``MockSocketFactory`` +- ``MockSocket`` +- ``RecordingLogSink`` diff --git a/swift/Sources/ZeroUI/ZeroUI.docc/ZeroUI.md b/swift/Sources/ZeroUI/ZeroUI.docc/ZeroUI.md new file mode 100644 index 0000000000..068916fb24 --- /dev/null +++ b/swift/Sources/ZeroUI/ZeroUI.docc/ZeroUI.md @@ -0,0 +1,56 @@ +# ``ZeroUI`` + +Zero in SwiftUI, UIKit, and AppKit. + +## Overview + +Put the client in the environment, and read queries with `@ZeroQuery`. +A view updates when its query's rows change: + +```swift +@main +struct IssuesApp: App { + @State private var zero = try! Zero( + schema: .app, context: auth, mutators: [IssueMutators.create], options: options) + + var body: some Scene { + WindowGroup { IssueList().zero(zero) } + } +} + +struct IssueList: View { + @ZeroQuery(IssueQueries.open()) var issues + + var body: some View { + List(issues) { Text($0.title) } + .overlay { if $issues.resultType == .unknown { ProgressView() } } + } +} +``` + +The `zero-react` hooks map to it as follows: + +| zero-react | ZeroUI | +| ---------------------------- | ---------------------------- | +| `` | `.zero(zero)` | +| `useZero()` | `@Environment(\.zero)` | +| `useQuery(query)` | `@ZeroQuery(query) var rows` | +| `useConnectionState()` | `zero.connection.state` | + +In UIKit and AppKit, `DiffableAdapter` feeds a diffable data source from a +`MaterializedView`. Each row is an item, identified by its primary key, and +its cell is reconfigured when the row changes. `LiveQuery` in `Zero` is the +observable to use elsewhere. + +`ZeroUI` re-exports `Zero`. + +## Topics + +### SwiftUI + +- ``ZeroQuery`` +- ``QueryState`` + +### UIKit and AppKit + +- ``DiffableAdapter`` diff --git a/swift/scripts/docs.sh b/swift/scripts/docs.sh new file mode 100755 index 0000000000..fcf0040b18 --- /dev/null +++ b/swift/scripts/docs.sh @@ -0,0 +1,35 @@ +#!/bin/sh +# Builds the DocC catalogs, failing on any warning (a broken symbol link, an +# undocumented parameter). ZeroUI's symbols exist only on Apple platforms, so +# Linux builds the other two. +# +# scripts/docs.sh [...] default: Zero ZeroUI ZeroTesting +# +# Writes .build/docs/.doccarchive. The archives are for checking and +# for Xcode; they aren't transformed for static hosting, which fails in the +# Linux toolchain's docc. +set -eu +cd "$(dirname "$0")/.." + +modules=${*:-Zero ZeroUI ZeroTesting} +graphs=$(swift package dump-symbol-graph --minimum-access-level public | + sed -n 's/^Files written to //p') +docc=$(command -v docc || xcrun --find docc) + +for m in $modules; do + dir=.build/docs/symbolgraph-$m + rm -rf "$dir" ".build/docs/$m.doccarchive" + mkdir -p "$dir" + # The module's own symbols, and its extensions of other modules' types. + cp "$graphs/$m.symbols.json" "$dir/" + for extension in "$graphs/$m@"*.symbols.json; do + [ -e "$extension" ] && cp "$extension" "$dir/" + done + "$docc" convert "Sources/$m/$m.docc" \ + --additional-symbol-graph-dir "$dir" \ + --fallback-display-name "$m" \ + --fallback-bundle-identifier "dev.rocicorp.$m" \ + --output-path ".build/docs/$m.doccarchive" \ + --no-transform-for-static-hosting \ + --warnings-as-errors +done From 6ae3c049f8f19b84a8b2de48d484ec6ec47fd73b Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 19 Oct 2026 17:08:43 +0000 Subject: [PATCH 032/104] 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 --- .github/workflows/swift.yml | 3 ++ packages/zero-swift-cli/README.md | 61 ++++++++++++++++++++++++++++ packages/zero-swift-cli/package.json | 21 +++++++++- packages/zero-swift-cli/src/bin.ts | 4 ++ packages/zero-swift-cli/src/cli.ts | 7 +--- pnpm-lock.yaml | 6 +++ swift/DESIGN.md | 7 +++- swift/README.md | 4 +- 8 files changed, 101 insertions(+), 12 deletions(-) create mode 100644 packages/zero-swift-cli/README.md create mode 100644 packages/zero-swift-cli/src/bin.ts diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 0e10aa41ec..f25bd4e635 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -47,6 +47,9 @@ jobs: run: pnpm --filter zero-swift run test - name: Check types run: pnpm --filter zero-conformance --filter zero-swift run check-types + # The published CLI is a bundle; this keeps it bundling. + - name: Build the zero-swift CLI + run: pnpm --filter zero-swift run build - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: generated-vectors diff --git a/packages/zero-swift-cli/README.md b/packages/zero-swift-cli/README.md new file mode 100644 index 0000000000..658ac901ab --- /dev/null +++ b/packages/zero-swift-cli/README.md @@ -0,0 +1,61 @@ +# zero-swift + +The command-line tool for [Zero for Swift](https://github.com/wevm/zero-swift). +Your Zero app's TypeScript schema, queries, and mutators stay the source of +truth, and this tool generates what the Swift client needs from them. + +```sh +npm install --save-dev zero-swift +``` + +It needs Node 22.18 or later, which loads TypeScript files directly. It also +needs the app's own `@rocicorp/zero`, 1.11 or later. + +## `zero-swift codegen` + +Generates Swift from the TypeScript schema. For each table it writes a row +struct, a `ZeroSchema`, and a `zql` builder with a `Query`. + +```sh +npx zero-swift codegen # reads zero-swift.json +npx zero-swift codegen --check # fails when the output is out of date +``` + +```json +{ + "schema": "../web/shared/schema.ts", + "output": "Sources/App/Schema.swift", + "schemaName": "app", + "tables": { + "issue": {"columns": {"shortID": {"type": "int"}}} + } +} +``` + +`number()` columns are `Double`. Set a column's type to `int` to make it an +`Int`; it must then hold integers. For a `json()` column whose `T` is a +plain object or a literal union, the tool generates a `Codable` type. + +`--schema`, `--out`, and `--config` override the config file. + +## `zero-swift drift record` + +You write the Swift queries and mutators by hand, and the drift harness +checks them against the TypeScript ones. This command runs the TypeScript +definitions on samples and records what they did. `Drift.expectNoDrift` in +the Swift `ZeroTesting` library then fails a test wherever the Swift ones +differ. + +```sh +npx zero-swift drift record --samples drift-samples.ts --out Tests/AppTests/drift-vectors.json +``` + +The samples module's default export holds: + +- `schema` +- the `queries` and `mutators` registries +- `querySamples`, each a name with optional `args` and `context` +- `mutatorSamples`, each also with optional `seed` rows + +Record again whenever either side changes. `--check` fails when the recorded +file is out of date. diff --git a/packages/zero-swift-cli/package.json b/packages/zero-swift-cli/package.json index b3f9386190..0548e30760 100644 --- a/packages/zero-swift-cli/package.json +++ b/packages/zero-swift-cli/package.json @@ -1,14 +1,23 @@ { "name": "zero-swift", - "version": "0.0.0", + "version": "0.1.0", "private": true, "description": "Generates the Swift row types and schema of a Zero app for the Zero Swift client.", "license": "Apache-2.0", "bin": { - "zero-swift": "./src/cli.ts" + "zero-swift": "./src/bin.ts" }, + "files": [ + "dist" + ], "type": "module", + "publishConfig": { + "bin": { + "zero-swift": "./dist/bin.js" + } + }, "scripts": { + "build": "esbuild src/bin.ts --bundle --platform=node --format=esm --target=node22 --splitting --outdir=dist --external:@rocicorp/zero --external:typescript", "test": "vitest run", "generate": "UPDATE_GENERATED=1 vitest run", "check-types": "tsc", @@ -20,11 +29,19 @@ "typescript": "6.0.3" }, "devDependencies": { + "@rocicorp/zero": "workspace:*", "@types/node": "^22.10.5", + "esbuild": "0.27.7", "oxfmt": "^0.65.0", "vitest": "^5.0.0", "zero-schema": "workspace:*", "zero-types": "workspace:*", "zql": "workspace:*" + }, + "peerDependencies": { + "@rocicorp/zero": ">=1.11.0" + }, + "engines": { + "node": ">=22.18" } } diff --git a/packages/zero-swift-cli/src/bin.ts b/packages/zero-swift-cli/src/bin.ts new file mode 100644 index 0000000000..56293eb4d7 --- /dev/null +++ b/packages/zero-swift-cli/src/bin.ts @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import {main} from './cli.ts'; + +process.exitCode = await main(process.argv.slice(2)); diff --git a/packages/zero-swift-cli/src/cli.ts b/packages/zero-swift-cli/src/cli.ts index 62b87e249d..3487d6bc3e 100644 --- a/packages/zero-swift-cli/src/cli.ts +++ b/packages/zero-swift-cli/src/cli.ts @@ -1,4 +1,3 @@ -#!/usr/bin/env node /** * `zero-swift codegen`: generates the Swift row types, schema, and query * builder of a Zero app from its TypeScript schema. @@ -49,7 +48,7 @@ export async function codegenFile(config: Config, cwd: string) { ); } -async function main(argv: string[]): Promise { +export async function main(argv: string[]): Promise { const {values, positionals} = parseArgs({ args: argv, allowPositionals: true, @@ -120,7 +119,3 @@ function writeOutput( process.stdout.write(`Wrote ${relative(cwd, output)}\n`); return 0; } - -if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { - process.exitCode = await main(process.argv.slice(2)); -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index abe973901a..bae767193b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1596,9 +1596,15 @@ importers: specifier: 6.0.3 version: 6.0.3 devDependencies: + '@rocicorp/zero': + specifier: workspace:* + version: link:../zero '@types/node': specifier: ^22.10.5 version: 22.19.19 + esbuild: + specifier: 0.27.7 + version: 0.27.7 oxfmt: specifier: ^0.65.0 version: 0.65.0 diff --git a/swift/DESIGN.md b/swift/DESIGN.md index ac14c17724..9228af69c3 100644 --- a/swift/DESIGN.md +++ b/swift/DESIGN.md @@ -84,8 +84,11 @@ apps/zbugs-swift/ dogfood app (iOS + macOS) A `package` type with `@inlinable` members must itself be `@usableFromInline`, or other modules silently get unspecialized calls. - The CLI ships as the unscoped npm package `zero-swift` (we don't own the - `@rocicorp` scope) with a peer dependency on `@rocicorp/zero`. It is - versioned with the Swift releases. + `@rocicorp` scope) with a peer dependency on `@rocicorp/zero` (1.11 or + later: `drift record` must run the app's own copy). It is versioned with + the Swift releases. `pnpm --filter zero-swift build` bundles it with + esbuild, inlining the workspace sources it imports. The package stays + `private` until the first publish is approved. - License: Apache-2.0, like this repository. - Docs: a DocC catalog per library product, with a getting-started article and a JS→Swift parity matrix (`Zero.docc/JSParity.md`). Oxfmt skips the diff --git a/swift/README.md b/swift/README.md index ee3e645f08..84f383a6e7 100644 --- a/swift/README.md +++ b/swift/README.md @@ -16,7 +16,7 @@ end-to-end tests against `zero-cache` and the zbugs app. - Swift 6.2 - iOS 26 or macOS 26. The `Zero` module also builds on Linux. -- A `zero-cache` that speaks sync protocol 53 (Zero 1.12 or later) +- A `zero-cache` that speaks sync protocol 53 (Zero 1.11 or later) ## Install @@ -89,7 +89,7 @@ Xcode with Product › Build Documentation. | Zero for Swift | Sync protocol | `zero-cache` | Conformance vectors from | | -------------- | ------------- | ------------ | ------------------------ | -| 0.1 | 53 | 1.12+ | Zero 1.12.0 | +| 0.1 | 53 | 1.11+ | Zero 1.12 (`main`) | ## License From 6b8e68684b3e481eaf84a95bfbc5010f3f038a88 Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 19 Oct 2026 17:12:38 +0000 Subject: [PATCH 033/104] 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 --- .github/workflows/zbugs-screenshots.yml | 96 +++++++++++++++++ .../App/UITests/ScreenshotTests.swift | 100 ++++++++++++++++++ apps/zbugs-swift/App/project.yml | 17 +++ apps/zbugs-swift/README.md | 13 +++ .../Sources/ZBugsUI/AppModel.swift | 21 +++- .../Sources/ZBugsUI/RootView.swift | 23 +++- swift/DESIGN.md | 3 + 7 files changed, 267 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/zbugs-screenshots.yml create mode 100644 apps/zbugs-swift/App/UITests/ScreenshotTests.swift diff --git a/.github/workflows/zbugs-screenshots.yml b/.github/workflows/zbugs-screenshots.yml new file mode 100644 index 0000000000..8275ae15f0 --- /dev/null +++ b/.github/workflows/zbugs-screenshots.yml @@ -0,0 +1,96 @@ +name: zbugs screenshots + +# Runs the Swift zbugs app in the iOS Simulator against zbugs' backend +# (apps/zbugs-swift/server/server.ts) and keeps a screenshot of each screen +# (apps/zbugs-swift/App/UITests) as the zbugs-screenshots artifact. +# Path-filtered to the app and the UI layer: macOS runners are slow to start. + +on: + push: + branches: [main, swift-client] + paths: + - 'apps/zbugs-swift/**' + - 'swift/Sources/ZeroUI/**' + - '.github/workflows/zbugs-screenshots.yml' + pull_request: + branches: [main] + paths: + - 'apps/zbugs-swift/**' + - 'swift/Sources/ZeroUI/**' + - '.github/workflows/zbugs-screenshots.yml' + workflow_dispatch: + +concurrency: + group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' + cancel-in-progress: true + +permissions: + contents: read + +jobs: + screenshots: + name: zbugs screenshots (iOS Simulator) + runs-on: macos-26 + timeout-minutes: 60 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + version: 11.11.0 + # zero-cache runs from source, which needs Node 24 (`using`). + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.x + package-manager-cache: false + - run: pnpm install --frozen-lockfile + - name: Start Postgres + run: | + brew install postgresql@17 + pg="$(brew --prefix postgresql@17)/bin" + echo "$pg" >> "$GITHUB_PATH" + "$pg/initdb" -D "$RUNNER_TEMP/pg" -U postgres --auth=trust + "$pg/pg_ctl" -D "$RUNNER_TEMP/pg" -l "$RUNNER_TEMP/pg.log" \ + -o "-c wal_level=logical -p 5432" start + until "$pg/pg_isready" -q -h 127.0.0.1; do sleep 1; done + # On the ports the app connects to by default. + - name: Start zbugs' backend + env: + ZBUGS_PG: postgresql://postgres@127.0.0.1:5432/postgres + run: | + node apps/zbugs-swift/server/server.ts > "$RUNNER_TEMP/zbugs.log" 2>&1 & + for _ in $(seq 1 300); do + grep -q '^ready ' "$RUNNER_TEMP/zbugs.log" && exit 0 + sleep 1 + done + cat "$RUNNER_TEMP/zbugs.log" + exit 1 + - name: Generate the Xcode project + working-directory: apps/zbugs-swift/App + run: brew install xcodegen && xcodegen generate + - name: Take screenshots + working-directory: apps/zbugs-swift/App + run: | + TEST_RUNNER_ZBUGS_TOKEN="$(grep '^token ' "$RUNNER_TEMP/zbugs.log" | cut -d' ' -f2)" + export TEST_RUNNER_ZBUGS_TOKEN + device="$(xcrun simctl list devices available -j | + 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")))')" + xcodebuild test -project ZBugs.xcodeproj -scheme ZBugsApp \ + -destination "id=$device" -resultBundlePath "$RUNNER_TEMP/screenshots.xcresult" \ + -skipPackagePluginValidation CODE_SIGNING_ALLOWED=NO + - name: Export screenshots + if: always() + run: | + xcrun xcresulttool export attachments \ + --path "$RUNNER_TEMP/screenshots.xcresult" --output-path "$RUNNER_TEMP/screenshots" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: always() + with: + name: zbugs-screenshots + path: ${{ runner.temp }}/screenshots + - name: Server logs + if: failure() + run: | + cat "$RUNNER_TEMP/zbugs.log" + cat "$RUNNER_TEMP/pg.log" || true diff --git a/apps/zbugs-swift/App/UITests/ScreenshotTests.swift b/apps/zbugs-swift/App/UITests/ScreenshotTests.swift new file mode 100644 index 0000000000..fdce97cb95 --- /dev/null +++ b/apps/zbugs-swift/App/UITests/ScreenshotTests.swift @@ -0,0 +1,100 @@ +import XCTest + +/// Drives the app against zbugs' backend (`server/server.ts`) and keeps a +/// screenshot of each screen, which CI uploads. `ZBUGS_TOKEN` is a token for +/// the seeded crew user; xcodebuild passes `TEST_RUNNER_ZBUGS_TOKEN` as it. +/// +/// A step whose screen doesn't appear records a failure and the test goes +/// on, so one run shows every screen it can reach. +final class ScreenshotTests: XCTestCase { + @MainActor + func testScreens() throws { + let token = try XCTUnwrap( + ProcessInfo.processInfo.environment["ZBUGS_TOKEN"], "Set TEST_RUNNER_ZBUGS_TOKEN") + let app = XCUIApplication() + app.launchEnvironment["ZBUGS_TOKEN"] = token + app.launch() + + // The issue list, once zero-cache has synced it. + let firstIssue = app.cells.firstMatch + guard expect(firstIssue, "an issue in the list", timeout: 120) else { + snapshot("1 Issues (empty)") + return + } + settle() + snapshot("1 Issues") + + // An issue with its comments. + firstIssue.tap() + if expect(app.buttons["Comment"], "the issue's comment button") { + settle() + snapshot("2 Issue") + } + + // A new issue. + if UIDevice.current.userInterfaceIdiom == .phone { + back(app) + } + let newIssue = app.buttons["New Issue"] + if expect(newIssue, "the New Issue button") { + newIssue.tap() + if expect(app.navigationBars["New Issue"], "the new issue sheet") { + settle() + snapshot("3 New Issue") + app.buttons["Cancel"].tap() + } + } + + // The filters, then the UIKit list. + if UIDevice.current.userInterfaceIdiom == .phone { + back(app) + } + let listPicker = app.buttons["List"] + if expect(listPicker, "the filters' List picker") { + settle() + snapshot("4 Filters") + listPicker.tap() + let uiKit = app.buttons["UIKit"] + if expect(uiKit, "the UIKit option") { + uiKit.tap() + } + if UIDevice.current.userInterfaceIdiom == .phone { + let issues = app.buttons["Issues"] + if expect(issues, "the Issues button") { issues.tap() } + } + if expect(app.tables.cells.firstMatch, "an issue in the UIKit list") { + settle() + snapshot("5 Issues (UIKit)") + } + } + } + + /// Waits for `element`, recording a failure when it doesn't appear. + @MainActor + private func expect(_ element: XCUIElement, _ what: String, timeout: Double = 20) -> Bool { + if element.waitForExistence(timeout: timeout) { return true } + snapshot("Missing \(what)") + XCTFail("No \(what)") + return false + } + + /// The navigation bar's back button. + @MainActor + private func back(_ app: XCUIApplication) { + let button = app.navigationBars.buttons.element(boundBy: 0) + if button.waitForExistence(timeout: 5) { button.tap() } + } + + /// Lets animations and the rows' relationships finish. + private func settle() { + Thread.sleep(forTimeInterval: 2) + } + + @MainActor + private func snapshot(_ name: String) { + let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/apps/zbugs-swift/App/project.yml b/apps/zbugs-swift/App/project.yml index 84d74ab7ea..c449f156dd 100644 --- a/apps/zbugs-swift/App/project.yml +++ b/apps/zbugs-swift/App/project.yml @@ -14,6 +14,8 @@ targets: ZBugsApp: type: application supportedDestinations: [iOS, macOS] + scheme: + testTargets: [ZBugsUITests] sources: - path: ZBugsApp.swift - path: Info.plist @@ -37,3 +39,18 @@ targets: TARGETED_DEVICE_FAMILY: '1,2' CODE_SIGN_STYLE: Automatic ENABLE_USER_SCRIPT_SANDBOXING: YES + # Screenshots of each screen against zbugs' backend (UITests/). + ZBugsUITests: + type: bundle.ui-testing + platform: iOS + sources: + - path: UITests + dependencies: + - target: ZBugsApp + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: dev.rocicorp.zbugs.uitests + SWIFT_VERSION: '6.0' + GENERATE_INFOPLIST_FILE: YES + TEST_TARGET_NAME: ZBugsApp + CODE_SIGN_STYLE: Automatic diff --git a/apps/zbugs-swift/README.md b/apps/zbugs-swift/README.md index a9ff51e1f3..233ef3d8bd 100644 --- a/apps/zbugs-swift/README.md +++ b/apps/zbugs-swift/README.md @@ -49,6 +49,19 @@ ZBUGS_E2E_URL=http://127.0.0.1:4848 ZBUGS_E2E_API=http://127.0.0.1:5173 \ ZBUGS_E2E_TOKEN= swift test --filter BackendTests ``` +`App/UITests` drives the app through each screen against the backend and +keeps a screenshot of each one. The `zbugs screenshots` workflow runs it in +the iOS Simulator and uploads the screenshots as an artifact. To run it +locally, generate the project and pass the backend's token: + +```sh +TEST_RUNNER_ZBUGS_TOKEN= xcodebuild test -project App/ZBugs.xcodeproj \ + -scheme ZBugsApp -destination 'platform=iOS Simulator,name=iPhone 17' +``` + +The app reads `ZBUGS_TOKEN`, `ZBUGS_CACHE_URL`, and `ZBUGS_API_URL` from its +environment as well; they override the saved login and servers. + ## Keeping the port current - `Sources/ZBugs/Schema.swift` is generated from `apps/zbugs/shared/schema.ts` diff --git a/apps/zbugs-swift/Sources/ZBugsUI/AppModel.swift b/apps/zbugs-swift/Sources/ZBugsUI/AppModel.swift index d80cd11017..210c1f4293 100644 --- a/apps/zbugs-swift/Sources/ZBugsUI/AppModel.swift +++ b/apps/zbugs-swift/Sources/ZBugsUI/AppModel.swift @@ -24,13 +24,24 @@ public nonisolated static let projectName = zeroProjectName - public init(defaults: UserDefaults = .standard) { + /// - Parameter environment: `ZBUGS_TOKEN`, `ZBUGS_CACHE_URL`, and + /// `ZBUGS_API_URL` override the saved token and servers without saving + /// them, for UI tests. + public init( + defaults: UserDefaults = .standard, + environment: [String: String] = ProcessInfo.processInfo.environment + ) { self.defaults = defaults - let cacheURL = defaults.string(forKey: "cacheURL") ?? "http://localhost:4848" - let apiURL = defaults.string(forKey: "apiURL") ?? "http://localhost:5173" + let cacheURL = + environment["ZBUGS_CACHE_URL"] ?? defaults.string(forKey: "cacheURL") + ?? "http://localhost:4848" + let apiURL = + environment["ZBUGS_API_URL"] ?? defaults.string(forKey: "apiURL") + ?? "http://localhost:5173" // An expired token logs out. - let auth = Keychain.token.flatMap { AuthData(jwt: $0, now: Date().timeIntervalSince1970) } - let token = auth == nil ? nil : Keychain.token + let saved = environment["ZBUGS_TOKEN"] ?? Keychain.token + let auth = saved.flatMap { AuthData(jwt: $0, now: Date().timeIntervalSince1970) } + let token = auth == nil ? nil : saved self.cacheURL = cacheURL self.apiURL = apiURL self.token = token diff --git a/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift b/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift index 00442e51ef..e61f5da3d0 100644 --- a/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift +++ b/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift @@ -22,6 +22,12 @@ @State private var selection: String? @State private var listLayer = ListLayer.swiftUI @State private var sheet: Sheet? + /// The column an iPhone shows: the list, with the filters before it and + /// the selected issue after. + @State private var compactColumn = NavigationSplitViewColumn.content + #if os(iOS) + @Environment(\.horizontalSizeClass) private var sizeClass + #endif enum Sheet: String, Identifiable { case newIssue, login, servers @@ -31,9 +37,19 @@ public init() {} public var body: some View { - NavigationSplitView { + NavigationSplitView(preferredCompactColumn: $compactColumn) { Sidebar(filter: $filter, listLayer: $listLayer) .navigationTitle("Zero Bugs") + #if os(iOS) + .toolbar { + // The columns don't report the window's size class. + if sizeClass == .compact { + ToolbarItem(placement: .confirmationAction) { + Button("Issues") { compactColumn = .content } + } + } + } + #endif } content: { list .navigationTitle(filter.status.title + " Issues") @@ -46,6 +62,11 @@ ContentUnavailableView("No Issue Selected", systemImage: "list.bullet.rectangle") } } + // The UIKit and AppKit lists set the selection outside a List, which + // a collapsed split view doesn't follow on its own. + .onChange(of: selection) { _, selection in + if selection != nil { compactColumn = .detail } + } .zero(model.zero) .environment(model) .sheet(item: $sheet) { sheet in diff --git a/swift/DESIGN.md b/swift/DESIGN.md index 9228af69c3..5c3263cc37 100644 --- a/swift/DESIGN.md +++ b/swift/DESIGN.md @@ -498,6 +498,9 @@ the Zero convention of passing IDs and timestamps as arguments. `apps/zbugs-swift/`.** The Apple layer (URLSession transport against a local mock WebSocket server, SwiftUI, UIKit, and AppKit helpers), iOS simulator tests, all three DocC catalogs, and the zbugs app built for iOS and macOS. +- **zbugs screenshots, path-filtered to the app and `ZeroUI`.** The app in the + iOS Simulator against zbugs' backend, through each screen, with the + screenshots kept as an artifact. - **Benchmarks.** Ports of the key JS benchmarks (`zero-client/src/client/zero.bench.ts`, the shared B-tree and hash benchmarks) plus Swift benchmarks for query hydration over N rows, poke From 53505657a502da5c445e123fd1914104a97f1e54 Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 19 Oct 2026 17:34:21 +0000 Subject: [PATCH 034/104] 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 --- apps/zbugs-swift/App/ZBugsApp.swift | 5 ++++- apps/zbugs-swift/Sources/ZBugsUI/RootView.swift | 10 ++++++++-- swift/Sources/ZeroUI/ZeroUI.docc/ZeroUI.md | 5 +++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/zbugs-swift/App/ZBugsApp.swift b/apps/zbugs-swift/App/ZBugsApp.swift index 2ee22e6e56..903a7d2790 100644 --- a/apps/zbugs-swift/App/ZBugsApp.swift +++ b/apps/zbugs-swift/App/ZBugsApp.swift @@ -3,9 +3,12 @@ import ZBugsUI @main struct ZBugsApp: App { + // Once: each model starts a client. + @State private var model = AppModel() + var body: some Scene { WindowGroup { - RootView() + RootView(model: model) } } } diff --git a/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift b/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift index e61f5da3d0..e06938b492 100644 --- a/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift +++ b/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift @@ -16,8 +16,12 @@ } /// The app: filters, the issue list, and the selected issue. + /// + /// The app makes the model once and passes it in: SwiftUI runs a view's + /// `@State` initializer each time it re-creates the view, and each model + /// starts a client. public struct RootView: View { - @State private var model = AppModel() + let model: AppModel @State private var filter = IssueFilter() @State private var selection: String? @State private var listLayer = ListLayer.swiftUI @@ -34,7 +38,9 @@ var id: Self { self } } - public init() {} + public init(model: AppModel) { + self.model = model + } public var body: some View { NavigationSplitView(preferredCompactColumn: $compactColumn) { diff --git a/swift/Sources/ZeroUI/ZeroUI.docc/ZeroUI.md b/swift/Sources/ZeroUI/ZeroUI.docc/ZeroUI.md index 068916fb24..a6384904db 100644 --- a/swift/Sources/ZeroUI/ZeroUI.docc/ZeroUI.md +++ b/swift/Sources/ZeroUI/ZeroUI.docc/ZeroUI.md @@ -28,6 +28,11 @@ struct IssueList: View { } ``` +Make the client once, in the `App` as here, or in an object the app owns. +Don't make it in a view's `@State` initializer. SwiftUI runs that +initializer each time it re-creates the view, and each client it makes +connects before being dropped. + The `zero-react` hooks map to it as follows: | zero-react | ZeroUI | From 04de14fbe6afc5bf10c7bf750a233309039a57c1 Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 19 Oct 2026 17:50:54 +0000 Subject: [PATCH 035/104] 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:, 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 --- .github/workflows/zbugs-screenshots.yml | 4 ++++ apps/zbugs-swift/Sources/ZBugsUI/AppModel.swift | 4 ++-- apps/zbugs-swift/server/server.ts | 16 ++++++++++++++-- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/workflows/zbugs-screenshots.yml b/.github/workflows/zbugs-screenshots.yml index 8275ae15f0..4f8783807d 100644 --- a/.github/workflows/zbugs-screenshots.yml +++ b/.github/workflows/zbugs-screenshots.yml @@ -76,14 +76,18 @@ jobs: export TEST_RUNNER_ZBUGS_TOKEN device="$(xcrun simctl list devices available -j | 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")))')" + echo "DEVICE=$device" >> "$GITHUB_ENV" xcodebuild test -project ZBugs.xcodeproj -scheme ZBugsApp \ -destination "id=$device" -resultBundlePath "$RUNNER_TEMP/screenshots.xcresult" \ -skipPackagePluginValidation CODE_SIGNING_ALLOWED=NO + # With the client's log (os.Logger, subsystem zero). - name: Export screenshots if: always() run: | xcrun xcresulttool export attachments \ --path "$RUNNER_TEMP/screenshots.xcresult" --output-path "$RUNNER_TEMP/screenshots" + xcrun simctl spawn "$DEVICE" log show --last 1h --info --style compact \ + --predicate 'subsystem == "zero"' > "$RUNNER_TEMP/screenshots/client.log" || true - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: always() with: diff --git a/apps/zbugs-swift/Sources/ZBugsUI/AppModel.swift b/apps/zbugs-swift/Sources/ZBugsUI/AppModel.swift index 210c1f4293..1cbe924f64 100644 --- a/apps/zbugs-swift/Sources/ZBugsUI/AppModel.swift +++ b/apps/zbugs-swift/Sources/ZBugsUI/AppModel.swift @@ -34,10 +34,10 @@ self.defaults = defaults let cacheURL = environment["ZBUGS_CACHE_URL"] ?? defaults.string(forKey: "cacheURL") - ?? "http://localhost:4848" + ?? "http://127.0.0.1:4848" let apiURL = environment["ZBUGS_API_URL"] ?? defaults.string(forKey: "apiURL") - ?? "http://localhost:5173" + ?? "http://127.0.0.1:5173" // An expired token logs out. let saved = environment["ZBUGS_TOKEN"] ?? Keychain.token let auth = saved.flatMap { AuthData(jwt: $0, now: Date().timeIntervalSince1970) } diff --git a/apps/zbugs-swift/server/server.ts b/apps/zbugs-swift/server/server.ts index 851e804dce..baae5684ce 100644 --- a/apps/zbugs-swift/server/server.ts +++ b/apps/zbugs-swift/server/server.ts @@ -47,6 +47,18 @@ const upstreamURL = (() => { return url.toString(); })(); +/** + * The API URLs zero-cache may forward to: clients send the URL they know + * the API server by, which on this machine can be either loopback name. + */ +function apiURLs(path: string) { + const hosts = + host === '127.0.0.1' || host === 'localhost' + ? ['127.0.0.1', 'localhost'] + : [host]; + return hosts.map(h => `http://${h}:${apiPort}/api/${path}`).join(','); +} + function psql(url: string, ...args: string[]) { execFileSync('psql', [url, '-v', 'ON_ERROR_STOP=1', '-q', ...args], { stdio: ['ignore', 'ignore', 'inherit'], @@ -136,8 +148,8 @@ const zeroCache = spawn( ['ZERO_UPSTREAM_DB']: upstreamURL, ['ZERO_REPLICA_FILE']: join(workDir, 'replica.db'), ['ZERO_PORT']: String(port), - ['ZERO_QUERY_URL']: `http://${host}:${apiPort}/api/query`, - ['ZERO_MUTATE_URL']: `http://${host}:${apiPort}/api/mutate`, + ['ZERO_QUERY_URL']: apiURLs('query'), + ['ZERO_MUTATE_URL']: apiURLs('mutate'), ['ZERO_NUM_SYNC_WORKERS']: '1', ['ZERO_LOG_LEVEL']: env['ZERO_LOG_LEVEL'] ?? 'info', }, From 5f85ee0ebb671093725cc06fe723317452a89671 Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 19 Oct 2026 18:05:01 +0000 Subject: [PATCH 036/104] 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 --- .github/workflows/zbugs-screenshots.yml | 4 ---- apps/zbugs-swift/App/UITests/ScreenshotTests.swift | 3 ++- apps/zbugs-swift/Sources/ZBugsUI/IssueDetail.swift | 3 ++- apps/zbugs-swift/Sources/ZBugsUI/RootView.swift | 11 +++++++++-- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.github/workflows/zbugs-screenshots.yml b/.github/workflows/zbugs-screenshots.yml index 4f8783807d..8275ae15f0 100644 --- a/.github/workflows/zbugs-screenshots.yml +++ b/.github/workflows/zbugs-screenshots.yml @@ -76,18 +76,14 @@ jobs: export TEST_RUNNER_ZBUGS_TOKEN device="$(xcrun simctl list devices available -j | 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")))')" - echo "DEVICE=$device" >> "$GITHUB_ENV" xcodebuild test -project ZBugs.xcodeproj -scheme ZBugsApp \ -destination "id=$device" -resultBundlePath "$RUNNER_TEMP/screenshots.xcresult" \ -skipPackagePluginValidation CODE_SIGNING_ALLOWED=NO - # With the client's log (os.Logger, subsystem zero). - name: Export screenshots if: always() run: | xcrun xcresulttool export attachments \ --path "$RUNNER_TEMP/screenshots.xcresult" --output-path "$RUNNER_TEMP/screenshots" - xcrun simctl spawn "$DEVICE" log show --last 1h --info --style compact \ - --predicate 'subsystem == "zero"' > "$RUNNER_TEMP/screenshots/client.log" || true - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: always() with: diff --git a/apps/zbugs-swift/App/UITests/ScreenshotTests.swift b/apps/zbugs-swift/App/UITests/ScreenshotTests.swift index fdce97cb95..c60b6061db 100644 --- a/apps/zbugs-swift/App/UITests/ScreenshotTests.swift +++ b/apps/zbugs-swift/App/UITests/ScreenshotTests.swift @@ -49,7 +49,8 @@ final class ScreenshotTests: XCTestCase { if UIDevice.current.userInterfaceIdiom == .phone { back(app) } - let listPicker = app.buttons["List"] + // A menu picker's label is its title and its value. + let listPicker = app.buttons.matching(NSPredicate(format: "label BEGINSWITH 'List,'")).firstMatch if expect(listPicker, "the filters' List picker") { settle() snapshot("4 Filters") diff --git a/apps/zbugs-swift/Sources/ZBugsUI/IssueDetail.swift b/apps/zbugs-swift/Sources/ZBugsUI/IssueDetail.swift index 900781c576..8e73ba7b31 100644 --- a/apps/zbugs-swift/Sources/ZBugsUI/IssueDetail.swift +++ b/apps/zbugs-swift/Sources/ZBugsUI/IssueDetail.swift @@ -45,7 +45,8 @@ .padding() .frame(maxWidth: 760, alignment: .leading) } - .navigationTitle(issue.shortID.map { "Issue #\($0)" } ?? "Issue") + // String(_:), or the title formats the number for the locale (3,000). + .navigationTitle(issue.shortID.map { "Issue #" + String($0) } ?? "Issue") .toolbar { if canEdit { ToolbarItem { diff --git a/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift b/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift index e06938b492..511c259583 100644 --- a/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift +++ b/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift @@ -199,14 +199,21 @@ case .connected: (.green, "Connected") case .connecting, .initializing: (.yellow, "Connecting") case .needsAuth: (.orange, "Log In Again") - case .disconnected, .error, .closed: (.red, "Offline") + case .disconnected, .closed: (.red, "Offline") + case .error: (.red, "Error") } SwiftUI.Label { Text(text) } icon: { Image(systemName: "circle.fill").foregroundStyle(color) } - .labelStyle(.titleAndIcon) + #if os(iOS) + // The text doesn't fit in an iPhone's navigation bar. + .labelStyle(.iconOnly) + .accessibilityLabel(text) + #else + .labelStyle(.titleAndIcon) + #endif .font(.caption) .help(String(describing: connection.state)) } From af8aa5f4ceb51e71e16ab82bef6099525756c9c7 Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 19 Oct 2026 18:19:34 +0000 Subject: [PATCH 037/104] 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 --- .github/workflows/zbugs-screenshots.yml | 39 ++++-- .../App/UITests/ScreenshotTests.swift | 115 ++++++++++++------ apps/zbugs-swift/App/ZBugsApp.swift | 1 + apps/zbugs-swift/App/project.yml | 2 +- apps/zbugs-swift/README.md | 9 +- .../Sources/ZBugsUI/IssueList.swift | 2 + .../Sources/ZBugsUI/IssueTable.swift | 2 + .../Sources/ZBugsUI/RootView.swift | 5 +- swift/DESIGN.md | 5 +- 9 files changed, 125 insertions(+), 55 deletions(-) diff --git a/.github/workflows/zbugs-screenshots.yml b/.github/workflows/zbugs-screenshots.yml index 8275ae15f0..57a722fdbb 100644 --- a/.github/workflows/zbugs-screenshots.yml +++ b/.github/workflows/zbugs-screenshots.yml @@ -1,8 +1,9 @@ name: zbugs screenshots -# Runs the Swift zbugs app in the iOS Simulator against zbugs' backend -# (apps/zbugs-swift/server/server.ts) and keeps a screenshot of each screen -# (apps/zbugs-swift/App/UITests) as the zbugs-screenshots artifact. +# Runs the Swift zbugs app on an iPhone and an iPad simulator and on macOS +# against zbugs' backend (apps/zbugs-swift/server/server.ts), and keeps a +# screenshot of each screen (apps/zbugs-swift/App/UITests) as the +# zbugs-screenshots artifact. # Path-filtered to the app and the UI layer: macOS runners are slow to start. on: @@ -29,7 +30,7 @@ permissions: jobs: screenshots: - name: zbugs screenshots (iOS Simulator) + name: zbugs screenshots (iPhone, iPad, and macOS) runs-on: macos-26 timeout-minutes: 60 steps: @@ -74,16 +75,32 @@ jobs: run: | TEST_RUNNER_ZBUGS_TOKEN="$(grep '^token ' "$RUNNER_TEMP/zbugs.log" | cut -d' ' -f2)" export TEST_RUNNER_ZBUGS_TOKEN - device="$(xcrun simctl list devices available -j | - 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")))')" - xcodebuild test -project ZBugs.xcodeproj -scheme ZBugsApp \ - -destination "id=$device" -resultBundlePath "$RUNNER_TEMP/screenshots.xcresult" \ - -skipPackagePluginValidation CODE_SIGNING_ALLOWED=NO + # The first available simulator whose name starts with $1. + simulator() { + xcrun simctl list devices available -j | + 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(sys.argv[1])))' "$1" + } + # Every run, even after one fails, so each keeps its screenshots. + status=0 + screenshots() { + name="$1" + shift + xcodebuild test -project ZBugs.xcodeproj -scheme ZBugsApp \ + -resultBundlePath "$RUNNER_TEMP/results/$name.xcresult" \ + -skipPackagePluginValidation "$@" || status=1 + } + screenshots iPhone -destination "id=$(simulator iPhone)" CODE_SIGNING_ALLOWED=NO + screenshots iPad -destination "id=$(simulator iPad)" CODE_SIGNING_ALLOWED=NO + # A macOS UI test needs a signed app; ad hoc does. + screenshots macOS -destination platform=macOS CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY=- + exit "$status" - name: Export screenshots if: always() run: | - xcrun xcresulttool export attachments \ - --path "$RUNNER_TEMP/screenshots.xcresult" --output-path "$RUNNER_TEMP/screenshots" + for result in "$RUNNER_TEMP"/results/*.xcresult; do + xcrun xcresulttool export attachments --path "$result" \ + --output-path "$RUNNER_TEMP/screenshots/$(basename "$result" .xcresult)" || true + done - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: always() with: diff --git a/apps/zbugs-swift/App/UITests/ScreenshotTests.swift b/apps/zbugs-swift/App/UITests/ScreenshotTests.swift index c60b6061db..6a32e555ce 100644 --- a/apps/zbugs-swift/App/UITests/ScreenshotTests.swift +++ b/apps/zbugs-swift/App/UITests/ScreenshotTests.swift @@ -4,86 +4,121 @@ import XCTest /// screenshot of each screen, which CI uploads. `ZBUGS_TOKEN` is a token for /// the seeded crew user; xcodebuild passes `TEST_RUNNER_ZBUGS_TOKEN` as it. /// -/// A step whose screen doesn't appear records a failure and the test goes -/// on, so one run shows every screen it can reach. +/// A step whose screen doesn't appear records a failure, with the app's +/// accessibility tree, and the test goes on, so one run shows every screen +/// it can reach. final class ScreenshotTests: XCTestCase { @MainActor func testScreens() throws { let token = try XCTUnwrap( ProcessInfo.processInfo.environment["ZBUGS_TOKEN"], "Set TEST_RUNNER_ZBUGS_TOKEN") + #if os(iOS) + if UIDevice.current.userInterfaceIdiom == .pad { + XCUIDevice.shared.orientation = .landscapeLeft + } + #endif let app = XCUIApplication() app.launchEnvironment["ZBUGS_TOKEN"] = token app.launch() // The issue list, once zero-cache has synced it. - let firstIssue = app.cells.firstMatch - guard expect(firstIssue, "an issue in the list", timeout: 120) else { - snapshot("1 Issues (empty)") - return - } + let firstIssue = app.descendants(matching: .any).matching(identifier: "IssueRow").firstMatch + guard expect(app, firstIssue, "an issue in the list", timeout: 120) else { return } settle() - snapshot("1 Issues") + snapshot(app, "1 Issues") // An issue with its comments. - firstIssue.tap() - if expect(app.buttons["Comment"], "the issue's comment button") { + press(firstIssue) + if expect(app, app.buttons["Comment"], "the issue's comment button") { settle() - snapshot("2 Issue") + snapshot(app, "2 Issue") } // A new issue. - if UIDevice.current.userInterfaceIdiom == .phone { + if isPhone { back(app) } let newIssue = app.buttons["New Issue"] - if expect(newIssue, "the New Issue button") { - newIssue.tap() - if expect(app.navigationBars["New Issue"], "the new issue sheet") { + if expect(app, newIssue, "the New Issue button") { + press(newIssue) + if expect(app, app.buttons["Create"], "the new issue sheet") { settle() - snapshot("3 New Issue") - app.buttons["Cancel"].tap() + snapshot(app, "3 New Issue") + press(app.buttons["Cancel"]) } } - // The filters, then the UIKit list. - if UIDevice.current.userInterfaceIdiom == .phone { + // The filters, then the UIKit or AppKit list. + if isPhone { back(app) } - // A menu picker's label is its title and its value. - let listPicker = app.buttons.matching(NSPredicate(format: "label BEGINSWITH 'List,'")).firstMatch - if expect(listPicker, "the filters' List picker") { + #if os(macOS) + let listPicker = app.popUpButtons["List"] + let native = app.menuItems["AppKit"] + #else + // A menu picker's label is its title and its value. + let listPicker = app.buttons.matching(NSPredicate(format: "label BEGINSWITH 'List,'")) + .firstMatch + let native = app.buttons["UIKit"] + #endif + if expect(app, listPicker, "the filters' List picker") { settle() - snapshot("4 Filters") - listPicker.tap() - let uiKit = app.buttons["UIKit"] - if expect(uiKit, "the UIKit option") { - uiKit.tap() + snapshot(app, "4 Filters") + press(listPicker) + if expect(app, native, "the native list option") { + press(native) } - if UIDevice.current.userInterfaceIdiom == .phone { + if isPhone { let issues = app.buttons["Issues"] - if expect(issues, "the Issues button") { issues.tap() } + if expect(app, issues, "the Issues button") { press(issues) } } - if expect(app.tables.cells.firstMatch, "an issue in the UIKit list") { + let cell = app.descendants(matching: .any).matching(identifier: "IssueCell").firstMatch + if expect(app, cell, "an issue in the native list") { settle() - snapshot("5 Issues (UIKit)") + snapshot(app, "5 Issues (native)") } } } - /// Waits for `element`, recording a failure when it doesn't appear. @MainActor - private func expect(_ element: XCUIElement, _ what: String, timeout: Double = 20) -> Bool { + private var isPhone: Bool { + #if os(iOS) + UIDevice.current.userInterfaceIdiom == .phone + #else + false + #endif + } + + /// Waits for `element`. When it doesn't appear, records a failure with a + /// screenshot and the accessibility tree. + @MainActor + private func expect( + _ app: XCUIApplication, _ element: XCUIElement, _ what: String, timeout: Double = 20 + ) -> Bool { if element.waitForExistence(timeout: timeout) { return true } - snapshot("Missing \(what)") + snapshot(app, "Missing \(what)") + let tree = XCTAttachment(string: app.debugDescription) + tree.name = "Missing \(what).txt" + tree.lifetime = .keepAlways + add(tree) XCTFail("No \(what)") return false } + @MainActor + private func press(_ element: XCUIElement) { + #if os(macOS) + element.click() + #else + element.tap() + #endif + } + /// The navigation bar's back button. @MainActor private func back(_ app: XCUIApplication) { let button = app.navigationBars.buttons.element(boundBy: 0) - if button.waitForExistence(timeout: 5) { button.tap() } + if button.waitForExistence(timeout: 5) { press(button) } } /// Lets animations and the rows' relationships finish. @@ -91,9 +126,15 @@ final class ScreenshotTests: XCTestCase { Thread.sleep(forTimeInterval: 2) } + /// The screen on iOS, the window and its sheets on macOS. @MainActor - private func snapshot(_ name: String) { - let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + private func snapshot(_ app: XCUIApplication, _ name: String) { + #if os(macOS) + let screenshot = app.windows.firstMatch.screenshot() + #else + let screenshot = XCUIScreen.main.screenshot() + #endif + let attachment = XCTAttachment(screenshot: screenshot) attachment.name = name attachment.lifetime = .keepAlways add(attachment) diff --git a/apps/zbugs-swift/App/ZBugsApp.swift b/apps/zbugs-swift/App/ZBugsApp.swift index 903a7d2790..f01693aa9c 100644 --- a/apps/zbugs-swift/App/ZBugsApp.swift +++ b/apps/zbugs-swift/App/ZBugsApp.swift @@ -10,5 +10,6 @@ struct ZBugsApp: App { WindowGroup { RootView(model: model) } + .defaultSize(width: 1200, height: 800) } } diff --git a/apps/zbugs-swift/App/project.yml b/apps/zbugs-swift/App/project.yml index c449f156dd..de5f915ef6 100644 --- a/apps/zbugs-swift/App/project.yml +++ b/apps/zbugs-swift/App/project.yml @@ -42,7 +42,7 @@ targets: # Screenshots of each screen against zbugs' backend (UITests/). ZBugsUITests: type: bundle.ui-testing - platform: iOS + supportedDestinations: [iOS, macOS] sources: - path: UITests dependencies: diff --git a/apps/zbugs-swift/README.md b/apps/zbugs-swift/README.md index 233ef3d8bd..33874a54f2 100644 --- a/apps/zbugs-swift/README.md +++ b/apps/zbugs-swift/README.md @@ -50,15 +50,18 @@ ZBUGS_E2E_URL=http://127.0.0.1:4848 ZBUGS_E2E_API=http://127.0.0.1:5173 \ ``` `App/UITests` drives the app through each screen against the backend and -keeps a screenshot of each one. The `zbugs screenshots` workflow runs it in -the iOS Simulator and uploads the screenshots as an artifact. To run it -locally, generate the project and pass the backend's token: +keeps a screenshot of each one. The `zbugs screenshots` workflow runs it on +an iPhone and an iPad simulator and on macOS, and uploads the screenshots as +an artifact. To run it locally, generate the project and pass the backend's +token: ```sh TEST_RUNNER_ZBUGS_TOKEN= xcodebuild test -project App/ZBugs.xcodeproj \ -scheme ZBugsApp -destination 'platform=iOS Simulator,name=iPhone 17' ``` +On macOS the destination is `platform=macOS`. + The app reads `ZBUGS_TOKEN`, `ZBUGS_CACHE_URL`, and `ZBUGS_API_URL` from its environment as well; they override the saved login and servers. diff --git a/apps/zbugs-swift/Sources/ZBugsUI/IssueList.swift b/apps/zbugs-swift/Sources/ZBugsUI/IssueList.swift index f924cbf82f..6967f86040 100644 --- a/apps/zbugs-swift/Sources/ZBugsUI/IssueList.swift +++ b/apps/zbugs-swift/Sources/ZBugsUI/IssueList.swift @@ -86,6 +86,8 @@ .foregroundStyle(.secondary) } .padding(.vertical, 2) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("IssueRow") } } diff --git a/apps/zbugs-swift/Sources/ZBugsUI/IssueTable.swift b/apps/zbugs-swift/Sources/ZBugsUI/IssueTable.swift index 37e30cd30d..6b5e0487d3 100644 --- a/apps/zbugs-swift/Sources/ZBugsUI/IssueTable.swift +++ b/apps/zbugs-swift/Sources/ZBugsUI/IssueTable.swift @@ -28,6 +28,7 @@ content.imageProperties.tintColor = issue.open ? .systemGreen : .systemPurple } cell.contentConfiguration = content + cell.accessibilityIdentifier = "IssueCell" return cell } } @@ -85,6 +86,7 @@ dataSource = NSTableViewDiffableDataSource(tableView: tableView) { [unowned self] _, _, _, id in let cell = NSTableCellView() + cell.setAccessibilityIdentifier("IssueCell") let title = NSTextField(labelWithString: "") title.font = .preferredFont(forTextStyle: .headline) title.lineBreakMode = .byTruncatingTail diff --git a/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift b/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift index 511c259583..fa411d0e4c 100644 --- a/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift +++ b/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift @@ -26,6 +26,9 @@ @State private var selection: String? @State private var listLayer = ListLayer.swiftUI @State private var sheet: Sheet? + /// All three columns, on an iPad or a Mac: the filters are how you get + /// around. + @State private var columns = NavigationSplitViewVisibility.all /// The column an iPhone shows: the list, with the filters before it and /// the selected issue after. @State private var compactColumn = NavigationSplitViewColumn.content @@ -43,7 +46,7 @@ } public var body: some View { - NavigationSplitView(preferredCompactColumn: $compactColumn) { + NavigationSplitView(columnVisibility: $columns, preferredCompactColumn: $compactColumn) { Sidebar(filter: $filter, listLayer: $listLayer) .navigationTitle("Zero Bugs") #if os(iOS) diff --git a/swift/DESIGN.md b/swift/DESIGN.md index 5c3263cc37..f98e4360b2 100644 --- a/swift/DESIGN.md +++ b/swift/DESIGN.md @@ -498,8 +498,9 @@ the Zero convention of passing IDs and timestamps as arguments. `apps/zbugs-swift/`.** The Apple layer (URLSession transport against a local mock WebSocket server, SwiftUI, UIKit, and AppKit helpers), iOS simulator tests, all three DocC catalogs, and the zbugs app built for iOS and macOS. -- **zbugs screenshots, path-filtered to the app and `ZeroUI`.** The app in the - iOS Simulator against zbugs' backend, through each screen, with the +- **zbugs screenshots, path-filtered to the app and `ZeroUI`.** The app on an + iPhone and an iPad simulator and on macOS against zbugs' backend, through + each screen and both the SwiftUI and the UIKit or AppKit list, with the screenshots kept as an artifact. - **Benchmarks.** Ports of the key JS benchmarks (`zero-client/src/client/zero.bench.ts`, the shared B-tree and hash From 56728969871c7d377028c4403bffcea929101582 Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 19 Oct 2026 18:38:18 +0000 Subject: [PATCH 038/104] 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 --- apps/zbugs-swift/App/Info.plist | 2 ++ .../App/UITests/ScreenshotTests.swift | 11 ++++++-- apps/zbugs-swift/README.md | 6 +++- apps/zbugs-swift/server/server.ts | 28 ++++++++++++++----- 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/apps/zbugs-swift/App/Info.plist b/apps/zbugs-swift/App/Info.plist index b41a6329d9..cd2a6287d7 100644 --- a/apps/zbugs-swift/App/Info.plist +++ b/apps/zbugs-swift/App/Info.plist @@ -2,6 +2,8 @@ + NSLocalNetworkUsageDescription + Zero Bugs connects to a zbugs server on your local network. NSAppTransportSecurity diff --git a/apps/zbugs-swift/App/UITests/ScreenshotTests.swift b/apps/zbugs-swift/App/UITests/ScreenshotTests.swift index 6a32e555ce..d77a216525 100644 --- a/apps/zbugs-swift/App/UITests/ScreenshotTests.swift +++ b/apps/zbugs-swift/App/UITests/ScreenshotTests.swift @@ -2,7 +2,8 @@ import XCTest /// Drives the app against zbugs' backend (`server/server.ts`) and keeps a /// screenshot of each screen, which CI uploads. `ZBUGS_TOKEN` is a token for -/// the seeded crew user; xcodebuild passes `TEST_RUNNER_ZBUGS_TOKEN` as it. +/// the seeded crew user; xcodebuild passes `TEST_RUNNER_ZBUGS_TOKEN` as it, +/// and likewise `ZBUGS_CACHE_URL` and `ZBUGS_API_URL` when they're set. /// /// A step whose screen doesn't appear records a failure, with the app's /// accessibility tree, and the test goes on, so one run shows every screen @@ -10,8 +11,8 @@ import XCTest final class ScreenshotTests: XCTestCase { @MainActor func testScreens() throws { - let token = try XCTUnwrap( - ProcessInfo.processInfo.environment["ZBUGS_TOKEN"], "Set TEST_RUNNER_ZBUGS_TOKEN") + let environment = ProcessInfo.processInfo.environment + let token = try XCTUnwrap(environment["ZBUGS_TOKEN"], "Set TEST_RUNNER_ZBUGS_TOKEN") #if os(iOS) if UIDevice.current.userInterfaceIdiom == .pad { XCUIDevice.shared.orientation = .landscapeLeft @@ -19,6 +20,10 @@ final class ScreenshotTests: XCTestCase { #endif let app = XCUIApplication() app.launchEnvironment["ZBUGS_TOKEN"] = token + // On a device, the backend's network address. + for key in ["ZBUGS_CACHE_URL", "ZBUGS_API_URL"] { + app.launchEnvironment[key] = environment[key] + } app.launch() // The issue list, once zero-cache has synced it. diff --git a/apps/zbugs-swift/README.md b/apps/zbugs-swift/README.md index 33874a54f2..187ea84fd8 100644 --- a/apps/zbugs-swift/README.md +++ b/apps/zbugs-swift/README.md @@ -60,7 +60,11 @@ TEST_RUNNER_ZBUGS_TOKEN= xcodebuild test -project App/ZBugs.xcodeproj \ -scheme ZBugsApp -destination 'platform=iOS Simulator,name=iPhone 17' ``` -On macOS the destination is `platform=macOS`. +On macOS the destination is `platform=macOS`. On an iPhone or an iPad, start +the backend with `ZBUGS_HOST=0.0.0.0` so the device can reach it, and pass +its address on this Mac's network as well, for example +`TEST_RUNNER_ZBUGS_CACHE_URL=http://192.168.1.20:4848` and +`TEST_RUNNER_ZBUGS_API_URL=http://192.168.1.20:5173`. The app reads `ZBUGS_TOKEN`, `ZBUGS_CACHE_URL`, and `ZBUGS_API_URL` from its environment as well; they override the saved login and servers. diff --git a/apps/zbugs-swift/server/server.ts b/apps/zbugs-swift/server/server.ts index baae5684ce..9c534481d5 100644 --- a/apps/zbugs-swift/server/server.ts +++ b/apps/zbugs-swift/server/server.ts @@ -17,7 +17,8 @@ * ZBUGS_DB database to (re)create (default zbugs_swift) * ZBUGS_SEED seed data directory in apps/zbugs/db/seed-data * (default github) - * ZBUGS_HOST interface to listen on (default 127.0.0.1) + * ZBUGS_HOST interface to listen on (default 127.0.0.1); 0.0.0.0 + * serves the network too, for an app on a device * ZBUGS_PORT zero-cache port (default 4848) * ZBUGS_API_PORT API server port (default 5173) * ZBUGS_DIR working directory for the replica (default a temp dir) @@ -25,7 +26,7 @@ import {execFileSync, spawn} from 'node:child_process'; import {mkdtempSync, readdirSync} from 'node:fs'; import {registerHooks} from 'node:module'; -import {tmpdir} from 'node:os'; +import {networkInterfaces, tmpdir} from 'node:os'; import {join} from 'node:path'; import {fileURLToPath, pathToFileURL} from 'node:url'; import {createJwkPair} from '../../../packages/zero-cache/src/auth/jwt.ts'; @@ -35,6 +36,9 @@ const adminURL = env['ZBUGS_PG'] ?? 'postgresql://postgres@127.0.0.1:5432/postgres'; const dbName = env['ZBUGS_DB'] ?? 'zbugs_swift'; const host = env['ZBUGS_HOST'] ?? '127.0.0.1'; +const everywhere = host === '0.0.0.0'; +/** Where this script reaches the servers. */ +const localHost = everywhere ? '127.0.0.1' : host; const port = Number(env['ZBUGS_PORT'] ?? 4848); const apiPort = Number(env['ZBUGS_API_PORT'] ?? 5173); const workDir = env['ZBUGS_DIR'] ?? mkdtempSync(join(tmpdir(), 'zbugs-swift-')); @@ -49,13 +53,21 @@ const upstreamURL = (() => { /** * The API URLs zero-cache may forward to: clients send the URL they know - * the API server by, which on this machine can be either loopback name. + * the API server by. On this machine that can be either loopback name; on a + * device, any of this machine's network addresses. */ function apiURLs(path: string) { const hosts = - host === '127.0.0.1' || host === 'localhost' + localHost === '127.0.0.1' || localHost === 'localhost' ? ['127.0.0.1', 'localhost'] - : [host]; + : [localHost]; + if (everywhere) { + for (const address of Object.values(networkInterfaces()).flat()) { + if (address?.family === 'IPv4' && !address.internal) { + hosts.push(address.address); + } + } + } return hosts.map(h => `http://${h}:${apiPort}/api/${path}`).join(','); } @@ -170,7 +182,7 @@ process.on('SIGTERM', () => stop(0)); for (let i = 0; ; i++) { try { - const res = await fetch(`http://${host}:${port}/`); + const res = await fetch(`http://${localHost}:${port}/`); if (res.ok) { break; } @@ -195,4 +207,6 @@ const token = execFileSync( }, ).trim(); process.stdout.write(`token ${token}\n`); -process.stdout.write(`ready ws://${host}:${port} http://${host}:${apiPort}\n`); +process.stdout.write( + `ready ws://${localHost}:${port} http://${localHost}:${apiPort}\n`, +); From 2344a0a38b47edc4e5b30e765f4cb30e97ab76df Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 19 Oct 2026 18:42:56 +0000 Subject: [PATCH 039/104] 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 --- apps/zbugs-swift/App/UITests/ScreenshotTests.swift | 6 +++++- apps/zbugs-swift/Sources/ZBugsUI/RootView.swift | 7 +++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/zbugs-swift/App/UITests/ScreenshotTests.swift b/apps/zbugs-swift/App/UITests/ScreenshotTests.swift index d77a216525..25092eeadb 100644 --- a/apps/zbugs-swift/App/UITests/ScreenshotTests.swift +++ b/apps/zbugs-swift/App/UITests/ScreenshotTests.swift @@ -58,7 +58,9 @@ final class ScreenshotTests: XCTestCase { back(app) } #if os(macOS) - let listPicker = app.popUpButtons["List"] + // The pop-up button has no label; its title is a text beside it. + let listPicker = app.popUpButtons.matching(NSPredicate(format: "value == 'SwiftUI'")) + .firstMatch let native = app.menuItems["AppKit"] #else // A menu picker's label is its title and its value. @@ -134,6 +136,8 @@ final class ScreenshotTests: XCTestCase { /// The screen on iOS, the window and its sheets on macOS. @MainActor private func snapshot(_ app: XCUIApplication, _ name: String) { + // On an iPad in landscape, a PNG of the portrait screen's pixels with an + // EXIF orientation that most viewers apply. #if os(macOS) let screenshot = app.windows.firstMatch.screenshot() #else diff --git a/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift b/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift index fa411d0e4c..e885297be1 100644 --- a/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift +++ b/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift @@ -49,6 +49,10 @@ NavigationSplitView(columnVisibility: $columns, preferredCompactColumn: $compactColumn) { Sidebar(filter: $filter, listLayer: $listLayer) .navigationTitle("Zero Bugs") + #if os(macOS) + // Wide enough for the pickers' values. + .navigationSplitViewColumnWidth(min: 220, ideal: 240) + #endif #if os(iOS) .toolbar { // The columns don't report the window's size class. @@ -62,6 +66,9 @@ } content: { list .navigationTitle(filter.status.title + " Issues") + #if os(macOS) + .navigationSplitViewColumnWidth(min: 280, ideal: 340) + #endif .searchable(text: $filter.text, prompt: "Search issues") .toolbar { toolbar } } detail: { From 7986f5a9fbd4e4d1b021ccc35e3b7cdbe7a87ecf Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 19 Oct 2026 19:02:25 +0000 Subject: [PATCH 040/104] 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 --- .../App/UITests/ScreenshotTests.swift | 5 ++ .../Sources/ZBugsUI/IssueDetail.swift | 6 +- .../Sources/ZBugsUI/IssueList.swift | 4 +- .../Sources/ZBugsUI/IssueTable.swift | 73 +++++++++++++++++-- .../Sources/ZBugsUI/RootView.swift | 11 ++- apps/zbugs-swift/Sources/ZBugsUI/Sheets.swift | 16 +++- 6 files changed, 99 insertions(+), 16 deletions(-) diff --git a/apps/zbugs-swift/App/UITests/ScreenshotTests.swift b/apps/zbugs-swift/App/UITests/ScreenshotTests.swift index 25092eeadb..fa74d5b273 100644 --- a/apps/zbugs-swift/App/UITests/ScreenshotTests.swift +++ b/apps/zbugs-swift/App/UITests/ScreenshotTests.swift @@ -25,6 +25,11 @@ final class ScreenshotTests: XCTestCase { app.launchEnvironment[key] = environment[key] } app.launch() + #if os(iOS) && !targetEnvironment(simulator) + // A device asks for local network access on the first launch. + let allow = XCUIApplication(bundleIdentifier: "com.apple.springboard").alerts.buttons["Allow"] + if allow.waitForExistence(timeout: 10) { allow.tap() } + #endif // The issue list, once zero-cache has synced it. let firstIssue = app.descendants(matching: .any).matching(identifier: "IssueRow").firstMatch diff --git a/apps/zbugs-swift/Sources/ZBugsUI/IssueDetail.swift b/apps/zbugs-swift/Sources/ZBugsUI/IssueDetail.swift index 8e73ba7b31..812b4f8a52 100644 --- a/apps/zbugs-swift/Sources/ZBugsUI/IssueDetail.swift +++ b/apps/zbugs-swift/Sources/ZBugsUI/IssueDetail.swift @@ -170,6 +170,7 @@ TextField("Description", text: $description, axis: .vertical) .lineLimit(5...20) } + .formStyle(.grouped) .navigationTitle("Edit Issue") .toolbar { ToolbarItem(placement: .cancellationAction) { @@ -187,7 +188,10 @@ } } } - .frame(minWidth: 420, minHeight: 300) + // A sheet on iOS is the screen's width. + #if os(macOS) + .frame(minWidth: 420, minHeight: 300) + #endif } } diff --git a/apps/zbugs-swift/Sources/ZBugsUI/IssueList.swift b/apps/zbugs-swift/Sources/ZBugsUI/IssueList.swift index 6967f86040..8b35d91bba 100644 --- a/apps/zbugs-swift/Sources/ZBugsUI/IssueList.swift +++ b/apps/zbugs-swift/Sources/ZBugsUI/IssueList.swift @@ -75,9 +75,11 @@ Text(issue.title).font(.headline).lineLimit(2) } HStack(spacing: 6) { - Text(issue.subtitle) + // On one line, before the labels. + Text(issue.subtitle).lineLimit(1).layoutPriority(1) ForEach(issue.labels ?? [], id: \.id) { label in Text(label.name) + .lineLimit(1) .padding(.horizontal, 6) .background(.quaternary, in: Capsule()) } diff --git a/apps/zbugs-swift/Sources/ZBugsUI/IssueTable.swift b/apps/zbugs-swift/Sources/ZBugsUI/IssueTable.swift index 6b5e0487d3..1c5b0fad59 100644 --- a/apps/zbugs-swift/Sources/ZBugsUI/IssueTable.swift +++ b/apps/zbugs-swift/Sources/ZBugsUI/IssueTable.swift @@ -42,6 +42,19 @@ adapter = DiffableAdapter(view, section: 0, dataSource: dataSource) } + /// Highlights the issue the detail shows, except on an iPhone, where + /// the list and the issue aren't side by side. + func select(_ issueID: String?) { + guard traitCollection.horizontalSizeClass != .compact else { return } + let indexPath = issueID.flatMap { issueID in + adapter?.rows.first { $0.value.id == issueID } + .flatMap { dataSource?.indexPath(for: $0.key) } + } + if indexPath != tableView.indexPathForSelectedRow { + tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none) + } + } + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard let id = dataSource?.itemIdentifier(for: indexPath), let issue = adapter?.row(for: id) @@ -53,15 +66,19 @@ struct IssueTable: UIViewControllerRepresentable { let zero: Zero let filter: IssueFilter - @Binding var selection: String? + /// The issue the detail shows. + let selection: String? + /// Opens an issue, even the one the detail shows. + let open: (String) -> Void func makeUIViewController(context: Context) -> IssueTableController { IssueTableController(style: .plain) } func updateUIViewController(_ controller: IssueTableController, context: Context) { - controller.onSelect = { selection = $0 } + controller.onSelect = open controller.show(zero, filter) + controller.select(selection) } } #elseif canImport(AppKit) @@ -74,6 +91,9 @@ private var adapter: DiffableAdapter? private var dataSource: NSTableViewDiffableDataSource? private var key: (zero: ObjectIdentifier, filter: IssueFilter)? + /// Whether `select` is changing the selection, which isn't the user + /// choosing an issue. + private var selecting = false override func loadView() { let column = NSTableColumn(identifier: .init("issue")) @@ -86,18 +106,35 @@ dataSource = NSTableViewDiffableDataSource(tableView: tableView) { [unowned self] _, _, _, id in let cell = NSTableCellView() - cell.setAccessibilityIdentifier("IssueCell") + let icon = NSImageView() + icon.symbolConfiguration = .init(textStyle: .headline) let title = NSTextField(labelWithString: "") title.font = .preferredFont(forTextStyle: .headline) title.lineBreakMode = .byTruncatingTail + // The cell view's identifier doesn't reach the accessibility tree. + title.setAccessibilityIdentifier("IssueCell") let subtitle = NSTextField(labelWithString: "") subtitle.font = .preferredFont(forTextStyle: .caption1) subtitle.textColor = .secondaryLabelColor + subtitle.lineBreakMode = .byTruncatingTail + // Truncated, not clipped, in a column narrower than the text. + for label in [title, subtitle] { + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + } if let issue = adapter?.row(for: id) { - title.stringValue = (issue.open ? "○ " : "✓ ") + issue.title + icon.image = NSImage( + systemSymbolName: issue.open ? "circle" : "checkmark.circle.fill", + accessibilityDescription: issue.open ? "Open" : "Closed") + icon.contentTintColor = issue.open ? .systemGreen : .systemPurple + title.stringValue = issue.title subtitle.stringValue = issue.subtitle } - let stack = NSStackView(views: [title, subtitle]) + // So a selected row draws them in its colors. + cell.imageView = icon + cell.textField = title + let heading = NSStackView(views: [icon, title]) + heading.spacing = 6 + let stack = NSStackView(views: [heading, subtitle]) stack.orientation = .vertical stack.alignment = .leading stack.spacing = 2 @@ -127,8 +164,24 @@ adapter = DiffableAdapter(view, section: 0, dataSource: dataSource) } + /// Highlights the issue the detail shows. + func select(_ issueID: String?) { + let row = issueID.flatMap { issueID in + adapter?.rows.first { $0.value.id == issueID } + .flatMap { dataSource?.snapshot().indexOfItem($0.key) } + } + guard (row ?? -1) != tableView.selectedRow else { return } + selecting = true + defer { selecting = false } + if let row { + tableView.selectRowIndexes([row], byExtendingSelection: false) + } else { + tableView.deselectAll(nil) + } + } + func tableViewSelectionDidChange(_ notification: Notification) { - guard tableView.selectedRow >= 0, + guard !selecting, tableView.selectedRow >= 0, let id = dataSource?.itemIdentifier(forRow: tableView.selectedRow), let issue = adapter?.row(for: id) else { return } @@ -139,15 +192,19 @@ struct IssueTable: NSViewControllerRepresentable { let zero: Zero let filter: IssueFilter - @Binding var selection: String? + /// The issue the detail shows. + let selection: String? + /// Opens an issue. + let open: (String) -> Void func makeNSViewController(context: Context) -> IssueTableController { IssueTableController() } func updateNSViewController(_ controller: IssueTableController, context: Context) { - controller.onSelect = { selection = $0 } + controller.onSelect = open controller.show(zero, filter) + controller.select(selection) } } #endif diff --git a/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift b/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift index e885297be1..1113007b64 100644 --- a/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift +++ b/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift @@ -78,8 +78,8 @@ ContentUnavailableView("No Issue Selected", systemImage: "list.bullet.rectangle") } } - // The UIKit and AppKit lists set the selection outside a List, which - // a collapsed split view doesn't follow on its own. + // A new issue is selected outside a List, which a collapsed split + // view doesn't follow on its own. .onChange(of: selection) { _, selection in if selection != nil { compactColumn = .detail } } @@ -114,7 +114,12 @@ case .swiftUI: IssueList(filter: filter, selection: $selection) case .native: - IssueTable(zero: model.zero, filter: filter, selection: $selection) + IssueTable(zero: model.zero, filter: filter, selection: selection) { id in + selection = id + // Also when it's the issue the detail shows, which doesn't change + // the selection. + compactColumn = .detail + } } } diff --git a/apps/zbugs-swift/Sources/ZBugsUI/Sheets.swift b/apps/zbugs-swift/Sources/ZBugsUI/Sheets.swift index 8abb3d1567..d43a644463 100644 --- a/apps/zbugs-swift/Sources/ZBugsUI/Sheets.swift +++ b/apps/zbugs-swift/Sources/ZBugsUI/Sheets.swift @@ -27,6 +27,7 @@ TextField("Description", text: $description, axis: .vertical) .lineLimit(5...20) } + .formStyle(.grouped) .navigationTitle("New Issue") .toolbar { ToolbarItem(placement: .cancellationAction) { @@ -49,7 +50,10 @@ } } } - .frame(minWidth: 420, minHeight: 300) + // A sheet on iOS is the screen's width. + #if os(macOS) + .frame(minWidth: 420, minHeight: 300) + #endif } private var project: Project? { @@ -93,7 +97,10 @@ } } } - .frame(minWidth: 420, minHeight: 320) + // A sheet on iOS is the screen's width. + #if os(macOS) + .frame(minWidth: 420, minHeight: 320) + #endif } private func logInWithGitHub() async { @@ -156,7 +163,10 @@ apiURL = model.apiURL } } - .frame(minWidth: 420, minHeight: 220) + // A sheet on iOS is the screen's width. + #if os(macOS) + .frame(minWidth: 420, minHeight: 220) + #endif } } #endif From 3f6333c4ce0bc348e34fda6deb46ddd0720e0358 Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 19 Oct 2026 19:06:49 +0000 Subject: [PATCH 041/104] 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 --- .../Sources/ZBugsUI/IssueList.swift | 37 +++++++++++++++---- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/apps/zbugs-swift/Sources/ZBugsUI/IssueList.swift b/apps/zbugs-swift/Sources/ZBugsUI/IssueList.swift index 8b35d91bba..d21a8c827c 100644 --- a/apps/zbugs-swift/Sources/ZBugsUI/IssueList.swift +++ b/apps/zbugs-swift/Sources/ZBugsUI/IssueList.swift @@ -74,14 +74,21 @@ .foregroundStyle(issue.open ? Color.green : Color.purple) Text(issue.title).font(.headline).lineLimit(2) } - HStack(spacing: 6) { - // On one line, before the labels. - Text(issue.subtitle).lineLimit(1).layoutPriority(1) - ForEach(issue.labels ?? [], id: \.id) { label in - Text(label.name) - .lineLimit(1) - .padding(.horizontal, 6) - .background(.quaternary, in: Capsule()) + Group { + if (issue.labels ?? []).isEmpty { + subtitle + } else { + // The labels after the subtitle, or below it in a narrow column. + ViewThatFits(in: .horizontal) { + HStack(spacing: 6) { + subtitle + labels + } + VStack(alignment: .leading, spacing: 4) { + subtitle + HStack(spacing: 6) { labels } + } + } } } .font(.caption) @@ -91,6 +98,20 @@ .accessibilityElement(children: .combine) .accessibilityIdentifier("IssueRow") } + + private var subtitle: some View { + Text(issue.subtitle).lineLimit(1) + } + + /// Whole, never squeezed. + private var labels: some View { + ForEach(issue.labels ?? [], id: \.id) { label in + Text(label.name) + .padding(.horizontal, 6) + .background(.quaternary, in: Capsule()) + .fixedSize() + } + } } extension Issue { From f595276eac1102a5d390f011dfc667094a55491f Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 19 Oct 2026 19:28:24 +0000 Subject: [PATCH 042/104] 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 --- apps/zbugs-swift/Sources/ZBugsUI/IssueTable.swift | 4 +--- apps/zbugs-swift/Sources/ZBugsUI/RootView.swift | 13 ++++++++++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/zbugs-swift/Sources/ZBugsUI/IssueTable.swift b/apps/zbugs-swift/Sources/ZBugsUI/IssueTable.swift index 1c5b0fad59..57e11f196c 100644 --- a/apps/zbugs-swift/Sources/ZBugsUI/IssueTable.swift +++ b/apps/zbugs-swift/Sources/ZBugsUI/IssueTable.swift @@ -42,10 +42,8 @@ adapter = DiffableAdapter(view, section: 0, dataSource: dataSource) } - /// Highlights the issue the detail shows, except on an iPhone, where - /// the list and the issue aren't side by side. + /// Highlights the issue the detail shows. func select(_ issueID: String?) { - guard traitCollection.horizontalSizeClass != .compact else { return } let indexPath = issueID.flatMap { issueID in adapter?.rows.first { $0.value.id == issueID } .flatMap { dataSource?.indexPath(for: $0.key) } diff --git a/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift b/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift index 1113007b64..7e6cfcdf69 100644 --- a/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift +++ b/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift @@ -114,7 +114,7 @@ case .swiftUI: IssueList(filter: filter, selection: $selection) case .native: - IssueTable(zero: model.zero, filter: filter, selection: selection) { id in + IssueTable(zero: model.zero, filter: filter, selection: highlighted) { id in selection = id // Also when it's the issue the detail shows, which doesn't change // the selection. @@ -123,6 +123,17 @@ } } + /// The issue the list highlights: the selected one, when the list is + /// beside it (not on an iPhone). The columns themselves are compact on an + /// iPad too. + private var highlighted: String? { + #if os(iOS) + sizeClass == .compact ? nil : selection + #else + selection + #endif + } + @ToolbarContentBuilder private var toolbar: some ToolbarContent { ToolbarItem { ConnectionBadge(connection: model.zero.connection) From f7c1726f2d47086332281b4e044c67d1e830528c Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 19 Oct 2026 19:31:32 +0000 Subject: [PATCH 043/104] 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 --- apps/zbugs-swift/Sources/ZBugsUI/RootView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift b/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift index 7e6cfcdf69..21a8072277 100644 --- a/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift +++ b/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift @@ -66,10 +66,11 @@ } content: { list .navigationTitle(filter.status.title + " Issues") + .searchable(text: $filter.text, prompt: "Search issues") #if os(macOS) + // After `searchable`, which drops a width set before it. .navigationSplitViewColumnWidth(min: 280, ideal: 340) #endif - .searchable(text: $filter.text, prompt: "Search issues") .toolbar { toolbar } } detail: { if let selection { From f33ac868137a19768808922fe0dc8a428dbf9b85 Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 19 Oct 2026 19:57:41 +0000 Subject: [PATCH 044/104] 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 --- apps/zbugs-swift/Sources/ZBugsUI/IssueTable.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/zbugs-swift/Sources/ZBugsUI/IssueTable.swift b/apps/zbugs-swift/Sources/ZBugsUI/IssueTable.swift index 57e11f196c..80ba0abab7 100644 --- a/apps/zbugs-swift/Sources/ZBugsUI/IssueTable.swift +++ b/apps/zbugs-swift/Sources/ZBugsUI/IssueTable.swift @@ -16,6 +16,8 @@ override func viewDidLoad() { super.viewDidLoad() + // It would clear the highlight `select` sets before the list appears. + clearsSelectionOnViewWillAppear = false tableView.register(UITableViewCell.self, forCellReuseIdentifier: "issue") dataSource = UITableViewDiffableDataSource(tableView: tableView) { [unowned self] tableView, indexPath, id in From 8ea2728a13a3dab51e91bd30f357992fab00b0e6 Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 19 Oct 2026 20:07:50 +0000 Subject: [PATCH 045/104] 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 --- apps/zbugs-swift/Sources/ZBugs/Schema.swift | 100 +++---- packages/zero-swift-cli/src/generate.ts | 6 +- swift/DESIGN.md | 8 + swift/Sources/Zero/AST/AST.swift | 5 +- swift/Sources/Zero/Builder/Builder.swift | 2 +- swift/Sources/Zero/Client/IVMBranch.swift | 9 +- swift/Sources/Zero/Client/Keys.swift | 14 +- swift/Sources/Zero/Client/PokeHandler.swift | 4 +- swift/Sources/Zero/Client/ReplicaStore.swift | 4 +- swift/Sources/Zero/Client/Transaction.swift | 18 +- swift/Sources/Zero/IVM/Change.swift | 10 + swift/Sources/Zero/IVM/Data.swift | 15 +- swift/Sources/Zero/IVM/MemorySource.swift | 46 +-- swift/Sources/Zero/IVM/Take.swift | 11 +- swift/Sources/Zero/IVM/View.swift | 8 +- swift/Sources/Zero/JSON/JSONValue.swift | 4 - swift/Sources/Zero/JSON/Row.swift | 282 ++++++++++++++++++ swift/Sources/Zero/Protocol/Mutation.swift | 8 +- swift/Sources/Zero/Protocol/Primitives.swift | 4 + swift/Sources/Zero/Protocol/RowsPatch.swift | 4 +- swift/Sources/Zero/Query/AnyQuery.swift | 5 + swift/Sources/Zero/Query/Query.swift | 23 +- swift/Sources/Zero/Schema/NameMapper.swift | 6 +- swift/Sources/ZeroBenchmarks/Fixtures.swift | 20 +- swift/Sources/ZeroTesting/Drift.swift | 6 +- swift/Tests/ConformanceTests/ClientApp.swift | 2 +- .../Generated/TestSchema.swift | 44 +-- .../IVMConformanceTests.swift | 17 +- .../Tests/E2ETests/Generated/TestSchema.swift | 44 +-- .../Tests/E2ETests/WalkingSkeletonTests.swift | 2 +- .../ZeroTests/Generated/FixtureSchema.swift | 34 +-- .../ZeroTests/Generated/TestSchema.swift | 44 +-- swift/Tests/ZeroTests/IVMTests.swift | 33 ++ 33 files changed, 620 insertions(+), 222 deletions(-) create mode 100644 swift/Sources/Zero/JSON/Row.swift diff --git a/apps/zbugs-swift/Sources/ZBugs/Schema.swift b/apps/zbugs-swift/Sources/ZBugs/Schema.swift index d64e586ada..bd72d46ea7 100644 --- a/apps/zbugs-swift/Sources/ZBugs/Schema.swift +++ b/apps/zbugs-swift/Sources/ZBugs/Schema.swift @@ -48,11 +48,11 @@ public struct User: ZeroRow, Codable, Hashable, Identifiable { ] public init(entry: ViewEntry) throws(ZeroValueError) { - id = try entry.column("id") - login = try entry.column("login") - name = try entry.column("name") - avatar = try entry.column("avatar") - role = try entry.column("role") + id = try entry.column("id", 0) + login = try entry.column("login", 1) + name = try entry.column("name", 2) + avatar = try entry.column("avatar", 3) + role = try entry.column("role", 4) createdIssues = try entry.relationship("createdIssues") assignedIssues = try entry.relationship("assignedIssues") } @@ -121,13 +121,13 @@ public struct Project: ZeroRow, Codable, Hashable, Identifiable { ] public init(entry: ViewEntry) throws(ZeroValueError) { - id = try entry.column("id") - name = try entry.column("name") - lowerCaseName = try entry.column("lowerCaseName") - issueCountEstimate = try entry.column("issueCountEstimate") - supportsSearch = try entry.column("supportsSearch") - markURL = try entry.column("markURL") - logoURL = try entry.column("logoURL") + id = try entry.column("id", 0) + name = try entry.column("name", 1) + lowerCaseName = try entry.column("lowerCaseName", 2) + issueCountEstimate = try entry.column("issueCountEstimate", 3) + supportsSearch = try entry.column("supportsSearch", 4) + markURL = try entry.column("markURL", 5) + logoURL = try entry.column("logoURL", 6) issues = try entry.relationship("issues") labels = try entry.relationship("labels") } @@ -242,17 +242,17 @@ public struct Issue: ZeroRow, Codable, Hashable, Identifiable { ] public init(entry: ViewEntry) throws(ZeroValueError) { - id = try entry.column("id") - shortID = try entry.column("shortID") - title = try entry.column("title") - `open` = try entry.column("open") - modified = try entry.column("modified") - created = try entry.column("created") - projectID = try entry.column("projectID") - creatorID = try entry.column("creatorID") - assigneeID = try entry.column("assigneeID") - description = try entry.column("description") - visibility = try entry.column("visibility") + id = try entry.column("id", 0) + shortID = try entry.column("shortID", 1) + title = try entry.column("title", 2) + `open` = try entry.column("open", 3) + modified = try entry.column("modified", 4) + created = try entry.column("created", 5) + projectID = try entry.column("projectID", 6) + creatorID = try entry.column("creatorID", 7) + assigneeID = try entry.column("assigneeID", 8) + description = try entry.column("description", 9) + visibility = try entry.column("visibility", 10) project = try entry.relationship("project") issueLabels = try entry.relationship("issueLabels") labels = try entry.relationship("labels") @@ -330,11 +330,11 @@ public struct Comment: ZeroRow, Codable, Hashable, Identifiable { ] public init(entry: ViewEntry) throws(ZeroValueError) { - id = try entry.column("id") - issueID = try entry.column("issueID") - created = try entry.column("created") - body = try entry.column("body") - creatorID = try entry.column("creatorID") + id = try entry.column("id", 0) + issueID = try entry.column("issueID", 1) + created = try entry.column("created", 2) + body = try entry.column("body", 3) + creatorID = try entry.column("creatorID", 4) creator = try entry.relationship("creator") emoji = try entry.relationship("emoji") issue = try entry.relationship("issue") @@ -384,9 +384,9 @@ public struct Label: ZeroRow, Codable, Hashable, Identifiable { ] public init(entry: ViewEntry) throws(ZeroValueError) { - id = try entry.column("id") - name = try entry.column("name") - projectID = try entry.column("projectID") + id = try entry.column("id", 0) + name = try entry.column("name", 1) + projectID = try entry.column("projectID", 2) project = try entry.relationship("project") } @@ -436,9 +436,9 @@ public struct IssueLabel: ZeroRow, Codable, Hashable { ] public init(entry: ViewEntry) throws(ZeroValueError) { - issueID = try entry.column("issueID") - labelID = try entry.column("labelID") - projectID = try entry.column("projectID") + issueID = try entry.column("issueID", 0) + labelID = try entry.column("labelID", 1) + projectID = try entry.column("projectID", 2) issue = try entry.relationship("issue") label = try entry.relationship("label") } @@ -478,9 +478,9 @@ public struct ViewState: ZeroRow, Codable, Hashable { public static let relationships: KeyPathNames = [:] public init(entry: ViewEntry) throws(ZeroValueError) { - issueID = try entry.column("issueID") - userID = try entry.column("userID") - viewed = try entry.column("viewed") + issueID = try entry.column("issueID", 0) + userID = try entry.column("userID", 1) + viewed = try entry.column("viewed", 2) } public var columnValues: Row { @@ -545,12 +545,12 @@ public struct Emoji: ZeroRow, Codable, Hashable, Identifiable { ] public init(entry: ViewEntry) throws(ZeroValueError) { - id = try entry.column("id") - value = try entry.column("value") - annotation = try entry.column("annotation") - subjectID = try entry.column("subjectID") - creatorID = try entry.column("creatorID") - created = try entry.column("created") + id = try entry.column("id", 0) + value = try entry.column("value", 1) + annotation = try entry.column("annotation", 2) + subjectID = try entry.column("subjectID", 3) + creatorID = try entry.column("creatorID", 4) + created = try entry.column("created", 5) creator = try entry.relationship("creator") issue = try entry.relationship("issue") comment = try entry.relationship("comment") @@ -594,9 +594,9 @@ public struct UserPref: ZeroRow, Codable, Hashable { public static let relationships: KeyPathNames = [:] public init(entry: ViewEntry) throws(ZeroValueError) { - key = try entry.column("key") - userID = try entry.column("userID") - value = try entry.column("value") + key = try entry.column("key", 0) + userID = try entry.column("userID", 1) + value = try entry.column("value", 2) } public var columnValues: Row { @@ -638,10 +638,10 @@ public struct IssueNotifications: ZeroRow, Codable, Hashable { public static let relationships: KeyPathNames = [:] public init(entry: ViewEntry) throws(ZeroValueError) { - userID = try entry.column("userID") - issueID = try entry.column("issueID") - subscribed = try entry.column("subscribed") - created = try entry.column("created") + userID = try entry.column("userID", 0) + issueID = try entry.column("issueID", 1) + subscribed = try entry.column("subscribed", 2) + created = try entry.column("created", 3) } public var columnValues: Row { diff --git a/packages/zero-swift-cli/src/generate.ts b/packages/zero-swift-cli/src/generate.ts index cffd274fc5..a4ec5cc39c 100644 --- a/packages/zero-swift-cli/src/generate.ts +++ b/packages/zero-swift-cli/src/generate.ts @@ -374,8 +374,10 @@ export function generate( lines.push(''); lines.push(` ${pub}init(entry: ViewEntry) throws(ZeroValueError) {`); - for (const c of columns) { - lines.push(` ${c.property} = try entry.column(${str(c.name)})`); + // The index is the column's position in the table, where the rows have + // it: reading it there skips looking the name up. + for (const [i, c] of columns.entries()) { + lines.push(` ${c.property} = try entry.column(${str(c.name)}, ${i})`); } for (const r of relationships) { lines.push(` ${r.property} = try entry.relationship(${str(r.name)})`); diff --git a/swift/DESIGN.md b/swift/DESIGN.md index f98e4360b2..3d1a73c102 100644 --- a/swift/DESIGN.md +++ b/swift/DESIGN.md @@ -143,6 +143,14 @@ Rationale: - **Replicache storage machinery goes.** The dag, B-tree, memdag, and refresh stack exist mostly for IndexedDB performance and multi-tab sharing. Swift uses SQLite tables for persistence and in-memory IVM sources for reads. +- **Rows are keyed by column index inside the engine.** A JS row is an + object, whose property reads V8 makes cheap; a Swift dictionary hashes the + name on every read. A `Row` reads and writes like a dictionary but stores + 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, so its + comparators and generated `init(entry:)`s read columns by index, and fall + back to the name for a row in another layout. Rows on the wire and in + storage stay dictionaries. ### Threading diff --git a/swift/Sources/Zero/AST/AST.swift b/swift/Sources/Zero/AST/AST.swift index 521d5825de..4696f54adb 100644 --- a/swift/Sources/Zero/AST/AST.swift +++ b/swift/Sources/Zero/AST/AST.swift @@ -35,10 +35,11 @@ public struct AST: Sendable, Hashable { } public struct Bound: Sendable, Hashable { - public var row: Row + /// The cursor's columns: those the query orders by. + public var row: [String: JSONValue] public var exclusive: Bool - public init(row: Row, exclusive: Bool) { + public init(row: [String: JSONValue], exclusive: Bool) { self.row = row self.exclusive = exclusive } diff --git a/swift/Sources/Zero/Builder/Builder.swift b/swift/Sources/Zero/Builder/Builder.swift index 5003bfbd56..39c163f861 100644 --- a/swift/Sources/Zero/Builder/Builder.swift +++ b/swift/Sources/Zero/Builder/Builder.swift @@ -71,7 +71,7 @@ package func bindStaticParameters(_ ast: AST, _ params: StaticQueryParameters?) guard case .static(let anchor, let field) = v else { return v } guard let params else { preconditionFailure("Static query params do not exist") } let anchorValue: [String: JSONValue]? = - anchor == .authData ? params.authData : params.preMutationRow + anchor == .authData ? params.authData : params.preMutationRow?.dictionary guard let anchorValue else { return .literal(.null) } switch field { case .single(let f): diff --git a/swift/Sources/Zero/Client/IVMBranch.swift b/swift/Sources/Zero/Client/IVMBranch.swift index 087069c67a..03dbad9f6b 100644 --- a/swift/Sources/Zero/Client/IVMBranch.swift +++ b/swift/Sources/Zero/Client/IVMBranch.swift @@ -41,10 +41,13 @@ final class IVMBranch { } switch op { case .del(_, let old): - source.push(.remove(old.objectValue ?? [:])) + source.push(.remove(Row(old.objectValue ?? [:], layout: source.layout))) i += 1 case .change(_, let old, let new): - source.push(.edit(new.objectValue ?? [:], oldRow: old.objectValue ?? [:])) + source.push( + .edit( + Row(new.objectValue ?? [:], layout: source.layout), + oldRow: Row(old.objectValue ?? [:], layout: source.layout))) i += 1 case .add: let tablePrefix = prefix + name + "/" @@ -52,7 +55,7 @@ final class IVMBranch { while i < diff.count, case .add(let key, let value) = diff[i], key.utf8.starts(with: tablePrefix.utf8) { - rows.append(value.objectValue ?? [:]) + rows.append(Row(value.objectValue ?? [:], layout: source.layout)) i += 1 } source.pushAdds(rows) diff --git a/swift/Sources/Zero/Client/Keys.swift b/swift/Sources/Zero/Client/Keys.swift index 330c8cadc9..29203c365c 100644 --- a/swift/Sources/Zero/Client/Keys.swift +++ b/swift/Sources/Zero/Client/Keys.swift @@ -18,9 +18,21 @@ package enum Keys { /// JSON array of its values. package static func primaryKeyString( table: String, primaryKey: [String], row: Row + ) throws(InvalidPrimaryKeyValue) -> String { + try primaryKeyString(table: table, primaryKey: primaryKey) { row[$0] } + } + + package static func primaryKeyString( + table: String, primaryKey: [String], row: RowRecord + ) throws(InvalidPrimaryKeyValue) -> String { + try primaryKeyString(table: table, primaryKey: primaryKey) { row[$0] } + } + + private static func primaryKeyString( + table: String, primaryKey: [String], value: (String) -> JSONValue? ) throws(InvalidPrimaryKeyValue) -> String { func scalar(_ column: String) throws(InvalidPrimaryKeyValue) -> JSONValue { - let v = row[column] + let v = value(column) switch v { case .string?, .number?, .bool?: return v! default: throw InvalidPrimaryKeyValue(column: column, value: v) diff --git a/swift/Sources/Zero/Client/PokeHandler.swift b/swift/Sources/Zero/Client/PokeHandler.swift index 758a4746e8..3b634e87fb 100644 --- a/swift/Sources/Zero/Client/PokeHandler.swift +++ b/swift/Sources/Zero/Client/PokeHandler.swift @@ -270,7 +270,7 @@ final class PokeHandler { return .del(key: try key(table, try serverToClient.row(serverTable, id))) case .update(let serverTable, let id, let merge, let constrain): guard let table = serverToClient.tableNameIfKnown(serverTable) else { return nil } - let mergeRow = try merge.map { m -> Row in + let mergeRow = try merge.map { m -> RowRecord in try serverToClient.row(serverTable, m.objectValue ?? [:]) } return .update( @@ -279,7 +279,7 @@ final class PokeHandler { } } - private func key(_ table: String, _ row: Row) throws -> String { + private func key(_ table: String, _ row: RowRecord) throws -> String { try Keys.primaryKeyString(table: table, primaryKey: schema.tables[table]!.primaryKey, row: row) } } diff --git a/swift/Sources/Zero/Client/ReplicaStore.swift b/swift/Sources/Zero/Client/ReplicaStore.swift index d333bda3bc..366435ba45 100644 --- a/swift/Sources/Zero/Client/ReplicaStore.swift +++ b/swift/Sources/Zero/Client/ReplicaStore.swift @@ -124,7 +124,7 @@ final class KVWrite { /// A patch operation of a pull response (`PatchOperationInternal`). enum KVPatchOp { case put(key: String, value: JSONValue) - case update(key: String, merge: Row?, constrain: [String]?) + case update(key: String, merge: RowRecord?, constrain: [String]?) case del(key: String) case clear @@ -225,7 +225,7 @@ final class ReplicaStore { entries += o.map { ($0.key, $0.value) } } if let merge { entries += merge.map { ($0.key, $0.value) } } - var next: Row = [:] + var next: RowRecord = [:] for (k, v) in entries where constrain == nil || constrain!.isEmpty || constrain!.contains(k) { next[k] = v diff --git a/swift/Sources/Zero/Client/Transaction.swift b/swift/Sources/Zero/Client/Transaction.swift index 567923a44d..0956ad646b 100644 --- a/swift/Sources/Zero/Client/Transaction.swift +++ b/swift/Sources/Zero/Client/Transaction.swift @@ -71,10 +71,11 @@ public final class Transaction { let table = try tableSchema(tableName) let key = try key(table, value) guard !write.has(key) else { return } - var row = value + let source = source(tableName) + var row = value.conformed(to: source.layout) for column in table.columnNames where row[column] == nil { row[column] = .null } - write.set(key, .object(row)) - source(tableName).push(.add(row)) + write.set(key, JSONValue(row)) + source.push(.add(row)) } func upsert(_ tableName: String, _ value: Row) throws { @@ -94,7 +95,8 @@ public final class Transaction { var next = prev for (k, v) in value { next[k] = v } write.set(key, .object(next)) - source(tableName).push(.edit(next, oldRow: prev)) + let source = source(tableName) + source.push(.edit(Row(next, layout: source.layout), oldRow: Row(prev, layout: source.layout))) } func delete(_ tableName: String, _ id: Row) throws { @@ -102,7 +104,8 @@ public final class Transaction { let key = try key(table, id) guard let prev = write.get(key)?.objectValue else { return } write.delete(key) - source(tableName).push(.remove(prev)) + let source = source(tableName) + source.push(.remove(Row(prev, layout: source.layout))) } } @@ -146,4 +149,9 @@ public struct TableMutator { public func update(_ value: Row) throws { try tx.update(table, value) } /// Deletes the row with the primary key in `id`, if any. public func delete(_ id: Row) throws { try tx.delete(table, id) } + + public func insert(_ value: [String: JSONValue]) throws { try insert(Row(value)) } + public func upsert(_ value: [String: JSONValue]) throws { try upsert(Row(value)) } + public func update(_ value: [String: JSONValue]) throws { try update(Row(value)) } + public func delete(_ id: [String: JSONValue]) throws { try delete(Row(id)) } } diff --git a/swift/Sources/Zero/IVM/Change.swift b/swift/Sources/Zero/IVM/Change.swift index cddf66d951..1ae24f9a5b 100644 --- a/swift/Sources/Zero/IVM/Change.swift +++ b/swift/Sources/Zero/IVM/Change.swift @@ -120,6 +120,16 @@ package enum SourceChange: Sendable { case remove(Row) case edit(Row, oldRow: Row) + /// The change with its rows in `layout`. + func conformed(to layout: RowLayout) -> SourceChange { + switch self { + case .add(let row): .add(row.conformed(to: layout)) + case .remove(let row): .remove(row.conformed(to: layout)) + case .edit(let row, let oldRow): + .edit(row.conformed(to: layout), oldRow: oldRow.conformed(to: layout)) + } + } + @inlinable package var type: ChangeType { switch self { diff --git a/swift/Sources/Zero/IVM/Data.swift b/swift/Sources/Zero/IVM/Data.swift index 976c2bebd7..172a6929c4 100644 --- a/swift/Sources/Zero/IVM/Data.swift +++ b/swift/Sources/Zero/IVM/Data.swift @@ -122,16 +122,19 @@ package enum ValueCompare { /// Orders rows (`Comparator` in `data.ts`). package typealias Comparator = (Row, Row) -> Int -/// `makeComparator(order, reverse)`. -package func makeComparator(_ order: AST.Ordering, reverse: Bool = false) -> Comparator { +/// `makeComparator(order, reverse)`. Rows in `layout` read the columns by +/// index. +package func makeComparator( + _ order: AST.Ordering, reverse: Bool = false, layout: RowLayout = .empty +) -> Comparator { if order.count == 1 && order[0].direction == .asc && !reverse { - let field = order[0].field - return { a, b in ValueCompare.compare(a[field], b[field]) } + let field = layout.column(order[0].field) + return { a, b in ValueCompare.compare(a[column: field], b[column: field]) } } - let parts = order.map { ($0.field, $0.direction == .asc) } + let parts = order.map { (layout.column($0.field), $0.direction == .asc) } return { a, b in for (field, asc) in parts { - let comp = ValueCompare.compare(a[field], b[field]) + let comp = ValueCompare.compare(a[column: field], b[column: field]) if comp != 0 { let result = asc ? comp : -comp return reverse ? -result : result diff --git a/swift/Sources/Zero/IVM/MemorySource.swift b/swift/Sources/Zero/IVM/MemorySource.swift index 1f431a3f35..22df8183e6 100644 --- a/swift/Sources/Zero/IVM/MemorySource.swift +++ b/swift/Sources/Zero/IVM/MemorySource.swift @@ -47,18 +47,21 @@ package final class MemorySource: Source { var overlay: Overlay? var pushEpoch = 0 private let schema: TableSchema + /// The layout of the source's rows: its table's columns. + package let layout: RowLayout package convenience init(_ table: TableSchema) { - self.init(table: table, primaryIndexData: nil) + self.init(table: table, layout: RowLayout(table), primaryIndexData: nil) } - init(table: TableSchema, primaryIndexData: BTreeSet?) { + init(table: TableSchema, layout: RowLayout, primaryIndexData: BTreeSet?) { schema = table + self.layout = layout tableName = table.name columns = table.columns primaryKey = table.primaryKey primaryIndexSort = table.primaryKey.map { AST.OrderPart($0, .asc) } - let comparator = BoundComparator(primaryIndexSort) + let comparator = BoundComparator(primaryIndexSort, layout: layout) primaryIndexKey = Self.indexKey(primaryIndexSort) indexes[primaryIndexKey] = Index( comparator: comparator, data: primaryIndexData ?? BTreeSet(comparator.compareRows)) @@ -70,7 +73,7 @@ package final class MemorySource: Source { /// A copy whose primary index shares nodes with this one until either /// changes. package func fork() -> MemorySource { - MemorySource(table: schema, primaryIndexData: primaryIndex.data) + MemorySource(table: schema, layout: layout, primaryIndexData: primaryIndex.data) } /// The rows, in primary key order. @@ -95,7 +98,7 @@ package final class MemorySource: Source { } let connection = Connection( sort: internalSort, splitEditKeys: splitEditKeys, - compareRows: makeComparator(internalSort), filters: connFilters) + compareRows: makeComparator(internalSort, layout: layout), filters: connFilters) let schema = SourceSchema( tableName: tableName, columns: columns, primaryKey: primaryKey, compareRows: connection.compareRows, sort: unordered ? nil : internalSort) @@ -122,7 +125,7 @@ package final class MemorySource: Source { func getOrCreateIndex(_ sort: AST.Ordering) -> Index { let key = Self.indexKey(sort) if let index = indexes[key] { return index } - let comparator = BoundComparator(sort) + let comparator = BoundComparator(sort, layout: layout) let rows = Array(primaryIndex.data.values()).sorted { comparator.compareRows($0, $1) < 0 } let index = Index(comparator: comparator, data: BTreeSet(sorted: rows, comparator.compareRows)) indexes[key] = index @@ -281,6 +284,7 @@ package final class MemorySource: Source { /// Pushes an add for each of `rows`. When nothing is connected and the /// source is empty, builds the primary index in one pass instead. package func pushAdds(_ rows: [Row]) { + let rows = rows.map { $0.conformed(to: layout) } let index = primaryIndex let canBulkLoad = connections.isEmpty && indexes.count == 1 && index.data.isEmpty if !canBulkLoad { @@ -296,7 +300,7 @@ package final class MemorySource: Source { } package func push(_ change: SourceChange) { - genPushAndWriteWithSplitEdit(change) + genPushAndWriteWithSplitEdit(change.conformed(to: layout)) } func genPushAndWriteWithSplitEdit(_ change: SourceChange) { @@ -384,10 +388,10 @@ package final class MemorySource: Source { static func describe(_ change: SourceChange) -> String { switch change { - case .add(let r): "[0,\(JSONSerializer.stringify(.object(r))),null]" - case .remove(let r): "[1,\(JSONSerializer.stringify(.object(r))),null]" + case .add(let r): "[0,\(JSONSerializer.stringify(JSONValue(r))),null]" + case .remove(let r): "[1,\(JSONSerializer.stringify(JSONValue(r))),null]" case .edit(let r, let o): - "[2,\(JSONSerializer.stringify(.object(r))),\(JSONSerializer.stringify(.object(o)))]" + "[2,\(JSONSerializer.stringify(JSONValue(r))),\(JSONSerializer.stringify(JSONValue(o)))]" } } } @@ -424,29 +428,27 @@ enum Bound { } /// `makeBoundComparator(sort)`: orders rows by `sort`, and rows against -/// partial rows whose fields may be `min` or `max`. +/// partial rows whose fields may be `min` or `max`. Rows in `layout` read +/// the columns by index. struct BoundComparator { let sort: AST.Ordering + let layout: RowLayout let compareRows: Comparator - init(_ sort: AST.Ordering) { + init(_ sort: AST.Ordering, layout: RowLayout) { self.sort = sort - let parts = sort.map { ($0.field, $0.direction == .asc) } - compareRows = { a, b in - for (field, asc) in parts { - let c = ValueCompare.compare(a[field], b[field]) - if c != 0 { return asc ? c : -c } - } - return 0 - } + self.layout = layout + compareRows = makeComparator(sort, layout: layout) } /// Compares rows to `bound`: negative when the row is before it. func probe(_ bound: [String: Bound]) -> (Row) -> Int { - let parts = sort.map { ($0.field, $0.direction == .asc, bound[$0.field] ?? .value(nil)) } + let parts = sort.map { + (layout.column($0.field), $0.direction == .asc, bound[$0.field] ?? .value(nil)) + } return { row in for (field, asc, b) in parts { - let c = compareBounds(.value(row[field]), b) + let c = compareBounds(.value(row[column: field]), b) if c != 0 { return asc ? c : -c } } return 0 diff --git a/swift/Sources/Zero/IVM/Take.swift b/swift/Sources/Zero/IVM/Take.swift index 88034ed29c..1996367427 100644 --- a/swift/Sources/Zero/IVM/Take.swift +++ b/swift/Sources/Zero/IVM/Take.swift @@ -393,6 +393,8 @@ func constraintContainsPartitionKey(_ constraint: Constraint?, _ partitionKey: [ final class Skip: Operator { let input: any Input let bound: AST.Bound + /// The bound's row. + let boundRow: Row let comparator: Comparator weak var output: (any Output)? @@ -401,6 +403,7 @@ final class Skip: Operator { precondition(schema.sort != nil, "Skip requires sorted input") self.input = input self.bound = bound + boundRow = Row(bound.row) comparator = schema.compareRows input.setOutput(self) } @@ -428,7 +431,7 @@ final class Skip: Operator { } func shouldBePresent(_ row: Row) -> Bool { - let cmp = comparator(bound.row, row) + let cmp = comparator(boundRow, row) return cmp < 0 || (cmp == 0 && !bound.exclusive) } @@ -448,17 +451,17 @@ final class Skip: Operator { } private func getStart(_ req: FetchRequest) -> FetchStart { - let boundStart = Start(row: bound.row, basis: bound.exclusive ? .after : .at) + let boundStart = Start(row: boundRow, basis: bound.exclusive ? .after : .at) guard let reqStart = req.start else { return req.reverse ? .start(nil) : .start(boundStart) } - let cmp = comparator(bound.row, reqStart.row) + let cmp = comparator(boundRow, reqStart.row) if !req.reverse { // The skip bound is after the requested start. if cmp > 0 { return .start(boundStart) } if cmp == 0 { if bound.exclusive || reqStart.basis == .after { - return .start(Start(row: bound.row, basis: .after)) + return .start(Start(row: boundRow, basis: .after)) } return .start(boundStart) } diff --git a/swift/Sources/Zero/IVM/View.swift b/swift/Sources/Zero/IVM/View.swift index 405d15ef7f..1afb1b2cb3 100644 --- a/swift/Sources/Zero/IVM/View.swift +++ b/swift/Sources/Zero/IVM/View.swift @@ -26,7 +26,7 @@ public struct ViewEntry: Sendable { /// The row with its relationships, as JS views present it. public var json: JSONValue { - var o = row + var o = row.dictionary for (name, value) in relationships { switch value { case .many(let entries): o[name] = .array(entries.map(\.json)) @@ -261,7 +261,11 @@ enum ViewApply { /// `{...existing, ...row}`. static func applyEdit(_ entry: inout ViewEntry, _ row: Row) { - entry.row.merge(row) { _, new in new } + if entry.row.layout === row.layout { + for (i, value) in row.slots.enumerated() where value != nil { entry.row.slots[i] = value } + } else { + for (name, value) in row { entry.row[name] = value } + } } /// Removes and returns the list for `relationship`, so that it is mutated diff --git a/swift/Sources/Zero/JSON/JSONValue.swift b/swift/Sources/Zero/JSON/JSONValue.swift index e5d2817f4a..0e9e215d77 100644 --- a/swift/Sources/Zero/JSON/JSONValue.swift +++ b/swift/Sources/Zero/JSON/JSONValue.swift @@ -15,10 +15,6 @@ public enum JSONValue: Sendable { case object([String: JSONValue]) } -/// A row: column name to value. A missing column reads as `null`, as an -/// `undefined` property does in JS. -public typealias Row = [String: JSONValue] - /// A value in a row or a query literal. package typealias Value = JSONValue diff --git a/swift/Sources/Zero/JSON/Row.swift b/swift/Sources/Zero/JSON/Row.swift new file mode 100644 index 0000000000..6dedcd0abe --- /dev/null +++ b/swift/Sources/Zero/JSON/Row.swift @@ -0,0 +1,282 @@ +/// A row: column name to value. A missing column reads as `nil`, as an +/// `undefined` property does in JS. +/// +/// It reads and writes like a `[String: JSONValue]`. Inside, the values sit +/// in an array, in the order of a layout the rows of a table share, so code +/// that knows the layout reads a column by index instead of hashing its +/// name. +public struct Row: Sendable { + @usableFromInline package var layout: RowLayout + /// A value for each of the layout's columns; nil where the row has none. + @usableFromInline package var slots: [JSONValue?] + + @inlinable + package init(layout: RowLayout, slots: [JSONValue?]) { + self.layout = layout + self.slots = slots + } + + /// An empty row. + public init() { + self.init(layout: .empty, slots: []) + } + + /// The row of `dictionary`'s columns. + public init(_ dictionary: [String: JSONValue]) { + let names = dictionary.keys.sorted(by: StringCompare.utf16Less) + self.init(layout: RowLayout(names), slots: names.map { dictionary[$0] }) + } + + /// The row of `dictionary`'s columns in `layout`, with any others after + /// them. + package init(_ dictionary: [String: JSONValue], layout: RowLayout) { + var slots = [JSONValue?](repeating: nil, count: layout.names.count) + var others: [String] = [] + for (name, value) in dictionary { + if let i = layout.index(of: name) { + slots[i] = value + } else { + others.append(name) + } + } + if others.isEmpty { + self.init(layout: layout, slots: slots) + } else { + others.sort(by: StringCompare.utf16Less) + self.init( + layout: RowLayout(layout.names + others), slots: slots + others.map { dictionary[$0] }) + } + } + + /// This row in `layout`, with any other columns after its own. The row + /// itself when it's in `layout` already. + package func conformed(to layout: RowLayout) -> Row { + if self.layout === layout { return self } + var slots = [JSONValue?](repeating: nil, count: layout.names.count) + var others: [(String, JSONValue)] = [] + for (name, value) in self { + if let i = layout.index(of: name) { + slots[i] = value + } else { + others.append((name, value)) + } + } + if others.isEmpty { return Row(layout: layout, slots: slots) } + return Row( + layout: RowLayout(layout.names + others.map(\.0)), slots: slots + others.map(\.1)) + } + + /// The value of the column `name`. + @inlinable + public subscript(name: String) -> JSONValue? { + get { + guard let i = layout.index(of: name) else { return nil } + return slots[i] + } + set { + if let i = layout.index(of: name) { + slots[i] = newValue + } else if let newValue { + // A new layout: others' rows keep theirs. + layout = layout.appending(name) + slots.append(newValue) + } + } + } + + /// The value of the column `name`, or `defaultValue`. + @inlinable + public subscript(name: String, default defaultValue: @autoclosure () -> JSONValue) -> JSONValue { + self[name] ?? defaultValue() + } + + /// The value of `column`: by index when this row is in its layout. + @inlinable + package subscript(column column: ColumnRef) -> JSONValue? { + if layout === column.layout { + guard let i = column.index else { return nil } + return slots[i] + } + return self[column.name] + } + + /// Removes the column `name`, returning its value. + @discardableResult + public mutating func removeValue(forKey name: String) -> JSONValue? { + guard let i = layout.index(of: name) else { return nil } + defer { slots[i] = nil } + return slots[i] + } + + /// How many columns have a value. + public var count: Int { + var n = 0 + for slot in slots where slot != nil { n += 1 } + return n + } + + public var isEmpty: Bool { slots.allSatisfy { $0 == nil } } + + /// The names of the columns with a value, in the layout's order. + public var keys: [String] { + zip(layout.names, slots).compactMap { $1 == nil ? nil : $0 } + } + + /// The row as a dictionary. + public var dictionary: [String: JSONValue] { + var out: [String: JSONValue] = [:] + out.reserveCapacity(slots.count) + for (name, value) in self { out[name] = value } + return out + } +} + +extension Row: Sequence { + public struct Iterator: IteratorProtocol { + @usableFromInline let row: Row + @usableFromInline var i = 0 + + @inlinable + init(_ row: Row) { self.row = row } + + @inlinable + public mutating func next() -> (key: String, value: JSONValue)? { + while i < row.slots.count { + defer { i += 1 } + if let value = row.slots[i] { return (row.layout.names[i], value) } + } + return nil + } + } + + @inlinable + public func makeIterator() -> Iterator { Iterator(self) } + + @inlinable + public var underestimatedCount: Int { 0 } +} + +extension Row: ExpressibleByDictionaryLiteral { + public init(dictionaryLiteral elements: (String, JSONValue)...) { + var names: [String] = [] + var slots: [JSONValue?] = [] + var indices: [String: Int] = [:] + for (name, value) in elements { + if let i = indices[name] { + slots[i] = value + } else { + indices[name] = names.count + names.append(name) + slots.append(value) + } + } + self.init(layout: RowLayout(names, indices: indices), slots: slots) + } +} + +extension Row: Hashable { + /// The same columns with equal values, whatever the layouts. + public static func == (a: Row, b: Row) -> Bool { + if a.layout === b.layout { return a.slots == b.slots } + var n = 0 + for (name, value) in a { + guard let other = b[name], other == value else { return false } + n += 1 + } + return n == b.count + } + + /// Independent of the layout, as `==` is. + public func hash(into hasher: inout Hasher) { + var acc = 0 + var n = 0 + for (name, value) in self { + var h = Hasher() + h.combine(name) + h.combine(value) + acc ^= h.finalize() + n += 1 + } + hasher.combine(n) + hasher.combine(acc) + } +} + +extension Row: CustomStringConvertible, CustomDebugStringConvertible { + public var description: String { + "[" + map { "\(String(reflecting: $0.key)): \($0.value)" }.joined(separator: ", ") + "]" + } + + public var debugDescription: String { description } +} + +extension JSONValue { + /// The row as a JSON object. + public init(_ row: Row) { + self = .object(row.dictionary) + } +} + +/// The columns of rows, in order. The rows of a table share one, so a row +/// stores only its values, and a column's index is the same in all of them. +/// +/// Immutable, so reading it needs no exclusivity checks: a row that adds a +/// column gets a new layout. +@usableFromInline +package final class RowLayout: Sendable { + @usableFromInline let names: [String] + @usableFromInline let indices: [String: Int] + + package init(_ names: [String]) { + var indices: [String: Int] = [:] + indices.reserveCapacity(names.count) + for (i, name) in names.enumerated() where indices[name] == nil { indices[name] = i } + self.names = names + self.indices = indices + } + + init(_ names: [String], indices: [String: Int]) { + self.names = names + self.indices = indices + } + + package static let empty = RowLayout([]) + + /// The layout of `table`'s columns, in declaration order. + package convenience init(_ table: TableSchema) { + self.init(table.columnNames) + } + + @inlinable + package func index(of name: String) -> Int? { indices[name] } + + /// A column of rows in this layout. + package func column(_ name: String) -> ColumnRef { + ColumnRef(layout: self, index: index(of: name), name: name) + } + + /// This layout with `name` after its columns. + @usableFromInline + func appending(_ name: String) -> RowLayout { + var indices = indices + indices[name] = names.count + return RowLayout(names + [name], indices: indices) + } +} + +/// A column, resolved against a layout so rows in it read it by index. +/// Rows in other layouts read it by name. +@usableFromInline +package struct ColumnRef: Sendable { + @usableFromInline let layout: RowLayout + /// Nil when the layout has no such column. + @usableFromInline let index: Int? + @usableFromInline package let name: String + + @usableFromInline + init(layout: RowLayout, index: Int?, name: String) { + self.layout = layout + self.index = index + self.name = name + } +} diff --git a/swift/Sources/Zero/Protocol/Mutation.swift b/swift/Sources/Zero/Protocol/Mutation.swift index 18677139fa..b7f930ebf7 100644 --- a/swift/Sources/Zero/Protocol/Mutation.swift +++ b/swift/Sources/Zero/Protocol/Mutation.swift @@ -5,10 +5,10 @@ package let crudMutationName = "_zero_crud" /// One operation of a CRUD mutation (`crudOpSchema` in /// `packages/zero-protocol/src/mutation.ts`). package enum CRUDOp: Sendable, Hashable, JSONCodable { - case insert(tableName: String, primaryKey: [String], value: Row) - case upsert(tableName: String, primaryKey: [String], value: Row) + case insert(tableName: String, primaryKey: [String], value: RowRecord) + case upsert(tableName: String, primaryKey: [String], value: RowRecord) /// `value` is a partial row with at least the primary key columns. - case update(tableName: String, primaryKey: [String], value: Row) + case update(tableName: String, primaryKey: [String], value: RowRecord) /// `value` holds the primary key columns. case delete(tableName: String, primaryKey: [String], value: PrimaryKeyValueRecord) @@ -43,7 +43,7 @@ package enum CRUDOp: Sendable, Hashable, JSONCodable { } package var json: JSONValue { - let (op, tableName, primaryKey, value): (String, String, [String], Row) = + let (op, tableName, primaryKey, value): (String, String, [String], RowRecord) = switch self { case .insert(let t, let pk, let v): ("insert", t, pk, v) case .upsert(let t, let pk, let v): ("upsert", t, pk, v) diff --git a/swift/Sources/Zero/Protocol/Primitives.swift b/swift/Sources/Zero/Protocol/Primitives.swift index 3dffe3726a..794c8a5269 100644 --- a/swift/Sources/Zero/Protocol/Primitives.swift +++ b/swift/Sources/Zero/Protocol/Primitives.swift @@ -8,6 +8,10 @@ package typealias Version = String /// Each value is a string, number, or boolean. package typealias PrimaryKeyValueRecord = [String: JSONValue] +/// A row as the protocol carries it (`rowSchema` in +/// `packages/zero-protocol/src/data.ts`): a record of JSON values. +package typealias RowRecord = [String: JSONValue] + /// `packages/zero-protocol/src/mutation-id.ts`. package struct MutationID: Sendable, Hashable, JSONCodable { package var id: Double diff --git a/swift/Sources/Zero/Protocol/RowsPatch.swift b/swift/Sources/Zero/Protocol/RowsPatch.swift index 9c791de6ee..d90503ec7a 100644 --- a/swift/Sources/Zero/Protocol/RowsPatch.swift +++ b/swift/Sources/Zero/Protocol/RowsPatch.swift @@ -2,7 +2,7 @@ /// `packages/zero-protocol/src/row-patch.ts`). package enum RowPatchOp: Sendable, Hashable, JSONCodable { /// Inserts or replaces the row. - case put(tableName: String, value: Row) + case put(tableName: String, value: RowRecord) /// Merges `merge` into the row with primary key `id`. `merge` is a /// `jsonObjectSchema`, which checks `typeof v === 'object'` and so admits /// an array as well as an object; it is kept as sent. `constrain` limits @@ -61,7 +61,7 @@ package typealias RowsPatch = [RowPatchOp] extension JSONDecode { /// `rowSchema` (`packages/zero-protocol/src/data.ts`): a record of JSON /// values. - static func row(_ v: JSONValue) throws(DecodingError) -> Row { + static func row(_ v: JSONValue) throws(DecodingError) -> RowRecord { try object(v) } } diff --git a/swift/Sources/Zero/Query/AnyQuery.swift b/swift/Sources/Zero/Query/AnyQuery.swift index b876cd78b2..dd3698904f 100644 --- a/swift/Sources/Zero/Query/AnyQuery.swift +++ b/swift/Sources/Zero/Query/AnyQuery.swift @@ -138,6 +138,11 @@ public struct AnyQuery: Sendable { } public func start(_ row: Row, inclusive: Bool = false) -> AnyQuery { + start(row.dictionary, inclusive: inclusive) + } + + /// Starts after the row with these columns, or at it when `inclusive`. + public func start(_ row: [String: JSONValue], inclusive: Bool = false) -> AnyQuery { var ast = self.ast ast.start = AST.Bound(row: row, exclusive: !inclusive) return with(ast: ast) diff --git a/swift/Sources/Zero/Query/Query.swift b/swift/Sources/Zero/Query/Query.swift index 855b6c5b2d..703ec6e03e 100644 --- a/swift/Sources/Zero/Query/Query.swift +++ b/swift/Sources/Zero/Query/Query.swift @@ -48,7 +48,23 @@ extension ViewEntry { /// column that can't be null reads as `V.valueForNull`. @inlinable public func column(_ name: String) throws(ZeroValueError) -> V { - let value = row[name] ?? .null + try Self.decode(name, row[name] ?? .null) + } + + /// A column of the row, for generated `init(entry:)`s: `index` is its + /// position in the table's schema, where the row has it unless it came + /// from elsewhere. Reading it there skips looking the name up. + @inlinable + public func column(_ name: String, _ index: Int) throws(ZeroValueError) -> V { + let names = row.layout.names + if index < names.count && names[index] == name { + return try Self.decode(name, row.slots[index] ?? .null) + } + return try Self.decode(name, row[name] ?? .null) + } + + @inlinable + static func decode(_ name: String, _ value: JSONValue) throws(ZeroValueError) -> V { if case .null = value, let fallback = V.valueForNull { return fallback } do { return try V(zeroValue: value) @@ -84,9 +100,12 @@ public protocol QueryResult: Sendable { extension Array: QueryResult where Element: ZeroRow { public typealias Row = Element + @inlinable public init(view: ViewValue) throws(ZeroValueError) { + let entries = view.entries var out: [Element] = [] - for e in view.entries { out.append(try Element(entry: e)) } + out.reserveCapacity(entries.count) + for e in entries { out.append(try Element(entry: e)) } self = out } diff --git a/swift/Sources/Zero/Schema/NameMapper.swift b/swift/Sources/Zero/Schema/NameMapper.swift index f3b9097a51..957e4173a4 100644 --- a/swift/Sources/Zero/Schema/NameMapper.swift +++ b/swift/Sources/Zero/Schema/NameMapper.swift @@ -66,10 +66,10 @@ package struct NameMapper: Sendable { } /// Renames the known columns of `row`; others keep their names. - package func row(_ table: String, _ row: Row) throws(NameMappingError) -> Row { + package func row(_ table: String, _ row: RowRecord) throws(NameMappingError) -> RowRecord { let dest = try self.table(table) if dest.allColumnsSame { return row } - var out: Row = [:] + var out: RowRecord = [:] for (column, value) in row { out[dest.columns[column] ?? column] = value } return out } @@ -96,7 +96,7 @@ package struct NameMapper: Sendable { out.related = mapped } if let start = ast.start { - var row: Row = [:] + var row: [String: JSONValue] = [:] for (column, value) in start.row { row[try columnName(ast.table, column)] = value } out.start = AST.Bound(row: row, exclusive: start.exclusive) } diff --git a/swift/Sources/ZeroBenchmarks/Fixtures.swift b/swift/Sources/ZeroBenchmarks/Fixtures.swift index efb7ed703f..f1c5ba6119 100644 --- a/swift/Sources/ZeroBenchmarks/Fixtures.swift +++ b/swift/Sources/ZeroBenchmarks/Fixtures.swift @@ -25,16 +25,16 @@ struct User: ZeroRow, Hashable { static let relationships: KeyPathNames = [:] init(entry: ViewEntry) throws(ZeroValueError) { - a = try entry.column("a") - b = try entry.column("b") - c = try entry.column("c") - d = try entry.column("d") - e = try entry.column("e") - f = try entry.column("f") - g = try entry.column("g") - h = try entry.column("h") - i = try entry.column("i") - j = try entry.column("j") + a = try entry.column("a", 0) + b = try entry.column("b", 1) + c = try entry.column("c", 2) + d = try entry.column("d", 3) + e = try entry.column("e", 4) + f = try entry.column("f", 5) + g = try entry.column("g", 6) + h = try entry.column("h", 7) + i = try entry.column("i", 8) + j = try entry.column("j", 9) } var columnValues: Row { diff --git a/swift/Sources/ZeroTesting/Drift.swift b/swift/Sources/ZeroTesting/Drift.swift index 24fe38c0a2..590aece39e 100644 --- a/swift/Sources/ZeroTesting/Drift.swift +++ b/swift/Sources/ZeroTesting/Drift.swift @@ -187,9 +187,9 @@ public enum Drift { for key in Set(old.keys).union(new.keys).sorted(by: { StringCompare.utf8($0, $1) < 0 }) { switch (old[key], new[key]) { case (let o, let n?) where o != n: - changes.append(["table": .string(table), "op": "put", "row": .object(n)]) + changes.append(["table": .string(table), "op": "put", "row": JSONValue(n)]) case (let o?, nil): - var id: Row = [:] + var id: [String: JSONValue] = [:] for c in pk { id[c] = o[c] ?? .null } changes.append(["table": .string(table), "op": "del", "id": .object(id)]) default: @@ -213,7 +213,7 @@ private struct SeedMutator: Mutator { func run(_ tx: Transaction, args: JSONValue?, context: Context) throws -> JSONValue? { for (table, rows) in args?.objectValue ?? [:] { for row in rows.arrayValue ?? [] { - try tx.mutate[dynamicMember: table].upsert(row.objectValue ?? [:]) + try tx.mutate[dynamicMember: table].upsert(Row(row.objectValue ?? [:])) } } return nil diff --git a/swift/Tests/ConformanceTests/ClientApp.swift b/swift/Tests/ConformanceTests/ClientApp.swift index 19485f9e0a..65cc104c71 100644 --- a/swift/Tests/ConformanceTests/ClientApp.swift +++ b/swift/Tests/ConformanceTests/ClientApp.swift @@ -75,7 +75,7 @@ enum ClientApp { return nil }, JSONMutator(name: "issue.update") { tx, args in - try tx.mutate.issue.update(args?.objectValue ?? [:]) + try tx.mutate.issue.update(Row(args?.objectValue ?? [:])) return nil }, JSONMutator(name: "issue.delete") { tx, args in diff --git a/swift/Tests/ConformanceTests/Generated/TestSchema.swift b/swift/Tests/ConformanceTests/Generated/TestSchema.swift index 36c65420d4..04e7b05ab3 100644 --- a/swift/Tests/ConformanceTests/Generated/TestSchema.swift +++ b/swift/Tests/ConformanceTests/Generated/TestSchema.swift @@ -64,12 +64,12 @@ struct TestIssue: ZeroRow, Codable, Hashable, Identifiable { ] init(entry: ViewEntry) throws(ZeroValueError) { - id = try entry.column("id") - title = try entry.column("title") - description = try entry.column("description") - closed = try entry.column("closed") - ownerId = try entry.column("ownerId") - createdAt = try entry.column("createdAt") + id = try entry.column("id", 0) + title = try entry.column("title", 1) + description = try entry.column("description", 2) + closed = try entry.column("closed", 3) + ownerId = try entry.column("ownerId", 4) + createdAt = try entry.column("createdAt", 5) owner = try entry.relationship("owner") comments = try entry.relationship("comments") oneComment = try entry.relationship("oneComment") @@ -122,9 +122,9 @@ struct TestUser: ZeroRow, Codable, Hashable, Identifiable { ] init(entry: ViewEntry) throws(ZeroValueError) { - id = try entry.column("id") - name = try entry.column("name") - metadata = try entry.column("metadata") + id = try entry.column("id", 0) + name = try entry.column("name", 1) + metadata = try entry.column("metadata", 2) issues = try entry.relationship("issues") } @@ -186,11 +186,11 @@ struct TestComment: ZeroRow, Codable, Hashable, Identifiable { ] init(entry: ViewEntry) throws(ZeroValueError) { - id = try entry.column("id") - authorId = try entry.column("authorId") - issueId = try entry.column("issueId") - text = try entry.column("text") - createdAt = try entry.column("createdAt") + id = try entry.column("id", 0) + authorId = try entry.column("authorId", 1) + issueId = try entry.column("issueId", 2) + text = try entry.column("text", 3) + createdAt = try entry.column("createdAt", 4) issue = try entry.relationship("issue") revisions = try entry.relationship("revisions") author = try entry.relationship("author") @@ -248,10 +248,10 @@ struct TestRevision: ZeroRow, Codable, Hashable, Identifiable { ] init(entry: ViewEntry) throws(ZeroValueError) { - id = try entry.column("id") - authorId = try entry.column("authorId") - commentId = try entry.column("commentId") - text = try entry.column("text") + id = try entry.column("id", 0) + authorId = try entry.column("authorId", 1) + commentId = try entry.column("commentId", 2) + text = try entry.column("text", 3) comment = try entry.relationship("comment") author = try entry.relationship("author") } @@ -295,8 +295,8 @@ struct TestLabel: ZeroRow, Codable, Hashable, Identifiable { ] init(entry: ViewEntry) throws(ZeroValueError) { - id = try entry.column("id") - name = try entry.column("name") + id = try entry.column("id", 0) + name = try entry.column("name", 1) issues = try entry.relationship("issues") } @@ -330,8 +330,8 @@ struct TestIssueLabel: ZeroRow, Codable, Hashable { static let relationships: KeyPathNames = [:] init(entry: ViewEntry) throws(ZeroValueError) { - issueId = try entry.column("issueId") - labelId = try entry.column("labelId") + issueId = try entry.column("issueId", 0) + labelId = try entry.column("labelId", 1) } var columnValues: Row { diff --git a/swift/Tests/ConformanceTests/IVMConformanceTests.swift b/swift/Tests/ConformanceTests/IVMConformanceTests.swift index 005edd26c2..ac30c6a07b 100644 --- a/swift/Tests/ConformanceTests/IVMConformanceTests.swift +++ b/swift/Tests/ConformanceTests/IVMConformanceTests.swift @@ -37,12 +37,13 @@ struct IVMConformanceTests { static func record(_ change: Change) -> JSONValue { switch change { - case .add(let n): ["type": "add", "row": .object(n.row)] - case .remove(let n): ["type": "remove", "row": .object(n.row)] - case .edit(let n, let o): ["type": "edit", "row": .object(n.row), "oldRow": .object(o.row)] + case .add(let n): ["type": "add", "row": JSONValue(n.row)] + case .remove(let n): ["type": "remove", "row": JSONValue(n.row)] + case .edit(let n, let o): + ["type": "edit", "row": JSONValue(n.row), "oldRow": JSONValue(o.row)] case .child(let n, let c): [ - "type": "child", "row": .object(n.row), + "type": "child", "row": JSONValue(n.row), "child": ["relationshipName": .string(c.relationshipName), "change": record(c.change)], ] } @@ -51,11 +52,11 @@ struct IVMConformanceTests { static func sourceChange(_ push: JSONValue) -> (String, SourceChange) { let table = push["table"]!.stringValue! - let row = push["row"]!.objectValue! + let row = Row(push["row"]!.objectValue!) switch push["type"]!.stringValue! { case "add": return (table, .add(row)) case "remove": return (table, .remove(row)) - default: return (table, .edit(row, oldRow: push["oldRow"]!.objectValue!)) + default: return (table, .edit(row, oldRow: Row(push["oldRow"]!.objectValue!))) } } @@ -123,7 +124,9 @@ struct IVMConformanceTests { var sources: [String: MemorySource] = [:] for name in schema.tableNames { let source = MemorySource(schema.tables[name]!) - for row in dataset["data"]?[name]?.arrayValue ?? [] { source.push(.add(row.objectValue!)) } + for row in dataset["data"]?[name]?.arrayValue ?? [] { + source.push(.add(Row(row.objectValue!))) + } sources[name] = source } let ast = try AST(json: c["ast"]!) diff --git a/swift/Tests/E2ETests/Generated/TestSchema.swift b/swift/Tests/E2ETests/Generated/TestSchema.swift index 36c65420d4..04e7b05ab3 100644 --- a/swift/Tests/E2ETests/Generated/TestSchema.swift +++ b/swift/Tests/E2ETests/Generated/TestSchema.swift @@ -64,12 +64,12 @@ struct TestIssue: ZeroRow, Codable, Hashable, Identifiable { ] init(entry: ViewEntry) throws(ZeroValueError) { - id = try entry.column("id") - title = try entry.column("title") - description = try entry.column("description") - closed = try entry.column("closed") - ownerId = try entry.column("ownerId") - createdAt = try entry.column("createdAt") + id = try entry.column("id", 0) + title = try entry.column("title", 1) + description = try entry.column("description", 2) + closed = try entry.column("closed", 3) + ownerId = try entry.column("ownerId", 4) + createdAt = try entry.column("createdAt", 5) owner = try entry.relationship("owner") comments = try entry.relationship("comments") oneComment = try entry.relationship("oneComment") @@ -122,9 +122,9 @@ struct TestUser: ZeroRow, Codable, Hashable, Identifiable { ] init(entry: ViewEntry) throws(ZeroValueError) { - id = try entry.column("id") - name = try entry.column("name") - metadata = try entry.column("metadata") + id = try entry.column("id", 0) + name = try entry.column("name", 1) + metadata = try entry.column("metadata", 2) issues = try entry.relationship("issues") } @@ -186,11 +186,11 @@ struct TestComment: ZeroRow, Codable, Hashable, Identifiable { ] init(entry: ViewEntry) throws(ZeroValueError) { - id = try entry.column("id") - authorId = try entry.column("authorId") - issueId = try entry.column("issueId") - text = try entry.column("text") - createdAt = try entry.column("createdAt") + id = try entry.column("id", 0) + authorId = try entry.column("authorId", 1) + issueId = try entry.column("issueId", 2) + text = try entry.column("text", 3) + createdAt = try entry.column("createdAt", 4) issue = try entry.relationship("issue") revisions = try entry.relationship("revisions") author = try entry.relationship("author") @@ -248,10 +248,10 @@ struct TestRevision: ZeroRow, Codable, Hashable, Identifiable { ] init(entry: ViewEntry) throws(ZeroValueError) { - id = try entry.column("id") - authorId = try entry.column("authorId") - commentId = try entry.column("commentId") - text = try entry.column("text") + id = try entry.column("id", 0) + authorId = try entry.column("authorId", 1) + commentId = try entry.column("commentId", 2) + text = try entry.column("text", 3) comment = try entry.relationship("comment") author = try entry.relationship("author") } @@ -295,8 +295,8 @@ struct TestLabel: ZeroRow, Codable, Hashable, Identifiable { ] init(entry: ViewEntry) throws(ZeroValueError) { - id = try entry.column("id") - name = try entry.column("name") + id = try entry.column("id", 0) + name = try entry.column("name", 1) issues = try entry.relationship("issues") } @@ -330,8 +330,8 @@ struct TestIssueLabel: ZeroRow, Codable, Hashable { static let relationships: KeyPathNames = [:] init(entry: ViewEntry) throws(ZeroValueError) { - issueId = try entry.column("issueId") - labelId = try entry.column("labelId") + issueId = try entry.column("issueId", 0) + labelId = try entry.column("labelId", 1) } var columnValues: Row { diff --git a/swift/Tests/E2ETests/WalkingSkeletonTests.swift b/swift/Tests/E2ETests/WalkingSkeletonTests.swift index cd5b4cc002..0be548ec39 100644 --- a/swift/Tests/E2ETests/WalkingSkeletonTests.swift +++ b/swift/Tests/E2ETests/WalkingSkeletonTests.swift @@ -74,7 +74,7 @@ struct WalkingSkeletonTests { var connected = false var chunks: [ArraySlice] = [] - var rows: [String: [Row]] = [:] + var rows: [String: [RowRecord]] = [:] var gotQueries: Set = [] receiving: while let message = try await socket.receive() { switch message { diff --git a/swift/Tests/ZeroTests/Generated/FixtureSchema.swift b/swift/Tests/ZeroTests/Generated/FixtureSchema.swift index 376efe25be..5108e65ca7 100644 --- a/swift/Tests/ZeroTests/Generated/FixtureSchema.swift +++ b/swift/Tests/ZeroTests/Generated/FixtureSchema.swift @@ -56,13 +56,13 @@ public struct FxProject: ZeroRow, Codable, Hashable, Identifiable { ] public init(entry: ViewEntry) throws(ZeroValueError) { - id = try entry.column("id") - name = try entry.column("name") - `default` = try entry.column("default") - status = try entry.column("status") - settings = try entry.column("settings") - position = try entry.column("position") - archivedStatus = try entry.column("archivedStatus") + id = try entry.column("id", 0) + name = try entry.column("name", 1) + `default` = try entry.column("default", 2) + status = try entry.column("status", 3) + settings = try entry.column("settings", 4) + position = try entry.column("position", 5) + archivedStatus = try entry.column("archivedStatus", 6) queries = try entry.relationship("queries") people = try entry.relationship("people") } @@ -117,10 +117,10 @@ public struct FxQuery: ZeroRow, Codable, Hashable, Identifiable { ] public init(entry: ViewEntry) throws(ZeroValueError) { - id = try entry.column("id") - projectID = try entry.column("projectID") - text = try entry.column("text") - `class` = try entry.column("class") + id = try entry.column("id", 0) + projectID = try entry.column("projectID", 1) + text = try entry.column("text", 2) + `class` = try entry.column("class", 3) project = try entry.relationship("project") } @@ -160,9 +160,9 @@ public struct FxMember: ZeroRow, Codable, Hashable { public static let relationships: KeyPathNames = [:] public init(entry: ViewEntry) throws(ZeroValueError) { - projectID = try entry.column("projectID") - userID = try entry.column("userID") - role = try entry.column("role") + projectID = try entry.column("projectID", 0) + userID = try entry.column("userID", 1) + role = try entry.column("role", 2) } public var columnValues: Row { @@ -200,9 +200,9 @@ public struct FxPerson: ZeroRow, Codable, Hashable, Identifiable { public static let relationships: KeyPathNames = [:] public init(entry: ViewEntry) throws(ZeroValueError) { - id = try entry.column("id") - name = try entry.column("name") - nickname = try entry.column("nickname") + id = try entry.column("id", 0) + name = try entry.column("name", 1) + nickname = try entry.column("nickname", 2) } public var columnValues: Row { diff --git a/swift/Tests/ZeroTests/Generated/TestSchema.swift b/swift/Tests/ZeroTests/Generated/TestSchema.swift index 383b9373c3..b98b915e65 100644 --- a/swift/Tests/ZeroTests/Generated/TestSchema.swift +++ b/swift/Tests/ZeroTests/Generated/TestSchema.swift @@ -64,12 +64,12 @@ public struct Issue: ZeroRow, Codable, Hashable, Identifiable { ] public init(entry: ViewEntry) throws(ZeroValueError) { - id = try entry.column("id") - title = try entry.column("title") - description = try entry.column("description") - closed = try entry.column("closed") - ownerId = try entry.column("ownerId") - createdAt = try entry.column("createdAt") + id = try entry.column("id", 0) + title = try entry.column("title", 1) + description = try entry.column("description", 2) + closed = try entry.column("closed", 3) + ownerId = try entry.column("ownerId", 4) + createdAt = try entry.column("createdAt", 5) owner = try entry.relationship("owner") comments = try entry.relationship("comments") oneComment = try entry.relationship("oneComment") @@ -122,9 +122,9 @@ public struct User: ZeroRow, Codable, Hashable, Identifiable { ] public init(entry: ViewEntry) throws(ZeroValueError) { - id = try entry.column("id") - name = try entry.column("name") - metadata = try entry.column("metadata") + id = try entry.column("id", 0) + name = try entry.column("name", 1) + metadata = try entry.column("metadata", 2) issues = try entry.relationship("issues") } @@ -186,11 +186,11 @@ public struct Comment: ZeroRow, Codable, Hashable, Identifiable { ] public init(entry: ViewEntry) throws(ZeroValueError) { - id = try entry.column("id") - authorId = try entry.column("authorId") - issueId = try entry.column("issueId") - text = try entry.column("text") - createdAt = try entry.column("createdAt") + id = try entry.column("id", 0) + authorId = try entry.column("authorId", 1) + issueId = try entry.column("issueId", 2) + text = try entry.column("text", 3) + createdAt = try entry.column("createdAt", 4) issue = try entry.relationship("issue") revisions = try entry.relationship("revisions") author = try entry.relationship("author") @@ -248,10 +248,10 @@ public struct Revision: ZeroRow, Codable, Hashable, Identifiable { ] public init(entry: ViewEntry) throws(ZeroValueError) { - id = try entry.column("id") - authorId = try entry.column("authorId") - commentId = try entry.column("commentId") - text = try entry.column("text") + id = try entry.column("id", 0) + authorId = try entry.column("authorId", 1) + commentId = try entry.column("commentId", 2) + text = try entry.column("text", 3) comment = try entry.relationship("comment") author = try entry.relationship("author") } @@ -295,8 +295,8 @@ public struct Label: ZeroRow, Codable, Hashable, Identifiable { ] public init(entry: ViewEntry) throws(ZeroValueError) { - id = try entry.column("id") - name = try entry.column("name") + id = try entry.column("id", 0) + name = try entry.column("name", 1) issues = try entry.relationship("issues") } @@ -330,8 +330,8 @@ public struct IssueLabel: ZeroRow, Codable, Hashable { public static let relationships: KeyPathNames = [:] public init(entry: ViewEntry) throws(ZeroValueError) { - issueId = try entry.column("issueId") - labelId = try entry.column("labelId") + issueId = try entry.column("issueId", 0) + labelId = try entry.column("labelId", 1) } public var columnValues: Row { diff --git a/swift/Tests/ZeroTests/IVMTests.swift b/swift/Tests/ZeroTests/IVMTests.swift index d7fd15b033..8dbfebaf5d 100644 --- a/swift/Tests/ZeroTests/IVMTests.swift +++ b/swift/Tests/ZeroTests/IVMTests.swift @@ -76,4 +76,37 @@ import Testing #expect(titles() == ["b", "c"]) #expect(notified > 0) } + + /// Rows in a table's layout and rows in their own read the same. + @Test func rowsReadTheSameInAnyLayout() throws { + let comments = MemorySource(Self.schema.tables["comment"]!) + comments.push(.add(["body": "x", "id": "c1", "issueID": "i1"])) + comments.push(.add(["id": "c2", "issueID": "i1", "body": "y"])) + var c1 = comments.data.values().first { $0["id"] == "c1" }! + let c2 = comments.data.values().first { $0["id"] == "c2" }! + + // A column the layout lacks is the row's own. + c1["extra"] = 1 + #expect(c1["extra"] == 1) + #expect(c2["extra"] == nil) + #expect(c1.keys == ["id", "issueID", "body", "extra"]) + + // Equal, with equal hashes, whatever the layout or the column order. + let own: Row = ["extra": 1, "issueID": "i1", "body": "x", "id": "c1"] + #expect(own == c1) + #expect(own.hashValue == c1.hashValue) + #expect(own != c2) + + // A comparator for the source's layout reads rows in others by name. + let compare = makeComparator([AST.OrderPart("body", .desc)], layout: comments.layout) + #expect(compare(c2, own) < 0) + #expect(compare(own, c2) > 0) + + // A generated `init(entry:)` whose index is off reads the column by name. + let entry = ViewEntry(row: c1) + let body: String = try entry.column("body", 0) + let extra: Double = try entry.column("extra", 7) + #expect(body == "x") + #expect(extra == 1) + } } From 314642a1862f307e8dac66fb4fbc222f031c3fac Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 19 Oct 2026 20:28:10 +0000 Subject: [PATCH 046/104] 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 --- swift/DESIGN.md | 10 +- swift/Sources/Zero/Client/Engine.swift | 2 +- swift/Sources/Zero/Client/IVMBranch.swift | 25 ++- swift/Sources/Zero/Client/QueryContext.swift | 4 +- swift/Sources/Zero/Client/Transaction.swift | 9 +- swift/Sources/Zero/IVM/MemorySource.swift | 7 +- swift/Sources/Zero/IVM/View.swift | 4 +- swift/Sources/Zero/JSON/Row.swift | 179 ++++++++++++++----- swift/Sources/Zero/Query/Query.swift | 2 +- 9 files changed, 181 insertions(+), 61 deletions(-) diff --git a/swift/DESIGN.md b/swift/DESIGN.md index 3d1a73c102..a34ec1860f 100644 --- a/swift/DESIGN.md +++ b/swift/DESIGN.md @@ -146,11 +146,11 @@ Rationale: - **Rows are keyed by column index inside the engine.** A JS row is an object, whose property reads V8 makes cheap; a Swift dictionary hashes the name on every read. A `Row` reads and writes like a dictionary but stores - 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, so its - comparators and generated `init(entry:)`s read columns by index, and fall - back to the name for a row in another layout. Rows on the wire and in - storage stay dictionaries. + its values in one buffer, in the order of a layout the rows of a table + share (a branch and its forks too). A source puts the rows it gets in its + table's layout, so its comparators and generated `init(entry:)`s read + columns by index, and fall back to the name for a row in another layout. + Rows on the wire and in storage stay dictionaries. ### Threading diff --git a/swift/Sources/Zero/Client/Engine.swift b/swift/Sources/Zero/Client/Engine.swift index a4213db019..5c81cc7b47 100644 --- a/swift/Sources/Zero/Client/Engine.swift +++ b/swift/Sources/Zero/Client/Engine.swift @@ -1170,7 +1170,7 @@ final class Engine { clientID: clientID, id: mutationID, name: name, args: pending.args, timestamp: pending.timestamp)) ]) - context.processChanges(diff) + context.processChanges(diff, rows: tx.rows) requestPush(now: false) fireWatchers(diff) client.resolve(.success(data: result)) diff --git a/swift/Sources/Zero/Client/IVMBranch.swift b/swift/Sources/Zero/Client/IVMBranch.swift index 03dbad9f6b..65a76da591 100644 --- a/swift/Sources/Zero/Client/IVMBranch.swift +++ b/swift/Sources/Zero/Client/IVMBranch.swift @@ -5,27 +5,36 @@ final class IVMBranch { private var sources: [String: MemorySource?] private let schema: ZeroSchema + /// The layout of each table's rows. Forks share them, so the rows a + /// mutator writes in a fork go into this branch's sources as they are. + private let layouts: [String: RowLayout] - init(schema: ZeroSchema, sources: [String: MemorySource?] = [:]) { + convenience init(schema: ZeroSchema) { + self.init(schema: schema, sources: [:], layouts: schema.tables.mapValues { RowLayout($0) }) + } + + private init(schema: ZeroSchema, sources: [String: MemorySource?], layouts: [String: RowLayout]) { self.schema = schema self.sources = sources + self.layouts = layouts } func source(_ table: String) -> MemorySource? { if let source = sources[table] { return source } - let source = schema.tables[table].map { MemorySource($0) } + let source = schema.tables[table].map { MemorySource($0, layout: layouts[table]!) } sources[table] = .some(source) return source } /// A copy whose sources share rows with these until written. func fork() -> IVMBranch { - IVMBranch(schema: schema, sources: sources.mapValues { $0?.fork() }) + IVMBranch(schema: schema, sources: sources.mapValues { $0?.fork() }, layouts: layouts) } /// Applies the entity changes of `diff` (`applyDiffs`). A run of adds to - /// one table is pushed together. - func apply(_ diff: KVDiff) { + /// one table is pushed together. A row in `rows`, by key, is the one a + /// transaction wrote, which is the value the diff has for it. + func apply(_ diff: KVDiff, rows written: [String: Row] = [:]) { let prefix = Keys.entitiesPrefix var i = 0 while i < diff.count { @@ -43,10 +52,10 @@ final class IVMBranch { case .del(_, let old): source.push(.remove(Row(old.objectValue ?? [:], layout: source.layout))) i += 1 - case .change(_, let old, let new): + case .change(let key, let old, let new): source.push( .edit( - Row(new.objectValue ?? [:], layout: source.layout), + written[key] ?? Row(new.objectValue ?? [:], layout: source.layout), oldRow: Row(old.objectValue ?? [:], layout: source.layout))) i += 1 case .add: @@ -55,7 +64,7 @@ final class IVMBranch { while i < diff.count, case .add(let key, let value) = diff[i], key.utf8.starts(with: tablePrefix.utf8) { - rows.append(Row(value.objectValue ?? [:], layout: source.layout)) + rows.append(written[key] ?? Row(value.objectValue ?? [:], layout: source.layout)) i += 1 } source.pushAdds(rows) diff --git a/swift/Sources/Zero/Client/QueryContext.swift b/swift/Sources/Zero/Client/QueryContext.swift index 74fbbf09bd..608015a399 100644 --- a/swift/Sources/Zero/Client/QueryContext.swift +++ b/swift/Sources/Zero/Client/QueryContext.swift @@ -102,9 +102,9 @@ final class QueryContext { } /// Pushes `diff` into the branch and flushes the views. - func processChanges(_ diff: KVDiff) { + func processChanges(_ diff: KVDiff, rows: [String: Row] = [:]) { batchViewUpdates { - branch.apply(diff) + branch.apply(diff, rows: rows) endTransaction() } } diff --git a/swift/Sources/Zero/Client/Transaction.swift b/swift/Sources/Zero/Client/Transaction.swift index 0956ad646b..52b27cfff7 100644 --- a/swift/Sources/Zero/Client/Transaction.swift +++ b/swift/Sources/Zero/Client/Transaction.swift @@ -21,6 +21,9 @@ public final class Transaction { let write: KVWrite let branch: IVMBranch let schema: ZeroSchema + /// The rows written, by key, as the branch has them, so the main branch + /// can take the same rows rather than a copy of each. + private(set) var rows: [String: Row] = [:] private lazy var context = QueryContext.local(branch) init( @@ -75,6 +78,7 @@ public final class Transaction { var row = value.conformed(to: source.layout) for column in table.columnNames where row[column] == nil { row[column] = .null } write.set(key, JSONValue(row)) + rows[key] = row source.push(.add(row)) } @@ -96,7 +100,9 @@ public final class Transaction { for (k, v) in value { next[k] = v } write.set(key, .object(next)) let source = source(tableName) - source.push(.edit(Row(next, layout: source.layout), oldRow: Row(prev, layout: source.layout))) + let row = Row(next, layout: source.layout) + rows[key] = row + source.push(.edit(row, oldRow: Row(prev, layout: source.layout))) } func delete(_ tableName: String, _ id: Row) throws { @@ -104,6 +110,7 @@ public final class Transaction { let key = try key(table, id) guard let prev = write.get(key)?.objectValue else { return } write.delete(key) + rows[key] = nil let source = source(tableName) source.push(.remove(Row(prev, layout: source.layout))) } diff --git a/swift/Sources/Zero/IVM/MemorySource.swift b/swift/Sources/Zero/IVM/MemorySource.swift index 22df8183e6..762a46b661 100644 --- a/swift/Sources/Zero/IVM/MemorySource.swift +++ b/swift/Sources/Zero/IVM/MemorySource.swift @@ -51,7 +51,12 @@ package final class MemorySource: Source { package let layout: RowLayout package convenience init(_ table: TableSchema) { - self.init(table: table, layout: RowLayout(table), primaryIndexData: nil) + self.init(table, layout: RowLayout(table)) + } + + /// A source whose rows are in `layout`, which has `table`'s columns. + package convenience init(_ table: TableSchema, layout: RowLayout) { + self.init(table: table, layout: layout, primaryIndexData: nil) } init(table: TableSchema, layout: RowLayout, primaryIndexData: BTreeSet?) { diff --git a/swift/Sources/Zero/IVM/View.swift b/swift/Sources/Zero/IVM/View.swift index 1afb1b2cb3..fefd25034e 100644 --- a/swift/Sources/Zero/IVM/View.swift +++ b/swift/Sources/Zero/IVM/View.swift @@ -262,7 +262,9 @@ enum ViewApply { /// `{...existing, ...row}`. static func applyEdit(_ entry: inout ViewEntry, _ row: Row) { if entry.row.layout === row.layout { - for (i, value) in row.slots.enumerated() where value != nil { entry.row.slots[i] = value } + for i in 0.. Row { if self.layout === layout { return self } - var slots = [JSONValue?](repeating: nil, count: layout.names.count) var others: [(String, JSONValue)] = [] - for (name, value) in self { - if let i = layout.index(of: name) { - slots[i] = value - } else { - others.append((name, value)) + let storage = RowStorage.make(layout) { values in + values.initialize(repeating: nil, count: layout.names.count) + for (name, value) in self { + if let i = layout.index(of: name) { + values[i] = value + } else { + others.append((name, value)) + } } } - if others.isEmpty { return Row(layout: layout, slots: slots) } + let row = Row(storage: storage) + if others.isEmpty { return row } return Row( - layout: RowLayout(layout.names + others.map(\.0)), slots: slots + others.map(\.1)) + layout: RowLayout(layout.names + others.map(\.0)), + slots: (0.. JSONValue? { + get { + precondition(i >= 0 && i < slotCount) + return storage.withUnsafeMutablePointerToElements { $0[i] } + } + set { + precondition(i >= 0 && i < slotCount) + if !isKnownUniquelyReferenced(&storage) { storage = storage.copy() } + storage.withUnsafeMutablePointerToElements { $0[i] = newValue } + } } /// The value of the column `name`. @@ -71,15 +109,14 @@ public struct Row: Sendable { public subscript(name: String) -> JSONValue? { get { guard let i = layout.index(of: name) else { return nil } - return slots[i] + return self[slot: i] } set { if let i = layout.index(of: name) { - slots[i] = newValue + self[slot: i] = newValue } else if let newValue { // A new layout: others' rows keep theirs. - layout = layout.appending(name) - slots.append(newValue) + storage = storage.appending(name, newValue) } } } @@ -95,7 +132,7 @@ public struct Row: Sendable { package subscript(column column: ColumnRef) -> JSONValue? { if layout === column.layout { guard let i = column.index else { return nil } - return slots[i] + return self[slot: i] } return self[column.name] } @@ -104,28 +141,26 @@ public struct Row: Sendable { @discardableResult public mutating func removeValue(forKey name: String) -> JSONValue? { guard let i = layout.index(of: name) else { return nil } - defer { slots[i] = nil } - return slots[i] + defer { self[slot: i] = nil } + return self[slot: i] } /// How many columns have a value. public var count: Int { var n = 0 - for slot in slots where slot != nil { n += 1 } + for i in 0.. (key: String, value: JSONValue)? { - while i < row.slots.count { + while i < row.slotCount { defer { i += 1 } - if let value = row.slots[i] { return (row.layout.names[i], value) } + if let value = row[slot: i] { return (row.layout.names[i], value) } } return nil } @@ -177,7 +212,11 @@ extension Row: ExpressibleByDictionaryLiteral { extension Row: Hashable { /// The same columns with equal values, whatever the layouts. public static func == (a: Row, b: Row) -> Bool { - if a.layout === b.layout { return a.slots == b.slots } + if a.storage === b.storage { return true } + if a.layout === b.layout { + for i in 0.. RowLayout { var indices = indices indices[name] = names.count @@ -280,3 +318,62 @@ package struct ColumnRef: Sendable { self.name = name } } + +@usableFromInline +struct RowHeader { + @usableFromInline let layout: RowLayout + /// How many values follow: one for each of the layout's columns. + @usableFromInline let count: Int + + @inlinable + init(layout: RowLayout, count: Int) { + self.layout = layout + self.count = count + } +} + +/// A row's layout and its values, in one allocation. Rows share it until +/// one changes, which copies it first unless it's the only owner. +@usableFromInline +final class RowStorage: ManagedBuffer { + /// Storage for a value for each of `layout`'s columns, which + /// `initialize` must initialize. + @inlinable + static func make( + _ layout: RowLayout, _ initialize: (UnsafeMutablePointer) throws -> Void + ) rethrows -> RowStorage { + let count = layout.names.count + let storage = unsafeDowncast( + create(minimumCapacity: count) { _ in RowHeader(layout: layout, count: count) }, + to: RowStorage.self) + try storage.withUnsafeMutablePointerToElements { try initialize($0) } + return storage + } + + @inlinable + func copy() -> RowStorage { + withUnsafeMutablePointers { header, values in + RowStorage.make(header.pointee.layout) { + $0.initialize(from: values, count: header.pointee.count) + } + } + } + + /// A copy with the column `name` after the others. + @usableFromInline + func appending(_ name: String, _ value: JSONValue) -> RowStorage { + withUnsafeMutablePointers { header, values in + let count = header.pointee.count + return RowStorage.make(header.pointee.layout.appending(name)) { + $0.initialize(from: values, count: count) + ($0 + count).initialize(to: value) + } + } + } + + deinit { + withUnsafeMutablePointers { header, values in + _ = values.deinitialize(count: header.pointee.count) + } + } +} diff --git a/swift/Sources/Zero/Query/Query.swift b/swift/Sources/Zero/Query/Query.swift index 703ec6e03e..ad7de54b58 100644 --- a/swift/Sources/Zero/Query/Query.swift +++ b/swift/Sources/Zero/Query/Query.swift @@ -58,7 +58,7 @@ extension ViewEntry { public func column(_ name: String, _ index: Int) throws(ZeroValueError) -> V { let names = row.layout.names if index < names.count && names[index] == name { - return try Self.decode(name, row.slots[index] ?? .null) + return try Self.decode(name, row[slot: index] ?? .null) } return try Self.decode(name, row[name] ?? .null) } From 10b81e205f8011c6337e23fdc70cb7d7a18a8222 Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 19 Oct 2026 20:28:10 +0000 Subject: [PATCH 047/104] 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 --- swift/BENCHMARKS.md | 76 ++++++++++++++++------------- swift/Sources/Zero/Util/BTree.swift | 35 ++++++++++--- 2 files changed, 69 insertions(+), 42 deletions(-) diff --git a/swift/BENCHMARKS.md b/swift/BENCHMARKS.md index 47ab963d17..901373ef87 100644 --- a/swift/BENCHMARKS.md +++ b/swift/BENCHMARKS.md @@ -35,47 +35,48 @@ mode, Node 22.23. Medians; lower is better. | Benchmark | JS | Swift | | ------------------------------------------ | ------- | ------- | -| zero.bench: all 1000 rows x 10 columns | 263 µs | 220 µs | -| zero.bench: pk = N | 7.72 µs | 7.68 µs | -| zero.bench: lower 500 rows | 261 µs | 213 µs | -| materialize 1,000 rows | 282 µs | 135 µs | -| materialize 10,000 rows | 2.70 ms | 1.69 ms | -| materialize 100,000 rows | 43.2 ms | 23.2 ms | -| memory for 100,000 rows and a view of them | 164 MB | 98 MB | -| optimistic update with a 1000-row view | 178 µs | 21 µs | +| zero.bench: all 1000 rows x 10 columns | 263 µs | 197 µs | +| zero.bench: pk = N | 7.72 µs | 8.11 µs | +| zero.bench: lower 500 rows | 261 µs | 206 µs | +| materialize 1,000 rows | 282 µs | 111 µs | +| materialize 10,000 rows | 2.70 ms | 1.21 ms | +| materialize 100,000 rows | 43.2 ms | 11.5 ms | +| memory for 100,000 rows and a view of them | 164 MB | 128 MB | +| optimistic update with a 1000-row view | 178 µs | 23 µs | Swift only: | Benchmark | Swift | | --------------------------------------------------------- | ------- | -| materialize 1,000 rows as structs | 552 µs | -| materialize 10,000 rows as structs | 6.19 ms | -| materialize 100,000 rows as structs | 85.5 ms | -| optimistic update with a 1000-row live query of structs | 92 µs | -| poke putting 1 row, 1000-row view | 28 µs | -| poke putting 100 rows, 1000-row view | 1.08 ms | -| poke putting 1000 rows, 1000-row view | 10.7 ms | -| round trip (update, push, confirming poke), 1000-row view | 79 µs | +| materialize 1,000 rows as structs | 256 µs | +| materialize 10,000 rows as structs | 2.86 ms | +| materialize 100,000 rows as structs | 29.7 ms | +| optimistic update with a 1000-row live query of structs | 91 µs | +| poke putting 1 row, 1000-row view | 29 µs | +| poke putting 100 rows, 1000-row view | 1.00 ms | +| poke putting 1000 rows, 1000-row view | 9.82 ms | +| round trip (update, push, confirming poke), 1000-row view | 80 µs | JS views hand out their rows; Swift apps read them as structs, which costs -about four times the materialization. A live query reads only the rows -that changed after the first read. +about two and a half times the materialization, and less than JS takes to +materialize the rows alone. A live query reads only the rows that changed +after the first read. ### Data structures | Benchmark | JS | Swift | | -------------------------------------- | --------------------------- | ------------------------ | -| BTreeSet values() full scan (1000) | 3.60 µs | 2.67 µs | -| BTreeSet valuesFrom() from mid | 2.35 µs | 1.40 µs | -| BTreeSet valuesReversed() full scan | 4.29 µs | 0.92 µs | -| BTreeSet valuesFromReversed() from mid | 2.55 µs | 0.59 µs | -| BTreeSet has() hit (x3) | 67 ns | 199 ns | -| BTreeSet has() miss (x3) | 63 ns | 91 ns | -| BTreeSet get() hit (x3) | 70 ns | 193 ns | -| BTreeSet add() then delete() | 109 ns | 160 ns | -| BTreeSet add() 100 sequential keys | 6.29 µs | 4.62 µs | -| BTreeSet add() 1000 sequential keys | 63.6 µs | 71.5 µs | -| BTreeSet sort, then add() 1000 keys | 104 µs | 42 µs | +| BTreeSet values() full scan (1000) | 3.60 µs | 2.71 µs | +| BTreeSet valuesFrom() from mid | 2.35 µs | 1.45 µs | +| BTreeSet valuesReversed() full scan | 4.29 µs | 0.99 µs | +| BTreeSet valuesFromReversed() from mid | 2.55 µs | 0.60 µs | +| BTreeSet has() hit (x3) | 67 ns | 139 ns | +| BTreeSet has() miss (x3) | 63 ns | 61 ns | +| BTreeSet get() hit (x3) | 70 ns | 129 ns | +| BTreeSet add() then delete() | 109 ns | 149 ns | +| BTreeSet add() 100 sequential keys | 6.29 µs | 4.34 µs | +| BTreeSet add() 1000 sequential keys | 63.6 µs | 64.3 µs | +| BTreeSet sort, then add() 1000 keys | 104 µs | 38 µs | | h32, 64 / 1024 / 8192 chars | 290 ns / 2.78 µs / 23.7 µs | 23 ns / 168 ns / 1.26 µs | | h64, 64 / 1024 / 8192 chars | 552 ns / 5.46 µs / 41.3 µs | 35 ns / 318 ns / 2.45 µs | | h128, 64 / 1024 / 8192 chars | 1.02 µs / 10.2 µs / 82.0 µs | 54 ns / 628 ns / 5.0 µs | @@ -99,8 +100,15 @@ times faster. every row. - Comparing strings in UTF-8 order walked them with iterators rather than their bytes. - -Where Swift is still slower: B-tree lookups call their comparator through -a closure, which V8 inlines. And a row is a `[String: JSONValue]`, so -reading a column hashes its name: comparing rows by key and reading rows as -structs mostly hash. Rows keyed by column index would remove most of that. +- A row was a `[String: JSONValue]`, so reading a column hashed its name: + comparing rows by key and reading rows as structs mostly hashed. Rows are + now keyed by column index (see DESIGN.md). Reading 100,000 rows as structs + went from 86 to 30 ms, and materializing them from 23 to 12 ms. The cost + is memory: a source's row no longer shares its storage with the local + store's copy, which adds about 30 MB per 100,000 rows of 10 columns. +- B-tree lookups retained each node they passed (a lookup took about + 64 ns, now 43 ns). + +Where Swift is still slower: B-tree lookups, which call their comparator +through a closure that V8 inlines, and retain each node's keys as they read +them. diff --git a/swift/Sources/Zero/Util/BTree.swift b/swift/Sources/Zero/Util/BTree.swift index c91e98bdc5..8444a00018 100644 --- a/swift/Sources/Zero/Util/BTree.swift +++ b/swift/Sources/Zero/Util/BTree.swift @@ -79,15 +79,22 @@ package struct BTreeSet { /// The stored element equal to `element`. @inlinable package func get(_ element: Element) -> Element? { - var node = root - while !node.isLeaf { - let i = lowerBound(node.keys) { compare($0, element) } - if i == node.keys.count { return nil } - node = node.children[i] + // The tree keeps its nodes alive for the walk, which therefore needn't + // retain each one it passes. + withExtendedLifetime(root) { + unowned(unsafe) var node = root + while !node.isLeaf { + let i = node.keys.withUnsafeBufferPointer { keys in + lowerBound(keys) { compare($0, element) } + } + if i == node.keys.count { return nil } + node = node.children.withUnsafeBufferPointer { $0[i] } + } + return node.keys.withUnsafeBufferPointer { keys in + let i = lowerBound(keys) { compare($0, element) } + return i < keys.count && compare(keys[i], element) == 0 ? keys[i] : nil + } } - let i = lowerBound(node.keys) { compare($0, element) } - if i < node.keys.count && compare(node.keys[i], element) == 0 { return node.keys[i] } - return nil } @inlinable @@ -320,6 +327,18 @@ package struct BTreeSet { } } +/// The first index whose element compares `>= 0`. +@inlinable +func lowerBound(_ keys: UnsafeBufferPointer, _ cmp: (E) -> Int) -> Int { + var low = 0 + var high = keys.count + while low < high { + let mid = (low + high) >> 1 + if cmp(keys[mid]) < 0 { low = mid + 1 } else { high = mid } + } + return low +} + /// The first index whose element compares `>= 0`. @inlinable func lowerBound(_ keys: [E], _ cmp: (E) -> Int) -> Int { From e3edee2d609b8045ee946de58bd24e215a2059a3 Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 19 Oct 2026 20:28:26 +0000 Subject: [PATCH 048/104] 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 --- swift/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/swift/README.md b/swift/README.md index 84f383a6e7..0fd2f81648 100644 --- a/swift/README.md +++ b/swift/README.md @@ -15,7 +15,8 @@ end-to-end tests against `zero-cache` and the zbugs app. ## Requirements - Swift 6.2 -- iOS 26 or macOS 26. The `Zero` module also builds on Linux. +- iOS 26 or macOS 26. The `Zero` module also builds on Linux, without a + socket: pass your own `SocketFactory` to sync there. - A `zero-cache` that speaks sync protocol 53 (Zero 1.11 or later) ## Install From f24a8453369f7a93d900bdd71afd210785d5be0d Mon Sep 17 00:00:00 2001 From: tmm Date: Mon, 19 Oct 2026 16:54:56 -0400 Subject: [PATCH 049/104] 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 --- apps/zbugs-swift/Sources/ZBugsUI/RootView.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift b/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift index 21a8072277..fa4563f071 100644 --- a/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift +++ b/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift @@ -30,8 +30,11 @@ /// around. @State private var columns = NavigationSplitViewVisibility.all /// The column an iPhone shows: the list, with the filters before it and - /// the selected issue after. - @State private var compactColumn = NavigationSplitViewColumn.content + /// the selected issue after. It starts at the filters and moves to the + /// list as the view appears: a list that has rows before its navigation + /// bar is set up (from the local store, offline) opens with its large + /// title collapsed. + @State private var compactColumn = NavigationSplitViewColumn.sidebar #if os(iOS) @Environment(\.horizontalSizeClass) private var sizeClass #endif @@ -79,6 +82,7 @@ ContentUnavailableView("No Issue Selected", systemImage: "list.bullet.rectangle") } } + .onAppear { compactColumn = .content } // A new issue is selected outside a List, which a collapsed split // view doesn't follow on its own. .onChange(of: selection) { _, selection in From 87c4f433d5a2ee7be82730a61a86976a19f908aa Mon Sep 17 00:00:00 2001 From: tmm Date: Mon, 19 Oct 2026 16:55:00 -0400 Subject: [PATCH 050/104] 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 --- .github/workflows/zbugs-screenshots.yml | 17 ++++++++++++++++- apps/zbugs-swift/README.md | 7 ++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/workflows/zbugs-screenshots.yml b/.github/workflows/zbugs-screenshots.yml index 57a722fdbb..421d516efe 100644 --- a/.github/workflows/zbugs-screenshots.yml +++ b/.github/workflows/zbugs-screenshots.yml @@ -3,7 +3,8 @@ name: zbugs screenshots # Runs the Swift zbugs app on an iPhone and an iPad simulator and on macOS # against zbugs' backend (apps/zbugs-swift/server/server.ts), and keeps a # screenshot of each screen (apps/zbugs-swift/App/UITests) as the -# zbugs-screenshots artifact. +# zbugs-screenshots artifact. Then it stops the backend and runs each again +# offline, from what the app stored locally. # Path-filtered to the app and the UI layer: macOS runners are slow to start. on: @@ -93,6 +94,20 @@ jobs: screenshots iPad -destination "id=$(simulator iPad)" CODE_SIGNING_ALLOWED=NO # A macOS UI test needs a signed app; ad hoc does. screenshots macOS -destination platform=macOS CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY=- + # Again offline: with the backend stopped, each screen comes from + # what the app stored locally while it synced. + pkill -f apps/zbugs-swift/server/server.ts + for _ in $(seq 1 30); do + curl -s -o /dev/null http://127.0.0.1:4848/ || break + sleep 1 + done + if curl -s -o /dev/null http://127.0.0.1:4848/; then + echo "zero-cache is still serving" + exit 1 + fi + screenshots iPhone-offline -destination "id=$(simulator iPhone)" CODE_SIGNING_ALLOWED=NO + screenshots iPad-offline -destination "id=$(simulator iPad)" CODE_SIGNING_ALLOWED=NO + screenshots macOS-offline -destination platform=macOS CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY=- exit "$status" - name: Export screenshots if: always() diff --git a/apps/zbugs-swift/README.md b/apps/zbugs-swift/README.md index 187ea84fd8..cc9cb6cc79 100644 --- a/apps/zbugs-swift/README.md +++ b/apps/zbugs-swift/README.md @@ -51,9 +51,10 @@ ZBUGS_E2E_URL=http://127.0.0.1:4848 ZBUGS_E2E_API=http://127.0.0.1:5173 \ `App/UITests` drives the app through each screen against the backend and keeps a screenshot of each one. The `zbugs screenshots` workflow runs it on -an iPhone and an iPad simulator and on macOS, and uploads the screenshots as -an artifact. To run it locally, generate the project and pass the backend's -token: +an iPhone and an iPad simulator and on macOS, then again with the backend +stopped, where each screen comes from the app's local store, and uploads the +screenshots as an artifact. To run it locally, generate the project and pass +the backend's token: ```sh TEST_RUNNER_ZBUGS_TOKEN= xcodebuild test -project App/ZBugs.xcodeproj \ From 25962c5291141478f030d6b3a4ee2dfef3eca1ab Mon Sep 17 00:00:00 2001 From: tmm Date: Mon, 19 Oct 2026 17:05:17 -0400 Subject: [PATCH 051/104] 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 --- apps/zbugs-swift/README.md | 13 +++++ apps/zbugs-swift/server/server.ts | 94 ++++++++++++++++++++++--------- 2 files changed, 81 insertions(+), 26 deletions(-) diff --git a/apps/zbugs-swift/README.md b/apps/zbugs-swift/README.md index cc9cb6cc79..14f90f9a36 100644 --- a/apps/zbugs-swift/README.md +++ b/apps/zbugs-swift/README.md @@ -25,6 +25,19 @@ ZBUGS_PG=postgresql://user:password@127.0.0.1:6434/postgres \ It recreates the `zbugs_swift` database with zbugs' seed data, then prints a token for a crew user and `ready ` (ports 4848 and 5173). +Each start makes new signing keys and a new database, so an app that ran +against the last one needs the new token and starts over from an empty local +store. To keep both across restarts, set `ZBUGS_KEEP=1` and a directory for +the replica and the keys: + +```sh +ZBUGS_KEEP=1 ZBUGS_DIR=~/.zbugs-swift ZBUGS_PG=... \ + node apps/zbugs-swift/server/server.ts +``` + +It seeds the database only when it doesn't exist; drop it, or run once +without `ZBUGS_KEEP`, to start over. + Then generate the Xcode project and run the `ZBugsApp` scheme on a Mac or an iOS simulator: diff --git a/apps/zbugs-swift/server/server.ts b/apps/zbugs-swift/server/server.ts index 9c534481d5..594c4804ca 100644 --- a/apps/zbugs-swift/server/server.ts +++ b/apps/zbugs-swift/server/server.ts @@ -2,7 +2,9 @@ * Runs zbugs' backend for the Swift app, in development and in the * end-to-end tests: a fresh Postgres database with zbugs' migrations and * seed data, zbugs' API server (apps/zbugs/api), and zero-cache, all from - * this repository's sources. + * this repository's sources. With `ZBUGS_KEEP=1` it keeps the database, the + * replica, and its signing keys from the last run instead, so an app keeps + * its local data and its token across restarts. * * node apps/zbugs-swift/server/server.ts * @@ -22,9 +24,18 @@ * ZBUGS_PORT zero-cache port (default 4848) * ZBUGS_API_PORT API server port (default 5173) * ZBUGS_DIR working directory for the replica (default a temp dir) + * ZBUGS_KEEP 1 to keep the last run's database, and the replica and + * keys in ZBUGS_DIR, which it needs */ import {execFileSync, spawn} from 'node:child_process'; -import {mkdtempSync, readdirSync} from 'node:fs'; +import { + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from 'node:fs'; import {registerHooks} from 'node:module'; import {networkInterfaces, tmpdir} from 'node:os'; import {join} from 'node:path'; @@ -42,6 +53,13 @@ const localHost = everywhere ? '127.0.0.1' : host; const port = Number(env['ZBUGS_PORT'] ?? 4848); const apiPort = Number(env['ZBUGS_API_PORT'] ?? 5173); const workDir = env['ZBUGS_DIR'] ?? mkdtempSync(join(tmpdir(), 'zbugs-swift-')); +const keep = env['ZBUGS_KEEP'] === '1'; +if (keep && !env['ZBUGS_DIR']) { + process.stderr.write( + 'ZBUGS_KEEP needs ZBUGS_DIR, for the replica and keys\n', + ); + process.exit(2); +} const here = (path: string) => fileURLToPath(new URL(path, import.meta.url)); const zbugs = here('../../zbugs/'); @@ -77,29 +95,43 @@ function psql(url: string, ...args: string[]) { }); } -// The database: zbugs' migrations, then its seed script. -psql(adminURL, '-c', `DROP DATABASE IF EXISTS ${dbName} WITH (FORCE)`); -psql(adminURL, '-c', `CREATE DATABASE ${dbName}`); -const migrations = join(zbugs, 'db/migrations'); -for (const file of readdirSync(migrations) - .filter(f => f.endsWith('.sql')) - .sort()) { - // In a transaction each, as drizzle-kit applies them. - psql(upstreamURL, '--single-transaction', '-f', join(migrations, file)); +// The database: zbugs' migrations, then its seed script. Kept, it also +// holds zero-cache's client groups, which an app's local data belongs to. +const kept = + keep && + execFileSync( + 'psql', + [adminURL, '-tAc', `SELECT 1 FROM pg_database WHERE datname = '${dbName}'`], + {encoding: 'utf8'}, + ).trim() === '1'; +if (!kept) { + psql(adminURL, '-c', `DROP DATABASE IF EXISTS ${dbName} WITH (FORCE)`); + psql(adminURL, '-c', `CREATE DATABASE ${dbName}`); + const migrations = join(zbugs, 'db/migrations'); + for (const file of readdirSync(migrations) + .filter(f => f.endsWith('.sql')) + .sort()) { + // In a transaction each, as drizzle-kit applies them. + psql(upstreamURL, '--single-transaction', '-f', join(migrations, file)); + } + execFileSync(process.execPath, [join(zbugs, 'scripts/seed.ts')], { + cwd: zbugs, + stdio: ['ignore', 'ignore', 'inherit'], + env: { + ...env, + ['ZERO_UPSTREAM_DB']: upstreamURL, + ['ZERO_SEED_DATA_DIR']: join( + zbugs, + 'db/seed-data', + env['ZBUGS_SEED'] ?? 'github', + ), + }, + }); + // A replica of an earlier database. + for (const file of readdirSync(workDir)) { + if (file.startsWith('replica.db')) rmSync(join(workDir, file)); + } } -execFileSync(process.execPath, [join(zbugs, 'scripts/seed.ts')], { - cwd: zbugs, - stdio: ['ignore', 'ignore', 'inherit'], - env: { - ...env, - ['ZERO_UPSTREAM_DB']: upstreamURL, - ['ZERO_SEED_DATA_DIR']: join( - zbugs, - 'db/seed-data', - env['ZBUGS_SEED'] ?? 'github', - ), - }, -}); const crewID = execFileSync( 'psql', [ @@ -110,8 +142,18 @@ const crewID = execFileSync( {encoding: 'utf8'}, ).trim(); -// The API server, as `pnpm dev` runs it in apps/zbugs, with keys of its own. -const {privateJwk, publicJwk} = await createJwkPair(); +// The API server, as `pnpm dev` runs it in apps/zbugs, with keys of its own: +// the last run's, kept, so that the tokens it printed still work. +const keysFile = join(workDir, 'keys.json'); +const {privateJwk, publicJwk} = + keep && existsSync(keysFile) + ? (JSON.parse(readFileSync(keysFile, 'utf8')) as Awaited< + ReturnType + >) + : await createJwkPair(); +if (keep) { + writeFileSync(keysFile, JSON.stringify({privateJwk, publicJwk})); +} env['ZERO_UPSTREAM_DB'] = upstreamURL; env['PRIVATE_JWK'] = JSON.stringify(privateJwk); env['VITE_PUBLIC_JWK'] = JSON.stringify(publicJwk); From 088a4decbb8ee94be730c8acb8db3bff4eaf3fb1 Mon Sep 17 00:00:00 2001 From: tmm Date: Mon, 19 Oct 2026 17:05:17 -0400 Subject: [PATCH 052/104] docs(zbugs-swift): running the app on a device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/zbugs-swift/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/zbugs-swift/README.md b/apps/zbugs-swift/README.md index 14f90f9a36..3f3df1a0de 100644 --- a/apps/zbugs-swift/README.md +++ b/apps/zbugs-swift/README.md @@ -51,6 +51,14 @@ Log in from the account menu with the printed token, or through GitHub when the API server has `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` (it redirects back to `zbugs-swift://auth`). Servers… points the app elsewhere. +On an iPhone or an iPad, start the backend with `ZBUGS_HOST=0.0.0.0` so the +device can reach it, and in the app set Servers… to this Mac's address on the +network (for example `http://192.168.1.20:4848` and +`http://192.168.1.20:5173`), then log in with the token. The app keeps both. +A token and servers passed in its environment, as Xcode's scheme or the UI +test passes them, last only for that launch: opened from the home screen, the +app goes back to the saved ones. + ## End-to-end tests `BackendTests` runs the Swift queries and mutators against the backend: the From 21b80d447536b130666c63c15fe1fa4cb2bfddb6 Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 20 Oct 2026 00:02:55 +0000 Subject: [PATCH 053/104] 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 --- .../Tests/ZBugsTests/DriftTests.swift | 3 +- packages/zero-swift-cli/README.md | 6 ++-- swift/DESIGN.md | 6 ++-- .../Sources/Zero/Zero.docc/GettingStarted.md | 4 +-- swift/Sources/ZeroTesting/Drift.swift | 28 +++---------------- .../ZeroTesting.docc/ZeroTesting.md | 5 ++-- 6 files changed, 18 insertions(+), 34 deletions(-) diff --git a/apps/zbugs-swift/Tests/ZBugsTests/DriftTests.swift b/apps/zbugs-swift/Tests/ZBugsTests/DriftTests.swift index e8de0d6b46..2eb5e99c84 100644 --- a/apps/zbugs-swift/Tests/ZBugsTests/DriftTests.swift +++ b/apps/zbugs-swift/Tests/ZBugsTests/DriftTests.swift @@ -11,7 +11,8 @@ import ZeroTesting let vectors = try String( contentsOf: URL(fileURLWithPath: #filePath).deletingLastPathComponent() .appendingPathComponent("drift-vectors.json"), encoding: .utf8) - Drift.expectNoDrift( + let failures = try Drift.failures( vectors: vectors, schema: .zbugs, queries: Queries.all, mutators: Mutators.all) + #expect(failures == []) } } diff --git a/packages/zero-swift-cli/README.md b/packages/zero-swift-cli/README.md index 658ac901ab..862b817d3c 100644 --- a/packages/zero-swift-cli/README.md +++ b/packages/zero-swift-cli/README.md @@ -42,9 +42,9 @@ plain object or a literal union, the tool generates a `Codable` type. You write the Swift queries and mutators by hand, and the drift harness checks them against the TypeScript ones. This command runs the TypeScript -definitions on samples and records what they did. `Drift.expectNoDrift` in -the Swift `ZeroTesting` library then fails a test wherever the Swift ones -differ. +definitions on samples and records what they did. `Drift.failures` in +the Swift `ZeroTesting` library then lists wherever the Swift ones differ, +for a test to expect none. ```sh npx zero-swift drift record --samples drift-samples.ts --out Tests/AppTests/drift-vectors.json diff --git a/swift/DESIGN.md b/swift/DESIGN.md index a34ec1860f..e3338e703c 100644 --- a/swift/DESIGN.md +++ b/swift/DESIGN.md @@ -474,8 +474,10 @@ The drift harness catches it in the app's own tests. offline client of its own, and writes `drift-vectors.json`. In this repo, the zero-swift tests record the apps' vectors and fail when they are stale. - **Swift side.** `ZeroTesting` exposes - `Drift.failures(vectors:schema:queries:mutators:)` and - `Drift.expectNoDrift`, which records a Swift Testing issue per difference. + `Drift.failures(vectors:schema:queries:mutators:)`, which a test expects + to be empty. It doesn't import `Testing`: on Apple platforms that links + `Testing.framework`, which only test bundles find, so anything else that + linked `ZeroTesting`, such as the benchmarks, failed to launch. Queries are `NamedQuery`s, the query definitions with decodable arguments. Checks: diff --git a/swift/Sources/Zero/Zero.docc/GettingStarted.md b/swift/Sources/Zero/Zero.docc/GettingStarted.md index ce4f5c354f..6319e8d027 100644 --- a/swift/Sources/Zero/Zero.docc/GettingStarted.md +++ b/swift/Sources/Zero/Zero.docc/GettingStarted.md @@ -79,8 +79,8 @@ same rows. The drift harness checks the ports: `zero-swift drift record` runs your TypeScript queries and mutators on sample arguments and writes what they -did, and `Drift.expectNoDrift` in `ZeroTesting` fails a test where the Swift -ones do something else, or where a query or mutator exists on one side only. +did, and `Drift.failures` in `ZeroTesting` lists where the Swift ones +do something else, or where a query or mutator exists on one side only. ## Create a client diff --git a/swift/Sources/ZeroTesting/Drift.swift b/swift/Sources/ZeroTesting/Drift.swift index 590aece39e..ae4fd156dd 100644 --- a/swift/Sources/ZeroTesting/Drift.swift +++ b/swift/Sources/ZeroTesting/Drift.swift @@ -1,7 +1,3 @@ -#if canImport(Testing) - import Testing -#endif - /// The drift harness: checks an app's Swift queries and mutators against /// what their TS definitions did on the same samples /// (`zero-swift drift record` writes the vectors). @@ -16,7 +12,10 @@ @MainActor public enum Drift { /// The differences between `vectors` and the Swift definitions; empty when - /// there are none. + /// there are none. A test expects none: `#expect(failures == [])`. + /// + /// (No `Testing` helper here: a module that imports `Testing` links + /// `Testing.framework`, which only test bundles find on Apple platforms.) /// /// - Parameters: /// - vectors: The contents of the `drift-vectors.json` that @@ -71,25 +70,6 @@ public enum Drift { return failures } - #if canImport(Testing) - /// Records an issue for each difference between `vectors` and the Swift - /// definitions. - public static func expectNoDrift( - vectors: String, schema: ZeroSchema, queries: [any NamedQuery], - mutators: [any Mutator], sourceLocation: SourceLocation = #_sourceLocation - ) { - do { - for failure in try failures( - vectors: vectors, schema: schema, queries: queries, mutators: mutators) - { - Issue.record(Comment(rawValue: failure), sourceLocation: sourceLocation) - } - } catch { - Issue.record(error, sourceLocation: sourceLocation) - } - } - #endif - private static func context(_ sample: JSONValue) throws -> Context { try JSONValueDecoder.decode(Context.self, from: sample["context"] ?? .null) } diff --git a/swift/Sources/ZeroTesting/ZeroTesting.docc/ZeroTesting.md b/swift/Sources/ZeroTesting/ZeroTesting.docc/ZeroTesting.md index 8c6c504738..223501b90b 100644 --- a/swift/Sources/ZeroTesting/ZeroTesting.docc/ZeroTesting.md +++ b/swift/Sources/ZeroTesting/ZeroTesting.docc/ZeroTesting.md @@ -15,12 +15,13 @@ vectors file. `Drift` checks the Swift definitions against those vectors: ```swift @Test @MainActor func matchesTheTSQueriesAndMutators() throws { let vectors = try String(contentsOf: vectorsURL, encoding: .utf8) - Drift.expectNoDrift( + let failures = try Drift.failures( vectors: vectors, schema: .app, queries: Queries.all, mutators: Mutators.all) + #expect(failures == []) } ``` -The test fails when: +The test fails, listing each difference, when: - a name is in one registry but not the other; - a sample's arguments don't round-trip through the Swift argument type; From e4c3ed28f0852f272a8edc984e06720069b3f182 Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 20 Oct 2026 00:02:55 +0000 Subject: [PATCH 054/104] perf(zero-swift): zero.bench checks its sum by column index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- swift/Sources/ZeroBenchmarks/Benchmarks.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/swift/Sources/ZeroBenchmarks/Benchmarks.swift b/swift/Sources/ZeroBenchmarks/Benchmarks.swift index 29fdcb4c83..a44a54cd38 100644 --- a/swift/Sources/ZeroBenchmarks/Benchmarks.swift +++ b/swift/Sources/ZeroBenchmarks/Benchmarks.swift @@ -276,6 +276,16 @@ enum Benchmarks { /// The sum of column `a` over the view's rows, as the JS benchmarks check. static func sum(_ view: MaterializedView) -> Double { - view.data.entries.reduce(0) { $0 + ($1.row["a"]?.numberValue ?? 0) } + sum(view.data.entries) + } + + /// Reads `a` by index, as JS reads `row.a` off the object's shape, and off + /// the main actor, which would check its executor for every row. + nonisolated static func sum(_ entries: [ViewEntry]) -> Double { + guard let first = entries.first else { return 0 } + let a = first.row.layout.column("a") + var total = 0.0 + for entry in entries { total += entry.row[column: a]?.numberValue ?? 0 } + return total } } From 631f78404d056a6b911791a35a26bf9fcd346a3f Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 20 Oct 2026 00:17:22 +0000 Subject: [PATCH 055/104] 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 --- swift/BENCHMARKS.md | 168 ++++++++++++++++++++++++++++---------------- 1 file changed, 107 insertions(+), 61 deletions(-) diff --git a/swift/BENCHMARKS.md b/swift/BENCHMARKS.md index 901373ef87..81bdc6fb0b 100644 --- a/swift/BENCHMARKS.md +++ b/swift/BENCHMARKS.md @@ -16,75 +16,115 @@ does. The JS sides: -| Swift group | JS | -| ------------------------------------- | ------------------------------------------------------------------------------------------- | -| `zero.bench.ts` | `pnpm --filter zero-client bench` | -| `btree-set.bench.ts`, `hash.bench.ts` | `pnpm --filter shared bench src/btree-set.bench.ts src/hash.bench.ts` | -| `memory`, `hydration`, `mutations` | `pnpm --filter zero-conformance bench` (`src/bench/client.bench.ts`) | -| `pokes`, the round trip | none: the JS client applies pokes on the next frame, so a JS timing measures the scheduling | +| Swift group | JS | +| ------------------------------------- | ------------------------------------------------------------------------------------------------- | +| `zero.bench.ts` | `pnpm --filter zero-client bench` | +| `btree-set.bench.ts`, `hash.bench.ts` | `pnpm --filter shared bench --project shared/bench/node src/btree-set.bench.ts src/hash.bench.ts` | +| `memory`, `hydration`, `mutations` | `pnpm --filter zero-conformance bench` (`src/bench/client.bench.ts`) | +| `pokes`, the round trip | none: the JS client applies pokes on the next frame, so a JS timing measures the scheduling | + +Without `--project`, the shared benchmarks also run in headless Chromium at +the same time, which slows the Node run by up to 10%. The benchmarks don't run in CI; numbers from shared runners vary too much to gate on. CI builds them, so they keep compiling. ## Results -Linux, one machine (8-core Intel Xeon at 2.6 GHz), Swift 6.2.4 in release -mode, Node 22.23. Medians; lower is better. - -### The client - -| Benchmark | JS | Swift | -| ------------------------------------------ | ------- | ------- | -| zero.bench: all 1000 rows x 10 columns | 263 µs | 197 µs | -| zero.bench: pk = N | 7.72 µs | 8.11 µs | -| zero.bench: lower 500 rows | 261 µs | 206 µs | -| materialize 1,000 rows | 282 µs | 111 µs | -| materialize 10,000 rows | 2.70 ms | 1.21 ms | -| materialize 100,000 rows | 43.2 ms | 11.5 ms | -| memory for 100,000 rows and a view of them | 164 MB | 128 MB | -| optimistic update with a 1000-row view | 178 µs | 23 µs | - -Swift only: - -| Benchmark | Swift | -| --------------------------------------------------------- | ------- | -| materialize 1,000 rows as structs | 256 µs | -| materialize 10,000 rows as structs | 2.86 ms | -| materialize 100,000 rows as structs | 29.7 ms | -| optimistic update with a 1000-row live query of structs | 91 µs | -| poke putting 1 row, 1000-row view | 29 µs | -| poke putting 100 rows, 1000-row view | 1.00 ms | -| poke putting 1000 rows, 1000-row view | 9.82 ms | -| round trip (update, push, confirming poke), 1000-row view | 80 µs | - -JS views hand out their rows; Swift apps read them as structs, which costs -about two and a half times the materialization, and less than JS takes to -materialize the rows alone. A live query reads only the rows that changed -after the first read. - -### Data structures - -| Benchmark | JS | Swift | -| -------------------------------------- | --------------------------- | ------------------------ | -| BTreeSet values() full scan (1000) | 3.60 µs | 2.71 µs | -| BTreeSet valuesFrom() from mid | 2.35 µs | 1.45 µs | -| BTreeSet valuesReversed() full scan | 4.29 µs | 0.99 µs | -| BTreeSet valuesFromReversed() from mid | 2.55 µs | 0.60 µs | -| BTreeSet has() hit (x3) | 67 ns | 139 ns | -| BTreeSet has() miss (x3) | 63 ns | 61 ns | -| BTreeSet get() hit (x3) | 70 ns | 129 ns | -| BTreeSet add() then delete() | 109 ns | 149 ns | -| BTreeSet add() 100 sequential keys | 6.29 µs | 4.34 µs | -| BTreeSet add() 1000 sequential keys | 63.6 µs | 64.3 µs | -| BTreeSet sort, then add() 1000 keys | 104 µs | 38 µs | -| h32, 64 / 1024 / 8192 chars | 290 ns / 2.78 µs / 23.7 µs | 23 ns / 168 ns / 1.26 µs | -| h64, 64 / 1024 / 8192 chars | 552 ns / 5.46 µs / 41.3 µs | 35 ns / 318 ns / 2.45 µs | -| h128, 64 / 1024 / 8192 chars | 1.02 µs / 10.2 µs / 82.0 µs | 54 ns / 628 ns / 5.0 µs | +Medians; lower is better. The iPhone is where the client ships, so its +numbers come first. In short: on the read path (zero.bench, materializing) +JS in WebKit is ahead of Swift on the iPhone, while Swift is well ahead on +optimistic updates, memory, hashing, and B-tree scans. Linux, where JS is +slowest, flatters Swift. + +### iPhone + +iPhone 15 Pro, iOS 26.7.1, on power, thermal state nominal throughout. +Swift ran in a Release build of an app wrapping `ZeroBenchmarks`; JS ran in +a `WKWebView` (JIT on) in the same app, on esbuild bundles of the same +`.bench.ts` files. The phone slows down under sustained load before its +thermal state shows it, so each suite ran as its own launch after a +three-minute cool-down, in two rounds that swapped which language went +first; each cell is the mean of the two rounds. Swift's rounds agreed within +3%. JS's materialize rows varied by about 30% between rounds, WebKit's timer +has a 20 µs grain, and WebKit gives JS no memory figure. + +| Benchmark | JS (WebKit) | Swift | +| ------------------------------------------ | ----------- | ------- | +| zero.bench: all 1000 rows x 10 columns | 65.7 µs | 95.7 µs | +| zero.bench: pk = N | 2.34 µs | 6.48 µs | +| zero.bench: lower 500 rows | 79.5 µs | 111 µs | +| materialize 1,000 rows | 85 µs | 94.6 µs | +| materialize 10,000 rows | 807 µs | 902 µs | +| materialize 100,000 rows | 8.22 ms | 10.1 ms | +| memory for 100,000 rows and a view of them | n/a | 160 MB | +| optimistic update with a 1000-row view | 111 µs | 12.3 µs | + +| Benchmark | JS (WebKit) | Swift | +| -------------------------------------- | -------------------------- | ------------------------ | +| BTreeSet values() full scan (1000) | 5.56 µs | 2.54 µs | +| BTreeSet valuesFrom() from mid | 2.04 µs | 1.33 µs | +| BTreeSet valuesReversed() full scan | 4.05 µs | 1.86 µs | +| BTreeSet valuesFromReversed() from mid | 1.99 µs | 1.02 µs | +| BTreeSet has() hit (x3) | 46 ns | 141 ns | +| BTreeSet has() miss (x3) | 21 ns | 63 ns | +| BTreeSet get() hit (x3) | 45 ns | 144 ns | +| BTreeSet add() then delete() | 85 ns | 90 ns | +| BTreeSet add() 100 sequential keys | 4.44 µs | 3.72 µs | +| BTreeSet add() 1000 sequential keys | 52.0 µs | 50.1 µs | +| BTreeSet sort, then add() 1000 keys | 98.2 µs | 46.4 µs | +| h32, 64 / 1024 / 8192 chars | 156 ns / 1.13 µs / 2.88 µs | 15 ns / 142 ns / 1.10 µs | +| h64, 64 / 1024 / 8192 chars | 155 ns / 1.19 µs / 3.56 µs | 42 ns / 262 ns / 2.14 µs | +| h128, 64 / 1024 / 8192 chars | 232 ns / 1.45 µs / 5.74 µs | 47 ns / 539 ns / 4.42 µs | + +### Mac and Linux + +The Mac is an M2 Max (32 GB) on macOS 26.7 with Swift 6.4 and Node 24.6; +Linux is an 8-core Intel Xeon at 2.6 GHz with Swift 6.2.4 and Node 22.23. +JS ran with the commands above, under vitest. On the Mac, the same bundles +the iPhone ran are 14–40% faster than vitest on the client rows. + +| Benchmark | Mac JS | Mac Swift | Linux JS | Linux Swift | +| ------------------------------------------ | ---------- | --------- | -------- | ----------- | +| zero.bench: all 1000 rows x 10 columns | 141 µs | 135 µs | 263 µs | 124 µs | +| zero.bench: pk = N | 5.42 µs | 7.00 µs | 7.72 µs | 8.47 µs | +| zero.bench: lower 500 rows | 169 µs | 160 µs | 261 µs | 167 µs | +| materialize 1,000 rows | 133 µs | 131 µs | 282 µs | 111 µs | +| materialize 10,000 rows | 1.27 ms | 1.28 ms | 2.70 ms | 1.21 ms | +| materialize 100,000 rows | 16.3 ms | 14.0 ms | 43.2 ms | 11.5 ms | +| memory for 100,000 rows and a view of them | 240–258 MB | 162 MB | 164 MB | 128 MB | +| optimistic update with a 1000-row view | 120 µs | 14.7 µs | 178 µs | 23 µs | + +| Benchmark | Mac JS | Mac Swift | Linux JS | Linux Swift | +| ----------------------------------- | ------- | --------- | -------- | ----------- | +| BTreeSet values() full scan (1000) | 2.67 µs | 2.83 µs | 3.60 µs | 2.71 µs | +| BTreeSet valuesReversed() full scan | 2.33 µs | 2.49 µs | 4.29 µs | 0.99 µs | +| BTreeSet get() hit (x3) | 62 ns | 153 ns | 70 ns | 129 ns | +| BTreeSet add() 1000 sequential keys | 46.5 µs | 58.0 µs | 63.6 µs | 64.3 µs | +| BTreeSet sort, then add() 1000 keys | 94 µs | 54 µs | 104 µs | 38 µs | +| h64, 1024 chars | 5.26 µs | 302 ns | 5.46 µs | 318 ns | As `hash.bench.ts` notes, vitest keeps imported functions from being inlined, so its JS hash numbers are pessimistic; bundled, they are a few times faster. +### Swift only + +| Benchmark | iPhone | Mac | Linux | +| --------------------------------------------------------- | ------- | ------- | ------- | +| materialize 1,000 rows as structs | 210 µs | 265 µs | 256 µs | +| materialize 10,000 rows as structs | 2.15 ms | 2.61 ms | 2.86 ms | +| materialize 100,000 rows as structs | 23.0 ms | 28.5 ms | 29.7 ms | +| optimistic update with a 1000-row live query of structs | 61 µs | 78 µs | 91 µs | +| poke putting 1 row, 1000-row view | 16 µs | 19 µs | 29 µs | +| poke putting 100 rows, 1000-row view | 551 µs | 695 µs | 1.00 ms | +| poke putting 1000 rows, 1000-row view | 5.13 ms | 6.58 ms | 9.82 ms | +| round trip (update, push, confirming poke), 1000-row view | 37 µs | 42 µs | 80 µs | + +JS views hand out their rows; Swift apps read them as structs, which costs +a little over twice the materialization. A live query reads only the rows +that changed after the first read. + ## What they found - The mutation tracker looked mutations up by scanning a list, so each @@ -109,6 +149,12 @@ times faster. - B-tree lookups retained each node they passed (a lookup took about 64 ns, now 43 ns). -Where Swift is still slower: B-tree lookups, which call their comparator -through a closure that V8 inlines, and retain each node's keys as they read -them. +- zero.bench's Swift 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 (243 µs, now 135 µs; 121 µs on the iPhone, now 96 µs). It + now reads `a` by index, as JS reads `row.a`. + +Where Swift is still slower: materializing on Apple platforms, where a +profile on the Mac puts about a third of the time in retain/release and a +seventh in exclusivity checks; and B-tree lookups, which call their +comparator through a closure that JS engines inline. From 8172d5e42a047125fda8902158dd3923fa1dda1c Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 20 Oct 2026 01:21:48 +0000 Subject: [PATCH 056/104] 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 --- packages/zero-swift-cli/src/codegen.test.ts | 11 + swift/Package.swift | 4 + swift/Sources/Zero/Client/QueryContext.swift | 2 +- swift/Sources/ZeroVirtual/Rows.swift | 274 +++++ .../Sources/ZeroVirtual/VirtualizerCore.swift | 1029 +++++++++++++++++ .../Sources/ZeroVirtual/ZeroVirtualizer.swift | 306 +++++ .../Generated/TestSchema.swift | 507 ++++++++ swift/Tests/ZeroVirtualTests/Harness.swift | 262 +++++ swift/Tests/ZeroVirtualTests/RowsTests.swift | 95 ++ .../Tests/ZeroVirtualTests/ScrollTests.swift | 927 +++++++++++++++ .../ZeroVirtualTests/StickToBottomTests.swift | 92 ++ swift/Tests/ZeroVirtualTests/UPSTREAM.md | 153 +++ .../ZeroVirtualTests/VirtualizerTests.swift | 185 +++ .../ZeroVirtualizerTests.swift | 244 ++++ 14 files changed, 4090 insertions(+), 1 deletion(-) create mode 100644 swift/Sources/ZeroVirtual/Rows.swift create mode 100644 swift/Sources/ZeroVirtual/VirtualizerCore.swift create mode 100644 swift/Sources/ZeroVirtual/ZeroVirtualizer.swift create mode 100644 swift/Tests/ZeroVirtualTests/Generated/TestSchema.swift create mode 100644 swift/Tests/ZeroVirtualTests/Harness.swift create mode 100644 swift/Tests/ZeroVirtualTests/RowsTests.swift create mode 100644 swift/Tests/ZeroVirtualTests/ScrollTests.swift create mode 100644 swift/Tests/ZeroVirtualTests/StickToBottomTests.swift create mode 100644 swift/Tests/ZeroVirtualTests/UPSTREAM.md create mode 100644 swift/Tests/ZeroVirtualTests/VirtualizerTests.swift create mode 100644 swift/Tests/ZeroVirtualTests/ZeroVirtualizerTests.swift diff --git a/packages/zero-swift-cli/src/codegen.test.ts b/packages/zero-swift-cli/src/codegen.test.ts index 50e3eccee8..c9cde0fb0b 100644 --- a/packages/zero-swift-cli/src/codegen.test.ts +++ b/packages/zero-swift-cli/src/codegen.test.ts @@ -42,6 +42,17 @@ const fixtures: {name: string; dir?: string; config: Config}[] = [ access: 'internal', }, }, + { + name: 'zql test schema for the virtual list tests', + config: { + schema: 'packages/zql/src/query/test/test-schemas.ts', + output: 'swift/Tests/ZeroVirtualTests/Generated/TestSchema.swift', + schemaName: 'test', + zql: 'testZQL', + typePrefix: 'Test', + access: 'internal', + }, + }, { name: 'codegen fixture schema', config: { diff --git a/swift/Package.swift b/swift/Package.swift index da41f81500..5c257fee05 100644 --- a/swift/Package.swift +++ b/swift/Package.swift @@ -7,6 +7,7 @@ let package = Package( products: [ .library(name: "Zero", targets: ["Zero"]), .library(name: "ZeroUI", targets: ["ZeroUI"]), + .library(name: "ZeroVirtual", targets: ["ZeroVirtual"]), .library(name: "ZeroTesting", targets: ["ZeroTesting"]), .library(name: "ZeroNIOTesting", targets: ["ZeroNIOTesting"]), ], @@ -20,6 +21,8 @@ let package = Package( .systemLibrary(name: "CSQLite", path: "Sources/CSQLite"), .target(name: "Zero", dependencies: ["CSQLite"]), .target(name: "ZeroUI", dependencies: ["Zero"]), + // Virtual lists that page their rows in (`@rocicorp/zero-virtual`). + .target(name: "ZeroVirtual", dependencies: ["Zero"]), .target(name: "ZeroTesting", dependencies: ["Zero"]), // A plain ws:// transport for end-to-end tests on Linux. .target( @@ -38,6 +41,7 @@ let package = Package( // The Apple layer: the URLSession transport, SwiftUI, UIKit, and AppKit. // Empty elsewhere. .testTarget(name: "AppleTests", dependencies: ["Zero", "ZeroUI", "ZeroTesting"]), + .testTarget(name: "ZeroVirtualTests", dependencies: ["Zero", "ZeroVirtual", "ZeroTesting"]), .testTarget(name: "E2ETests", dependencies: ["Zero", "ZeroNIOTesting"]), ], swiftLanguageModes: [.v6] diff --git a/swift/Sources/Zero/Client/QueryContext.swift b/swift/Sources/Zero/Client/QueryContext.swift index 608015a399..46933e7bc9 100644 --- a/swift/Sources/Zero/Client/QueryContext.swift +++ b/swift/Sources/Zero/Client/QueryContext.swift @@ -339,7 +339,7 @@ public struct QueryError: Error, Sendable, CustomStringConvertible { /// What the view held when the query failed. JS rejects `run` with it. let data: ViewValue? - init(_ error: ErroredQuery?, data: ViewValue? = nil) { + package init(_ error: ErroredQuery?, data: ViewValue? = nil) { self.error = error self.data = data } diff --git a/swift/Sources/ZeroVirtual/Rows.swift b/swift/Sources/ZeroVirtual/Rows.swift new file mode 100644 index 0000000000..da4898d9a2 --- /dev/null +++ b/swift/Sources/ZeroVirtual/Rows.swift @@ -0,0 +1,274 @@ +/// Which way a page query reads from its start row. +public enum VirtualDirection: String, Sendable, Codable, Hashable { + case forward, backward +} + +/// Where the loaded window is anchored in the list (`Anchor` in +/// zero-virtual's `types.ts`). +/// +/// - `forward`: the page after `startRow` (the list's start when nil), its +/// first row at `index`. +/// - `backward`: the page before `startRow`, its last row at `index - 1`. +/// - `permalink`: the row with `id` at `index`, half a page either side. +public enum VirtualAnchor: Equatable, Sendable { + case forward(index: Int, startRow: StartRow?) + case backward(index: Int, startRow: StartRow) + case permalink(index: Int, id: String) + + public var index: Int { + switch self { + case .forward(let index, _), .backward(let index, _), .permalink(let index, _): index + } + } + + func with(index: Int) -> Self { + switch self { + case .forward(_, let startRow): .forward(index: index, startRow: startRow) + case .backward(_, let startRow): .backward(index: index, startRow: startRow) + case .permalink(_, let id): .permalink(index: index, id: id) + } + } + + var isPermalink: Bool { + if case .permalink = self { return true } + return false + } + + static var top: Self { .forward(index: 0, startRow: nil) } +} + +extension VirtualAnchor: Codable where StartRow: Codable { + private enum CodingKeys: String, CodingKey { case kind, index, startRow, id } + + public init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let index = try c.decode(Int.self, forKey: .index) + switch try c.decode(String.self, forKey: .kind) { + case "forward": + self = .forward( + index: index, startRow: try c.decodeIfPresent(StartRow.self, forKey: .startRow)) + case "backward": + self = .backward(index: index, startRow: try c.decode(StartRow.self, forKey: .startRow)) + case "permalink": + self = .permalink(index: index, id: try c.decode(String.self, forKey: .id)) + case let kind: + throw DecodingError.dataCorruptedError( + forKey: .kind, in: c, debugDescription: "Unknown anchor kind \(kind)") + } + } + + public func encode(to encoder: any Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(index, forKey: .index) + switch self { + case .forward(_, let startRow): + try c.encode("forward", forKey: .kind) + try c.encodeIfPresent(startRow, forKey: .startRow) + case .backward(_, let startRow): + try c.encode("backward", forKey: .kind) + try c.encode(startRow, forKey: .startRow) + case .permalink(_, let id): + try c.encode("permalink", forKey: .kind) + try c.encode(id, forKey: .id) + } + } +} + +/// What a page query is asked for (`GetPageQueryOptions`). +public struct VirtualPageRequest: Equatable, Sendable { + /// How many rows to read, one more than the page to tell whether more + /// follow. + public var limit: Int + /// The row to read from, exclusive; nil for the list's start (forward) + /// or end (backward). + public var start: StartRow? + /// Backward pages read the rows before `start`, closest first. + public var direction: VirtualDirection + /// Whether the list has been idle for the settle time. + public var settled: Bool +} + +/// What the single-row query is asked for (`GetSingleQueryOptions`). +public struct VirtualRowRequest: Equatable, Sendable { + /// The id the app addresses the row by: a permalink or `scrollToItem`'s. + public var id: String + /// Whether the list has been idle for the settle time. + public var settled: Bool +} + +/// The inputs that decide which queries run (`RowsQueryInputs`). +package struct RowsQueryInputs: Equatable, Sendable { + package var pageSize: Int + package var anchor: VirtualAnchor + package var settled: Bool + /// An id to look up without moving the window: `scrollToItem` checks a + /// row exists before re-anchoring on it. Nil under a permalink anchor, + /// which runs its own lookup. + package var probeID: String? +} + +/// The raw results of the (up to) three queries (`RowsQueryResults`). +package struct RowsQueryResults { + package var singleRow: Row? + package var singleComplete = false + /// The page before a permalink's row, or the whole page. + package var mainRows: [Row]? + package var mainComplete = false + /// The page after a permalink's row. + package var afterRows: [Row]? + package var afterComplete = false + + package init( + singleRow: Row? = nil, singleComplete: Bool = false, mainRows: [Row]? = nil, + mainComplete: Bool = false, afterRows: [Row]? = nil, afterComplete: Bool = false + ) { + self.singleRow = singleRow + self.singleComplete = singleComplete + self.mainRows = mainRows + self.mainComplete = mainComplete + self.afterRows = afterRows + self.afterComplete = afterComplete + } +} + +/// The loaded rows as a window of the list (`RowsSnapshot`). +package struct RowsSnapshot { + /// The anchor these rows answer, whose frame `firstRowIndex` counts in. + /// It lags a re-anchor until the new window's rows arrive. + package var anchor: VirtualAnchor + /// The page size these rows answer. + package var pageSize: Int + package var rowAt: (Int) -> Row? + package var rowsLength: Int + package var complete: Bool + package var rowsEmpty: Bool + package var atStart: Bool + package var atEnd: Bool + package var firstRowIndex: Int + /// A permalink anchor's row doesn't exist. + package var permalinkNotFound: Bool + /// The single-row lookup: its id, the row it found, and whether it + /// completed. Check the id first: it may answer a previous lookup. + package var lookup: (id: String, row: Row?, complete: Bool)? + + package static var empty: Self { + RowsSnapshot( + anchor: .top, pageSize: 0, rowAt: { _ in nil }, rowsLength: 0, complete: false, + rowsEmpty: true, atStart: false, atEnd: false, firstRowIndex: 0, permalinkNotFound: false, + lookup: nil) + } +} + +/// The single-row lookup: a permalink anchor's row, or the probe's. +package func singleRequest(_ inputs: RowsQueryInputs) -> VirtualRowRequest? { + if case .permalink(_, let id) = inputs.anchor { + precondition(inputs.probeID == nil, "a probe and a permalink share the lookup") + return VirtualRowRequest(id: id, settled: inputs.settled) + } + return inputs.probeID.map { VirtualRowRequest(id: $0, settled: inputs.settled) } +} + +/// The page queries: under a page anchor the whole page, one row more than +/// the page; under a permalink anchor the pages before and after its row, +/// once the lookup has found it. +package func pageRequests( + _ inputs: RowsQueryInputs, toStartRow: (Row) -> StartRow, singleRow: Row? +) -> (main: VirtualPageRequest?, after: VirtualPageRequest?) { + let settled = inputs.settled + switch inputs.anchor { + case .forward(_, let start): + return ( + VirtualPageRequest( + limit: inputs.pageSize + 1, start: start, direction: .forward, settled: settled), nil + ) + case .backward(_, let start): + return ( + VirtualPageRequest( + limit: inputs.pageSize + 1, start: start, direction: .backward, settled: settled), nil + ) + case .permalink: + precondition(inputs.pageSize % 2 == 0) + guard let singleRow else { return (nil, nil) } + let start = toStartRow(singleRow) + let half = inputs.pageSize / 2 + return ( + VirtualPageRequest(limit: half + 1, start: start, direction: .backward, settled: settled), + VirtualPageRequest(limit: half, start: start, direction: .forward, settled: settled) + ) + } +} + +/// The window the query results make (`assembleRows`). +package func assembleRows( + _ inputs: RowsQueryInputs, _ results: RowsQueryResults +) -> RowsSnapshot { + let anchor = inputs.anchor + let anchorIndex = anchor.index + let half = inputs.pageSize / 2 + let r = results + + let lookupID: String? + var permalinkNotFound = false + if case .permalink(_, let id) = anchor { + lookupID = id + permalinkNotFound = r.singleComplete && r.singleRow == nil + } else { + lookupID = inputs.probeID + } + let lookup = lookupID.map { (id: $0, row: r.singleRow, complete: r.singleComplete) } + + let beforeLength = r.mainRows?.count ?? 0 + let afterLength = r.afterRows?.count ?? 0 + + switch anchor { + case .permalink: + let beforeSize = min(beforeLength, half) + let afterSize = min(afterLength, half - 1) + let single = r.singleRow + let main = r.mainRows ?? [] + let after = r.afterRows ?? [] + let rowAt: (Int) -> Row? = { index in + if index == anchorIndex { return single } + if index > anchorIndex { + let offset = index - anchorIndex - 1 + return offset < afterSize ? after[offset] : nil + } + let offset = anchorIndex - index - 1 + return offset < beforeSize ? main[offset] : nil + } + return RowsSnapshot( + anchor: anchor, pageSize: inputs.pageSize, rowAt: rowAt, + rowsLength: permalinkNotFound ? 0 : beforeSize + afterSize + (single == nil ? 0 : 1), + complete: r.singleComplete && (permalinkNotFound || (r.mainComplete && r.afterComplete)), + rowsEmpty: permalinkNotFound || single == nil || (beforeSize == 0 && afterSize == 0), + atStart: permalinkNotFound || (r.mainComplete && beforeLength <= half), + atEnd: permalinkNotFound || (r.afterComplete && afterLength <= half - 1), + firstRowIndex: permalinkNotFound ? anchorIndex : anchorIndex - beforeSize, + permalinkNotFound: permalinkNotFound, lookup: lookup) + + case .forward, .backward: + let forward: Bool + let fromStart: Bool + if case .forward(_, let start) = anchor { + forward = true + fromStart = start == nil + } else { + forward = false + fromStart = false + } + let page = r.mainRows ?? [] + let hasMore = page.count > inputs.pageSize + let length = hasMore ? inputs.pageSize : page.count + let rowAt: (Int) -> Row? = { index in + let offset = forward ? index - anchorIndex : anchorIndex - index - 1 + return offset >= 0 && offset < length ? page[offset] : nil + } + return RowsSnapshot( + anchor: anchor, pageSize: inputs.pageSize, rowAt: rowAt, rowsLength: length, + complete: r.mainComplete, rowsEmpty: page.isEmpty, + atStart: forward ? fromStart || anchorIndex == 0 : r.mainComplete && !hasMore, + atEnd: forward && r.mainComplete && !hasMore, + firstRowIndex: forward ? anchorIndex : anchorIndex - length, + permalinkNotFound: permalinkNotFound, lookup: lookup) + } +} diff --git a/swift/Sources/ZeroVirtual/VirtualizerCore.swift b/swift/Sources/ZeroVirtual/VirtualizerCore.swift new file mode 100644 index 0000000000..2e89b9e11a --- /dev/null +++ b/swift/Sources/ZeroVirtual/VirtualizerCore.swift @@ -0,0 +1,1029 @@ +import Zero + +/// Where `scrollToItem` puts a row in the viewport. +public enum VirtualScrollAlignment: Sendable, Hashable { + /// The least scroll that brings the row fully into view: none when it + /// already is. A row taller than the viewport goes to the top. + case auto + /// The row's top at the viewport's top. + case start + /// The row in the middle of the viewport. + case center + /// The row's bottom at the viewport's bottom. + case end +} + +/// Who keeps what's on screen in place when rows above it are added, +/// removed, or change height. +public enum VirtualAnchoring: Sendable, Hashable { + /// The scroll view does, as SwiftUI's `scrollPosition` does. + case native + /// The virtualizer does: it pins a row in view and scrolls by however far + /// that row moves. + case manual +} + +/// A loaded row to render. +public struct VirtualItem { + /// The row's position in the list as far as the virtualizer knows it, + /// counted from the list's start once that's loaded. + public var index: Int + public var key: Key + public var row: Row +} + +extension VirtualItem: Sendable where Row: Sendable, Key: Sendable {} + +/// Where the list was: what to save so it can be put back +/// (`ScrollHistoryState`). +public struct VirtualScrollState: + Equatable, Sendable +{ + public var anchor: VirtualAnchor + public var scrollOffset: Double + public var estimatedTotal: Int + public var hasReachedStart: Bool + public var hasReachedEnd: Bool + /// The list the state is of. A state of another list isn't restored. + public var listContext: ListContext + + public init( + anchor: VirtualAnchor, scrollOffset: Double, estimatedTotal: Int, + hasReachedStart: Bool, hasReachedEnd: Bool, listContext: ListContext + ) { + self.anchor = anchor + self.scrollOffset = scrollOffset + self.estimatedTotal = estimatedTotal + self.hasReachedStart = hasReachedStart + self.hasReachedEnd = hasReachedEnd + self.listContext = listContext + } +} + +extension VirtualScrollState: Codable where StartRow: Codable, ListContext: Codable {} + +/// A rendered row's frame, in content coordinates: from the top of the +/// scroll view's content, which includes the space standing in for the rows +/// above the loaded ones. +public struct VirtualRowFrame { + public var index: Int + public var key: AnyHashable + public var minY: Double + public var maxY: Double + + public init(index: Int, key: AnyHashable, minY: Double, maxY: Double) { + self.index = index + self.key = key + self.minY = minY + self.maxY = maxY + } +} + +/// The scroll view a virtualizer drives: the DOM, for zero-virtual's core. +/// +/// The host reports every change of its offset with `didScroll()`, +/// including the virtualizer's own `scroll(to:)` and clamping as the content +/// shrinks, as `scrollViewDidScroll` does. +@MainActor +public protocol VirtualHost: AnyObject { + /// How far the content is scrolled: the viewport's top in content + /// coordinates. + var scrollOffset: Double { get } + /// Scrolls to `offset`, clamped to the content. Calling `didScroll()` + /// before returning is fine. + func scroll(to offset: Double) + /// The viewport's height. + var viewportHeight: Double { get } + /// Strips at the viewport's top and bottom that cover rows, such as a + /// toolbar. `scrollToItem` lines rows up clear of them. + var viewportInsets: (top: Double, bottom: Double) { get } + /// The content's height. + var contentHeight: Double { get } + /// The rendered rows, in list order. + func rowFrames() -> [VirtualRowFrame] +} + +/// What the list renders (`VirtualizerSnapshot`). +public struct VirtualSnapshot { + /// The loaded rows. + public var items: [VirtualItem] + /// The height standing in for the rows above the loaded ones. + public var spaceBefore: Double + /// The height standing in for the rows below the loaded ones. + public var spaceAfter: Double + /// The queries of the loaded rows have their server answers. + public var complete: Bool + /// The list has no rows, as far as it's loaded. + public var rowsEmpty: Bool + /// The permalink's row doesn't exist. + public var permalinkNotFound: Bool + /// How many rows the list has, at least: the loaded ones and those seen + /// above and below them. + public var estimatedTotal: Int + /// How many rows the list has, once both its ends have been seen. + public var total: Int? + /// The list has been idle for the settle time. + public var settled: Bool + + public init() { + items = [] + spaceBefore = 0 + spaceAfter = 0 + complete = false + rowsEmpty = true + permalinkNotFound = false + estimatedTotal = 0 + total = nil + settled = false + } +} + +extension VirtualSnapshot: Sendable where Row: Sendable, Key: Sendable {} + +/// The options of the paging core (`VirtualizerOptions`). +package struct VirtualizerOptions< + ListContext: Equatable & Sendable, Row, Key: Hashable, StartRow: Equatable & Sendable +> { + package var estimatedRowHeight: Double + package var overscan = 5 + package var anchoring = VirtualAnchoring.manual + package var rowKey: (Row) -> Key + package var toStartRow: (Row) -> StartRow + package var listContext: ListContext + package var permalinkID: String? + /// Milliseconds idle before the list is settled. + package var settleTime: Double = 2000 + package var minPageSize: Int? + /// A position to start at, as saved from `onScrollStateChange`. Later + /// restores go through `restore(_:)`. + package var scrollState: VirtualScrollState? + package var onScrollStateChange: ((VirtualScrollState) -> Void)? + package var onSettled: (() -> Void)? + /// Follow the bottom while the list is parked there: rows added below, or + /// a row there growing, scroll into view (`useStickToBottom`). + package var stickToBottom = false + /// How near the bottom counts as at it. + package var stickSlack = 4.0 + + package init( + estimatedRowHeight: Double, rowKey: @escaping (Row) -> Key, + toStartRow: @escaping (Row) -> StartRow, listContext: ListContext + ) { + self.estimatedRowHeight = estimatedRowHeight + self.rowKey = rowKey + self.toStartRow = toStartRow + self.listContext = listContext + } +} + +// One row of loading skeleton above a window opened mid-list. +private let skeletonRows = 1 +private let defaultMinPageSize = 50 +// Commits a permalink must be reported missing before the list gives up on +// it: the first may still carry the previous lookup's answer. +private let permalinkMissingCommits = 2 + +/// The paging and anchoring of a virtual list, without its queries or its +/// views: zero-virtual's `ZeroVirtualizer` core. Rows come in through +/// `setRows`, the scroll view through `attach`, and the queries to run are +/// `queryInputs`. +/// +/// The host calls `afterUpdate()` once it has rendered a new snapshot, +/// `didScroll()` when the content moves, and `viewportDidResize()`. +@MainActor +package final class VirtualizerCore< + ListContext: Equatable & Sendable, Row, Key: Hashable, StartRow: Equatable & Sendable +> { + package typealias Options = VirtualizerOptions + package typealias Anchor = VirtualAnchor + package typealias ScrollState = VirtualScrollState + + private struct Paging { + var estimatedTotal: Int + var hasReachedStart: Bool + var hasReachedEnd: Bool + var anchor: Anchor + var listContext: ListContext + } + + private struct PersistState: Equatable { + var anchor: Anchor + var estimatedTotal: Int + var hasReachedStart: Bool + var hasReachedEnd: Bool + } + + private enum Source { case option, imperative } + + /// A scroll to a row that hasn't landed. + private struct PendingScroll { + var id: String? + /// The rendered row's key, when the request named it or a lookup found it. + var rowKey: AnyHashable? + var align: VirtualScrollAlignment + var source: Source + } + + /// Where the viewport is going: a row (a jump) or an offset (a restore) + /// owns the position until it lands; a probe is a jump still looking its + /// row up. + private enum Target { + case probe(PendingScroll) + case row(PendingScroll) + case offset(Double) + + var isProbe: Bool { + if case .probe = self { return true } + return false + } + } + + private let scheduler: any Scheduler + private(set) package var options: Options + private var rows: RowsSnapshot = .empty + private var paging: Paging + private var pageSize: Int + private(set) package var settled = false + + private var listeners: [Int: () -> Void] = [:] + private var nextListener = 0 + private var version = 0 + private var cachedSnapshot: (version: Int, snapshot: VirtualSnapshot)? + + private weak var host: (any VirtualHost)? + private var programmaticScroll = false + // Entry calls under way, and a scroll the host reported during our own + // write, delivered when the outermost one ends, as a browser delivers the + // scroll event after the task that wrote the offset. + private var depth = 0 + private var writing = false + private var scrollDuringWrite = false + + private var anchorKey: AnyHashable? + // The reference row's top, in content coordinates. + private var anchorOffset = 0.0 + // From a reset until the new window's rows are in. + private var anchorSuppressed = false + private var settleTimer: (any ScheduledTimer)? + private var target: Target? + private var permalinkMissing = 0 + + // A restore not yet applied: from `scrollState` at first, then + // `restore(_:)`. + private var pendingRestore: ScrollState? + private var appliedPermalinkID: String? + private var lastPersisted: PersistState? + private var lastWritten: ScrollState? + private var lastSettleContext: ListContext + // Whether the list was at the bottom as of the last scroll, so growth + // since then re-pins it. + private var stuck = false + + package init(_ options: Options, scheduler: any Scheduler) { + self.options = options + self.scheduler = scheduler + pageSize = Self.minPageSize(options) + let restore = Self.restorable(options) + if let restore { + paging = Paging( + estimatedTotal: restore.estimatedTotal, hasReachedStart: restore.hasReachedStart, + hasReachedEnd: restore.hasReachedEnd, anchor: restore.anchor, + listContext: options.listContext) + } else if let id = options.permalinkID { + paging = Self.permalinkPaging(id, options.listContext) + } else { + paging = Paging( + estimatedTotal: skeletonRows, hasReachedStart: false, hasReachedEnd: false, anchor: .top, + listContext: options.listContext) + } + if let id = options.permalinkID, restore == nil { + target = .row(PendingScroll(id: id, align: .start, source: .option)) + } + appliedPermalinkID = options.permalinkID + lastSettleContext = options.listContext + pendingRestore = restore + } + + /// Puts the list back where `state` says, as on a navigation back. A + /// state of another list context is ignored, and so is the one this + /// core reported last, which is where the list already is. + package func restore(_ state: ScrollState) { + guard state.listContext == options.listContext, state != lastWritten else { return } + pendingRestore = state + version += 1 + } + + // MARK: - Host-facing surface + + /// Takes new options without notifying. + package func setOptions(_ new: Options) { + let old = options + options = new + if old.estimatedRowHeight != new.estimatedRowHeight { version += 1 } + } + + /// Takes new rows without notifying. + package func setRows(_ new: RowsSnapshot) { + rows = new + version += 1 + } + + /// The queries the rows must answer. + package var queryInputs: RowsQueryInputs { + var probe: String? + if case .probe(let p) = target { probe = p.id } + return RowsQueryInputs( + pageSize: pageSize, anchor: effectiveAnchor, settled: settled, probeID: probe) + } + + /// Calls `listener` whenever the snapshot or the query inputs change. + @discardableResult + package func subscribe(_ listener: @escaping () -> Void) -> () -> Void { + let id = nextListener + nextListener += 1 + listeners[id] = listener + return { [weak self] in self?.listeners[id] = nil } + } + + /// What to render. + package var snapshot: VirtualSnapshot { + if let cachedSnapshot, cachedSnapshot.version == version { return cachedSnapshot.snapshot } + let s = buildSnapshot() + cachedSnapshot = (version, s) + return s + } + + /// Drives `host`, replacing any other. + package func attach(_ host: any VirtualHost) { + if self.host === host { return } + detach() + self.host = host + measureStuck() + withNotify { resetSettleTimer() } + } + + package func detach() { + host = nil + settleTimer?.cancel() + settleTimer = nil + } + + /// After the host rendered a new snapshot, or its rows changed height. + package func afterUpdate() { + withNotify { afterUpdateImpl() } + } + + /// The viewport changed size. + package func viewportDidResize() { + withNotify { + repinToBottom() + evaluate() + } + } + + /// The content moved. + package func didScroll() { + if writing { + scrollDuringWrite = true + return + } + onScroll() + } + + /// Scrolls the row with `id`, the id the single-row query takes, into + /// view, loading it first if it isn't loaded. A rendered row whose key is + /// `id` scrolls without a lookup. An id with no row does nothing. + package func scrollToItem(_ id: String, align: VirtualScrollAlignment = .auto) { + guard !id.isEmpty else { return } + withNotify { startOrScroll(PendingScroll(id: id, align: align, source: .imperative)) } + } + + /// Scrolls the rendered row with `key` into view. A row that isn't + /// rendered is out of reach: scroll to it by id. + package func scrollToRow(key: Key, align: VirtualScrollAlignment = .auto) { + withNotify { + guard let host, frame(host, key: AnyHashable(key)) != nil else { return } + setTarget(.row(PendingScroll(rowKey: AnyHashable(key), align: align, source: .imperative))) + land() + } + } + + /// The first loaded row in the viewport. + package func firstVisibleItem() -> VirtualItem? { visibleItem(first: true) } + + /// The last loaded row in the viewport. + package func lastVisibleItem() -> VirtualItem? { visibleItem(first: false) } + + // MARK: - Requests + + private func startOrScroll(_ request: PendingScroll) { + if let host, findTarget(host, request) != nil { + setTarget(.row(request)) + land() + return + } + // A repeat while its page is still loading waits for it. + if let id = request.id, isListContextCurrent, isTargeting(id), !rows.complete { + setTarget(.row(request)) + return + } + // A loaded list checks the row exists before giving itself up for it. + if request.rowKey == nil, !paging.anchor.isPermalink, !rows.rowsEmpty, isListContextCurrent { + setTarget(.probe(request)) + return + } + guard let id = request.id else { return } + setTarget(.row(request)) + anchorKey = nil + anchorSuppressed = true + setPaging(Self.permalinkPaging(id, options.listContext)) + } + + // A probe is a query input; other targets aren't. + private func setTarget(_ new: Target?) { + if target?.isProbe == true || new?.isProbe == true { version += 1 } + target = new + } + + private func resolveProbe() { + guard case .probe(let probe) = target else { return } + guard isListContextCurrent else { + setTarget(nil) + return + } + guard let lookup = rows.lookup, lookup.id == probe.id, lookup.complete else { return } + guard let row = lookup.row else { + setTarget(nil) + return + } + var found = probe + found.rowKey = AnyHashable(options.rowKey(row)) + startOrScroll(found) + } + + private func findTarget(_ host: any VirtualHost, _ request: PendingScroll) -> VirtualRowFrame? { + if let id = request.id, let f = frame(host, key: AnyHashable(id)) { return f } + if let key = request.rowKey { return frame(host, key: key) } + return nil + } + + private func frame(_ host: any VirtualHost, key: AnyHashable) -> VirtualRowFrame? { + host.rowFrames().first { $0.key == key } + } + + private func afterUpdateImpl() { + if options.listContext != lastSettleContext { + lastSettleContext = options.listContext + resetSettleTimer() + } + measureAndCompensate() + restoreOrReset() + updatePageSize() + land() + recoverFromMissingPermalink() + + liftAnchorSuppression() + updatePaging() + resolveProbe() + repinToBottom() + evaluatePaging() + persist(onlyIfChanged: true) + } + + // MARK: - Stick to bottom + + private func measureStuck() { + guard let host else { return } + stuck = host.contentHeight - host.scrollOffset - host.viewportHeight <= options.stickSlack + } + + private func repinToBottom() { + guard options.stickToBottom, stuck, !landing, let host else { return } + setScrollOffset(host.contentHeight - host.viewportHeight) + stuck = true + } + + // MARK: - Derived + + private static func minPageSize(_ options: Options) -> Int { + guard let min = options.minPageSize else { return defaultMinPageSize } + return makeEven(max(2, min)) + } + + private static func restorable(_ options: Options) -> ScrollState? { + guard let state = options.scrollState, state.listContext == options.listContext else { + return nil + } + return state + } + + private static func permalinkPaging(_ id: String, _ context: ListContext) -> Paging { + Paging( + estimatedTotal: skeletonRows, hasReachedStart: false, hasReachedEnd: false, + anchor: .permalink(index: skeletonRows, id: id), listContext: context) + } + + private var isListContextCurrent: Bool { paging.listContext == options.listContext } + + // Queries use a new list context at once, before the reset commits. + private var effectiveAnchor: Anchor { + if isListContextCurrent { return paging.anchor } + if let id = options.permalinkID { return .permalink(index: skeletonRows, id: id) } + return .top + } + + private var rowEstimate: Double { max(1, options.estimatedRowHeight) } + + private var manual: Bool { options.anchoring == .manual } + + private var effectiveEstimatedTotal: Int { + let end = rows.firstRowIndex + rows.rowsLength + if rows.atEnd && rows.atStart && rows.complete { return rows.rowsLength } + return max(paging.estimatedTotal, end) + } + + private func buildSnapshot() -> VirtualSnapshot { + let total = effectiveEstimatedTotal + let end = rows.firstRowIndex + rows.rowsLength + let before = max(0, rows.firstRowIndex) + let after = rows.atEnd ? 0 : max(0, total - end) + var items: [VirtualItem] = [] + items.reserveCapacity(rows.rowsLength) + for index in rows.firstRowIndex..() + s.items = items + s.spaceBefore = rows.atStart ? 0 : Double(before) * rowEstimate + s.spaceAfter = Double(after) * rowEstimate + s.complete = rows.complete + s.rowsEmpty = rows.rowsEmpty + s.permalinkNotFound = rows.permalinkNotFound + s.estimatedTotal = total + s.total = + rows.atStart && rows.atEnd + ? rows.rowsLength + : paging.hasReachedStart && paging.hasReachedEnd ? paging.estimatedTotal : nil + s.settled = settled + return s + } + + private func notify() { + for listener in listeners.values { listener() } + } + + private func withNotify(_ body: () -> Void) { + let before = version + depth += 1 + body() + depth -= 1 + if depth == 0, scrollDuringWrite { + scrollDuringWrite = false + onScroll() + } + if version != before { notify() } + } + + private func setPaging(_ next: Paging) { + paging = next + version += 1 + } + + private func setAnchor(_ anchor: Anchor) { + // Paging can pick the same anchor every commit. + if paging.anchor == anchor { return } + var next = paging + next.anchor = anchor + setPaging(next) + } + + // MARK: - Geometry + + private func visibleIndexRange(_ host: any VirtualHost) -> (first: Int, last: Int)? { + let top = host.scrollOffset + let bottom = top + host.viewportHeight + var first = Int.max + var last = Int.min + for f in host.rowFrames() { + if f.minY >= bottom { break } + if f.maxY > top && f.minY < bottom { + first = min(first, f.index) + last = max(last, f.index) + } + } + return first == .max ? nil : (first, last) + } + + private func visibleItem(first: Bool) -> VirtualItem? { + guard let host, let range = visibleIndexRange(host) else { return nil } + let index = first ? range.first : range.last + return snapshot.items.first { $0.index == index } + } + + // Scrolls unless already there; whether it wrote. + @discardableResult + private func setScrollOffset(_ offset: Double) -> Bool { + guard let host, host.scrollOffset.rounded() != offset.rounded() else { return false } + programmaticScroll = true + writing = true + host.scroll(to: offset) + writing = false + if depth == 0, scrollDuringWrite { + scrollDuringWrite = false + onScroll() + } + return true + } + + // MARK: - Anchoring + + // The first row reaching below the viewport's top, as native anchoring + // picks it. + private func refreshAnchor() { + guard let host else { return } + let top = host.scrollOffset + let ref = host.rowFrames().first { $0.maxY > top + 0.5 } + anchorKey = ref?.key + anchorOffset = ref?.minY ?? 0 + } + + private func compensate(_ delta: Double) { + guard let host else { return } + anchorOffset += delta + setScrollOffset(host.scrollOffset + delta) + } + + private func measureAndCompensate() { + guard manual, !anchorSuppressed, isListContextCurrent, !landing, let host else { return } + // At the top, rows added above are revealed, not compensated away. + if host.scrollOffset <= 0 { + refreshAnchor() + return + } + guard let key = anchorKey, let ref = frame(host, key: key) else { + refreshAnchor() + return + } + let delta = ref.minY - anchorOffset + if abs(delta) < 0.5 { return } + compensate(delta) + } + + private func liftAnchorSuppression() { + if anchorSuppressed && rowsSettled { + anchorSuppressed = false + refreshAnchor() + } + } + + // The rows answer the query as it is now: its anchor and page size. + private var rowsAnswerQuery: Bool { + isListContextCurrent && rows.pageSize == pageSize && rows.anchor == paging.anchor + } + + private var rowsSettled: Bool { rowsAnswerQuery && rows.complete } + + private var landing: Bool { + guard let target else { return false } + return !target.isProbe + } + + private func landed() { + if !anchorSuppressed { refreshAnchor() } + persist() + } + + private func onScroll() { + let programmatic = programmaticScroll + programmaticScroll = false + if !programmatic, case .offset = target { target = nil } + // Our own scrolls keep it as it was: compensation, or the re-pin. + if !programmatic { measureStuck() } + if manual && !programmatic { refreshAnchor() } + withNotify { + resetSettleTimer() + evaluate() + persist() + } + } + + private func evaluate() { + updatePageSize() + evaluatePaging() + } + + // MARK: - Settling + + private func resetSettleTimer() { + if settled { + settled = false + version += 1 + } + settleTimer?.cancel() + settleTimer = scheduler.schedule(after: options.settleTime) { [weak self] in + guard let self else { return } + self.settled = true + self.version += 1 + self.options.onSettled?() + self.notify() + } + } + + // MARK: - Paging + + // About three viewports of rows. + private func updatePageSize() { + let min = Self.minPageSize(options) + let height = host?.viewportHeight ?? 0 + let size = + height > 0 ? max(min, makeEven(Int((height / rowEstimate).rounded(.up)) * 3)) : min + if size > pageSize { + pageSize = size + version += 1 + } + } + + // Takes in the loaded window's extent, and renumbers it. + private func updatePaging() { + guard rowsAnswerQuery else { return } + let s = paging + var anchor = s.anchor + var total = s.estimatedTotal + let reachedStart = s.hasReachedStart || rows.atStart + let reachedEnd = s.hasReachedEnd || rows.atEnd + if rows.complete { + let extent = + rows.atStart && rows.atEnd ? rows.rowsLength : rows.firstRowIndex + rows.rowsLength + if extent > total { total = extent } + } + if !rows.rowsEmpty { + if rows.firstRowIndex < 0 { + // Start the window at 0, or a skeleton row down with more above. + let offset = rows.atStart ? -rows.firstRowIndex : skeletonRows - rows.firstRowIndex + anchor = anchor.with(index: anchor.index + offset) + total += offset + } else if rows.atStart && rows.firstRowIndex > 0 { + anchor = .top + total -= rows.firstRowIndex + } + } + if anchor != s.anchor || total != s.estimatedTotal || reachedStart != s.hasReachedStart + || reachedEnd != s.hasReachedEnd + { + setPaging( + Paging( + estimatedTotal: total, hasReachedStart: reachedStart, hasReachedEnd: reachedEnd, + anchor: anchor, listContext: s.listContext)) + } + } + + private func restoreOrReset() { + let restore = pendingRestore.flatMap { $0.listContext == options.listContext ? $0 : nil } + let permalinkID = options.permalinkID + let scrollStateChanged = restore != nil + let permalinkChanged = permalinkID != appliedPermalinkID + + // Not before a scroll view: the scroll would be lost. + if (restore != nil || permalinkID != nil) && host == nil { return } + + // A jump outranks a restore. + if let t = target { + var jump = false + switch t { + case .probe: jump = true + case .row(let p): jump = p.source == .imperative + case .offset: break + } + if jump { + if isListContextCurrent && !permalinkChanged { + pendingRestore = nil + appliedPermalinkID = permalinkID + return + } + setTarget(nil) + } + } + + pendingRestore = nil + appliedPermalinkID = permalinkID + + if isListContextCurrent && !scrollStateChanged && !permalinkChanged { return } + + if !isListContextCurrent { + anchorKey = nil + anchorSuppressed = true + } + + if case .offset = target { setTarget(nil) } + if let restore { + setTarget(.offset(restore.scrollOffset)) + if setScrollOffset(restore.scrollOffset) { anchorKey = nil } + setPaging( + Paging( + estimatedTotal: restore.estimatedTotal, hasReachedStart: restore.hasReachedStart, + hasReachedEnd: restore.hasReachedEnd, anchor: restore.anchor, + listContext: options.listContext)) + } else if let permalinkID { + // A visible permalink row stays put; any other is scrolled to. + let targetFrame = host.flatMap { frame($0, key: AnyHashable(permalinkID)) } + var visible = false + if let host, let f = targetFrame { + visible = f.maxY > host.scrollOffset && f.minY < host.scrollOffset + host.viewportHeight + } + if !visible { + let request = PendingScroll(id: permalinkID, align: .start, source: .option) + if targetFrame != nil { + setTarget(.row(request)) + } else { + startOrScroll(request) + } + } + } else { + anchorKey = nil + setScrollOffset(0) + setPaging( + Paging( + estimatedTotal: 0, hasReachedStart: true, hasReachedEnd: false, anchor: .top, + listContext: options.listContext)) + } + } + + // A missing permalink on a cold load has no list to keep: go to the top. + private func recoverFromMissingPermalink() { + guard rows.permalinkNotFound, isListContextCurrent, + case .permalink(_, let id) = paging.anchor, rows.lookup?.id == id + else { + permalinkMissing = 0 + return + } + permalinkMissing += 1 + if permalinkMissing < permalinkMissingCommits { return } + permalinkMissing = 0 + setTarget(nil) + anchorKey = nil + setPaging( + Paging( + estimatedTotal: 0, hasReachedStart: true, hasReachedEnd: false, anchor: .top, + listContext: options.listContext)) + } + + private func isTargeting(_ id: String) -> Bool { + if case .permalink(_, let anchorID) = paging.anchor { return anchorID == id } + return false + } + + private func alignDelta( + _ host: any VirtualHost, _ f: VirtualRowFrame, _ align: VirtualScrollAlignment + ) -> Double { + let insets = host.viewportInsets + let top = host.scrollOffset + insets.top + let bottom = host.scrollOffset + host.viewportHeight - insets.bottom + switch align { + case .start: return f.minY - top + case .end: return f.maxY - bottom + case .center: return (f.minY + f.maxY) / 2 - (top + bottom) / 2 + case .auto: + if f.maxY - f.minY > bottom - top || f.minY < top { return f.minY - top } + if f.maxY > bottom { return f.maxY - bottom } + return 0 + } + } + + // Puts the viewport where a jump or restore goes, every commit until its + // window has settled. + private func land() { + guard let target else { return } + switch target { + case .probe: return + case .offset(let offset): + guard rowsAnswerQuery else { return } + setScrollOffset(offset) + case .row(let pending): + guard landRow(pending) else { return } + } + if rowsSettled { + setTarget(nil) + landed() + } + } + + private func landRow(_ pending: PendingScroll) -> Bool { + if pending.source == .option, pending.id != options.permalinkID { + setTarget(nil) + return false + } + guard let host else { return false } + var f = findTarget(host, pending) + if f == nil, pending.rowKey == nil, let id = pending.id { + // The row's key, once its own page queries have completed. + if let lookup = rows.lookup, lookup.id == id, let row = lookup.row, rows.complete { + var found = pending + found.rowKey = AnyHashable(options.rowKey(row)) + setTarget(.row(found)) + f = frame(host, key: found.rowKey!) + } + } + guard let f else { + // Don't wait for a row that's missing or whose query is gone. + let missing = rows.permalinkNotFound && rows.lookup?.id == pending.id + let abandoned = rows.complete && !(pending.id.map(isTargeting) ?? false) + if missing || abandoned { setTarget(nil) } + return false + } + let delta = alignDelta(host, f, pending.align) + if abs(delta) > 1 { setScrollOffset(host.scrollOffset + delta) } + return true + } + + private func evaluatePaging() { + guard rowsSettled, !rows.rowsEmpty, !programmaticScroll, !landing, let host else { return } + let visible = visibleIndexRange(host) + let threshold = max(options.overscan, nearPageEdgeThreshold(pageSize)) + + func updateAnchorForEdge(_ targetIndex: Int, _ direction: VirtualDirection, _ offset: Int) { + let index = boundIndex(targetIndex, rows.firstRowIndex, rows.rowsLength) + let row = rows.rowAt(index) + switch direction { + case .forward: + setAnchor(.forward(index: index + offset, startRow: row.map(options.toStartRow))) + case .backward: + guard let row else { preconditionFailure("a backward page starts at a loaded row") } + setAnchor(.backward(index: index + offset, startRow: options.toStartRow(row))) + } + } + + guard let visible else { + // A jump into the unloaded space pages toward it. + guard let top = host.rowFrames().first?.minY else { return } + if top >= host.scrollOffset + host.viewportHeight { + if host.scrollOffset <= 0 || rows.atStart { + setAnchor(.top) + } else { + updateAnchorForEdge(rows.firstRowIndex, .backward, 0) + } + } else { + updateAnchorForEdge(rows.firstRowIndex + rows.rowsLength - 1, .forward, 1) + } + return + } + + if rows.atStart && rows.firstRowIndex != 0 { + setAnchor(.top) + return + } + + // A page must hold what's on screen with room on both sides, or paging + // flips between its edges. + let span = visible.last - visible.first + 1 + if pageSize < span + 4 * threshold { + pageSize = makeEven(span + 4 * threshold) + version += 1 + return + } + + let fromStart = visible.first - rows.firstRowIndex + let fromEnd = rows.firstRowIndex + rows.rowsLength - 1 - visible.last + if !rows.atStart && fromStart <= threshold { + updateAnchorForEdge(visible.last + 2 * threshold, .backward, 0) + return + } + if !rows.atEnd && fromEnd <= threshold { + updateAnchorForEdge(visible.first - 2 * threshold, .forward, 1) + } + } + + // MARK: - Persistence + + private var persistState: PersistState { + PersistState( + anchor: paging.anchor, estimatedTotal: effectiveEstimatedTotal, + hasReachedStart: paging.hasReachedStart, hasReachedEnd: paging.hasReachedEnd) + } + + // Scrolls report every time; updates only when paging changed. + private func persist(onlyIfChanged: Bool = false) { + if landing { return } + let next = persistState + if onlyIfChanged && lastPersisted == next { return } + guard let host, isListContextCurrent, let report = options.onScrollStateChange else { return } + lastPersisted = next + let state = ScrollState( + anchor: next.anchor, scrollOffset: host.scrollOffset, estimatedTotal: next.estimatedTotal, + hasReachedStart: next.hasReachedStart, hasReachedEnd: next.hasReachedEnd, + listContext: options.listContext) + lastWritten = state + report(state) + } +} + +private func boundIndex(_ target: Int, _ first: Int, _ length: Int) -> Int { + if length == 0 { return first } + return max(first, min(first + length - 1, target)) +} + +private func nearPageEdgeThreshold(_ pageSize: Int) -> Int { + (pageSize + 9) / 10 +} + +private func makeEven(_ n: Int) -> Int { n % 2 == 0 ? n : n + 1 } diff --git a/swift/Sources/ZeroVirtual/ZeroVirtualizer.swift b/swift/Sources/ZeroVirtual/ZeroVirtualizer.swift new file mode 100644 index 0000000000..fdcfa72565 --- /dev/null +++ b/swift/Sources/ZeroVirtual/ZeroVirtualizer.swift @@ -0,0 +1,306 @@ +import Observation +import Zero + +/// A list that pages its rows in from Zero queries as it scrolls, keeping +/// what's on screen in place (`useZeroVirtualizer` in +/// `@rocicorp/zero-virtual`). +/// +/// It runs at most three queries: a page of rows around where the list is +/// scrolled, or, opened at a permalink, the row and a page either side of +/// it. Rows far from the screen are dropped. +/// +/// ```swift +/// let list = ZeroVirtualizer( +/// zero: zero, listContext: filter, estimatedRowHeight: 56, +/// rowKey: \.id, toStartRow: { IssueStart(id: $0.id, modified: $0.modified) }, +/// pageQuery: { page, filter in Queries.issuePage(filter, page) }, +/// rowQuery: { row, filter in Queries.issueByID(filter, row.id) }) +/// ``` +/// +/// A scroll view adapter (`VirtualList`, `VirtualTableAdapter`) attaches +/// itself and renders `snapshot`. +@Observable +@MainActor +public final class ZeroVirtualizer< + Context: Sendable, Row: ZeroRow, Key: Hashable, StartRow: Equatable & Sendable, + ListContext: Equatable & Sendable +> { + /// A page of the list `ListContext` names. + public typealias PageQuery = (VirtualPageRequest, ListContext) -> QueryRequest< + Row, [Row], Context + > + /// The row an id names, if it's in the list `ListContext` names. + public typealias RowQuery = (VirtualRowRequest, ListContext) -> QueryRequest + public typealias ScrollState = VirtualScrollState + + /// What to render. + public private(set) var snapshot = VirtualSnapshot() + /// A query couldn't be run, or its rows read. + public private(set) var error: (any Error)? + + @ObservationIgnored let core: VirtualizerCore + @ObservationIgnored private let zero: Zero + @ObservationIgnored private var pageQuery: PageQuery + @ObservationIgnored private var rowQuery: RowQuery + @ObservationIgnored private let ttl: TTL + @ObservationIgnored private var single = Slot() + @ObservationIgnored private var main = Slot<[Row]>() + @ObservationIgnored private var after = Slot<[Row]>() + @ObservationIgnored private var syncing = false + @ObservationIgnored private var dirty = false + + /// - Parameters: + /// - listContext: What the list shows, such as its filter and sort. A + /// new value starts the list over at its top (or `permalinkID`). + /// - estimatedRowHeight: The height that stands in for rows not loaded. + /// - rowKey: The identity of a row in the views. + /// - toStartRow: What a page query needs of a row to start from it. + /// - pageQuery: A page: `limit` rows after `start` (forward), or before + /// it closest first (backward), in the list's order. + /// - rowQuery: The row an id names, if it's in the list. + /// - permalinkID: A row to open the list at. + /// - scrollState: Where the list was, from `onScrollStateChange`. + public init( + zero: Zero, listContext: ListContext, estimatedRowHeight: Double, + rowKey: @escaping (Row) -> Key, toStartRow: @escaping (Row) -> StartRow, + pageQuery: @escaping PageQuery, rowQuery: @escaping RowQuery, permalinkID: String? = nil, + scrollState: ScrollState? = nil, minPageSize: Int? = nil, settleTime: Duration = .seconds(2), + stickToBottom: Bool = false, ttl: TTL = .defaultTTL, scheduler: (any Scheduler)? = nil + ) { + var options = VirtualizerOptions( + estimatedRowHeight: estimatedRowHeight, rowKey: rowKey, toStartRow: toStartRow, + listContext: listContext) + options.permalinkID = permalinkID + options.scrollState = scrollState + options.minPageSize = minPageSize + options.settleTime = + Double(settleTime.components.seconds) * 1000 + + Double(settleTime.components.attoseconds) / 1e15 + options.stickToBottom = stickToBottom + self.zero = zero + self.pageQuery = pageQuery + self.rowQuery = rowQuery + self.ttl = ttl + core = VirtualizerCore(options, scheduler: scheduler ?? SystemScheduler()) + _ = core.subscribe { [weak self] in self?.sync() } + sync() + } + + isolated deinit { + core.detach() + single.close() + main.close() + after.close() + } + + // MARK: Options + + /// What the list shows. A new value starts it over. + public var listContext: ListContext { + get { core.options.listContext } + set { update { $0.listContext = newValue } } + } + + /// A row the list is opened at. A new value scrolls to it. + public var permalinkID: String? { + get { core.options.permalinkID } + set { update { $0.permalinkID = newValue } } + } + + /// How the list keeps what's on screen in place. Adapters set it. + public var anchoring: VirtualAnchoring { + get { core.options.anchoring } + set { update { $0.anchoring = newValue } } + } + + public var estimatedRowHeight: Double { + get { core.options.estimatedRowHeight } + set { update { $0.estimatedRowHeight = newValue } } + } + + /// Follow the bottom while the list is parked there. + public var stickToBottom: Bool { + get { core.options.stickToBottom } + set { update { $0.stickToBottom = newValue } } + } + + /// Called with where the list is, on every scroll and page. Save the + /// latest (debounced) to `restore(_:)` it later. + public var onScrollStateChange: ((ScrollState) -> Void)? { + get { core.options.onScrollStateChange } + set { update { $0.onScrollStateChange = newValue } } + } + + /// Called when the list has been idle for the settle time. + public var onSettled: (() -> Void)? { + get { core.options.onSettled } + set { update { $0.onSettled = newValue } } + } + + /// New queries, as when they capture state other than the list context + /// that changed. + public func setQueries(page: @escaping PageQuery, row: @escaping RowQuery) { + pageQuery = page + rowQuery = row + single.close() + main.close() + after.close() + sync() + } + + private func update(_ change: (inout VirtualizerOptions) -> Void) + { + var options = core.options + change(&options) + core.setOptions(options) + sync() + } + + // MARK: Scrolling + + /// Scrolls the row with `id` (what `rowQuery` takes) into view, loading + /// it first if needed. An id with no row does nothing. + public func scrollToItem(_ id: String, align: VirtualScrollAlignment = .auto) { + core.scrollToItem(id, align: align) + } + + /// Scrolls the rendered row with `key` into view. + public func scrollToRow(key: Key, align: VirtualScrollAlignment = .auto) { + core.scrollToRow(key: key, align: align) + } + + /// The first loaded row in the viewport. + public func firstVisibleItem() -> VirtualItem? { core.firstVisibleItem() } + + /// The last loaded row in the viewport. + public func lastVisibleItem() -> VirtualItem? { core.lastVisibleItem() } + + /// Puts the list back where `state` says. + public func restore(_ state: ScrollState) { core.restore(state) } + + // MARK: The adapter's side + + /// Drives `host`'s scrolling. + public func attach(_ host: any VirtualHost) { core.attach(host) } + + public func detach() { core.detach() } + + /// After the host rendered `snapshot`, or its rows changed height. + public func afterUpdate() { core.afterUpdate() } + + /// The content moved. + public func didScroll() { core.didScroll() } + + /// The viewport changed size. + public func viewportDidResize() { core.viewportDidResize() } + + // MARK: Queries + + /// Runs the queries the core asks for and hands it their rows. + private func sync() { + if syncing { + dirty = true + return + } + syncing = true + defer { syncing = false } + repeat { + dirty = false + let inputs = core.queryInputs + let context = core.options.listContext + if let next = reopen(single, singleRequest(inputs).map { rowQuery($0, context) }) { + single = next + } + let pages = pageRequests( + inputs, toStartRow: core.options.toStartRow, singleRow: single.rows ?? nil) + if let next = reopen(main, pages.main.map { pageQuery($0, context) }) { main = next } + if let next = reopen(after, pages.after.map { pageQuery($0, context) }) { after = next } + core.setRows( + assembleRows( + inputs, + RowsQueryResults( + singleRow: single.rows ?? nil, singleComplete: single.complete, mainRows: main.rows, + mainComplete: main.complete, afterRows: after.rows, afterComplete: after.complete))) + } while dirty + error = single.error ?? main.error ?? after.error + snapshot = core.snapshot + } + + /// The slot for `request`: nil when `slot` already runs it. + private func reopen( + _ slot: Slot, _ request: QueryRequest? + ) -> Slot? where R.Row == Row { + let key = request.map { SlotKey(name: $0.name, args: $0.args) } + if slot.key == key { return nil } + var old = slot + old.close() + var next = Slot() + next.key = key + guard let request, let key else { return next } + do { + let view = try zero.materialize(request, ttl: ttl) + next.view = view + next.take(view.data, view.resultType, view.error) + // Its first call, now, is the data just taken. + var first = true + next.remove = view.addListener { [weak self, weak view] value, type, error in + if first { + first = false + return + } + guard let self, let view, !view.isDestroyed else { return } + self.receive(value, type, error, into: key) + } + } catch { + next.error = error + } + return next + } + + private func receive( + _ value: ViewValue, _ type: ResultType, _ queryError: ErroredQuery?, into key: SlotKey + ) { + if single.key == key { single.take(value, type, queryError) } + if main.key == key { main.take(value, type, queryError) } + if after.key == key { after.take(value, type, queryError) } + sync() + } +} + +private struct SlotKey: Equatable { + var name: String + var args: [JSONValue] +} + +/// One live query of the virtualizer. +@MainActor +private struct Slot { + var key: SlotKey? + var view: MaterializedView? + var remove: () -> Void = {} + var rows: R? + var complete = false + var error: (any Error)? + + mutating func take(_ value: ViewValue, _ type: ResultType, _ queryError: ErroredQuery?) { + do { + rows = try R(view: value) + // An errored query has nothing more coming: done, as far as paging goes. + complete = type == .complete || type == .error + error = queryError.map { QueryError($0) } + } catch { + self.error = error + } + } + + mutating func close() { + remove() + remove = {} + view?.destroy() + view = nil + key = nil + rows = nil + complete = false + error = nil + } +} diff --git a/swift/Tests/ZeroVirtualTests/Generated/TestSchema.swift b/swift/Tests/ZeroVirtualTests/Generated/TestSchema.swift new file mode 100644 index 0000000000..04e7b05ab3 --- /dev/null +++ b/swift/Tests/ZeroVirtualTests/Generated/TestSchema.swift @@ -0,0 +1,507 @@ +// Generated by `zero-swift codegen` from packages/zql/src/query/test/test-schemas.ts. Do not edit. +// swift-format-ignore-file + +import Zero + +struct TestIssue: ZeroRow, Codable, Hashable, Identifiable { + var id: String + var title: String + var description: String + var closed: Bool + var ownerId: String? + var createdAt: Double + + // Relationships, filled when a query asks for them with `related`. + @Indirect var owner: TestUser? + var comments: [TestComment]? + @Indirect var oneComment: TestComment? + var ownerComments: [TestComment]? + var labels: [TestLabel]? + + init( + id: String, + title: String, + description: String, + closed: Bool, + ownerId: String? = nil, + createdAt: Double, + owner: TestUser? = nil, + comments: [TestComment]? = nil, + oneComment: TestComment? = nil, + ownerComments: [TestComment]? = nil, + labels: [TestLabel]? = nil + ) { + self.id = id + self.title = title + self.description = description + self.closed = closed + self.ownerId = ownerId + self.createdAt = createdAt + self.owner = owner + self.comments = comments + self.oneComment = oneComment + self.ownerComments = ownerComments + self.labels = labels + } + + static let tableName = "issue" + + static let columns: KeyPathNames = [ + \TestIssue.id: "id", + \TestIssue.title: "title", + \TestIssue.description: "description", + \TestIssue.closed: "closed", + \TestIssue.ownerId: "ownerId", + \TestIssue.createdAt: "createdAt", + ] + + static let relationships: KeyPathNames = [ + \TestIssue.owner: "owner", + \TestIssue.comments: "comments", + \TestIssue.oneComment: "oneComment", + \TestIssue.ownerComments: "ownerComments", + \TestIssue.labels: "labels", + ] + + init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id", 0) + title = try entry.column("title", 1) + description = try entry.column("description", 2) + closed = try entry.column("closed", 3) + ownerId = try entry.column("ownerId", 4) + createdAt = try entry.column("createdAt", 5) + owner = try entry.relationship("owner") + comments = try entry.relationship("comments") + oneComment = try entry.relationship("oneComment") + ownerComments = try entry.relationship("ownerComments") + labels = try entry.relationship("labels") + } + + var columnValues: Row { + [ + "id": id.zeroValue, + "title": title.zeroValue, + "description": description.zeroValue, + "closed": closed.zeroValue, + "ownerId": ownerId.zeroValue, + "createdAt": createdAt.zeroValue, + ] + } +} + +struct TestUser: ZeroRow, Codable, Hashable, Identifiable { + var id: String + var name: String + var metadata: TestUserMetadata? + + // Relationships, filled when a query asks for them with `related`. + var issues: [TestIssue]? + + init( + id: String, + name: String, + metadata: TestUserMetadata? = nil, + issues: [TestIssue]? = nil + ) { + self.id = id + self.name = name + self.metadata = metadata + self.issues = issues + } + + static let tableName = "user" + + static let columns: KeyPathNames = [ + \TestUser.id: "id", + \TestUser.name: "name", + \TestUser.metadata: "metadata", + ] + + static let relationships: KeyPathNames = [ + \TestUser.issues: "issues", + ] + + init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id", 0) + name = try entry.column("name", 1) + metadata = try entry.column("metadata", 2) + issues = try entry.relationship("issues") + } + + var columnValues: Row { + [ + "id": id.zeroValue, + "name": name.zeroValue, + "metadata": metadata.zeroValue, + ] + } +} + +struct TestComment: ZeroRow, Codable, Hashable, Identifiable { + var id: String + var authorId: String + var issueId: String + var text: String + var createdAt: Double + + // Relationships, filled when a query asks for them with `related`. + @Indirect var issue: TestIssue? + var revisions: [TestRevision]? + @Indirect var author: TestUser? + + init( + id: String, + authorId: String, + issueId: String, + text: String, + createdAt: Double, + issue: TestIssue? = nil, + revisions: [TestRevision]? = nil, + author: TestUser? = nil + ) { + self.id = id + self.authorId = authorId + self.issueId = issueId + self.text = text + self.createdAt = createdAt + self.issue = issue + self.revisions = revisions + self.author = author + } + + static let tableName = "comment" + + static let columns: KeyPathNames = [ + \TestComment.id: "id", + \TestComment.authorId: "authorId", + \TestComment.issueId: "issueId", + \TestComment.text: "text", + \TestComment.createdAt: "createdAt", + ] + + static let relationships: KeyPathNames = [ + \TestComment.issue: "issue", + \TestComment.revisions: "revisions", + \TestComment.author: "author", + ] + + init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id", 0) + authorId = try entry.column("authorId", 1) + issueId = try entry.column("issueId", 2) + text = try entry.column("text", 3) + createdAt = try entry.column("createdAt", 4) + issue = try entry.relationship("issue") + revisions = try entry.relationship("revisions") + author = try entry.relationship("author") + } + + var columnValues: Row { + [ + "id": id.zeroValue, + "authorId": authorId.zeroValue, + "issueId": issueId.zeroValue, + "text": text.zeroValue, + "createdAt": createdAt.zeroValue, + ] + } +} + +struct TestRevision: ZeroRow, Codable, Hashable, Identifiable { + var id: String + var authorId: String + var commentId: String + var text: String + + // Relationships, filled when a query asks for them with `related`. + @Indirect var comment: TestComment? + @Indirect var author: TestUser? + + init( + id: String, + authorId: String, + commentId: String, + text: String, + comment: TestComment? = nil, + author: TestUser? = nil + ) { + self.id = id + self.authorId = authorId + self.commentId = commentId + self.text = text + self.comment = comment + self.author = author + } + + static let tableName = "revision" + + static let columns: KeyPathNames = [ + \TestRevision.id: "id", + \TestRevision.authorId: "authorId", + \TestRevision.commentId: "commentId", + \TestRevision.text: "text", + ] + + static let relationships: KeyPathNames = [ + \TestRevision.comment: "comment", + \TestRevision.author: "author", + ] + + init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id", 0) + authorId = try entry.column("authorId", 1) + commentId = try entry.column("commentId", 2) + text = try entry.column("text", 3) + comment = try entry.relationship("comment") + author = try entry.relationship("author") + } + + var columnValues: Row { + [ + "id": id.zeroValue, + "authorId": authorId.zeroValue, + "commentId": commentId.zeroValue, + "text": text.zeroValue, + ] + } +} + +struct TestLabel: ZeroRow, Codable, Hashable, Identifiable { + var id: String + var name: String + + // Relationships, filled when a query asks for them with `related`. + var issues: [TestIssue]? + + init( + id: String, + name: String, + issues: [TestIssue]? = nil + ) { + self.id = id + self.name = name + self.issues = issues + } + + static let tableName = "label" + + static let columns: KeyPathNames = [ + \TestLabel.id: "id", + \TestLabel.name: "name", + ] + + static let relationships: KeyPathNames = [ + \TestLabel.issues: "issues", + ] + + init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id", 0) + name = try entry.column("name", 1) + issues = try entry.relationship("issues") + } + + var columnValues: Row { + [ + "id": id.zeroValue, + "name": name.zeroValue, + ] + } +} + +struct TestIssueLabel: ZeroRow, Codable, Hashable { + var issueId: String + var labelId: String + + init( + issueId: String, + labelId: String + ) { + self.issueId = issueId + self.labelId = labelId + } + + static let tableName = "issueLabel" + + static let columns: KeyPathNames = [ + \TestIssueLabel.issueId: "issueId", + \TestIssueLabel.labelId: "labelId", + ] + + static let relationships: KeyPathNames = [:] + + init(entry: ViewEntry) throws(ZeroValueError) { + issueId = try entry.column("issueId", 0) + labelId = try entry.column("labelId", 1) + } + + var columnValues: Row { + [ + "issueId": issueId.zeroValue, + "labelId": labelId.zeroValue, + ] + } +} + +struct TestUserMetadata: ZeroValue, Codable, Hashable { + var registrar: TestUserMetadataRegistrar + var email: String + var altContacts: [String]? + + init( + registrar: TestUserMetadataRegistrar, + email: String, + altContacts: [String]? = nil + ) { + self.registrar = registrar + self.email = email + self.altContacts = altContacts + } + + init(zeroValue: JSONValue) throws(ZeroValueError) { + let o = try ZeroObjectReader(zeroValue, "TestUserMetadata") + registrar = try o.field("registrar") + email = try o.field("email") + altContacts = try o.field("altContacts") + } + + var zeroValue: JSONValue { + var o = ZeroObjectWriter() + o.set("registrar", registrar) + o.set("email", email) + o.setIfPresent("altContacts", altContacts) + return o.value + } +} + +enum TestUserMetadataRegistrar: String, ZeroEnum, Codable, CaseIterable { + case github + case google +} + +extension ZeroSchema { + static let test = ZeroSchema( + tables: [ + TableSchema( + name: "issue", serverName: "issues", + columns: [ + ("id", ColumnSchema(.string)), + ("title", ColumnSchema(.string)), + ("description", ColumnSchema(.string)), + ("closed", ColumnSchema(.boolean)), + ("ownerId", ColumnSchema(.string, optional: true, serverName: "owner_id")), + ("createdAt", ColumnSchema(.number)), + ], + primaryKey: ["id"]), + TableSchema( + name: "user", serverName: "users", + columns: [ + ("id", ColumnSchema(.string)), + ("name", ColumnSchema(.string)), + ("metadata", ColumnSchema(.json, optional: true)), + ], + primaryKey: ["id"]), + TableSchema( + name: "comment", serverName: "comments", + columns: [ + ("id", ColumnSchema(.string)), + ("authorId", ColumnSchema(.string)), + ("issueId", ColumnSchema(.string, serverName: "issue_id")), + ("text", ColumnSchema(.string)), + ("createdAt", ColumnSchema(.number)), + ], + primaryKey: ["id"]), + TableSchema( + name: "revision", + columns: [ + ("id", ColumnSchema(.string)), + ("authorId", ColumnSchema(.string)), + ("commentId", ColumnSchema(.string)), + ("text", ColumnSchema(.string)), + ], + primaryKey: ["id"]), + TableSchema( + name: "label", + columns: [ + ("id", ColumnSchema(.string)), + ("name", ColumnSchema(.string)), + ], + primaryKey: ["id"], + uniqueKeys: [["name"]]), + TableSchema( + name: "issueLabel", + columns: [ + ("issueId", ColumnSchema(.string)), + ("labelId", ColumnSchema(.string)), + ], + primaryKey: ["issueId", "labelId"]), + ], + relationships: [ + "issue": [ + "owner": [ + RelationshipHop(sourceField: ["ownerId"], destField: ["id"], destSchema: "user", cardinality: .one), + ], + "comments": [ + RelationshipHop(sourceField: ["id"], destField: ["issueId"], destSchema: "comment", cardinality: .many), + ], + "oneComment": [ + RelationshipHop(sourceField: ["id"], destField: ["issueId"], destSchema: "comment", cardinality: .one), + ], + "ownerComments": [ + RelationshipHop(sourceField: ["ownerId"], destField: ["authorId"], destSchema: "comment", cardinality: .many), + ], + "labels": [ + RelationshipHop(sourceField: ["id"], destField: ["issueId"], destSchema: "issueLabel", cardinality: .many), + RelationshipHop(sourceField: ["labelId"], destField: ["id"], destSchema: "label", cardinality: .many), + ], + ], + "user": [ + "issues": [ + RelationshipHop(sourceField: ["id"], destField: ["ownerId"], destSchema: "issue", cardinality: .many), + ], + ], + "comment": [ + "issue": [ + RelationshipHop(sourceField: ["issueId"], destField: ["id"], destSchema: "issue", cardinality: .one), + ], + "revisions": [ + RelationshipHop(sourceField: ["id"], destField: ["commentId"], destSchema: "revision", cardinality: .many), + ], + "author": [ + RelationshipHop(sourceField: ["authorId"], destField: ["id"], destSchema: "user", cardinality: .one), + ], + ], + "revision": [ + "comment": [ + RelationshipHop(sourceField: ["commentId"], destField: ["id"], destSchema: "comment", cardinality: .one), + ], + "author": [ + RelationshipHop(sourceField: ["authorId"], destField: ["id"], destSchema: "user", cardinality: .one), + ], + ], + "label": [ + "issues": [ + RelationshipHop(sourceField: ["id"], destField: ["labelId"], destSchema: "issueLabel", cardinality: .many), + RelationshipHop(sourceField: ["issueId"], destField: ["id"], destSchema: "issue", cardinality: .many), + ], + ], + ]) +} + +/// The queries of `ZeroSchema.test`, one per table: `testZQL.issue.where(…)`. +struct TestZQL: Sendable { + init() {} + + var issue: Query { Query(AnyQuery(.test, "issue")) } + + var user: Query { Query(AnyQuery(.test, "user")) } + + var comment: Query { Query(AnyQuery(.test, "comment")) } + + var revision: Query { Query(AnyQuery(.test, "revision")) } + + var label: Query { Query(AnyQuery(.test, "label")) } + + var issueLabel: Query { Query(AnyQuery(.test, "issueLabel")) } +} + +let testZQL = TestZQL() diff --git a/swift/Tests/ZeroVirtualTests/Harness.swift b/swift/Tests/ZeroVirtualTests/Harness.swift new file mode 100644 index 0000000000..03702dd422 --- /dev/null +++ b/swift/Tests/ZeroVirtualTests/Harness.swift @@ -0,0 +1,262 @@ +import Testing +import ZeroTesting + +@testable import ZeroVirtual + +/// A row of the harness's list: `r0`, `r1`, … +struct TestRow: Equatable, Sendable, Codable { + var id: String +} + +/// The scroll view of the harness (zero-virtual's `createScroller` and the +/// DOM it lays out): rows in a column under `spaceBefore`, over +/// `spaceAfter`. The offset clamps to the content like a browser's, and a +/// change is reported when the test delivers it, as a browser's scroll event +/// comes after the write; with `synchronousEvents`, during the write, as +/// `UIScrollView` reports it. +@MainActor +final class FakeScrollView: VirtualHost { + var viewportHeight: Double + var viewportInsets: (top: Double, bottom: Double) = (0, 0) + var synchronousEvents = false + weak var core: VirtualizerCore? + + private(set) var items: [VirtualItem] = [] + private var spaceBefore = 0.0 + private var spaceAfter = 0.0 + /// Space above the rows that isn't theirs: other content over the list. + var marginTop = 0.0 + private var offset = 0.0 + private var pending = false + var heights: [String: Double] = [:] + let rowHeight: Double + + init(viewportHeight: Double, rowHeight: Double) { + self.viewportHeight = viewportHeight + self.rowHeight = rowHeight + } + + func render(_ snapshot: VirtualSnapshot) { + items = snapshot.items + spaceBefore = snapshot.spaceBefore + spaceAfter = snapshot.spaceAfter + } + + private func height(_ key: String) -> Double { heights[key] ?? rowHeight } + + var contentHeight: Double { + marginTop + spaceBefore + items.reduce(0) { $0 + height($1.key) } + spaceAfter + } + + private var maxOffset: Double { max(0, contentHeight - viewportHeight) } + + var scrollOffset: Double { + if offset > maxOffset { + offset = maxOffset + pending = true + } + return offset + } + + func scroll(to target: Double) { + let next = max(0, min(target, maxOffset)) + guard next != offset else { return } + offset = next + pending = true + if synchronousEvents { deliverScroll() } + } + + func rowFrames() -> [VirtualRowFrame] { + var y = marginTop + spaceBefore + return items.map { item in + let h = height(item.key) + defer { y += h } + return VirtualRowFrame(index: item.index, key: AnyHashable(item.key), minY: y, maxY: y + h) + } + } + + func deliverScroll() { + if pending { + pending = false + core?.didScroll() + } + } + + func userScroll(_ top: Double) { + let next = max(0, min(top, maxOffset)) + if next != offset { + offset = next + pending = true + } + deliverScroll() + } +} + +/// zero-virtual's DOM test harness (`virtualizer.dom.test.ts`): the core +/// driving a fake scroll view, with queries answered from an in-memory list +/// through the real `assembleRows`. +/// +/// `tick()` plays one render: answer the query inputs, render the snapshot, +/// run `afterUpdate`, then deliver any scroll the update wrote. `settle()` +/// ticks until the queries stop changing. +@MainActor +final class Harness { + typealias Core = VirtualizerCore + + var data: [TestRow] + let view: FakeScrollView + let scheduler = ManualScheduler() + let core: Core + var options: Core.Options + private var partial: (skip: Int, from: String, key: String?)? + private var permalinkBeforePending = false + + init( + rowCount: Int, rowHeight: Double = 20, viewportHeight: Double = 400, + configure: (inout Core.Options) -> Void = { _ in } + ) { + data = (0.. Void) { + change(&options) + core.setOptions(options) + } + + /// Zero-demo paging: an exclusive start, backward pages closest first. + func page(_ start: TestRow?, _ direction: VirtualDirection, _ limit: Int) -> [TestRow] { + let startIndex = + start.flatMap { s in data.firstIndex { $0.id == s.id } } + ?? (direction == .forward ? -1 : data.count) + switch direction { + case .forward: + return Array(data[min(data.count, startIndex + 1)..) -> String { + switch anchor { + case .permalink(_, let id): "permalink:\(id)" + case .forward(_, let s): "forward:\(s?.id ?? "")" + case .backward(_, let s): "backward:\(s.id)" + } + } + + func queryKey(_ i: RowsQueryInputs) -> String { + "\(Self.pageKey(i.anchor)):\(i.anchor.index):\(i.pageSize):\(i.probeID ?? "")" + } + + func answer(_ inputs: RowsQueryInputs) -> RowsSnapshot { + if case .permalink(_, let id) = inputs.anchor { + let single = data.first { $0.id == id } + let beforePending = permalinkBeforePending + permalinkBeforePending = false + return assembleRows( + inputs, + RowsQueryResults( + singleRow: single, singleComplete: true, + mainRows: single.map { + beforePending ? [] : page($0, .backward, inputs.pageSize / 2 + 1) + }, + mainComplete: !beforePending, + afterRows: single.map { page($0, .forward, inputs.pageSize / 2) }, afterComplete: true)) + } + let probe = inputs.probeID.flatMap { id in data.first { $0.id == id } } + let key = Self.pageKey(inputs.anchor) + let limit = inputs.pageSize + 1 + let (start, direction): (TestRow?, VirtualDirection) = + switch inputs.anchor { + case .forward(_, let s): (s, .forward) + case .backward(_, let s): (s, .backward) + case .permalink: (nil, .forward) + } + var main = page(start, direction, limit) + var complete = true + if var p = partial, key != p.from, (p.key ?? key) == key { + if p.key == nil { + p.key = key + partial = p + main = Array(page(start, direction, limit + p.skip).dropFirst(p.skip)) + complete = false + } else { + partial = nil + } + } + return assembleRows( + inputs, + RowsQueryResults( + singleRow: probe, singleComplete: inputs.probeID != nil, mainRows: main, + mainComplete: complete)) + } + + func tick(scroll: Bool = true) { + core.setRows(answer(core.queryInputs)) + view.render(core.snapshot) + core.attach(view) + core.afterUpdate() + if scroll { view.deliverScroll() } + } + + /// A render before the scroll view is there. + func tickDetached() { + core.setRows(answer(core.queryInputs)) + core.detach() + core.afterUpdate() + } + + func settle(maxTicks: Int = 20, sourceLocation: SourceLocation = #_sourceLocation) { + for _ in 0..] { core.snapshot.items } + + var visibleIndexes: [Int] { + let top = view.scrollOffset + return view.rowFrames().filter { $0.maxY > top && $0.minY < top + view.viewportHeight } + .map(\.index) + } + + /// Where the row is from the viewport's top. + func rowTop(_ key: String, sourceLocation: SourceLocation = #_sourceLocation) -> Double { + guard let f = view.rowFrames().first(where: { $0.key == AnyHashable(key) }) else { + Issue.record("row \(key) not rendered", sourceLocation: sourceLocation) + return .nan + } + return f.minY - view.scrollOffset + } + + func setRowHeight(_ key: String, _ height: Double) { view.heights[key] = height } + + /// Answers the next new page query partially once: missing its first + /// `skip` rows and incomplete, as Zero answers from what the client has. + func partialNext(_ skip: Int) { + partial = (skip, Self.pageKey(core.queryInputs.anchor), nil) + } + + /// Answers the next permalink query without the rows before its target. + func partialPermalinkNext() { permalinkBeforePending = true } +} + +/// `start...end` as an array. +func range(_ start: Int, _ end: Int) -> [Int] { Array(start...end) } diff --git a/swift/Tests/ZeroVirtualTests/RowsTests.swift b/swift/Tests/ZeroVirtualTests/RowsTests.swift new file mode 100644 index 0000000000..3bbd363d33 --- /dev/null +++ b/swift/Tests/ZeroVirtualTests/RowsTests.swift @@ -0,0 +1,95 @@ +import Testing + +@testable import ZeroVirtual + +/// zero-virtual's `src/core/rows.test.ts`. +@Suite("row window boundaries") struct RowsTests { + let complete = RowsQueryResults( + singleComplete: true, mainComplete: true, afterComplete: true) + + @Test( + "%s pages exclude the sentinel and retain query order", + arguments: [VirtualDirection.forward, .backward]) + func pagesExcludeTheSentinel(direction: VirtualDirection) { + var results = complete + results.mainRows = ["a", "b", "c", "d", "sentinel"] + let anchor: VirtualAnchor = + direction == .forward + ? .forward(index: 10, startRow: "cursor") : .backward(index: 10, startRow: "cursor") + let rows = assembleRows( + RowsQueryInputs(pageSize: 4, anchor: anchor, settled: false, probeID: nil), results) + let first = direction == .forward ? 10 : 6 + #expect(rows.firstRowIndex == first) + #expect(rows.rowsLength == 4) + #expect(!rows.atStart) + #expect(!rows.atEnd) + #expect( + (0..<4).map { rows.rowAt(first + $0) } + == (direction == .forward ? ["a", "b", "c", "d"] : ["d", "c", "b", "a"])) + #expect(rows.rowAt(first - 1) == nil) + #expect(rows.rowAt(first + 4) == nil) + } + + @Test("permalink pages reserve the center row and exclude both sentinels") + func permalinkPages() { + var results = complete + results.singleRow = "center" + results.mainRows = ["before", "first", "sentinel"] + results.afterRows = ["after", "sentinel"] + let rows = assembleRows( + RowsQueryInputs( + pageSize: 4, anchor: .permalink(index: 1, id: "center"), settled: false, probeID: nil), + results) + #expect(rows.firstRowIndex == -1) + #expect(rows.rowsLength == 4) + #expect( + [-2, -1, 0, 1, 2, 3].map { rows.rowAt($0) } == [ + nil, "first", "before", "center", "after", nil, + ] + ) + #expect(!rows.atStart) + #expect(!rows.atEnd) + } + + @Test("a lone permalink retains the existing empty-window semantics") + func lonePermalink() { + var results = complete + results.singleRow = "only" + results.mainRows = [] + results.afterRows = [] + let rows = assembleRows( + RowsQueryInputs( + pageSize: 4, anchor: .permalink(index: 1, id: "only"), settled: false, probeID: nil), + results) + #expect(rows.rowsLength == 1) + #expect(rows.rowsEmpty) + #expect(rows.rowAt(1) == "only") + #expect(rows.atStart && rows.atEnd && rows.complete) + } + + // From `create-rows.test.ts`, which checks the queries the window runs. + @Test("a page anchor asks for one row more than the page") + func pageQuery() { + let inputs = RowsQueryInputs( + pageSize: 50, anchor: .forward(index: 0, startRow: nil), settled: false, probeID: nil) + let (main, after) = pageRequests(inputs, toStartRow: { (r: String) in r }, singleRow: nil) + #expect(main == VirtualPageRequest(limit: 51, start: nil, direction: .forward, settled: false)) + #expect(after == nil) + #expect(singleRequest(inputs) == nil) + } + + @Test("a permalink looks its row up, then pages either side of it") + func permalinkQueries() { + let inputs = RowsQueryInputs( + pageSize: 50, anchor: .permalink(index: 1, id: "x"), settled: true, probeID: nil) + #expect(singleRequest(inputs) == VirtualRowRequest(id: "x", settled: true)) + let waiting = pageRequests(inputs, toStartRow: { (r: String) in r }, singleRow: nil) + #expect(waiting.main == nil && waiting.after == nil) + let (main, after) = pageRequests( + inputs, toStartRow: { (r: String) in "start:" + r }, singleRow: "x") + #expect( + main == VirtualPageRequest(limit: 26, start: "start:x", direction: .backward, settled: true)) + #expect( + after == VirtualPageRequest(limit: 25, start: "start:x", direction: .forward, settled: true)) + } +} diff --git a/swift/Tests/ZeroVirtualTests/ScrollTests.swift b/swift/Tests/ZeroVirtualTests/ScrollTests.swift new file mode 100644 index 0000000000..60c679703c --- /dev/null +++ b/swift/Tests/ZeroVirtualTests/ScrollTests.swift @@ -0,0 +1,927 @@ +import Foundation +import Testing +import ZeroTesting + +@testable import ZeroVirtual + +/// zero-virtual's `src/core/virtualizer.dom.test.ts`: the core driving a +/// scroll view whose geometry the test controls. Names are upstream's. +@MainActor +@Suite("paging against a real (fake-geometry) scroll container") struct PagingTests { + @Test("initial load anchors at the top and fills one page") + func initialLoad() { + let h = Harness(rowCount: 500) + h.settle() + let s = h.core.snapshot + #expect(s.items.first?.index == 0) + #expect(s.items.count == 100) + #expect(s.items.first?.row == TestRow(id: "r0")) + #expect(s.spaceBefore == 0) + #expect(h.visibleIndexes == range(0, 19)) + } + + @Test("scrolling near the window end advances the forward anchor") + func pagesForward() { + let h = Harness(rowCount: 500) + h.settle() + h.userScroll(1500) + h.settle() + let s = h.core.snapshot + #expect(s.items.first?.index == 56) + #expect(s.items.count == 100) + #expect(s.spaceBefore == 56 * 20) + for item in s.items { #expect(item.row.id == "r\(item.index)") } + #expect(h.visibleIndexes == range(75, 94)) + } + + @Test("scrolling back near the window start pages backward") + func pagesBackward() { + let h = Harness(rowCount: 500) + h.settle() + h.userScroll(1500) + h.settle() + #expect(h.items.first?.index == 56) + h.userScroll(1140) + let visibleBefore = h.visibleIndexes + #expect(visibleBefore == range(57, 76)) + h.settle() + let s = h.core.snapshot + #expect(s.items.first?.index == 0) + #expect(s.items.count == 96) + #expect(s.spaceBefore == 0) + for item in s.items { #expect(item.row.id == "r\(item.index)") } + #expect(h.visibleIndexes == visibleBefore) + } + + /// A list whose every row has been seen, back at the top: what's below + /// the loaded page is space standing in for rows seen before. Upstream + /// passes `count: 500` for that space instead. + func seenToTheEnd() -> Harness { + let h = Harness(rowCount: 500) + h.settle() + while h.items.last?.index != 499 { + h.userScroll(h.scrollOffset + 1500) + h.settle() + } + h.userScroll(0) + h.settle() + #expect(h.core.snapshot.spaceAfter == (500 - 100) * 20) + return h + } + + @Test("a far jump into unloaded space below recovers by cascading pages") + func farJumpDown() { + let h = seenToTheEnd() + h.userScroll(6000) + h.settle() + #expect(h.visibleIndexes == range(300, 319)) + let s = h.core.snapshot + #expect(s.items.first?.index == 239) + #expect(s.items.count == 100) + #expect(s.spaceBefore == 239 * 20) + #expect(h.scrollOffset == 6000) + for item in s.items { #expect(item.row.id == "r\(item.index)") } + } + + @Test("a far jump back to the very top re-anchors at the start") + func farJumpToTop() { + let h = seenToTheEnd() + h.userScroll(6000) + h.settle() + #expect(h.items.first?.index == 239) + h.userScroll(0) + h.settle() + let s = h.core.snapshot + #expect(s.items.first?.index == 0) + #expect(s.items.count == 100) + #expect(s.spaceBefore == 0) + #expect(h.visibleIndexes == range(0, 19)) + } + + @Test("a page too small for what is on screen grows instead of paging back and forth") + func pageGrows() { + let h = Harness(rowCount: 500) { + $0.estimatedRowHeight = 200 + $0.minPageSize = 10 + } + h.settle() + #expect(h.core.queryInputs.pageSize >= 30) + let visible = h.visibleIndexes + #expect(visible == range(0, 19)) + #expect(h.items.last!.index > visible.last! + 5) + } + + @Test("paging works the same when the scroll view reports scrolls as they happen") + func synchronousScrollEvents() { + let h = Harness(rowCount: 500) + h.view.synchronousEvents = true + h.settle() + h.userScroll(1500) + h.settle() + #expect(h.items.first?.index == 56) + #expect(h.visibleIndexes == range(75, 94)) + h.userScroll(1140) + h.settle() + #expect(h.items.first?.index == 0) + #expect(h.visibleIndexes == range(57, 76)) + } +} + +@MainActor +@Suite("manual scroll anchoring against a real (fake-geometry) container") +struct ManualAnchoringTests { + func scrolled() -> Harness { + let h = Harness(rowCount: 500) { $0.anchoring = .manual } + h.settle() + h.userScroll(1000) + h.settle() + return h + } + + @Test("growth above the anchor row is compensated into scrollTop") + func growthAbove() { + let h = scrolled() + #expect(h.rowTop("r50") == 0) + h.setRowHeight("r30", 50) + h.tick() + #expect(h.scrollOffset == 1030) + #expect(h.rowTop("r50") == 0) + } + + @Test("shrinkage above the anchor row is compensated too") + func shrinkageAbove() { + let h = scrolled() + h.setRowHeight("r30", 5) + h.tick() + #expect(h.scrollOffset == 985) + #expect(h.rowTop("r50") == 0) + } + + @Test("growth at scroll offset 0 is revealed, not compensated away") + func growthAtTop() { + let h = Harness(rowCount: 500) { $0.anchoring = .manual } + h.settle() + h.setRowHeight("r0", 60) + h.tick() + #expect(h.scrollOffset == 0) + } + + @Test("growth below the anchor row does not move the viewport") + func growthBelow() { + let h = scrolled() + h.setRowHeight("r80", 200) + h.tick() + #expect(h.scrollOffset == 1000) + #expect(h.rowTop("r50") == 0) + } + + @Test("rows of different heights page down and back without moving what is on screen") + func dynamicHeights() { + // Every row its own height, none the estimate: the space standing in for + // unloaded rows is wrong, and paging must not move the rows on screen. + let h = Harness(rowCount: 1000) { $0.anchoring = .manual } + for i in 0..<1000 { h.setRowHeight("r\(i)", Double(12 + (i * 7) % 40)) } + h.settle() + for step in [900.0, 900, 900, 900, -700, -700, -700] { + h.userScroll(h.scrollOffset + step) + let onScreen = h.visibleIndexes.map { ("r\($0)", h.rowTop("r\($0)")) } + h.settle() + for (key, top) in onScreen { #expect(h.rowTop(key) == top, "\(key)") } + for item in h.items { #expect(item.row.id == "r\(item.index)") } + } + } +} + +@MainActor +@Suite("scroll-state restore when the container mounts lazily") struct LazyRestoreTests { + static func saved(_ offset: Double) -> VirtualScrollState { + VirtualScrollState( + anchor: .forward(index: 0, startRow: nil), scrollOffset: offset, estimatedTotal: 100, + hasReachedStart: true, hasReachedEnd: false, listContext: "ctx") + } + + @Test("restores the saved scrollTop when the element attaches on a later commit") + func restoresAfterAttach() { + let h = Harness(rowCount: 100) { $0.scrollState = Self.saved(300) } + h.tickDetached() + h.tickDetached() + #expect(h.scrollOffset == 0) + h.settle() + #expect(h.scrollOffset == 300) + } + + @Test("does not persist a spurious scrollTop:0 while detached, clobbering the saved position") + func noSpuriousPersist() { + var persisted: [VirtualScrollState] = [] + let h = Harness(rowCount: 100) { + $0.scrollState = Self.saved(300) + $0.onScrollStateChange = { persisted.append($0) } + } + h.tickDetached() + h.tickDetached() + #expect(persisted.isEmpty) + h.settle() + #expect(!persisted.isEmpty) + #expect(!persisted.map(\.scrollOffset).contains(0)) + #expect(h.scrollOffset == 300) + } +} + +@MainActor +@Suite("reporting the scroll state") struct ReportingTests { + @Test("every scroll is reported as it happens, not once the scroll ends") + func everyScroll() { + var reported: [Double] = [] + let h = Harness(rowCount: 500) { $0.onScrollStateChange = { reported.append($0.scrollOffset) } } + h.settle() + h.userScroll(400) + #expect(reported.last == 400) + h.userScroll(900) + #expect(reported.last == 900) + } +} + +@MainActor +@Suite("permalink scroll") struct PermalinkTests { + @Test("scrolls the permalink row into view when its row key differs from the permalink id") + func keyDiffersFromID() { + let h = Harness(rowCount: 300) { + $0.permalinkID = "r120" + $0.rowKey = { "key-\($0.id)" } + } + h.settle() + let top = h.rowTop("key-r120") + #expect(top >= 0 && top < 40) + } + + @Test( + "%s anchoring: the row stays on top when the page grows under it", + arguments: [VirtualAnchoring.manual, .native]) + func pageGrowsUnderPermalink(anchoring: VirtualAnchoring) { + let h = Harness(rowCount: 1000, viewportHeight: 800) { + $0.permalinkID = "r500" + $0.anchoring = anchoring + } + h.settle() + #expect(h.core.queryInputs.pageSize == 120) + #expect(h.rowTop("r500") == (anchoring == .manual ? 0 : 20)) + } + + @Test("the jump holds on while the rows before its target are still on their way") + func holdsForRowsBefore() { + let h = Harness(rowCount: 1000) { + $0.permalinkID = "r500" + $0.anchoring = .manual + } + h.view.viewportInsets = (20, 0) + h.partialPermalinkNext() + h.settle() + #expect(h.rowTop("r500") == 20) + } +} + +@MainActor +@Suite("scroll-state restore of a position we wrote") struct WrittenRestoreTests { + @Test("a later navigation back to it restores it") + func navigationBack() { + var persisted: [VirtualScrollState] = [] + let h = Harness(rowCount: 500) { $0.onScrollStateChange = { persisted.append($0) } } + h.settle() + h.userScroll(400) + let earlier = persisted.last! + #expect(earlier.scrollOffset == 400) + h.userScroll(1200) + h.settle() + h.core.restore(earlier) + h.tick() + #expect(h.scrollOffset == 400) + + // And back to it a second time, an equal state. + h.userScroll(1200) + h.settle() + h.core.restore(earlier) + h.tick() + #expect(h.scrollOffset == 400) + } + + /// Runs `steps` against a host that keeps the state to itself or, with + /// `echo`, hands every write straight back, as zbugs' router does; and + /// returns where the list was after each. + func run(echo: Bool, _ steps: (Harness, () -> Void) -> Void) -> [String] { + var written: VirtualScrollState? + let h = Harness(rowCount: 1000) { + $0.onScrollStateChange = { if echo { written = $0 } } + } + func rerender() { + for _ in 0..<10 { + guard let state = written else { return } + written = nil + h.core.restore(state) + h.tick() + } + #expect(written == nil, "each write echoes another") + } + var trace: [String] = [] + func settle() { + h.settle() + rerender() + h.settle() + trace.append("\(h.scrollOffset) \(h.visibleIndexes)") + } + settle() + steps(h) { + rerender() + settle() + } + return trace + } + + @Test( + "from a host that hands every write straight back: scrolling: down several pages and back up") + func echoScrolling() { + let steps: (Harness, () -> Void) -> Void = { h, settle in + for step in [600.0, 600, 600, 600, 600, -900, -900] { + h.userScroll(h.scrollOffset + step) + settle() + } + } + #expect(run(echo: true, steps) == run(echo: false, steps)) + } + + @Test("from a host that hands every write straight back: a jump") + func echoJump() { + let steps: (Harness, () -> Void) -> Void = { h, settle in + h.userScroll(1500) + settle() + h.core.scrollToItem("r900", align: .start) + settle() + } + #expect(run(echo: true, steps) == run(echo: false, steps)) + } +} + +@MainActor +@Suite("a restore that moves the loaded window") struct MovingRestoreTests { + func scrolledAwayFromSaved( + _ anchoring: VirtualAnchoring + ) -> (Harness, VirtualScrollState) { + var persisted: [VirtualScrollState] = [] + let h = Harness(rowCount: 500) { + $0.anchoring = anchoring + $0.onScrollStateChange = { persisted.append($0) } + } + h.settle() + h.userScroll(600) + h.settle() + let saved = persisted.last! + #expect(saved.scrollOffset == 600) + for offset in [1600.0, 2600, 3600, 4600] { + h.userScroll(offset) + h.settle() + } + #expect(h.items.first!.index > 30) + return (h, saved) + } + + @Test( + "manual anchoring lands it where it was saved (first answer missing %i rows)", + arguments: [0, 30]) + func manualLands(skip: Int) { + let (h, saved) = scrolledAwayFromSaved(.manual) + h.partialNext(skip) + h.core.restore(saved) + h.tick(scroll: false) + h.tick(scroll: false) + h.view.deliverScroll() + h.settle() + #expect(h.scrollOffset == 600) + #expect(h.rowTop("r30") == 0) + } + + @Test( + "%s anchoring: a position the outgoing window was too short to reach still lands", + arguments: [VirtualAnchoring.manual, .native]) + func shortWindowLands(anchoring: VirtualAnchoring) { + let (far, _) = scrolledAwayFromSaved(anchoring) + var persisted: [VirtualScrollState] = [] + far.setOptions { $0.onScrollStateChange = { persisted.append($0) } } + far.userScroll(4600) + far.settle() + let saved = persisted.last! + let firstRow = Int(saved.scrollOffset / 20) + + let h = Harness(rowCount: 500) { $0.anchoring = anchoring } + h.settle() + h.core.restore(saved) + h.settle() + #expect(h.scrollOffset == saved.scrollOffset) + #expect(h.rowTop("r\(firstRow)") == 0) + } + + @Test( + "%s anchoring: paging leaves the restored window alone until its rows are in", + arguments: [VirtualAnchoring.manual, .native]) + func pagingWaits(anchoring: VirtualAnchoring) { + let (h, saved) = scrolledAwayFromSaved(anchoring) + h.core.restore(saved) + h.tick() + #expect(h.core.queryInputs.anchor == saved.anchor) + h.settle() + #expect(h.scrollOffset == 600) + #expect(h.rowTop("r30") == 0) + } +} + +@MainActor +@Suite("compareStartRows") struct StartRowTests { + struct Cursor: Equatable, Sendable, Codable { var id: String } + + @Test("or narrow the start row in toStartRow: the anchor carries what it returns") + func narrowStartRow() throws { + // Start rows are `Equatable`, so any type compares; a narrower one than + // the row still has to survive saving, which is `Codable` here. + typealias Core = VirtualizerCore + var persisted: [VirtualScrollState] = [] + var options = Core.Options( + estimatedRowHeight: 20, rowKey: \.id, toStartRow: { Cursor(id: $0.id) }, listContext: "ctx") + options.minPageSize = 100 + options.onScrollStateChange = { persisted.append($0) } + let core = Core(options, scheduler: ManualScheduler()) + let data = (0..<500).map { TestRow(id: "r\($0)") } + let view = CursorScrollView(core: core, data: data) + view.settle() + view.scroll(by: 1500) + view.settle() + + guard case .forward(_, let start?) = core.queryInputs.anchor else { + Issue.record("expected a page anchor") + return + } + #expect(start.id.hasPrefix("r")) + let saved = try #require(persisted.last) + #expect(saved.anchor == core.queryInputs.anchor) + let restored = try JSONDecoder().decode( + VirtualScrollState.self, from: JSONEncoder().encode(saved)) + #expect(restored == saved) + } +} + +@MainActor +@Suite("firstVisibleItem / lastVisibleItem") struct VisibleItemTests { + @Test("report the edges of the viewport, not of the loaded window") + func edges() { + let h = Harness(rowCount: 500) + h.settle() + #expect(h.core.firstVisibleItem()?.index == 0) + #expect(h.core.lastVisibleItem()?.index == 19) + #expect(h.items.count > 20) + } + + @Test("follow the scroll position") + func follow() { + let h = Harness(rowCount: 500) + h.settle() + h.userScroll(500) + h.tick() + #expect(h.core.firstVisibleItem()?.index == 25) + #expect(h.core.lastVisibleItem()?.index == 44) + } + + @Test("count a row that overlaps the scrollport at all") + func overlap() { + let h = Harness(rowCount: 500) + h.settle() + h.userScroll(510) + h.tick() + #expect(h.core.firstVisibleItem()?.index == 25) + } + + @Test("carry the row and its key, which scrollToItem takes") + func carryRowAndKey() { + let h = Harness(rowCount: 500) + h.settle() + h.userScroll(500) + h.tick() + let first = h.core.firstVisibleItem()! + #expect(first.row == TestRow(id: "r25")) + h.core.scrollToRow(key: first.key, align: .start) + #expect(h.rowTop("r25") == 0) + } + + @Test("are undefined with no scroll element attached") + func detached() { + let h = Harness(rowCount: 500) + h.settle() + h.core.detach() + #expect(h.core.firstVisibleItem() == nil) + #expect(h.core.lastVisibleItem() == nil) + } + + @Test("follow the viewport's size") + func viewportResize() { + let h = Harness(rowCount: 500) + h.settle() + #expect(h.core.lastVisibleItem()?.key == "r19") + h.view.viewportHeight = 200 + h.core.viewportDidResize() + #expect(h.core.lastVisibleItem()?.key == "r9") + h.view.viewportHeight = 0 + h.core.viewportDidResize() + #expect(h.core.firstVisibleItem() == nil) + } +} + +@MainActor +@Suite("no-op re-anchor does not spin the render loop") struct NoOpReanchorTests { + @Test( + "loaded rows entirely below the viewport at scroll-top stay put (window list under other page content)" + ) + func rowsBelowViewport() { + let h = Harness(rowCount: 5) + h.settle() + #expect(h.core.snapshot.complete) + h.view.marginTop = 1000 + var notified = 0 + let unsubscribe = h.core.subscribe { notified += 1 } + h.core.afterUpdate() + h.core.afterUpdate() + h.core.afterUpdate() + unsubscribe() + #expect(notified == 0) + } +} + +@MainActor +@Suite("below-viewport window at the start of the list") struct BelowViewportTests { + @Test("scrolling while the loaded rows sit below the viewport does not empty them") + func doesNotEmpty() { + let h = Harness(rowCount: 300) + h.settle() + #expect(!h.items.isEmpty) + h.view.marginTop = 1000 + h.userScroll(150) + h.settle() + #expect(!h.items.isEmpty) + } +} + +@MainActor +@Suite("scrollToItem") struct ScrollToItemTests { + @Test("a loaded row scrolls immediately, without re-anchoring") + func loadedRow() { + let h = Harness(rowCount: 500) + h.settle() + let anchorBefore = h.core.queryInputs.anchor + h.core.scrollToItem("r50", align: .start) + #expect(h.rowTop("r50") == 0) + #expect(h.core.queryInputs.anchor == anchorBefore) + } + + @Test("an unloaded row re-anchors and the scroll lands once its page renders") + func unloadedRow() { + let h = Harness(rowCount: 500) { $0.anchoring = .manual } + h.settle() + h.core.scrollToItem("r400", align: .start) + h.settle() + #expect(h.rowTop("r400") == 0) + } + + @Test("is level-triggered: the same id twice scrolls twice") + func levelTriggered() { + let h = Harness(rowCount: 500) + h.settle() + h.core.scrollToItem("r50", align: .start) + #expect(h.rowTop("r50") == 0) + h.userScroll(0) + h.tick() + #expect(h.rowTop("r50") == 1000) + h.core.scrollToItem("r50", align: .start) + #expect(h.rowTop("r50") == 0) + } + + @Test("align: defaults to auto: a row below the viewport scrolls just into view") + func alignAuto() { + let h = Harness(rowCount: 500) + h.settle() + h.core.scrollToItem("r50") + #expect(h.rowTop("r50") == 380) + } + + @Test("align: auto leaves an already-visible row where it is") + func alignAutoVisible() { + let h = Harness(rowCount: 500) + h.settle() + h.userScroll(500) + h.settle() + let before = h.scrollOffset + h.core.scrollToItem("r30", align: .auto) + #expect(h.scrollOffset == before) + } + + @Test( + "align: start, center and end", + arguments: [(VirtualScrollAlignment.start, 0.0), (.center, 190), (.end, 380)]) + func align(align: VirtualScrollAlignment, top: Double) { + let h = Harness(rowCount: 500) + h.settle() + h.core.scrollToItem("r50", align: align) + #expect(h.rowTop("r50") == top) + } + + @Test("aligns below the container's scroll-padding (a sticky header)") + func insets() { + let h = Harness(rowCount: 500) + h.settle() + h.view.viewportInsets = (60, 0) + h.core.scrollToItem("r50", align: .start) + #expect(h.rowTop("r50") == 60) + } + + @Test("alignment keeps clear of the strips covering both ends of the viewport") + func insetsBothEnds() { + let h = Harness(rowCount: 500) + h.settle() + h.view.viewportInsets = (40, 30) + for (align, top) in [(VirtualScrollAlignment.start, 40.0), (.end, 350), (.center, 195)] { + h.core.scrollToItem("r50", align: align) + #expect(h.rowTop("r50") == top) + #expect(h.core.firstVisibleItem()?.index == h.visibleIndexes.first) + #expect(h.core.lastVisibleItem()?.index == h.visibleIndexes.last) + } + } + + @Test("a clamped jump still releases the request, so paging keeps working") + func clampedJump() { + let h = Harness(rowCount: 500) + h.settle() + h.core.scrollToItem("r1", align: .center) + h.settle() + #expect(h.scrollOffset == 0) + h.userScroll(1500) + h.settle() + #expect(h.items.first?.index == 56) + } + + @Test("a loaded row keyed differently from its id is scrolled to, not re-fetched") + func keyedDifferently() { + let h = Harness(rowCount: 500) { + $0.anchoring = .manual + $0.rowKey = { "key-\($0.id)" } + } + h.settle() + h.core.scrollToItem("r50", align: .start) + var permalink = false + for _ in 0..<4 { + h.tick() + permalink = permalink || h.core.queryInputs.anchor.isPermalink + } + #expect(h.rowTop("key-r50") == 0) + #expect(!permalink) + } + + @Test("addressing a rendered row by its key: scrolls, and issues no lookup at all") + func byKey() { + let h = Harness(rowCount: 500) { + $0.anchoring = .manual + $0.rowKey = { "key-\($0.id)" } + } + h.settle() + h.core.scrollToItem("key-r50", align: .start) + #expect(h.rowTop("key-r50") == 0) + #expect(h.core.queryInputs.probeID == nil) + } + + @Test( + "addressing a rendered row by its key: reaches rendered rows only: an off-screen key does nothing" + ) + func byKeyOffScreen() { + let h = Harness(rowCount: 500) { + $0.anchoring = .manual + $0.rowKey = { "key-\($0.id)" } + } + h.settle() + let firstBefore = h.items.first?.index + h.core.scrollToItem("key-r400", align: .start) + h.settle() + #expect(h.items.first?.index == firstBefore) + #expect(!h.core.snapshot.rowsEmpty) + h.core.scrollToRow(key: "key-r400") + h.settle() + #expect(h.items.first?.index == firstBefore) + } + + @Test("a repeat jump to a row keyed differently from its id still lands") + func repeatJump() { + let h = Harness(rowCount: 500) { + $0.anchoring = .manual + $0.rowKey = { "key-\($0.id)" } + } + h.settle() + h.core.scrollToItem("r400", align: .start) + h.settle() + #expect(h.rowTop("key-r400") == 0) + var notified = 0 + let unsubscribe = h.core.subscribe { notified += 1 } + h.core.scrollToItem("r400", align: .center) + unsubscribe() + #expect(notified > 0) + h.settle() + #expect(h.rowTop("key-r400") == 190) + h.userScroll(0) + h.settle() + #expect(!h.items.isEmpty) + } + + @Test("an empty id is a no-op, not a lookup that never answers") + func emptyID() { + let h = Harness(rowCount: 500) { $0.anchoring = .manual } + h.settle() + h.core.scrollToItem("") + h.tick() + #expect(h.core.queryInputs.probeID == nil) + h.core.scrollToItem("r400", align: .start) + h.settle() + #expect(h.rowTop("r400") == 0) + } + + @Test("a second jump to a rendered row supersedes a lookup still in flight") + func supersedesLookup() { + let h = Harness(rowCount: 500) { $0.anchoring = .manual } + h.settle() + h.core.scrollToItem("r400", align: .start) + h.core.scrollToItem("r50", align: .start) + #expect(h.rowTop("r50") == 0) + h.settle() + #expect(h.rowTop("r50") == 0) + } + + @Test("a second jump supersedes one that is still loading") + func supersedesLoading() { + let h = Harness(rowCount: 500) { $0.anchoring = .manual } + h.settle() + h.core.scrollToItem("r400", align: .start) + h.core.scrollToItem("r200", align: .start) + h.settle() + #expect(h.rowTop("r200") == 0) + } + + @Test("an id that does not exist: does nothing: the list and the scroll position are left alone") + func missingIDNoop() { + let h = Harness(rowCount: 500) { $0.anchoring = .manual } + h.settle() + h.userScroll(500) + h.settle() + let count = h.items.count + let first = h.items.first?.index + let top = h.rowTop("r30") + let offset = h.scrollOffset + h.core.scrollToItem("nope") + h.settle() + #expect(h.items.count == count) + #expect(h.items.first?.index == first) + #expect(!h.core.snapshot.rowsEmpty) + #expect(h.rowTop("r30") == top) + #expect(h.scrollOffset == offset) + } + + @Test("an id that does not exist: takes its lookup back out of the query inputs") + func missingIDLookupDropped() { + let h = Harness(rowCount: 500) { $0.anchoring = .manual } + h.settle() + h.core.scrollToItem("nope") + #expect(h.core.queryInputs.probeID == "nope") + var notified = 0 + let unsubscribe = h.core.subscribe { notified += 1 } + h.tick() + unsubscribe() + #expect(h.core.queryInputs.probeID == nil) + #expect(notified > 0) + } + + @Test("an id that does not exist: leaves paging working afterwards") + func missingIDPaging() { + let h = Harness(rowCount: 500) + h.settle() + h.core.scrollToItem("nope") + h.settle() + h.userScroll(1500) + h.settle() + #expect(h.items.first?.index == 56) + } + + @Test("an id that does not exist: a later jump to a real id still lands") + func missingIDThenReal() { + let h = Harness(rowCount: 500) { $0.anchoring = .manual } + h.settle() + h.core.scrollToItem("nope") + h.settle() + h.core.scrollToItem("r400", align: .start) + h.settle() + #expect(h.rowTop("r400") == 0) + } + + @Test("an id that does not exist: the lookup does not disturb the list while it is in flight") + func lookupInFlight() { + let h = Harness(rowCount: 500) + h.settle() + let first = h.items.first?.index + h.core.scrollToItem("r400") + h.tick() + #expect(h.items.first?.index == first) + #expect(!h.core.snapshot.rowsEmpty) + } + + @Test("a permalink that does not exist leaves the list alone even mid-lookup") + func missingPermalinkMidLookup() { + let h = Harness(rowCount: 500) { $0.anchoring = .manual } + h.settle() + h.userScroll(500) + h.settle() + let first = h.items.first?.index + h.core.scrollToItem("r400") + h.setOptions { $0.permalinkID = "nope" } + h.settle() + #expect(!h.core.snapshot.rowsEmpty) + #expect(h.items.first?.index == first) + } + + @Test("a permalinkID that does not exist leaves a loaded list alone") + func missingPermalinkLoaded() { + let h = Harness(rowCount: 500) { $0.anchoring = .manual } + h.settle() + let count = h.items.count + h.setOptions { $0.permalinkID = "nope" } + h.settle() + #expect(h.items.count == count) + #expect(!h.core.snapshot.rowsEmpty) + } + + @Test("a deep link to a permalinkID that does not exist falls back to the top of the list") + func missingPermalinkColdLoad() { + let h = Harness(rowCount: 500) { $0.permalinkID = "nope" } + h.tick() + h.tick() + h.settle() + #expect(!h.core.snapshot.rowsEmpty) + #expect(h.items.first?.index == 0) + #expect(h.items.first?.row == TestRow(id: "r0")) + } +} + +/// A minimal host for a core whose start rows aren't its rows. +@MainActor +private final class CursorScrollView: VirtualHost { + typealias Core = VirtualizerCore + let core: Core + let data: [TestRow] + var items: [VirtualItem] = [] + var spaceBefore = 0.0 + var offset = 0.0 + let viewportHeight = 400.0 + let viewportInsets: (top: Double, bottom: Double) = (0, 0) + + init(core: Core, data: [TestRow]) { + self.core = core + self.data = data + } + + var scrollOffset: Double { offset } + var contentHeight: Double { spaceBefore + Double(items.count) * 20 + core.snapshot.spaceAfter } + func scroll(to target: Double) { offset = max(0, min(target, contentHeight - viewportHeight)) } + func rowFrames() -> [VirtualRowFrame] { + items.enumerated().map { i, item in + let y = spaceBefore + Double(i) * 20 + return VirtualRowFrame(index: item.index, key: AnyHashable(item.key), minY: y, maxY: y + 20) + } + } + + func scroll(by delta: Double) { + scroll(to: offset + delta) + core.didScroll() + } + + func settle() { + for _ in 0..<20 { + let inputs = core.queryInputs + let (start, direction): (StartRowTests.Cursor?, VirtualDirection) = + switch inputs.anchor { + case .forward(_, let s): (s, .forward) + case .backward(_, let s): (s, .backward) + case .permalink: (nil, .forward) + } + let i = + start.flatMap { s in data.firstIndex { $0.id == s.id } } + ?? (direction == .forward ? -1 : data.count) + let page = + direction == .forward + ? Array(data[min(data.count, i + 1).. Harness { + let h = Harness(rowCount: rows) { + $0.stickToBottom = true + $0.anchoring = .manual + } + h.settle() + return h + } + + func append(_ h: Harness, _ n: Int) { + let start = h.data.count + h.data += (start.. Double { + { $0.view.contentHeight - $0.scrollOffset - $0.view.viewportHeight } + } + + @Test("a list that starts short follows rows added below it once they overflow") + func startsStuck() { + let h = chat(rows: 10) + append(h, 30) + h.settle() + #expect(bottomGap(h) == 0) + #expect(h.visibleIndexes.last == 39) + } + + @Test("parked at the bottom, it follows new rows and a last row growing taller") + func follows() { + let h = chat(rows: 60) + h.userScroll(.infinity) + h.settle() + #expect(bottomGap(h) == 0) + append(h, 3) + h.settle() + #expect(h.visibleIndexes.last == 62) + #expect(bottomGap(h) == 0) + h.setRowHeight("r62", 120) + h.tick() + #expect(bottomGap(h) == 0) + } + + @Test("scrolled away, growth does not follow; back at the bottom, it re-arms") + func rearms() { + let h = chat(rows: 60) + h.userScroll(.infinity) + h.settle() + h.userScroll(h.scrollOffset - 300) + let offset = h.scrollOffset + append(h, 3) + h.settle() + #expect(h.scrollOffset == offset) + #expect(bottomGap(h) > 300) + + h.userScroll(.infinity) + h.settle() + append(h, 3) + h.settle() + #expect(h.visibleIndexes.last == 65) + #expect(bottomGap(h) == 0) + } + + @Test("a list not asked to stick stays where it was") + func off() { + let h = Harness(rowCount: 60) { $0.anchoring = .manual } + h.settle() + h.userScroll(.infinity) + h.settle() + let offset = h.scrollOffset + append(h, 3) + h.settle() + #expect(h.scrollOffset == offset) + } + + @Test("the viewport shrinking keeps the bottom in view") + func resize() { + let h = chat(rows: 60) + h.userScroll(.infinity) + h.settle() + h.view.viewportHeight = 200 + h.core.viewportDidResize() + #expect(bottomGap(h) == 0) + } +} diff --git a/swift/Tests/ZeroVirtualTests/UPSTREAM.md b/swift/Tests/ZeroVirtualTests/UPSTREAM.md new file mode 100644 index 0000000000..c06387f1d1 --- /dev/null +++ b/swift/Tests/ZeroVirtualTests/UPSTREAM.md @@ -0,0 +1,153 @@ +# zero-virtual tests in ZeroVirtual + +`ZeroVirtual` is a native counterpart of +[`@rocicorp/zero-virtual`](https://github.com/rocicorp/zero-virtual), not a +port of its code. Its behavior tests are ported by hand from the upstream +tests at commit +[`f89c468`](https://github.com/rocicorp/zero-virtual/commit/f89c4685b748fb80fba1077c8c9bcdb29b73326e), +keeping their names, with the DOM replaced by a fake scroll view +(`Harness.swift`) whose geometry the test sets. + +When zero-virtual changes, diff its tests from this commit and update this +list. + +- **ported**: same steps and expectations. +- **adapted**: same behavior, set up or checked the native way (noted). +- **n/a**: a feature ZeroVirtual doesn't have, or a browser detail with no + native counterpart (noted). + +## `src/core/rows.test.ts` → `RowsTests.swift` + +| Test | Status | +| ----------------------------------------------------------------- | ----------------------------------------------------------------------- | +| %s pages exclude the sentinel and retain query order | ported | +| permalink pages reserve the center row and exclude both sentinels | ported | +| a lone permalink retains the existing empty-window semantics | ported | +| row lookup stays lazy for mutable query arrays | n/a: Swift arrays are values, so there is nothing to see a later change | + +## `src/solid/create-rows.test.ts` → `RowsTests.swift` + +| Test | Status | +| ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| forward anchor: main query is built and rows assemble | adapted: "a page anchor asks for one row more than the page" | +| permalink anchor: stages queries 2/3 on query 1 result | adapted: "a permalink looks its row up, then pages either side of it", and `ZeroVirtualizerTests.opensAtAPermalinkWithAPageEitherSide` over a client | + +## `src/core/virtualizer.test.ts` → `VirtualizerTests.swift` + +| Test | Status | +| --------------------------------------------------------------------- | -------------------------------------------------------------------- | +| ZeroVirtualizer snapshot — items, space and total (6 cases) | ported | +| items carry the correct index and row | ported | +| explicit count overrides estimated total | n/a: no `count` (an estimate is enough) | +| explicit zero count reports empty list | n/a: no `count` | +| items survive metadata updates but change with row keys and data | n/a: snapshots are values; SwiftUI diffs by key | +| attachment cleanup runs once per container and restores its style | n/a: there are no DOM styles or observers to restore | +| changing count invalidates the cached snapshot | n/a: no `count` | +| changing estimateSize invalidates the cached snapshot | ported | +| scrolling notifies the settled → false transition | ported | +| snapshot identity is cached until state changes | n/a: snapshots are values | +| setRows/setOptions are silent; afterDOMUpdate notifies on transitions | ported (`afterUpdate`) | +| warns once when listContextParams churns identity without content | n/a: list contexts compare by value, so there is no churn to warn of | +| identity-churn warning is best-effort for non-serializable params | n/a: as above | +| query inputs fall back to the new context after a context change | ported | +| lanes (grid): all 5 tests | n/a: no grids yet | +| masonry: both tests | n/a: no masonry yet | +| minPageSize: all 3 tests | ported | + +## `src/core/virtualizer.dom.test.ts` → `ScrollTests.swift` + +| Test | Status | +| ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| **paging against a real (fake-geometry) scroll container** | | +| initial load anchors at the top and fills one page | ported | +| scrolling near the window end advances the forward anchor | ported | +| scrolling back near the window start pages backward | ported | +| a far jump into unloaded space below recovers by cascading pages | adapted: the space below comes from having seen the rows, not `count` | +| a far jump back to the very top re-anchors at the start | adapted: as above | +| a page too small for what is on screen grows instead of paging back and forth | ported | +| **manual scroll anchoring** | | +| growth above the anchor row is compensated into scrollTop | ported | +| shrinkage above the anchor row is compensated too | ported | +| growth at scroll offset 0 is revealed, not compensated away | ported | +| growth below the anchor row does not move the viewport | ported | +| **a grid (lanes)**: all 4 tests | n/a: no grids yet | +| **scroll-state restore when the container mounts lazily** | | +| restores the saved scrollTop when the element attaches on a later commit | ported | +| does not persist a spurious scrollTop:0 while detached | ported | +| **reporting the scroll state** | | +| every scroll is reported as it happens, not once the scroll ends | ported | +| **permalink scroll** | | +| scrolls the permalink row into view when its row key differs from the permalink id | ported | +| %s anchoring: the row stays on top when the page grows under it | ported | +| the jump holds on while the rows before its target are still on their way | adapted: `viewportInsets` for the CSS `scroll-padding-top` | +| **scroll-state restore of a position we wrote** | | +| a later navigation back to it restores it | adapted: `restore(_:)`, since a value can't be told apart by identity; also restores the same state twice | +| from a host that hands every write straight back: scrolling | ported | +| from a host that hands every write straight back: a jump | ported | +| **a restore that moves the loaded window** | | +| manual anchoring lands it where it was saved (first answer missing %i rows) | ported | +| %s anchoring: a position the outgoing window was too short to reach still lands | ported | +| %s anchoring: paging leaves the restored window alone until its rows are in | ported | +| **compareStartRows** | | +| a start row JSON cannot serialize survives the round trip | n/a: start rows are `Equatable`, compared with `==` | +| is what makes that work: without it the compare throws | n/a: as above | +| or narrow the start row in toStartRow: the anchor carries what it returns | adapted: a narrower `Codable` start row survives saving | +| **firstVisibleItem / lastVisibleItem** | | +| report the edges of the viewport, not of the loaded window | ported | +| follow the scroll position | ported | +| count a row that overlaps the scrollport at all | ported | +| carry the row and its key, which scrollToItem takes | adapted: a key goes to `scrollToRow(key:)` | +| are undefined with no scroll element attached | ported | +| **no-op re-anchor does not spin the render loop** | ported | +| **below-viewport window at the start of the list** | ported | +| **scrollToItem** | | +| a loaded row scrolls immediately, without re-anchoring | ported | +| an unloaded row re-anchors and the scroll lands once its page renders | ported | +| is level-triggered: the same id twice scrolls twice | ported | +| align: defaults to auto / auto leaves a visible row / start / center / end | ported | +| aligns below the container's scroll-padding (a sticky header) | adapted: `viewportInsets` | +| keeps the space the target row's scroll-margin asks for | n/a: rows have no per-row margin; insets cover sticky headers | +| a clamped jump still releases the request, so paging keeps working | ported | +| a loaded row keyed differently from its id is scrolled to, not re-fetched | ported | +| addressing a rendered row by its key: scrolls, and issues no lookup at all | ported | +| addressing a rendered row by its key: takes a numeric key | n/a: keys are typed | +| addressing a rendered row by its key: reaches rendered rows only | ported, plus `scrollToRow(key:)` | +| a repeat jump to a row keyed differently from its id still lands | ported | +| an empty id is a no-op, not a lookup that never answers | ported | +| a second jump to a rendered row supersedes a lookup still in flight | ported | +| a second jump supersedes one that is still loading | ported | +| an id that does not exist: all 5 tests | ported | +| a permalink that does not exist leaves the list alone even mid-lookup | ported | +| a permalinkID that does not exist leaves a loaded list alone | ported | +| a deep link to a permalinkID that does not exist falls back to the top | ported | +| **attachment geometry** | | +| alignment and visibility with window scrolling=%s | adapted: insets at both ends ("alignment keeps clear of the strips covering both ends of the viewport"); there is no window scrolling | +| moving a container updates its geometry without a resize notification | n/a: frames are in content coordinates | +| touch growth stays held until scrollend with window scrolling=%s | n/a: a browser workaround; native scroll views take offset changes mid-fling. Checked on a device instead | +| reattachment falls back to the new viewport until its observer reports | adapted: "follow the viewport's size" | + +## Stick to bottom → `StickToBottomTests.swift` + +| Upstream | Status | +| --------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| `src/react/use-zero-virtualizer.test.ts`: the `useStickToBottom` case | adapted: "parked at the bottom, it follows new rows and a last row growing taller" | +| `demo/react/e2e/tests/stick.spec.ts` | adapted: "scrolled away, growth does not follow; back at the bottom, it re-arms" | + +## Not ported + +- `src/core/virtualizer.masonry.dom.test.ts`: no masonry yet. +- `src/react/use-history-scroll-state.test.ts`, + `src/solid/create-history-scroll-state.test.ts`: the Navigation API + helpers. Apps save `onScrollStateChange`'s state themselves (zbugs-swift + in `SceneStorage`). +- The rest of the React and Solid binding tests: framework wiring. The + SwiftUI, UIKit, and AppKit adapters have their own tests. +- `demo/react/e2e`: the browser demo, other than `stick.spec.ts`. + +## Swift's own + +- `ZeroVirtualizerTests.swift`: the virtualizer over a client and a server + the test plays: paging, a permalink, a jump, and a new list context. +- "rows of different heights page down and back without moving what is on + screen", "paging works the same when the scroll view reports scrolls as + they happen". diff --git a/swift/Tests/ZeroVirtualTests/VirtualizerTests.swift b/swift/Tests/ZeroVirtualTests/VirtualizerTests.swift new file mode 100644 index 0000000000..03dc75f8f0 --- /dev/null +++ b/swift/Tests/ZeroVirtualTests/VirtualizerTests.swift @@ -0,0 +1,185 @@ +import Testing +import ZeroTesting + +@testable import ZeroVirtual + +/// zero-virtual's `src/core/virtualizer.test.ts`: the core without a scroll +/// view. +@MainActor +@Suite struct VirtualizerTests { + typealias Core = VirtualizerCore + nonisolated static let est = 48.0 + + static func options(_ change: (inout Core.Options) -> Void = { _ in }) -> Core.Options { + var o = Core.Options( + estimatedRowHeight: est, rowKey: \.id, toStartRow: { $0 }, listContext: "ctx") + change(&o) + return o + } + + /// Rows with nothing loaded, changed by `change`. + static func rows( + _ change: (inout RowsSnapshot) -> Void = { _ in } + ) -> RowsSnapshot { + var r = RowsSnapshot.empty + r.pageSize = 50 + change(&r) + return r + } + + static func loaded( + length: Int, first: Int, complete: Bool, atStart: Bool, atEnd: Bool + ) -> RowsSnapshot { + rows { + $0.rowsLength = length + $0.firstRowIndex = first + $0.complete = complete + $0.rowsEmpty = length == 0 + $0.atStart = atStart + $0.atEnd = atEnd + $0.rowAt = { TestRow(id: "r\($0)") } + } + } + + static func core( + _ rows: RowsSnapshot, _ change: (inout Core.Options) -> Void = { _ in } + ) -> Core { + let core = Core(options(change), scheduler: ManualScheduler()) + core.setRows(rows) + return core + } + + struct SnapshotCase: CustomTestStringConvertible, Sendable { + let name: String + let length, first: Int + let complete, atStart, atEnd: Bool + let items: Int + let spaceBefore: Double? + let spaceAfter: Double? + let total: Int? + var testDescription: String { name } + } + + @Test( + "ZeroVirtualizer snapshot — items, space and total", + arguments: [ + SnapshotCase( + name: "empty, loading (no rows yet)", length: 0, first: 0, complete: false, atStart: false, + atEnd: false, items: 0, spaceBefore: 0, spaceAfter: nil, total: nil), + SnapshotCase( + name: "empty, complete", length: 0, first: 0, complete: true, atStart: true, atEnd: true, + items: 0, spaceBefore: 0, spaceAfter: 0, total: 0), + SnapshotCase( + name: "loading from top, some rows", length: 20, first: 0, complete: false, atStart: true, + atEnd: false, items: 20, spaceBefore: 0, spaceAfter: nil, total: nil), + SnapshotCase( + name: "all rows loaded", length: 20, first: 0, complete: true, atStart: true, atEnd: true, + items: 20, spaceBefore: 0, spaceAfter: 0, total: 20), + SnapshotCase( + name: "loading at end, rows above (firstRowIndex>0)", length: 20, first: 5, + complete: false, atStart: false, atEnd: true, items: 20, spaceBefore: 5 * est, + spaceAfter: 0, total: nil), + SnapshotCase( + name: "complete in middle (firstRowIndex>0)", length: 50, first: 10, complete: true, + atStart: false, atEnd: false, items: 50, spaceBefore: 10 * est, spaceAfter: nil, total: nil), + ]) + func snapshot(c: SnapshotCase) { + let s = Self.core( + Self.loaded( + length: c.length, first: c.first, complete: c.complete, atStart: c.atStart, atEnd: c.atEnd) + ).snapshot + #expect(s.items.count == c.items) + #expect(s.total == c.total) + if let b = c.spaceBefore { #expect(s.spaceBefore == b) } + if let a = c.spaceAfter { #expect(s.spaceAfter == a) } + } + + @Test("items carry the correct index and row") + func itemsCarryIndexAndRow() { + let data = ["a", "b", "c"].map { TestRow(id: $0) } + let s = Self.core( + Self.rows { + $0.rowsLength = 3 + $0.complete = true + $0.rowsEmpty = false + $0.firstRowIndex = 7 + $0.rowAt = { i in (7..<10).contains(i) ? data[i - 7] : nil } + } + ).snapshot + #expect(s.items.map(\.index) == [7, 8, 9]) + #expect(s.items.map(\.key) == ["a", "b", "c"]) + #expect(s.items[0].row == TestRow(id: "a")) + } + + @Test("changing estimateSize invalidates the cached snapshot") + func changingEstimate() { + let core = Self.core( + Self.loaded(length: 20, first: 5, complete: true, atStart: false, atEnd: false)) + #expect(core.snapshot.spaceBefore == 5 * Self.est) + core.setOptions(Self.options { $0.estimatedRowHeight = 10 }) + #expect(core.snapshot.spaceBefore == 5 * 10) + } + + @Test("scrolling notifies the settled → false transition") + func scrollingUnsettles() { + let scheduler = ManualScheduler() + let core = Core(Self.options(), scheduler: scheduler) + let view = FakeScrollView(viewportHeight: 400, rowHeight: 48) + view.core = core + core.attach(view) + + scheduler.advance(by: 2000) + #expect(core.snapshot.settled) + + var notified = 0 + _ = core.subscribe { notified += 1 } + core.didScroll() + #expect(notified > 0) + #expect(!core.snapshot.settled) + core.detach() + } + + @Test("setRows/setOptions are silent; afterDOMUpdate notifies on transitions") + func silentIngestion() { + let core = Core(Self.options(), scheduler: ManualScheduler()) + var notified = 0 + _ = core.subscribe { notified += 1 } + core.setRows(Self.loaded(length: 10, first: 0, complete: true, atStart: true, atEnd: false)) + core.setOptions(Self.options()) + #expect(notified == 0) + core.afterUpdate() + #expect(notified > 0) + #expect(core.queryInputs.anchor == .forward(index: 0, startRow: nil)) + } + + @Test("query inputs fall back to the new context after a context change") + func contextFallback() { + let core = Core(Self.options { $0.listContext = "a" }, scheduler: ManualScheduler()) + #expect(core.queryInputs.anchor == .forward(index: 0, startRow: nil)) + core.setOptions(Self.options { $0.listContext = "b" }) + #expect(core.queryInputs.anchor == .forward(index: 0, startRow: nil)) + core.setOptions( + Self.options { + $0.listContext = "c" + $0.permalinkID = "row-9" + }) + #expect(core.queryInputs.anchor == .permalink(index: 1, id: "row-9")) + } + + @Test("minPageSize defaults the query page size to 50") + func minPageSizeDefault() { + #expect(Core(Self.options(), scheduler: ManualScheduler()).queryInputs.pageSize == 50) + } + + @Test("minPageSize lowers the floor for tall rows") + func minPageSizeLowers() { + let core = Core(Self.options { $0.minPageSize = 20 }, scheduler: ManualScheduler()) + #expect(core.queryInputs.pageSize == 20) + } + + @Test("minPageSize is rounded up to an even number (paging halves pages)") + func minPageSizeEven() { + let core = Core(Self.options { $0.minPageSize = 15 }, scheduler: ManualScheduler()) + #expect(core.queryInputs.pageSize == 16) + } +} diff --git a/swift/Tests/ZeroVirtualTests/ZeroVirtualizerTests.swift b/swift/Tests/ZeroVirtualTests/ZeroVirtualizerTests.swift new file mode 100644 index 0000000000..f4edd9b550 --- /dev/null +++ b/swift/Tests/ZeroVirtualTests/ZeroVirtualizerTests.swift @@ -0,0 +1,244 @@ +import Foundation +import Testing +import ZeroTesting + +@testable import Zero +@testable import ZeroVirtual + +/// `ZeroVirtualizer` over a client: its queries go to a server the test +/// plays, which answers every query it's asked for from 300 issues. +@MainActor +@Suite struct ZeroVirtualizerTests { + struct PageArgs: Codable, Sendable { + var limit: Int + var start: String? + var backward: Bool + var closed: Bool? + } + + static let page = defineQuery("issuePage") { (args: PageArgs) in + var q = testZQL.issue.orderBy(\.id, args.backward ? .desc : .asc) + if let closed = args.closed { q = q.where(\.closed, closed) } + if let start = args.start { q = q.start([\.id: start]) } + return q.limit(args.limit) + } + static let byID = defineQuery("issueByID") { (id: String) in + testZQL.issue.where(\.id, id).one() + } + + static func id(_ i: Int) -> String { "i" + String(format: "%03d", i) } + + let scheduler = ManualScheduler(now: 1_700_000_000_000) + let sockets = MockSocketFactory() + private let server = Server() + + typealias List = ZeroVirtualizer + + func makeList(closed: Bool? = nil, permalinkID: String? = nil) throws -> (Zero, List) { + var options = ZeroOptions( + cacheURL: "http://zero.test", userID: "u", storageKey: randomID(), storage: .memory) + options.logSink = RecordingLogSink() + let zero = try Zero( + schema: .test, options: options, scheduler: scheduler, socketFactory: sockets) + let list = List( + zero: zero, listContext: closed, estimatedRowHeight: 20, rowKey: \.id, toStartRow: \.id, + pageQuery: { r, closed in + Self.page( + PageArgs( + limit: r.limit, start: r.start, backward: r.direction == .backward, closed: closed)) + }, + rowQuery: { r, _ in Self.byID(r.id) }, permalinkID: permalinkID, minPageSize: 100, + scheduler: scheduler) + return (zero, list) + } + + /// One render and a server round: what the list asked for is answered. + func settle(_ list: List, _ view: ListView) { + for _ in 0..<30 { + server.serve(sockets, scheduler) + view.render(list) + let before = list.core.queryInputs + list.afterUpdate() + server.serve(sockets, scheduler) + if list.core.queryInputs == before && list.snapshot.complete { return } + } + Issue.record("did not settle") + } + + @Test func pagesThroughTheList() throws { + let (zero, list) = try makeList() + let view = ListView() + list.attach(view) + settle(list, view) + #expect(list.snapshot.items.map(\.key) == (0..<100).map(Self.id)) + #expect(list.snapshot.items.first?.index == 0) + + for offset in stride(from: 1500.0, through: 5000, by: 700) { + view.scroll(list, to: offset) + settle(list, view) + } + let s = list.snapshot + // The rows are where they are in the list, with the ones on screen loaded. + for item in s.items { #expect(item.key == Self.id(item.index)) } + #expect(view.visibleIndexes.allSatisfy { i in s.items.contains { $0.index == i } }) + // Near the end: the window reaches the last row. + #expect(s.items.last?.key == Self.id(299)) + #expect(s.items.first!.index > 150) + #expect(s.total == 300) + withExtendedLifetime(zero) {} + } + + @Test func opensAtAPermalinkWithAPageEitherSide() throws { + let (zero, list) = try makeList(permalinkID: Self.id(150)) + let view = ListView() + list.attach(view) + settle(list, view) + let keys = list.snapshot.items.map(\.key) + #expect(keys.contains(Self.id(150))) + // Half a page before it, and as much after as a page holds. + #expect(keys.first == Self.id(100)) + #expect(keys.last == Self.id(199)) + #expect(view.top(of: Self.id(150)) == 0) + withExtendedLifetime(zero) {} + } + + @Test func aJumpToAnIDWithNoRowDoesNothing() throws { + let (zero, list) = try makeList() + let view = ListView() + list.attach(view) + settle(list, view) + let before = list.snapshot.items.map(\.key) + list.scrollToItem("nope") + settle(list, view) + #expect(list.snapshot.items.map(\.key) == before) + + list.scrollToItem(Self.id(250), align: .start) + settle(list, view) + #expect(view.top(of: Self.id(250)) == 0) + withExtendedLifetime(zero) {} + } + + @Test func aNewListContextStartsOver() throws { + let (zero, list) = try makeList() + let view = ListView() + list.attach(view) + settle(list, view) + view.scroll(list, to: 1500) + settle(list, view) + list.listContext = true + settle(list, view) + // The closed issues are every third one. + #expect(list.snapshot.items.first?.key == Self.id(0)) + #expect(list.snapshot.items.map(\.key).prefix(3) == [Self.id(0), Self.id(3), Self.id(6)]) + #expect(view.offset == 0) + #expect(list.snapshot.total == 100) + withExtendedLifetime(zero) {} + } +} + +/// A server that sends every row at first, then reports each query the +/// client wants as got. +@MainActor +private final class Server { + var connected = false + var got: Set = [] + var cookie = 0 + + static func row(_ i: Int) -> String { + let id = ZeroVirtualizerTests.id(i) + return + #"{"op":"put","tableName":"issues","value":{"id":"\#(id)","title":"","description":"","closed":\#(i % 3 == 0),"owner_id":null,"createdAt":1}}"# + } + + func serve(_ sockets: MockSocketFactory, _ scheduler: ManualScheduler) { + // Past the client's throttle of query changes. + scheduler.advance(by: 100) + guard let socket = sockets.sockets.last else { return } + var rows = "" + if !connected { + connected = true + socket.receive(text: #"["connected",{"wsid":"w"}]"#) + scheduler.runDueTimers() + rows = #","rowsPatch":["# + (0..<300).map(Self.row).joined(separator: ",") + "]" + } + var wanted: [String] = [] + var texts = socket.messages + if let header = socket.secProtocol, let decoded = try? SecProtocols.decode(header) { + texts.append(JSONSerializer.stringify(decoded)) + } + for text in texts { + guard let json = try? JSONParser.parse(Array(text.utf8)) else { continue } + collect(json, &wanted) + } + let new = wanted.filter { !got.contains($0) } + guard !new.isEmpty || !rows.isEmpty else { return } + got.formUnion(new) + let base = cookie == 0 ? "null" : "\"\(String(format: "%04d", cookie))\"" + cookie += 1 + let patch = new.map { #"{"op":"put","hash":"\#($0)"}"# }.joined(separator: ",") + socket.receive(text: #"["pokeStart",{"pokeID":"p\#(cookie)","baseCookie":\#(base)}]"#) + socket.receive( + text: #"["pokePart",{"pokeID":"p\#(cookie)","gotQueriesPatch":[\#(patch)]\#(rows)}]"#) + socket.receive( + text: #"["pokeEnd",{"pokeID":"p\#(cookie)","cookie":"\#(String(format: "%04d", cookie))"}]"#) + scheduler.runDueTimers() + } + + private func collect(_ json: JSONValue, _ out: inout [String]) { + if let a = json.arrayValue { + for v in a { collect(v, &out) } + } else if let o = json.objectValue { + if o["op"]?.stringValue == "put", let hash = o["hash"]?.stringValue, o["name"] != nil { + out.append(hash) + } + for v in o.values { collect(v, &out) } + } + } +} + +/// A scroll view of 20-point rows, 400 points tall. +@MainActor +final class ListView: VirtualHost { + var offset = 0.0 + var frames: [VirtualRowFrame] = [] + var height = 0.0 + let viewportHeight = 400.0 + let viewportInsets: (top: Double, bottom: Double) = (0, 0) + + var scrollOffset: Double { offset } + var contentHeight: Double { height } + weak var list: ZeroVirtualizerTests.List? + + func scroll(to target: Double) { + let next = max(0, min(target, height - viewportHeight)) + guard next != offset else { return } + offset = next + list?.didScroll() + } + func rowFrames() -> [VirtualRowFrame] { frames } + + func render(_ list: ZeroVirtualizerTests.List) { + self.list = list + let s = list.snapshot + var y = s.spaceBefore + frames = s.items.map { item in + defer { y += 20 } + return VirtualRowFrame(index: item.index, key: AnyHashable(item.key), minY: y, maxY: y + 20) + } + height = y + s.spaceAfter + scroll(to: offset) + } + + func scroll(_ list: ZeroVirtualizerTests.List, to target: Double) { + self.list = list + scroll(to: target) + } + + var visibleIndexes: [Int] { + frames.filter { $0.maxY > offset && $0.minY < offset + viewportHeight }.map(\.index) + } + + func top(of key: String) -> Double? { + frames.first { $0.key == AnyHashable(key) }.map { $0.minY - offset } + } +} From 36fa8c7d5068f551e9c61ed3a341ac8f1549f3d3 Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 20 Oct 2026 01:48:58 +0000 Subject: [PATCH 057/104] 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 --- .github/workflows/zbugs-screenshots.yml | 47 +- packages/zero-swift-cli/src/codegen.test.ts | 11 + swift/.gitignore | 1 + swift/Package.swift | 4 +- .../Sources/ZeroTesting/LoopbackServer.swift | 154 ++++++ .../ZeroTesting.docc/ZeroTesting.md | 16 + .../ZeroVirtual/ScrollGeometryHost.swift | 114 ++++ swift/Sources/ZeroVirtual/VirtualList.swift | 103 ++++ swift/TestHost/App/Backend.swift | 129 +++++ swift/TestHost/App/Generated/TestSchema.swift | 507 ++++++++++++++++++ swift/TestHost/App/Screens.swift | 83 +++ swift/TestHost/App/TestHostApp.swift | 29 + .../TestHost/UITests/VirtualListUITests.swift | 160 ++++++ swift/TestHost/project.yml | 54 ++ .../ScrollGeometryHostTests.swift | 224 ++++++++ .../ZeroVirtualizerTests.swift | 52 ++ 16 files changed, 1686 insertions(+), 2 deletions(-) create mode 100644 swift/Sources/ZeroTesting/LoopbackServer.swift create mode 100644 swift/Sources/ZeroVirtual/ScrollGeometryHost.swift create mode 100644 swift/Sources/ZeroVirtual/VirtualList.swift create mode 100644 swift/TestHost/App/Backend.swift create mode 100644 swift/TestHost/App/Generated/TestSchema.swift create mode 100644 swift/TestHost/App/Screens.swift create mode 100644 swift/TestHost/App/TestHostApp.swift create mode 100644 swift/TestHost/UITests/VirtualListUITests.swift create mode 100644 swift/TestHost/project.yml create mode 100644 swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift diff --git a/.github/workflows/zbugs-screenshots.yml b/.github/workflows/zbugs-screenshots.yml index 421d516efe..df51c61b9b 100644 --- a/.github/workflows/zbugs-screenshots.yml +++ b/.github/workflows/zbugs-screenshots.yml @@ -4,7 +4,9 @@ name: zbugs screenshots # against zbugs' backend (apps/zbugs-swift/server/server.ts), and keeps a # screenshot of each screen (apps/zbugs-swift/App/UITests) as the # zbugs-screenshots artifact. Then it stops the backend and runs each again -# offline, from what the app stored locally. +# offline, from what the app stored locally. Alongside, the virtual list +# UI tests run the ZeroVirtual test app (swift/TestHost), which needs no +# backend. # Path-filtered to the app and the UI layer: macOS runners are slow to start. on: @@ -13,12 +15,18 @@ on: paths: - 'apps/zbugs-swift/**' - 'swift/Sources/ZeroUI/**' + - 'swift/Sources/ZeroVirtual/**' + - 'swift/Sources/ZeroTesting/**' + - 'swift/TestHost/**' - '.github/workflows/zbugs-screenshots.yml' pull_request: branches: [main] paths: - 'apps/zbugs-swift/**' - 'swift/Sources/ZeroUI/**' + - 'swift/Sources/ZeroVirtual/**' + - 'swift/Sources/ZeroTesting/**' + - 'swift/TestHost/**' - '.github/workflows/zbugs-screenshots.yml' workflow_dispatch: @@ -126,3 +134,40 @@ jobs: run: | cat "$RUNNER_TEMP/zbugs.log" cat "$RUNNER_TEMP/pg.log" || true + + virtual-list: + name: Virtual list UI tests (iPhone and macOS) + runs-on: macos-26 + timeout-minutes: 45 + defaults: + run: + working-directory: swift/TestHost + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Generate the Xcode project + run: brew install xcodegen && xcodegen generate + - name: UI tests + run: | + simulator() { + xcrun simctl list devices available -j | + 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(sys.argv[1])))' "$1" + } + # Both, even after one fails. + status=0 + uitests() { + name="$1" + shift + xcodebuild test -project TestHost.xcodeproj -scheme TestHost \ + -resultBundlePath "$RUNNER_TEMP/results/$name.xcresult" \ + -skipPackagePluginValidation "$@" || status=1 + } + uitests iPhone -destination "id=$(simulator iPhone)" CODE_SIGNING_ALLOWED=NO + uitests macOS -destination platform=macOS CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY=- + exit "$status" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: failure() + with: + name: virtual-list-results + path: ${{ runner.temp }}/results diff --git a/packages/zero-swift-cli/src/codegen.test.ts b/packages/zero-swift-cli/src/codegen.test.ts index c9cde0fb0b..95d27c3056 100644 --- a/packages/zero-swift-cli/src/codegen.test.ts +++ b/packages/zero-swift-cli/src/codegen.test.ts @@ -53,6 +53,17 @@ const fixtures: {name: string; dir?: string; config: Config}[] = [ access: 'internal', }, }, + { + name: 'zql test schema for the virtual list test app', + config: { + schema: 'packages/zql/src/query/test/test-schemas.ts', + output: 'swift/TestHost/App/Generated/TestSchema.swift', + schemaName: 'test', + zql: 'testZQL', + typePrefix: 'Test', + access: 'internal', + }, + }, { name: 'codegen fixture schema', config: { diff --git a/swift/.gitignore b/swift/.gitignore index 77b280c420..83a62146d5 100644 --- a/swift/.gitignore +++ b/swift/.gitignore @@ -2,3 +2,4 @@ .swiftpm/ *.xcodeproj/xcuserdata/ Package.resolved +TestHost/*.xcodeproj/ diff --git a/swift/Package.swift b/swift/Package.swift index 5c257fee05..3b2a07bb4d 100644 --- a/swift/Package.swift +++ b/swift/Package.swift @@ -41,7 +41,9 @@ let package = Package( // The Apple layer: the URLSession transport, SwiftUI, UIKit, and AppKit. // Empty elsewhere. .testTarget(name: "AppleTests", dependencies: ["Zero", "ZeroUI", "ZeroTesting"]), - .testTarget(name: "ZeroVirtualTests", dependencies: ["Zero", "ZeroVirtual", "ZeroTesting"]), + .testTarget( + name: "ZeroVirtualTests", dependencies: ["Zero", "ZeroVirtual", "ZeroTesting"], + exclude: ["UPSTREAM.md"]), .testTarget(name: "E2ETests", dependencies: ["Zero", "ZeroNIOTesting"]), ], swiftLanguageModes: [.v6] diff --git a/swift/Sources/ZeroTesting/LoopbackServer.swift b/swift/Sources/ZeroTesting/LoopbackServer.swift new file mode 100644 index 0000000000..65db5216a3 --- /dev/null +++ b/swift/Sources/ZeroTesting/LoopbackServer.swift @@ -0,0 +1,154 @@ +import Zero + +/// A server in the app's own process, for previews, demos, and UI tests of +/// screens that need synced data without a zero-cache: it holds rows, sends +/// them all when a client connects, and confirms every query the client +/// wants, so its views complete. `put(_:_:)` syncs a row to every client, +/// as another user's change would. +/// +/// It doesn't run queries or mutators: the client answers queries from the +/// rows it has, and its own mutations stay pending. Pass it as `Zero`'s `socketFactory`, with any `cacheURL`. +/// +/// ```swift +/// let server = LoopbackServer() +/// server.put("message", ["id": "m1", "body": "Hi", "createdAt": 1]) +/// let zero = try Zero(schema: .chat, options: options, socketFactory: server) +/// ``` +@MainActor +public final class LoopbackServer: SocketFactory { + private var rows: [RowKey: JSONValue] = [:] + private var order: [RowKey] = [] + private var sockets: [LoopbackSocket] = [] + + public init() {} + + /// Adds `row` to `table`, or replaces the row with the same primary key + /// (`primaryKey` names its columns), and sends it to connected clients. + public func put(_ table: String, _ row: [String: JSONValue], primaryKey: [String] = ["id"]) { + let key = RowKey(table: table, id: primaryKey.map { row[$0] ?? .null }) + if rows.updateValue(.object(row), forKey: key) == nil { order.append(key) } + let op = Self.put(table, .object(row)) + for socket in sockets where socket.connected { socket.poke(rows: [op]) } + } + + /// `put` for each row, in one sync. + public func put(_ table: String, _ rows: [[String: JSONValue]], primaryKey: [String] = ["id"]) { + for row in rows { + let key = RowKey(table: table, id: primaryKey.map { row[$0] ?? .null }) + if self.rows.updateValue(.object(row), forKey: key) == nil { order.append(key) } + } + let ops = rows.map { Self.put(table, .object($0)) } + for socket in sockets where socket.connected { socket.poke(rows: ops) } + } + + public func open( + url: String, secProtocol: String?, events: any SocketEvents + ) + -> any SocketConnection + { + sockets.removeAll { $0.closed } + let socket = LoopbackSocket(secProtocol: secProtocol, events: events) + sockets.append(socket) + socket.later { + socket.events?.socketDidOpen() + socket.deliver(#"["connected",{"wsid":"loopback"}]"#) + socket.connected = true + // Everything so far, and the queries asked for before connecting. + socket.poke( + rows: self.order.compactMap { k in self.rows[k].map { Self.put(k.table, $0) } }, + queries: socket.got.sorted()) + } + return socket + } + + private static func put(_ table: String, _ value: JSONValue) -> JSONValue { + .object(["op": .string("put"), "tableName": .string(table), "value": value]) + } +} + +private struct RowKey: Hashable { + var table: String + var id: [JSONValue] +} + +@MainActor +private final class LoopbackSocket: SocketConnection { + weak var events: (any SocketEvents)? + var connected = false + private(set) var closed = false + private(set) var got: Set = [] + private var cookie = 0 + + init(secProtocol: String?, events: any SocketEvents) { + self.events = events + // The first queries can ride in the connection's protocol header. + if let secProtocol, let decoded = try? SecProtocols.decode(secProtocol) { + later { self.receiveFromClient(decoded) } + } + } + + /// Runs `action` on a later turn, as a network would deliver it. + func later(_ action: @escaping @MainActor () -> Void) { + Task { @MainActor [weak self] in + guard let self, !self.closed else { return } + action() + } + } + + func deliver(_ text: String) { events?.socketDidReceive(.text(text)) } + + func send(_ text: String) { + guard let json = try? JSONParser.parse(text) else { return } + later { self.receiveFromClient(json) } + } + + func close(code: Int?) { closed = true } + + private func receiveFromClient(_ json: JSONValue) { + if case .array(let message) = json, message.first == .string("ping") { + deliver(#"["pong",{}]"#) + return + } + var wanted: [String] = [] + collect(json, &wanted) + let new = wanted.filter { !got.contains($0) } + got.formUnion(new) + // Before connecting, the connect poke confirms them. + if connected, !new.isEmpty { poke(queries: new) } + } + + func poke(rows: [JSONValue] = [], queries: [String] = []) { + let base: JSONValue = cookie == 0 ? .null : .string(Self.cookie(cookie)) + cookie += 1 + let id = JSONValue.string("p\(cookie)") + var part: [String: JSONValue] = ["pokeID": id] + if !rows.isEmpty { part["rowsPatch"] = .array(rows) } + if !queries.isEmpty { + part["gotQueriesPatch"] = .array( + queries.map { .object(["op": .string("put"), "hash": .string($0)]) }) + } + deliver(.string("pokeStart"), .object(["pokeID": id, "baseCookie": base])) + deliver(.string("pokePart"), .object(part)) + deliver(.string("pokeEnd"), .object(["pokeID": id, "cookie": .string(Self.cookie(cookie))])) + } + + private func deliver(_ tag: JSONValue, _ body: JSONValue) { + deliver(JSONSerializer.stringify(.array([tag, body]))) + } + + private static func cookie(_ n: Int) -> String { + let s = String(n) + return String(repeating: "0", count: max(0, 6 - s.count)) + s + } + + /// The hashes of the queries `json` puts in the desired set. + private func collect(_ json: JSONValue, _ out: inout [String]) { + switch json { + case .array(let a): for v in a { collect(v, &out) } + case .object(let o): + if o["op"] == .string("put"), case .string(let hash)? = o["hash"] { out.append(hash) } + for v in o.values { collect(v, &out) } + default: break + } + } +} diff --git a/swift/Sources/ZeroTesting/ZeroTesting.docc/ZeroTesting.md b/swift/Sources/ZeroTesting/ZeroTesting.docc/ZeroTesting.md index 223501b90b..203aa36861 100644 --- a/swift/Sources/ZeroTesting/ZeroTesting.docc/ZeroTesting.md +++ b/swift/Sources/ZeroTesting/ZeroTesting.docc/ZeroTesting.md @@ -50,6 +50,21 @@ socket.receive(text: #"["connected",{"wsid":"w1"}]"#) `RecordingLogSink` keeps the client's log messages for a test to check. +### A server in the app + +For previews, demos, and UI tests that need synced data on real time, +`LoopbackServer` plays a server in the app's process: it sends the rows it +holds, confirms every query, and syncs rows added later, as another user's +change would. + +```swift +let server = LoopbackServer() +server.put("message", ["id": "m1", "body": "Hi", "createdAt": 1]) +let zero = try Zero( + schema: .app, options: ZeroOptions(cacheURL: "http://loopback"), + socketFactory: server) +``` + `ZeroTesting` re-exports `Zero`. ## Topics @@ -64,3 +79,4 @@ socket.receive(text: #"["connected",{"wsid":"w1"}]"#) - ``MockSocketFactory`` - ``MockSocket`` - ``RecordingLogSink`` +- ``LoopbackServer`` diff --git a/swift/Sources/ZeroVirtual/ScrollGeometryHost.swift b/swift/Sources/ZeroVirtual/ScrollGeometryHost.swift new file mode 100644 index 0000000000..b4ed50cda1 --- /dev/null +++ b/swift/Sources/ZeroVirtual/ScrollGeometryHost.swift @@ -0,0 +1,114 @@ +/// A `VirtualHost` for a scroll view that reports its geometry after the +/// fact, as SwiftUI's does: its offset with `onScrollGeometryChange`, and +/// each row's frame with `onGeometryChange`. +/// +/// It keeps what the core asks of a scroll view as of the last report: row +/// frames in content coordinates, by key; the offset from the content's top, +/// insets aside. A scroll the core asks for takes effect here at once, and +/// goes to the view with `requestScroll`. The core hears of a new snapshot +/// (`afterUpdate`) once every one of its rows has been measured. +/// +/// The snapshot is already the window to draw, so the view lays its rows +/// out eagerly (`VStack`, not `LazyVStack`): a lazy stack might never +/// measure a row out of sight, and the update would wait for it forever. +/// Frames are in the rows' stack's coordinate space, which doesn't move as +/// the view scrolls. +@MainActor +package final class ScrollGeometryHost: VirtualHost { + package private(set) var scrollOffset = 0.0 + package private(set) var viewportHeight = 0.0 + package private(set) var contentHeight = 0.0 + package var viewportInsets: (top: Double, bottom: Double) { (0, 0) } + + /// Scrolls the view so content `y` is at the viewport's top. + package var requestScroll: (Double) -> Void = { _ in } + /// Runs `action` once the view has reported a layout pass: later on the + /// main actor. + package var defer_: (@escaping @MainActor () -> Void) -> Void = { action in + Task { @MainActor in action() } + } + package var didScroll: () -> Void = {} + package var viewportDidResize: () -> Void = {} + package var afterUpdate: () -> Void = {} + + /// The rendered rows, in list order. + private var items: [(key: AnyHashable, index: Int)] = [] + private var heights: [AnyHashable: (minY: Double, maxY: Double)] = [:] + private var pendingScroll: Double? + private var flushScheduled = false + + package init() {} + + package func rowFrames() -> [VirtualRowFrame] { + items.compactMap { item in + heights[item.key].map { + VirtualRowFrame(index: item.index, key: item.key, minY: $0.minY, maxY: $0.maxY) + } + } + } + + package func scroll(to target: Double) { + let offset = max(0, min(target, contentHeight - viewportHeight)) + guard offset != scrollOffset else { return } + scrollOffset = offset + pendingScroll = offset + requestScroll(offset) + } + + /// The view rendered a snapshot with these rows. + package func render(_ keys: [(key: Key, index: Int)]) { + items = keys.map { (AnyHashable($0.key), $0.index) } + let live = Set(items.map(\.key)) + heights = heights.filter { live.contains($0.key) } + scheduleFlush() + } + + /// A row's frame, in content coordinates. + package func rowFrame(_ key: Key, minY: Double, maxY: Double) { + let key = AnyHashable(key) + if let old = heights[key], old == (minY, maxY) { return } + heights[key] = (minY, maxY) + scheduleFlush() + } + + /// The scroll view's geometry: `contentOffset` as UIKit reports it (the + /// top inset is negative space above the content), the size it shows + /// content in, and its insets. + package func geometry( + contentOffset: Double, containerHeight: Double, contentHeight: Double, insetTop: Double, + insetBottom: Double + ) { + let viewport = max(0, containerHeight - insetTop - insetBottom) + let resized = viewport != viewportHeight + let grew = contentHeight != self.contentHeight + viewportHeight = viewport + self.contentHeight = contentHeight + let offset = contentOffset + insetTop + let moved = offset != scrollOffset + let landed = pendingScroll != nil + pendingScroll = nil + scrollOffset = offset + if resized { viewportDidResize() } + // The core's own scroll is reported too, landed or not: that's how it + // tells its scrolls from the user's. + if moved || landed { didScroll() } + if grew { scheduleFlush() } + } + + private func scheduleFlush() { + guard !flushScheduled else { return } + flushScheduled = true + defer_ { [weak self] in self?.flush() } + } + + private func flush() { + flushScheduled = false + // A row not measured yet is laid out on a pass still coming; so is + // content that doesn't reach the last row yet. Rows can report before + // the scroll view does, and the core scrolls within `contentHeight`. + guard items.allSatisfy({ heights[$0.key] != nil }), + contentHeight >= (rowFrames().last?.maxY ?? 0) + else { return } + afterUpdate() + } +} diff --git a/swift/Sources/ZeroVirtual/VirtualList.swift b/swift/Sources/ZeroVirtual/VirtualList.swift new file mode 100644 index 0000000000..36e26fa558 --- /dev/null +++ b/swift/Sources/ZeroVirtual/VirtualList.swift @@ -0,0 +1,103 @@ +#if canImport(SwiftUI) + import SwiftUI + import Zero + + /// A scroll view of a `ZeroVirtualizer`'s rows: they page in as it + /// scrolls, and what's on screen stays put as rows load, go, and change + /// height. + /// + /// ```swift + /// VirtualList(messages) { item in + /// MessageRow(message: item.row) + /// } + /// ``` + /// + /// Rows can be any height, and change it. The list draws only the rows + /// near the screen, with space standing in for the rest. + public struct VirtualList< + Context: Sendable, Row: ZeroRow, Key: Hashable, StartRow: Equatable & Sendable, + ListContext: Equatable & Sendable, RowContent: View + >: View { + public typealias Virtualizer = ZeroVirtualizer + + private let list: Virtualizer + private let row: (VirtualItem) -> RowContent + @State private var host = ScrollGeometryHost() + @State private var position = ScrollPosition() + + public init( + _ list: Virtualizer, @ViewBuilder row: @escaping (VirtualItem) -> RowContent + ) { + self.list = list + self.row = row + } + + public var body: some View { + let snapshot = list.snapshot + ScrollView { + // Not lazy: the snapshot is already the rows to draw, and each must + // be measured (`ScrollGeometryHost`). + VStack(spacing: 0) { + Color.clear.frame(height: snapshot.spaceBefore) + ForEach(snapshot.items, id: \.key) { item in + row(item) + .onGeometryChange(for: CGRect.self) { + $0.frame(in: .named(Self.rows)) + } action: { [host] frame in + host.rowFrame(item.key, minY: frame.minY, maxY: frame.maxY) + } + } + Color.clear.frame(height: snapshot.spaceAfter) + } + .coordinateSpace(.named(Self.rows)) + } + .scrollPosition($position) + .onScrollGeometryChange(for: Geometry.self) { + Geometry($0) + } action: { [host] _, g in + host.geometry( + contentOffset: g.offset, containerHeight: g.container, contentHeight: g.content, + insetTop: g.top, insetBottom: g.bottom) + } + .onChange(of: Rendered(snapshot), initial: true) { [host] in + host.render(snapshot.items.map { (key: $0.key, index: $0.index) }) + } + .onAppear { + host.requestScroll = { y in position.scrollTo(y: y) } + host.didScroll = { [list] in list.didScroll() } + host.viewportDidResize = { [list] in list.viewportDidResize() } + host.afterUpdate = { [list] in list.afterUpdate() } + list.anchoring = .manual + list.attach(host) + } + .onDisappear { list.detach() } + } + + private static var rows: String { "ZeroVirtual.VirtualList.rows" } + } + + /// What the host needs of `ScrollGeometry`. + private struct Geometry: Equatable { + var offset, container, content, top, bottom: Double + + init(_ g: ScrollGeometry) { + offset = g.contentOffset.y + container = g.containerSize.height + content = g.contentSize.height + top = g.contentInsets.top + bottom = g.contentInsets.bottom + } + } + + /// What a render of the snapshot laid out: a change needs measuring. + private struct Rendered: Equatable { + var keys: [AnyHashable] + var before, after: Double + + init(_ s: VirtualSnapshot) { + keys = s.items.map { AnyHashable($0.key) } + before = s.spaceBefore + after = s.spaceAfter + } + } +#endif diff --git a/swift/TestHost/App/Backend.swift b/swift/TestHost/App/Backend.swift new file mode 100644 index 0000000000..5942abfc14 --- /dev/null +++ b/swift/TestHost/App/Backend.swift @@ -0,0 +1,129 @@ +import Foundation +import Observation +import ZeroTesting +import ZeroVirtual + +/// A client of a `LoopbackServer` holding a long list of issues and a chat +/// of messages of many lengths. +@MainActor +final class Backend { + let server = LoopbackServer() + let zero: Zero + private(set) var messageCount: Int + + init(issues: Int = 2000, messages: Int = 200) { + messageCount = messages + server.put("issues", (0.. String { "i" + String(format: "%04d", i) } + static func messageID(_ i: Int) -> String { "m" + String(format: "%04d", i) } + + private static func issue(_ i: Int) -> [String: JSONValue] { + [ + "id": .string(issueID(i)), "title": .string("Issue \(i)"), + "description": .string(words(i % 5 == 0 ? 30 : 0, seed: i)), "closed": .bool(i % 3 == 0), + "owner_id": .null, "createdAt": .number(Double(i)), + ] + } + + /// Message `i`: 1 to 60 words, so rows are many heights. + static func message(_ i: Int, words count: Int? = nil) -> [String: JSONValue] { + [ + "id": .string(messageID(i)), "authorId": .string(["ana", "ben", "you"][i % 3]), + "issue_id": "chat", "text": .string("\(i): " + words(count ?? (i * 7919 % 60 + 1), seed: i)), + "createdAt": .number(Double(1_000 + i)), + ] + } + + private static func words(_ n: Int, seed: Int) -> String { + let lorem = "lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor" + .split(separator: " ") + return (0.. +typealias IssueList = ZeroVirtualizer + +extension Backend { + func chatList() -> ChatList { + ChatList( + zero: zero, listContext: "chat", estimatedRowHeight: 80, rowKey: \.id, + toStartRow: { ChatStart(createdAt: $0.createdAt, id: $0.id) }, + pageQuery: { r, _ in + ChatQueries.page(.init(limit: r.limit, start: r.start, backward: r.direction == .backward)) + }, + rowQuery: { r, _ in ChatQueries.message(r.id) }, minPageSize: 20, stickToBottom: true) + } + + func issueList() -> IssueList { + IssueList( + zero: zero, listContext: "issues", estimatedRowHeight: 44, rowKey: \.id, + toStartRow: \.id, + pageQuery: { r, _ in + IssueQueries.page(.init(limit: r.limit, start: r.start, backward: r.direction == .backward)) + }, + rowQuery: { r, _ in IssueQueries.issue(r.id) }) + } +} diff --git a/swift/TestHost/App/Generated/TestSchema.swift b/swift/TestHost/App/Generated/TestSchema.swift new file mode 100644 index 0000000000..04e7b05ab3 --- /dev/null +++ b/swift/TestHost/App/Generated/TestSchema.swift @@ -0,0 +1,507 @@ +// Generated by `zero-swift codegen` from packages/zql/src/query/test/test-schemas.ts. Do not edit. +// swift-format-ignore-file + +import Zero + +struct TestIssue: ZeroRow, Codable, Hashable, Identifiable { + var id: String + var title: String + var description: String + var closed: Bool + var ownerId: String? + var createdAt: Double + + // Relationships, filled when a query asks for them with `related`. + @Indirect var owner: TestUser? + var comments: [TestComment]? + @Indirect var oneComment: TestComment? + var ownerComments: [TestComment]? + var labels: [TestLabel]? + + init( + id: String, + title: String, + description: String, + closed: Bool, + ownerId: String? = nil, + createdAt: Double, + owner: TestUser? = nil, + comments: [TestComment]? = nil, + oneComment: TestComment? = nil, + ownerComments: [TestComment]? = nil, + labels: [TestLabel]? = nil + ) { + self.id = id + self.title = title + self.description = description + self.closed = closed + self.ownerId = ownerId + self.createdAt = createdAt + self.owner = owner + self.comments = comments + self.oneComment = oneComment + self.ownerComments = ownerComments + self.labels = labels + } + + static let tableName = "issue" + + static let columns: KeyPathNames = [ + \TestIssue.id: "id", + \TestIssue.title: "title", + \TestIssue.description: "description", + \TestIssue.closed: "closed", + \TestIssue.ownerId: "ownerId", + \TestIssue.createdAt: "createdAt", + ] + + static let relationships: KeyPathNames = [ + \TestIssue.owner: "owner", + \TestIssue.comments: "comments", + \TestIssue.oneComment: "oneComment", + \TestIssue.ownerComments: "ownerComments", + \TestIssue.labels: "labels", + ] + + init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id", 0) + title = try entry.column("title", 1) + description = try entry.column("description", 2) + closed = try entry.column("closed", 3) + ownerId = try entry.column("ownerId", 4) + createdAt = try entry.column("createdAt", 5) + owner = try entry.relationship("owner") + comments = try entry.relationship("comments") + oneComment = try entry.relationship("oneComment") + ownerComments = try entry.relationship("ownerComments") + labels = try entry.relationship("labels") + } + + var columnValues: Row { + [ + "id": id.zeroValue, + "title": title.zeroValue, + "description": description.zeroValue, + "closed": closed.zeroValue, + "ownerId": ownerId.zeroValue, + "createdAt": createdAt.zeroValue, + ] + } +} + +struct TestUser: ZeroRow, Codable, Hashable, Identifiable { + var id: String + var name: String + var metadata: TestUserMetadata? + + // Relationships, filled when a query asks for them with `related`. + var issues: [TestIssue]? + + init( + id: String, + name: String, + metadata: TestUserMetadata? = nil, + issues: [TestIssue]? = nil + ) { + self.id = id + self.name = name + self.metadata = metadata + self.issues = issues + } + + static let tableName = "user" + + static let columns: KeyPathNames = [ + \TestUser.id: "id", + \TestUser.name: "name", + \TestUser.metadata: "metadata", + ] + + static let relationships: KeyPathNames = [ + \TestUser.issues: "issues", + ] + + init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id", 0) + name = try entry.column("name", 1) + metadata = try entry.column("metadata", 2) + issues = try entry.relationship("issues") + } + + var columnValues: Row { + [ + "id": id.zeroValue, + "name": name.zeroValue, + "metadata": metadata.zeroValue, + ] + } +} + +struct TestComment: ZeroRow, Codable, Hashable, Identifiable { + var id: String + var authorId: String + var issueId: String + var text: String + var createdAt: Double + + // Relationships, filled when a query asks for them with `related`. + @Indirect var issue: TestIssue? + var revisions: [TestRevision]? + @Indirect var author: TestUser? + + init( + id: String, + authorId: String, + issueId: String, + text: String, + createdAt: Double, + issue: TestIssue? = nil, + revisions: [TestRevision]? = nil, + author: TestUser? = nil + ) { + self.id = id + self.authorId = authorId + self.issueId = issueId + self.text = text + self.createdAt = createdAt + self.issue = issue + self.revisions = revisions + self.author = author + } + + static let tableName = "comment" + + static let columns: KeyPathNames = [ + \TestComment.id: "id", + \TestComment.authorId: "authorId", + \TestComment.issueId: "issueId", + \TestComment.text: "text", + \TestComment.createdAt: "createdAt", + ] + + static let relationships: KeyPathNames = [ + \TestComment.issue: "issue", + \TestComment.revisions: "revisions", + \TestComment.author: "author", + ] + + init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id", 0) + authorId = try entry.column("authorId", 1) + issueId = try entry.column("issueId", 2) + text = try entry.column("text", 3) + createdAt = try entry.column("createdAt", 4) + issue = try entry.relationship("issue") + revisions = try entry.relationship("revisions") + author = try entry.relationship("author") + } + + var columnValues: Row { + [ + "id": id.zeroValue, + "authorId": authorId.zeroValue, + "issueId": issueId.zeroValue, + "text": text.zeroValue, + "createdAt": createdAt.zeroValue, + ] + } +} + +struct TestRevision: ZeroRow, Codable, Hashable, Identifiable { + var id: String + var authorId: String + var commentId: String + var text: String + + // Relationships, filled when a query asks for them with `related`. + @Indirect var comment: TestComment? + @Indirect var author: TestUser? + + init( + id: String, + authorId: String, + commentId: String, + text: String, + comment: TestComment? = nil, + author: TestUser? = nil + ) { + self.id = id + self.authorId = authorId + self.commentId = commentId + self.text = text + self.comment = comment + self.author = author + } + + static let tableName = "revision" + + static let columns: KeyPathNames = [ + \TestRevision.id: "id", + \TestRevision.authorId: "authorId", + \TestRevision.commentId: "commentId", + \TestRevision.text: "text", + ] + + static let relationships: KeyPathNames = [ + \TestRevision.comment: "comment", + \TestRevision.author: "author", + ] + + init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id", 0) + authorId = try entry.column("authorId", 1) + commentId = try entry.column("commentId", 2) + text = try entry.column("text", 3) + comment = try entry.relationship("comment") + author = try entry.relationship("author") + } + + var columnValues: Row { + [ + "id": id.zeroValue, + "authorId": authorId.zeroValue, + "commentId": commentId.zeroValue, + "text": text.zeroValue, + ] + } +} + +struct TestLabel: ZeroRow, Codable, Hashable, Identifiable { + var id: String + var name: String + + // Relationships, filled when a query asks for them with `related`. + var issues: [TestIssue]? + + init( + id: String, + name: String, + issues: [TestIssue]? = nil + ) { + self.id = id + self.name = name + self.issues = issues + } + + static let tableName = "label" + + static let columns: KeyPathNames = [ + \TestLabel.id: "id", + \TestLabel.name: "name", + ] + + static let relationships: KeyPathNames = [ + \TestLabel.issues: "issues", + ] + + init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id", 0) + name = try entry.column("name", 1) + issues = try entry.relationship("issues") + } + + var columnValues: Row { + [ + "id": id.zeroValue, + "name": name.zeroValue, + ] + } +} + +struct TestIssueLabel: ZeroRow, Codable, Hashable { + var issueId: String + var labelId: String + + init( + issueId: String, + labelId: String + ) { + self.issueId = issueId + self.labelId = labelId + } + + static let tableName = "issueLabel" + + static let columns: KeyPathNames = [ + \TestIssueLabel.issueId: "issueId", + \TestIssueLabel.labelId: "labelId", + ] + + static let relationships: KeyPathNames = [:] + + init(entry: ViewEntry) throws(ZeroValueError) { + issueId = try entry.column("issueId", 0) + labelId = try entry.column("labelId", 1) + } + + var columnValues: Row { + [ + "issueId": issueId.zeroValue, + "labelId": labelId.zeroValue, + ] + } +} + +struct TestUserMetadata: ZeroValue, Codable, Hashable { + var registrar: TestUserMetadataRegistrar + var email: String + var altContacts: [String]? + + init( + registrar: TestUserMetadataRegistrar, + email: String, + altContacts: [String]? = nil + ) { + self.registrar = registrar + self.email = email + self.altContacts = altContacts + } + + init(zeroValue: JSONValue) throws(ZeroValueError) { + let o = try ZeroObjectReader(zeroValue, "TestUserMetadata") + registrar = try o.field("registrar") + email = try o.field("email") + altContacts = try o.field("altContacts") + } + + var zeroValue: JSONValue { + var o = ZeroObjectWriter() + o.set("registrar", registrar) + o.set("email", email) + o.setIfPresent("altContacts", altContacts) + return o.value + } +} + +enum TestUserMetadataRegistrar: String, ZeroEnum, Codable, CaseIterable { + case github + case google +} + +extension ZeroSchema { + static let test = ZeroSchema( + tables: [ + TableSchema( + name: "issue", serverName: "issues", + columns: [ + ("id", ColumnSchema(.string)), + ("title", ColumnSchema(.string)), + ("description", ColumnSchema(.string)), + ("closed", ColumnSchema(.boolean)), + ("ownerId", ColumnSchema(.string, optional: true, serverName: "owner_id")), + ("createdAt", ColumnSchema(.number)), + ], + primaryKey: ["id"]), + TableSchema( + name: "user", serverName: "users", + columns: [ + ("id", ColumnSchema(.string)), + ("name", ColumnSchema(.string)), + ("metadata", ColumnSchema(.json, optional: true)), + ], + primaryKey: ["id"]), + TableSchema( + name: "comment", serverName: "comments", + columns: [ + ("id", ColumnSchema(.string)), + ("authorId", ColumnSchema(.string)), + ("issueId", ColumnSchema(.string, serverName: "issue_id")), + ("text", ColumnSchema(.string)), + ("createdAt", ColumnSchema(.number)), + ], + primaryKey: ["id"]), + TableSchema( + name: "revision", + columns: [ + ("id", ColumnSchema(.string)), + ("authorId", ColumnSchema(.string)), + ("commentId", ColumnSchema(.string)), + ("text", ColumnSchema(.string)), + ], + primaryKey: ["id"]), + TableSchema( + name: "label", + columns: [ + ("id", ColumnSchema(.string)), + ("name", ColumnSchema(.string)), + ], + primaryKey: ["id"], + uniqueKeys: [["name"]]), + TableSchema( + name: "issueLabel", + columns: [ + ("issueId", ColumnSchema(.string)), + ("labelId", ColumnSchema(.string)), + ], + primaryKey: ["issueId", "labelId"]), + ], + relationships: [ + "issue": [ + "owner": [ + RelationshipHop(sourceField: ["ownerId"], destField: ["id"], destSchema: "user", cardinality: .one), + ], + "comments": [ + RelationshipHop(sourceField: ["id"], destField: ["issueId"], destSchema: "comment", cardinality: .many), + ], + "oneComment": [ + RelationshipHop(sourceField: ["id"], destField: ["issueId"], destSchema: "comment", cardinality: .one), + ], + "ownerComments": [ + RelationshipHop(sourceField: ["ownerId"], destField: ["authorId"], destSchema: "comment", cardinality: .many), + ], + "labels": [ + RelationshipHop(sourceField: ["id"], destField: ["issueId"], destSchema: "issueLabel", cardinality: .many), + RelationshipHop(sourceField: ["labelId"], destField: ["id"], destSchema: "label", cardinality: .many), + ], + ], + "user": [ + "issues": [ + RelationshipHop(sourceField: ["id"], destField: ["ownerId"], destSchema: "issue", cardinality: .many), + ], + ], + "comment": [ + "issue": [ + RelationshipHop(sourceField: ["issueId"], destField: ["id"], destSchema: "issue", cardinality: .one), + ], + "revisions": [ + RelationshipHop(sourceField: ["id"], destField: ["commentId"], destSchema: "revision", cardinality: .many), + ], + "author": [ + RelationshipHop(sourceField: ["authorId"], destField: ["id"], destSchema: "user", cardinality: .one), + ], + ], + "revision": [ + "comment": [ + RelationshipHop(sourceField: ["commentId"], destField: ["id"], destSchema: "comment", cardinality: .one), + ], + "author": [ + RelationshipHop(sourceField: ["authorId"], destField: ["id"], destSchema: "user", cardinality: .one), + ], + ], + "label": [ + "issues": [ + RelationshipHop(sourceField: ["id"], destField: ["labelId"], destSchema: "issueLabel", cardinality: .many), + RelationshipHop(sourceField: ["issueId"], destField: ["id"], destSchema: "issue", cardinality: .many), + ], + ], + ]) +} + +/// The queries of `ZeroSchema.test`, one per table: `testZQL.issue.where(…)`. +struct TestZQL: Sendable { + init() {} + + var issue: Query { Query(AnyQuery(.test, "issue")) } + + var user: Query { Query(AnyQuery(.test, "user")) } + + var comment: Query { Query(AnyQuery(.test, "comment")) } + + var revision: Query { Query(AnyQuery(.test, "revision")) } + + var label: Query { Query(AnyQuery(.test, "label")) } + + var issueLabel: Query { Query(AnyQuery(.test, "issueLabel")) } +} + +let testZQL = TestZQL() diff --git a/swift/TestHost/App/Screens.swift b/swift/TestHost/App/Screens.swift new file mode 100644 index 0000000000..3021476da1 --- /dev/null +++ b/swift/TestHost/App/Screens.swift @@ -0,0 +1,83 @@ +import SwiftUI +import ZeroTesting +import ZeroUI +import ZeroVirtual + +/// zero-virtual's chat demo (`demo/chat`): it opens at the latest message +/// and follows new ones while it's at the bottom. +struct ChatScreen: View { + let backend: Backend + @State private var list: ChatList + @ZeroQuery(ChatQueries.latest()) private var latest + @State private var started = false + + init(backend: Backend) { + self.backend = backend + _list = State(initialValue: backend.chatList()) + } + + var body: some View { + VStack(spacing: 0) { + VirtualList(list) { item in + MessageRow(message: item.row) + } + .accessibilityIdentifier("list") + HStack { + Button("Receive") { backend.receive() }.accessibilityIdentifier("receive") + Button("Grow last") { backend.growLast() }.accessibilityIdentifier("grow") + Button("Latest") { + if let latest { list.scrollToItem(latest.id, align: .end) } + } + .accessibilityIdentifier("latest") + } + .padding(8) + } + .onChange(of: latest?.id, initial: true) { + guard !started, let id = latest?.id else { return } + started = true + list.scrollToItem(id, align: .end) + } + } +} + +struct MessageRow: View { + let message: TestComment + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(message.authorId).font(.caption).foregroundStyle(.secondary) + Text(message.text) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("row-\(message.id)") + } +} + +/// A long list to page through, both ways. +struct IssuesScreen: View { + @State private var list: IssueList + + init(backend: Backend) { + _list = State(initialValue: backend.issueList()) + } + + var body: some View { + VirtualList(list) { item in + VStack(alignment: .leading, spacing: 2) { + Text(item.row.title) + if !item.row.description.isEmpty { + Text(item.row.description).font(.caption).foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("row-\(item.row.id)") + } + .accessibilityIdentifier("list") + } +} diff --git a/swift/TestHost/App/TestHostApp.swift b/swift/TestHost/App/TestHostApp.swift new file mode 100644 index 0000000000..89d512aa8e --- /dev/null +++ b/swift/TestHost/App/TestHostApp.swift @@ -0,0 +1,29 @@ +import SwiftUI +import ZeroUI + +/// Opens the screen the `-screen` launch argument names (`chat`, `issues`), +/// or a menu of them. +@main +struct TestHostApp: App { + @State private var backend = Backend() + + var body: some Scene { + WindowGroup { + Group { + switch UserDefaults.standard.string(forKey: "screen") { + case "chat": ChatScreen(backend: backend) + case "issues": IssuesScreen(backend: backend) + default: + NavigationStack { + List { + NavigationLink("Chat") { ChatScreen(backend: backend) } + NavigationLink("Issues") { IssuesScreen(backend: backend) } + } + .navigationTitle("Zero Test Host") + } + } + } + .zero(backend.zero) + } + } +} diff --git a/swift/TestHost/UITests/VirtualListUITests.swift b/swift/TestHost/UITests/VirtualListUITests.swift new file mode 100644 index 0000000000..add61b03d8 --- /dev/null +++ b/swift/TestHost/UITests/VirtualListUITests.swift @@ -0,0 +1,160 @@ +import XCTest + +/// ZeroVirtual's SwiftUI list (`VirtualList`) in the test app: rows checked +/// by where they are on screen. +@MainActor +final class VirtualListUITests: XCTestCase { + var app: XCUIApplication! + + override func setUp() async throws { + continueAfterFailure = false + } + + func launch(_ screen: String) { + app = XCUIApplication() + app.launchArguments = ["-screen", screen] + app.launch() + } + + var list: XCUIElement { app.scrollViews.firstMatch } + + func row(_ id: String) -> XCUIElement { app.descendants(matching: .any)["row-\(id)"].firstMatch } + + /// The rows on screen, top to bottom. + func visibleRows() -> [(id: String, frame: CGRect)] { + let bounds = list.frame + return app.descendants(matching: .any) + .matching(NSPredicate(format: "identifier BEGINSWITH 'row-'")).allElementsBoundByIndex + .map { (String($0.identifier.dropFirst(4)), $0.frame) } + .filter { $0.frame.maxY > bounds.minY + 1 && $0.frame.minY < bounds.maxY - 1 } + .sorted { $0.frame.minY < $1.frame.minY } + } + + func waitUntil( + _ what: String, timeout: TimeInterval = 10, file: StaticString = #filePath, line: UInt = #line, + _ done: () -> Bool + ) { + let end = Date().addingTimeInterval(timeout) + while Date() < end { + if done() { return } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + XCTFail("timed out waiting until \(what)", file: file, line: line) + } + + /// Lets a fling come to rest and the list page in what it needs. + func rest(_ seconds: TimeInterval = 1.5) { + RunLoop.current.run(until: Date().addingTimeInterval(seconds)) + } + + /// Scrolls toward the top of the list. + func scrollTowardTop() { + #if os(macOS) + list.scroll(byDeltaX: 0, deltaY: 600) + #else + list.swipeDown() + #endif + } + + /// Scrolls toward the bottom of the list. + func scrollTowardBottom() { + #if os(macOS) + list.scroll(byDeltaX: 0, deltaY: -600) + #else + list.swipeUp() + #endif + } + + /// The row's bottom is at the list's. + func atBottom(_ id: String) -> Bool { + let r = row(id) + return r.exists && abs(r.frame.maxY - list.frame.maxY) <= 1.5 + } + + /// The top row on screen holds still once the list is at rest. + func assertHoldsStill(file: StaticString = #filePath, line: UInt = #line) { + guard let top = visibleRows().first else { + return XCTFail("no rows on screen", file: file, line: line) + } + rest(2.5) + let now = row(top.id).frame + XCTAssertEqual( + now.minY, top.frame.minY, accuracy: 1, "row \(top.id) moved", file: file, line: line) + } + + // MARK: Chat + + func testChatOpensAtTheLatestMessage() { + launch("chat") + waitUntil("the latest message is at the bottom") { atBottom("m0199") } + XCTAssertGreaterThan(visibleRows().count, 1) + } + + func testChatFollowsNewMessagesAndALastMessageGrowingTaller() { + launch("chat") + waitUntil("the latest message is at the bottom") { atBottom("m0199") } + app.buttons["receive"].tap() + waitUntil("a new message is at the bottom") { atBottom("m0200") } + let height = row("m0200").frame.height + app.buttons["grow"].tap() + waitUntil("the grown message is taller, at the bottom") { + row("m0200").frame.height > height + 20 && atBottom("m0200") + } + } + + func testChatScrolledUpStaysAndLatestFollowsAgain() { + launch("chat") + waitUntil("the latest message is at the bottom") { atBottom("m0199") } + scrollTowardTop() + rest() + guard let top = visibleRows().first else { return XCTFail("no rows on screen") } + app.buttons["receive"].tap() + rest() + XCTAssertFalse(visibleRows().contains { $0.id == "m0200" }, "followed while scrolled up") + XCTAssertEqual(row(top.id).frame.minY, top.frame.minY, accuracy: 1) + + app.buttons["latest"].tap() + waitUntil("the latest message is at the bottom") { atBottom("m0200") } + app.buttons["receive"].tap() + waitUntil("a new message is at the bottom again") { atBottom("m0201") } + } + + func testChatHoldsStillAsOlderMessagesOfManyHeightsLoad() { + launch("chat") + waitUntil("the latest message is at the bottom") { atBottom("m0199") } + var reachedStart = false + for _ in 0..<40 where !reachedStart { + scrollTowardTop() + rest() + assertHoldsStill() + reachedStart = row("m0000").exists && row("m0000").frame.minY >= list.frame.minY - 1 + } + XCTAssertTrue(reachedStart, "never reached the first message") + XCTAssertEqual(row("m0000").frame.minY, list.frame.minY, accuracy: 1.5) + } + + // MARK: A long list + + func testIssuesPageDownAndBackUp() { + launch("issues") + waitUntil("the first issue is at the top") { + row("i0000").exists && abs(row("i0000").frame.minY - list.frame.minY) <= 1.5 + } + var deepest = 0 + for _ in 0..<30 where deepest < 400 { + scrollTowardBottom() + rest(1) + deepest = visibleRows().compactMap { Int($0.id.dropFirst()) }.max() ?? deepest + } + XCTAssertGreaterThanOrEqual(deepest, 400, "didn't page far enough down") + assertHoldsStill() + + var top = false + for _ in 0..<60 where !top { + scrollTowardTop() + rest(1) + top = row("i0000").exists && abs(row("i0000").frame.minY - list.frame.minY) <= 1.5 + } + XCTAssertTrue(top, "never got back to the first issue") + } +} diff --git a/swift/TestHost/project.yml b/swift/TestHost/project.yml new file mode 100644 index 0000000000..29653fea76 --- /dev/null +++ b/swift/TestHost/project.yml @@ -0,0 +1,54 @@ +# A test app for ZeroVirtual's scroll view adapters: a chat that sticks to +# the bottom, and a long list, on data from a LoopbackServer (no backend). +# Generate the Xcode project with XcodeGen (`brew install xcodegen`): +# `xcodegen generate`, then open TestHost.xcodeproj. The UI tests run in +# .github/workflows/zbugs-screenshots.yml. +name: TestHost +options: + bundleIdPrefix: dev.rocicorp + deploymentTarget: + iOS: '26.0' + macOS: '26.0' +packages: + Zero: + path: .. +targets: + TestHost: + type: application + supportedDestinations: [iOS, macOS] + scheme: + testTargets: [TestHostUITests] + sources: + - path: App + dependencies: + - package: Zero + product: ZeroVirtual + - package: Zero + product: ZeroUI + - package: Zero + product: ZeroTesting + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: dev.rocicorp.zero.testhost + SWIFT_VERSION: '6.0' + GENERATE_INFOPLIST_FILE: YES + INFOPLIST_KEY_CFBundleDisplayName: Zero Test Host + INFOPLIST_KEY_UILaunchScreen_Generation: YES + INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES + TARGETED_DEVICE_FAMILY: '1,2' + CODE_SIGN_STYLE: Automatic + ENABLE_USER_SCRIPT_SANDBOXING: YES + TestHostUITests: + type: bundle.ui-testing + supportedDestinations: [iOS, macOS] + sources: + - path: UITests + dependencies: + - target: TestHost + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: dev.rocicorp.zero.testhost.uitests + SWIFT_VERSION: '6.0' + GENERATE_INFOPLIST_FILE: YES + TEST_TARGET_NAME: TestHost + CODE_SIGN_STYLE: Automatic diff --git a/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift b/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift new file mode 100644 index 0000000000..be661be4b4 --- /dev/null +++ b/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift @@ -0,0 +1,224 @@ +import Testing + +@testable import ZeroVirtual + +/// A SwiftUI scroll view as `ScrollGeometryHost` sees it: geometry reported +/// after each layout pass, row frames before the scroll view's own, a +/// requested scroll applied on the next pass, and deferred work run between +/// passes. +@MainActor +final class GeometryScrollView { + let host = ScrollGeometryHost() + let h: Harness + var containerHeight: Double + let insetTop: Double + var heights: [String: Double] = [:] + private var offset = 0.0 + private var requested: Double? + private var deferred: [@MainActor () -> Void] = [] + private var snapshot = VirtualSnapshot() + private(set) var afterUpdates = 0 + + init(_ h: Harness, containerHeight: Double = 400, insetTop: Double = 0) { + self.h = h + self.containerHeight = containerHeight + self.insetTop = insetTop + // At rest at the top, the inset is above the content. + offset = -insetTop + host.requestScroll = { [unowned self] in requested = $0 } + host.defer_ = { [unowned self] in deferred.append($0) } + host.didScroll = { [unowned self] in self.h.core.didScroll() } + host.viewportDidResize = { [unowned self] in self.h.core.viewportDidResize() } + host.afterUpdate = { [unowned self] in + afterUpdates += 1 + self.h.core.afterUpdate() + } + h.core.attach(host) + } + + private func height(_ key: String) -> Double { heights[key] ?? h.view.rowHeight } + + private var contentHeight: Double { + snapshot.spaceBefore + snapshot.items.reduce(0) { $0 + height($1.key) } + snapshot.spaceAfter + } + + /// One body evaluation and layout pass, then the work it deferred. + func pass() { + h.core.setRows(h.answer(h.core.queryInputs)) + let next = h.core.snapshot + let changed = next.items.map(\.key) != snapshot.items.map(\.key) + snapshot = next + if changed { host.render(snapshot.items.map { (key: $0.key, index: $0.index) }) } + var y = snapshot.spaceBefore + for item in snapshot.items { + host.rowFrame(item.key, minY: y, maxY: y + height(item.key)) + y += height(item.key) + } + let maxOffset = max(0, contentHeight - (containerHeight - insetTop)) - insetTop + if let target = requested { + requested = nil + offset = target - insetTop + } + offset = max(-insetTop, min(offset, maxOffset)) + host.geometry( + contentOffset: offset, containerHeight: containerHeight, contentHeight: contentHeight, + insetTop: insetTop, insetBottom: 0) + let work = deferred + deferred = [] + work.forEach { $0() } + } + + func settle(sourceLocation: SourceLocation = #_sourceLocation) { + for _ in 0..<30 { + let before = (h.queryKey(h.core.queryInputs), offset, requested) + pass() + if (h.queryKey(h.core.queryInputs), offset, requested) == before, deferred.isEmpty, + h.core.snapshot.complete + { + return + } + } + Issue.record("did not settle", sourceLocation: sourceLocation) + } + + func userScroll(_ y: Double) { + offset = y - insetTop + pass() + } + + /// Where the row is from the viewport's top. + func rowTop(_ key: String) -> Double? { + host.rowFrames().first { $0.key == AnyHashable(key) }.map { $0.minY - host.scrollOffset } + } + + var bottomGap: Double { host.contentHeight - host.scrollOffset - host.viewportHeight } +} + +@MainActor +@Suite("SwiftUI geometry host") struct ScrollGeometryHostTests { + @Test("the update waits for every row to be measured and the content to reach them") + func waitsForMeasurement() { + let host = ScrollGeometryHost() + var deferred: [@MainActor () -> Void] = [] + var updates = 0 + host.defer_ = { deferred.append($0) } + host.afterUpdate = { updates += 1 } + func run() { + let work = deferred + deferred = [] + work.forEach { $0() } + } + + host.render([(key: "a", index: 0), (key: "b", index: 1)]) + host.rowFrame("a", minY: 0, maxY: 20) + run() + #expect(updates == 0) + host.rowFrame("b", minY: 20, maxY: 50) + run() + // The rows are in, the scroll view hasn't reported content that fits them. + #expect(updates == 0) + host.geometry( + contentOffset: 0, containerHeight: 100, contentHeight: 50, insetTop: 0, insetBottom: 0) + run() + #expect(updates == 1) + #expect(host.rowFrames().map(\.maxY) == [20, 50]) + } + + @Test("its own scroll lands and is reported; the user's is reported once") + func reportsScrolls() { + let host = ScrollGeometryHost() + var requested: [Double] = [] + var scrolls = 0 + host.requestScroll = { requested.append($0) } + host.didScroll = { scrolls += 1 } + host.geometry( + contentOffset: -10, containerHeight: 110, contentHeight: 1000, insetTop: 10, insetBottom: 0) + #expect(host.scrollOffset == 0) + #expect(host.viewportHeight == 100) + #expect(scrolls == 0) + + host.scroll(to: 2000) + #expect(requested == [900]) + #expect(host.scrollOffset == 900) + // Landing where it already says it is still tells the core. + host.geometry( + contentOffset: 890, containerHeight: 110, contentHeight: 1000, insetTop: 10, insetBottom: 0) + #expect(scrolls == 1) + host.geometry( + contentOffset: 890, containerHeight: 110, contentHeight: 1000, insetTop: 10, insetBottom: 0) + #expect(scrolls == 1) + host.geometry( + contentOffset: 490, containerHeight: 110, contentHeight: 1000, insetTop: 10, insetBottom: 0) + #expect(scrolls == 2) + #expect(host.scrollOffset == 500) + } + + @Test("pages down and back up, the top row holding still as pages come and go") + func pages() { + let h = Harness(rowCount: 1000) { $0.anchoring = .manual } + let view = GeometryScrollView(h, containerHeight: 430, insetTop: 30) + view.settle() + #expect(h.core.snapshot.items.first?.key == "r0") + + for _ in 0..<40 { + view.userScroll(view.host.scrollOffset + 300) + view.settle() + } + let key = "r600" + #expect(view.rowTop(key) != nil) + let top = view.rowTop(key) + view.settle() + #expect(view.rowTop(key) == top) + + for _ in 0..<40 { + view.userScroll(view.host.scrollOffset - 300) + view.settle() + } + #expect(h.core.snapshot.items.first?.key == "r0") + #expect(view.host.scrollOffset == 0) + } + + @Test("rows taller than estimated above the viewport don't move it") + func dynamicHeights() { + let h = Harness(rowCount: 300) { $0.anchoring = .manual } + let view = GeometryScrollView(h) + view.settle() + view.userScroll(2000) + view.settle() + let key = h.core.firstVisibleItem()!.key + let top = view.rowTop(key)! + let index = h.core.firstVisibleItem()!.index + // Rows above it, rendered as overscan, turn out taller. + for i in max(0, index - 4).. Bool, sourceLocation: SourceLocation = #_sourceLocation + ) async throws { + for _ in 0..<500 { + if done() { return } + try await Task.sleep(for: .milliseconds(10)) + } + Issue.record("timed out", sourceLocation: sourceLocation) + } + + @Test func pagesOverALoopbackServerOnRealTime() async throws { + let server = LoopbackServer() + func issue(_ i: Int) -> [String: JSONValue] { + [ + "id": .string(Self.id(i)), "title": "", "description": "", "closed": .bool(false), + "owner_id": .null, "createdAt": 1, + ] + } + server.put("issues", (0..<150).map(issue)) + var options = ZeroOptions( + cacheURL: "http://zero.test", userID: "u", storageKey: randomID(), storage: .memory) + options.logSink = RecordingLogSink() + let zero = try Zero(schema: .test, options: options, socketFactory: server) + let list = List( + zero: zero, listContext: nil, estimatedRowHeight: 20, rowKey: \.id, toStartRow: \.id, + pageQuery: { r, _ in + Self.page(PageArgs(limit: r.limit, start: r.start, backward: r.direction == .backward)) + }, + rowQuery: { r, _ in Self.byID(r.id) }, minPageSize: 100) + let view = ListView() + list.attach(view) + view.render(list) + list.afterUpdate() + try await eventually { list.snapshot.complete && list.snapshot.items.count == 100 } + #expect(list.snapshot.items.first?.key == Self.id(0)) + + // Rows the server adds later sync, and the list pages to them. + server.put("issues", (150..<160).map(issue)) + view.scroll(list, to: .infinity) + for _ in 0..<20 where list.snapshot.items.last?.key != Self.id(159) { + view.render(list) + list.afterUpdate() + view.scroll(list, to: .infinity) + try await Task.sleep(for: .milliseconds(50)) + } + #expect(list.snapshot.items.last?.key == Self.id(159)) + #expect(list.snapshot.complete) + #expect(list.error == nil) + withExtendedLifetime(zero) {} + } } /// A server that sends every row at first, then reports each query the From 05b5f8e1060d4ce007236cdea8944b1e1c01bafe Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 20 Oct 2026 02:17:44 +0000 Subject: [PATCH 058/104] 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 Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 --- swift/BENCHMARKS.md | 127 +++++++++++++--------- swift/Sources/Zero/IVM/MemorySource.swift | 77 ++++++++++--- swift/Sources/Zero/IVM/View.swift | 12 +- swift/Sources/Zero/JSON/Row.swift | 7 +- swift/Sources/Zero/Query/Predicate.swift | 25 ++++- swift/Sources/Zero/Util/BTree.swift | 28 +++-- 6 files changed, 188 insertions(+), 88 deletions(-) diff --git a/swift/BENCHMARKS.md b/swift/BENCHMARKS.md index 81bdc6fb0b..5ba74d9347 100644 --- a/swift/BENCHMARKS.md +++ b/swift/BENCHMARKS.md @@ -32,10 +32,14 @@ gate on. CI builds them, so they keep compiling. ## Results Medians; lower is better. The iPhone is where the client ships, so its -numbers come first. In short: on the read path (zero.bench, materializing) -JS in WebKit is ahead of Swift on the iPhone, while Swift is well ahead on -optimistic updates, memory, hashing, and B-tree scans. Linux, where JS is -slowest, flatters Swift. +numbers come first. In short: on the iPhone, Swift is ahead of JS in +WebKit on the read path (zero.bench, materializing), optimistic updates, +memory, hashing, and B-tree scans. JS is still ahead on `pk = N`, which is +mostly the cost of setting up a query's pipeline, and on B-tree lookups. +Linux, where JS is slowest, flatters Swift. + +Cells marked † are from before the last performance pass (see What they +found) and weren't measured again. ### iPhone @@ -49,30 +53,30 @@ first; each cell is the mean of the two rounds. Swift's rounds agreed within 3%. JS's materialize rows varied by about 30% between rounds, WebKit's timer has a 20 µs grain, and WebKit gives JS no memory figure. -| Benchmark | JS (WebKit) | Swift | -| ------------------------------------------ | ----------- | ------- | -| zero.bench: all 1000 rows x 10 columns | 65.7 µs | 95.7 µs | -| zero.bench: pk = N | 2.34 µs | 6.48 µs | -| zero.bench: lower 500 rows | 79.5 µs | 111 µs | -| materialize 1,000 rows | 85 µs | 94.6 µs | -| materialize 10,000 rows | 807 µs | 902 µs | -| materialize 100,000 rows | 8.22 ms | 10.1 ms | -| memory for 100,000 rows and a view of them | n/a | 160 MB | -| optimistic update with a 1000-row view | 111 µs | 12.3 µs | +| Benchmark | JS (WebKit) | Swift | +| ------------------------------------------ | ----------- | -------- | +| zero.bench: all 1000 rows x 10 columns | 65.7 µs | 41.8 µs | +| zero.bench: pk = N | 2.34 µs | 6.25 µs | +| zero.bench: lower 500 rows | 79.5 µs | 54.4 µs | +| materialize 1,000 rows | 85 µs | 41.5 µs | +| materialize 10,000 rows | 807 µs | 366 µs | +| materialize 100,000 rows | 8.22 ms | 5.16 ms | +| memory for 100,000 rows and a view of them | n/a | 160 MB† | +| optimistic update with a 1000-row view | 111 µs | 12.3 µs† | | Benchmark | JS (WebKit) | Swift | | -------------------------------------- | -------------------------- | ------------------------ | -| BTreeSet values() full scan (1000) | 5.56 µs | 2.54 µs | -| BTreeSet valuesFrom() from mid | 2.04 µs | 1.33 µs | -| BTreeSet valuesReversed() full scan | 4.05 µs | 1.86 µs | -| BTreeSet valuesFromReversed() from mid | 1.99 µs | 1.02 µs | -| BTreeSet has() hit (x3) | 46 ns | 141 ns | -| BTreeSet has() miss (x3) | 21 ns | 63 ns | -| BTreeSet get() hit (x3) | 45 ns | 144 ns | -| BTreeSet add() then delete() | 85 ns | 90 ns | -| BTreeSet add() 100 sequential keys | 4.44 µs | 3.72 µs | -| BTreeSet add() 1000 sequential keys | 52.0 µs | 50.1 µs | -| BTreeSet sort, then add() 1000 keys | 98.2 µs | 46.4 µs | +| BTreeSet values() full scan (1000) | 5.56 µs | 708 ns | +| BTreeSet valuesFrom() from mid | 2.04 µs | 410 ns | +| BTreeSet valuesReversed() full scan | 4.05 µs | 575 ns | +| BTreeSet valuesFromReversed() from mid | 1.99 µs | 274 ns | +| BTreeSet has() hit (x3) | 46 ns | 69 ns | +| BTreeSet has() miss (x3) | 21 ns | 28 ns | +| BTreeSet get() hit (x3) | 45 ns | 71 ns | +| BTreeSet add() then delete() | 85 ns | 72 ns | +| BTreeSet add() 100 sequential keys | 4.44 µs | 3.72 µs† | +| BTreeSet add() 1000 sequential keys | 52.0 µs | 47.1 µs | +| BTreeSet sort, then add() 1000 keys | 98.2 µs | 43.9 µs | | h32, 64 / 1024 / 8192 chars | 156 ns / 1.13 µs / 2.88 µs | 15 ns / 142 ns / 1.10 µs | | h64, 64 / 1024 / 8192 chars | 155 ns / 1.19 µs / 3.56 µs | 42 ns / 262 ns / 2.14 µs | | h128, 64 / 1024 / 8192 chars | 232 ns / 1.45 µs / 5.74 µs | 47 ns / 539 ns / 4.42 µs | @@ -86,22 +90,21 @@ the iPhone ran are 14–40% faster than vitest on the client rows. | Benchmark | Mac JS | Mac Swift | Linux JS | Linux Swift | | ------------------------------------------ | ---------- | --------- | -------- | ----------- | -| zero.bench: all 1000 rows x 10 columns | 141 µs | 135 µs | 263 µs | 124 µs | -| zero.bench: pk = N | 5.42 µs | 7.00 µs | 7.72 µs | 8.47 µs | -| zero.bench: lower 500 rows | 169 µs | 160 µs | 261 µs | 167 µs | -| materialize 1,000 rows | 133 µs | 131 µs | 282 µs | 111 µs | -| materialize 10,000 rows | 1.27 ms | 1.28 ms | 2.70 ms | 1.21 ms | -| materialize 100,000 rows | 16.3 ms | 14.0 ms | 43.2 ms | 11.5 ms | -| memory for 100,000 rows and a view of them | 240–258 MB | 162 MB | 164 MB | 128 MB | -| optimistic update with a 1000-row view | 120 µs | 14.7 µs | 178 µs | 23 µs | +| zero.bench: all 1000 rows x 10 columns | 141 µs | 56.1 µs | 263 µs | 70.4 µs | +| zero.bench: pk = N | 5.42 µs | 6.82 µs | 7.72 µs | 8.39 µs | +| zero.bench: lower 500 rows | 169 µs | 69.6 µs | 261 µs | 89.7 µs | +| materialize 1,000 rows | 133 µs | 53.6 µs | 282 µs | 63.2 µs | +| materialize 10,000 rows | 1.27 ms | 507 µs | 2.70 ms | 757 µs | +| materialize 100,000 rows | 16.3 ms | 6.76 ms | 43.2 ms | 7.07 ms | +| memory for 100,000 rows and a view of them | 240–258 MB | 162 MB† | 164 MB | 128 MB† | +| optimistic update with a 1000-row view | 120 µs | 14.7 µs† | 178 µs | 23 µs† | | Benchmark | Mac JS | Mac Swift | Linux JS | Linux Swift | | ----------------------------------- | ------- | --------- | -------- | ----------- | -| BTreeSet values() full scan (1000) | 2.67 µs | 2.83 µs | 3.60 µs | 2.71 µs | -| BTreeSet valuesReversed() full scan | 2.33 µs | 2.49 µs | 4.29 µs | 0.99 µs | -| BTreeSet get() hit (x3) | 62 ns | 153 ns | 70 ns | 129 ns | -| BTreeSet add() 1000 sequential keys | 46.5 µs | 58.0 µs | 63.6 µs | 64.3 µs | -| BTreeSet sort, then add() 1000 keys | 94 µs | 54 µs | 104 µs | 38 µs | +| BTreeSet values() full scan (1000) | 2.67 µs | 720 ns | 3.60 µs | 2.51 µs | +| BTreeSet has() hit (x3) | 63 ns | 86 ns | 67 ns | 132 ns | +| BTreeSet add() 1000 sequential keys | 46.5 µs | 50.3 µs | 63.6 µs | 65.3 µs | +| BTreeSet sort, then add() 1000 keys | 94 µs | 47.6 µs | 104 µs | 38 µs | | h64, 1024 chars | 5.26 µs | 302 ns | 5.46 µs | 318 ns | As `hash.bench.ts` notes, vitest keeps imported functions from being @@ -110,19 +113,19 @@ times faster. ### Swift only -| Benchmark | iPhone | Mac | Linux | -| --------------------------------------------------------- | ------- | ------- | ------- | -| materialize 1,000 rows as structs | 210 µs | 265 µs | 256 µs | -| materialize 10,000 rows as structs | 2.15 ms | 2.61 ms | 2.86 ms | -| materialize 100,000 rows as structs | 23.0 ms | 28.5 ms | 29.7 ms | -| optimistic update with a 1000-row live query of structs | 61 µs | 78 µs | 91 µs | -| poke putting 1 row, 1000-row view | 16 µs | 19 µs | 29 µs | -| poke putting 100 rows, 1000-row view | 551 µs | 695 µs | 1.00 ms | -| poke putting 1000 rows, 1000-row view | 5.13 ms | 6.58 ms | 9.82 ms | -| round trip (update, push, confirming poke), 1000-row view | 37 µs | 42 µs | 80 µs | +| Benchmark | iPhone | Mac | Linux | +| --------------------------------------------------------- | -------- | -------- | -------- | +| materialize 1,000 rows as structs | 138 µs | 148 µs | 182 µs | +| materialize 10,000 rows as structs | 1.29 ms | 2.61 ms† | 1.94 ms | +| materialize 100,000 rows as structs | 14.7 ms | 16.6 ms | 24.8 ms | +| optimistic update with a 1000-row live query of structs | 61 µs† | 78 µs† | 91 µs† | +| poke putting 1 row, 1000-row view | 16 µs† | 19 µs† | 29 µs† | +| poke putting 100 rows, 1000-row view | 551 µs† | 695 µs† | 1.00 ms† | +| poke putting 1000 rows, 1000-row view | 5.13 ms† | 6.58 ms† | 9.82 ms† | +| round trip (update, push, confirming poke), 1000-row view | 37 µs† | 42 µs† | 80 µs† | JS views hand out their rows; Swift apps read them as structs, which costs -a little over twice the materialization. A live query reads only the rows +about three times the materialization. A live query reads only the rows that changed after the first read. ## What they found @@ -154,7 +157,25 @@ that changed after the first read. rows" there (243 µs, now 135 µs; 121 µs on the iPhone, now 96 µs). It now reads `a` by index, as JS reads `row.a`. -Where Swift is still slower: materializing on Apple platforms, where a -profile on the Mac puts about a third of the time in retain/release and a -seventh in exclusivity checks; and B-tree lookups, which call their -comparator through a closure that JS engines inline. +- On Apple platforms, where most of the time went: + - B-tree nodes held `Array`s, which check for NSArray bridging on every + read. They now hold `ContiguousArray`s: scans are 3.6–4.5× faster, + lookups about 2×. + - A row read its layout through a stored class property, an exclusivity + check each time. + - A source's scans were closures over captured variables, each access + checked, behind an `AnyIterator` that called the B-tree iterator's + unspecialized `next()`. They're struct iterators now. + - Hydration inserted each row at the end of the list, which copies it; + appending moves it. + - A filter hashed its column's name for every row; it now looks the + column's index up once per row layout. + + Together, on the iPhone: "all 1000 rows" 95.6 → 41.8 µs, "lower 500" + 115 → 54.4 µs, materializing 1,000 / 10,000 / 100,000 rows 2.0–2.5× + faster, and 1.6–1.8× as structs. The Mac moved the same way; Linux, where + `Array` doesn't bridge, gained on the read path but not in the B-tree. + +Where Swift is still slower: `pk = N`, whose time is spread over setting up +a query's pipeline with no single hotspot; and B-tree lookups, which call +their comparator through a closure that JS engines inline. diff --git a/swift/Sources/Zero/IVM/MemorySource.swift b/swift/Sources/Zero/IVM/MemorySource.swift index 762a46b661..636204b8dd 100644 --- a/swift/Sources/Zero/IVM/MemorySource.swift +++ b/swift/Sources/Zero/IVM/MemorySource.swift @@ -142,7 +142,7 @@ package final class MemorySource: Source { package var indexKeys: [String] { indexOrder } func fetch(_ req: FetchRequest, _ conn: Connection) -> NodeStream { - NodeStream { [self] in AnyIterator(self.fetchIterator(req, conn)) } + NodeStream { [self] in self.fetchIterator(req, conn) } } private func fetchIterator(_ req: FetchRequest, _ conn: Connection) -> AnyIterator { @@ -207,24 +207,12 @@ package final class MemorySource: Source { probe = nil } - let rows: AnyIterator = - req.reverse - ? AnyIterator(data.valuesReversed(from: probe)) : AnyIterator(data.values(from: probe)) + let rows = TreeRows(data, from: probe, reverse: req.reverse) // Fast path: no overlay, no start, and no filters. let overlayActive = overlay.map { conn.lastPushedEpoch >= $0.epoch } ?? false if !overlayActive && req.start == nil && conn.filters == nil && req.filter == nil { - let constraint = req.constraint - var done = false - return AnyIterator { - if done { return nil } - guard let row = rows.next() else { return nil } - if let constraint, !constraint.matches(row) { - done = true - return nil - } - return Node(row: row) - } + return AnyIterator(ScanIterator(rows: rows, constraint: req.constraint)) } // `req.constraint` and not the primary key constraint, which comes from @@ -232,6 +220,7 @@ package final class MemorySource: Source { let rowsIterable: AnyIterator = if pkConstraint != nil { { () -> AnyIterator in + var rows = rows var taken = false return AnyIterator { if taken { return nil } @@ -240,7 +229,7 @@ package final class MemorySource: Source { } }() } else { - rows + AnyIterator(rows) } let withOverlay = generateWithOverlay( startAt: startAt, rows: rowsIterable, constraint: req.constraint, overlay: overlay, @@ -608,11 +597,27 @@ func applyMultiConstraintsToOverlays( func generateWithOverlayInner( _ rows: AnyIterator, _ overlays: MemorySource.Overlays, _ compare: @escaping Comparator ) -> AnyIterator { + AnyIterator(OverlayIterator(rows: rows, overlays: overlays, compare: compare)) +} + +/// `generateWithOverlayInner`'s generator. A struct, so that its state isn't +/// variables captured by a closure, each access to which checks exclusivity. +private struct OverlayIterator: IteratorProtocol { + let rows: AnyIterator + let overlays: MemorySource.Overlays + let compare: Comparator var addOverlayYielded = false var removeOverlaySkipped = false var pending: Row? var finished = false - return AnyIterator { + + init(rows: AnyIterator, overlays: MemorySource.Overlays, compare: @escaping Comparator) { + self.rows = rows + self.overlays = overlays + self.compare = compare + } + + mutating func next() -> Node? { if let p = pending { pending = nil return Node(row: p) @@ -711,3 +716,41 @@ func mergeSortedStreams( } } } + +/// A tree's rows, ascending or descending. A struct rather than an +/// `AnyIterator` of the tree's iterator, which would call its unspecialized +/// `next()`, or a closure capturing it, each call of which checks exclusivity. +private struct TreeRows: IteratorProtocol { + var forward: BTreeSet.ForwardIterator? + var reverse: BTreeSet.ReverseIterator? + + init(_ data: BTreeSet, from probe: ((Row) -> Int)?, reverse: Bool) { + if reverse { + self.reverse = data.valuesReversed(from: probe) + } else { + forward = data.values(from: probe) + } + } + + mutating func next() -> Row? { + forward != nil ? forward!.next() : reverse!.next() + } +} + +/// The rows of a fetch without overlay, start, or filters, up to the first +/// that doesn't match `constraint`. +private struct ScanIterator: IteratorProtocol { + var rows: TreeRows + let constraint: Constraint? + var done = false + + mutating func next() -> Node? { + if done { return nil } + guard let row = rows.next() else { return nil } + if let constraint, !constraint.matches(row) { + done = true + return nil + } + return Node(row: row) + } +} diff --git a/swift/Sources/Zero/IVM/View.swift b/swift/Sources/Zero/IVM/View.swift index fefd25034e..e4e0c16b23 100644 --- a/swift/Sources/Zero/IVM/View.swift +++ b/swift/Sources/Zero/IVM/View.swift @@ -253,7 +253,7 @@ enum ViewApply { } else { var entry = ViewEntry(row: node.row) initializeRelationships(&entry, node, schema, format.relationships) - list.insert(entry, at: ~pos) + list.insertOrAppend(entry, at: ~pos) } } parent.relationships[relationship] = .many(list) @@ -302,7 +302,7 @@ enum ViewApply { } else { var e = ViewEntry(row: child.row) initializeRelationships(&e, child, childSchema, childFormat.relationships) - list.insert(e, at: ~pos) + list.insertOrAppend(e, at: ~pos) } } entry.relationships[name] = .many(list) @@ -494,3 +494,11 @@ extension Format: JSONCodable { return ["singular": .bool(singular), "relationships": .object(rels)] } } + +extension Array { + /// `insert(_:at:)`, appending at the end, where hydration adds rows. Unlike + /// `insert`, `append` moves the entry in rather than copying it. + mutating func insertOrAppend(_ entry: ViewEntry, at i: Int) { + if i == count { append(entry) } else { insert(entry, at: i) } + } +} diff --git a/swift/Sources/Zero/JSON/Row.swift b/swift/Sources/Zero/JSON/Row.swift index 9b2c10b6eb..60a984b810 100644 --- a/swift/Sources/Zero/JSON/Row.swift +++ b/swift/Sources/Zero/JSON/Row.swift @@ -82,13 +82,16 @@ public struct Row: @unchecked Sendable { slots: (0.. RowPre return { _ in result } } guard case .column(let name) = left else { preconditionFailure() } - return { row in impl(row[name]) } + let column = ColumnReader(name) + return { row in impl(column.read(row)) } default: break } @@ -59,13 +60,33 @@ func createPredicate(_ condition: AST.Condition) throws(PipelineError) -> RowPre return { _ in result } } guard case .column(let name) = left else { preconditionFailure() } + let column = ColumnReader(name) return { row in - guard let lhs = row[name], lhs != .null else { return false } + guard let lhs = column.read(row), lhs != .null else { return false } return impl(lhs) } } } +/// Reads a column of rows, looking its index up once for each layout +/// rather than hashing its name for each row. +final class ColumnReader { + let name: String + // Unchecked: the engine runs pipelines on the main actor, so no read of + // the cache overlaps a write to it. + @exclusivity(unchecked) private var column: ColumnRef? + + init(_ name: String) { self.name = name } + + func read(_ row: Row) -> Value? { + let layout = row.layout + if let column, column.layout === layout { return row[column: column] } + let column = layout.column(name) + self.column = column + return row[column: column] + } +} + /// `createPredicateImpl`: `lhs` and `rhs` are not null. func createPredicateImpl( _ rhs: Value, _ op: AST.SimpleOperator diff --git a/swift/Sources/Zero/Util/BTree.swift b/swift/Sources/Zero/Util/BTree.swift index 8444a00018..eedb5e92f7 100644 --- a/swift/Sources/Zero/Util/BTree.swift +++ b/swift/Sources/Zero/Util/BTree.swift @@ -12,11 +12,11 @@ package struct BTreeSet { // writing them, so no access to a node overlaps a write to it, and the // checks cost more than the reads (`ForwardIterator`). /// Leaf: the elements. Internal: the highest element of each child. - @exclusivity(unchecked) @usableFromInline var keys: [Element] + @exclusivity(unchecked) @usableFromInline var keys: ContiguousArray /// Empty for a leaf. - @exclusivity(unchecked) @usableFromInline var children: [Node] + @exclusivity(unchecked) @usableFromInline var children: ContiguousArray - @inlinable init(keys: [Element], children: [Node] = []) { + @inlinable init(keys: ContiguousArray, children: ContiguousArray = []) { self.keys = keys self.children = children } @@ -47,8 +47,8 @@ package struct BTreeSet { where S.Element == Element { self.compare = compare let max = Self.maxNodeSize - var level: [Node] = [] - var keys: [Element] = [] + var level: ContiguousArray = [] + var keys: ContiguousArray = [] keys.reserveCapacity(max) var n = 0 for e in elements { @@ -61,11 +61,14 @@ package struct BTreeSet { } if !keys.isEmpty || level.isEmpty { level.append(Node(keys: keys)) } while level.count > 1 { - var next: [Node] = [] + var next: ContiguousArray = [] var i = 0 while i < level.count { - let children = Array(level[i.. { if node.keys.count <= maxNodeSize { return nil } let half = node.keys.count / 2 let right = Node( - keys: Array(node.keys[half...]), children: node.isLeaf ? [] : Array(node.children[half...])) + keys: ContiguousArray(node.keys[half...]), + children: node.isLeaf ? [] : ContiguousArray(node.children[half...])) node.keys.removeSubrange(half...) if !node.isLeaf { node.children.removeSubrange(half...) } return right @@ -226,7 +230,7 @@ package struct BTreeSet { @usableFromInline var path: [(Node, Int)] = [] @usableFromInline var leaf: Node? /// The leaf's keys, read once per leaf. - @usableFromInline var keys: [Element] = [] + @usableFromInline var keys: ContiguousArray = [] @usableFromInline var index = 0 @inlinable @@ -341,7 +345,7 @@ func lowerBound(_ keys: UnsafeBufferPointer, _ cmp: (E) -> Int) -> Int { /// The first index whose element compares `>= 0`. @inlinable -func lowerBound(_ keys: [E], _ cmp: (E) -> Int) -> Int { +func lowerBound(_ keys: ContiguousArray, _ cmp: (E) -> Int) -> Int { var low = 0 var high = keys.count while low < high { @@ -353,7 +357,7 @@ func lowerBound(_ keys: [E], _ cmp: (E) -> Int) -> Int { /// The first index whose element compares `> 0`. @inlinable -func upperBound(_ keys: [E], _ cmp: (E) -> Int) -> Int { +func upperBound(_ keys: ContiguousArray, _ cmp: (E) -> Int) -> Int { var low = 0 var high = keys.count while low < high { From 5b9c8dc63850b97a5c2ff9101cea4ad7f75bab9d Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 20 Oct 2026 02:17:45 +0000 Subject: [PATCH 059/104] 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 --- swift/Package.swift | 3 +- .../ZeroVirtual/ScrollGeometryHost.swift | 3 +- swift/Sources/ZeroVirtual/VirtualList.swift | 25 ++-- .../Sources/ZeroVirtual/ZeroVirtualizer.swift | 5 + swift/TestHost/App/Screens.swift | 12 ++ swift/Tests/AppleTests/VirtualListTests.swift | 121 ++++++++++++++++++ .../ScrollGeometryHostTests.swift | 20 ++- 7 files changed, 167 insertions(+), 22 deletions(-) create mode 100644 swift/Tests/AppleTests/VirtualListTests.swift diff --git a/swift/Package.swift b/swift/Package.swift index 3b2a07bb4d..7ad8de3fcf 100644 --- a/swift/Package.swift +++ b/swift/Package.swift @@ -40,7 +40,8 @@ let package = Package( .testTarget(name: "ConformanceTests", dependencies: ["Zero", "ZeroTesting"]), // The Apple layer: the URLSession transport, SwiftUI, UIKit, and AppKit. // Empty elsewhere. - .testTarget(name: "AppleTests", dependencies: ["Zero", "ZeroUI", "ZeroTesting"]), + .testTarget( + name: "AppleTests", dependencies: ["Zero", "ZeroUI", "ZeroVirtual", "ZeroTesting"]), .testTarget( name: "ZeroVirtualTests", dependencies: ["Zero", "ZeroVirtual", "ZeroTesting"], exclude: ["UPSTREAM.md"]), diff --git a/swift/Sources/ZeroVirtual/ScrollGeometryHost.swift b/swift/Sources/ZeroVirtual/ScrollGeometryHost.swift index b4ed50cda1..ea935ef790 100644 --- a/swift/Sources/ZeroVirtual/ScrollGeometryHost.swift +++ b/swift/Sources/ZeroVirtual/ScrollGeometryHost.swift @@ -55,7 +55,8 @@ package final class ScrollGeometryHost: VirtualHost { requestScroll(offset) } - /// The view rendered a snapshot with these rows. + /// The view rendered a snapshot with these rows: every snapshot, even one + /// with the same rows, since the core waits on `afterUpdate` for it. package func render(_ keys: [(key: Key, index: Int)]) { items = keys.map { (AnyHashable($0.key), $0.index) } let live = Set(items.map(\.key)) diff --git a/swift/Sources/ZeroVirtual/VirtualList.swift b/swift/Sources/ZeroVirtual/VirtualList.swift index 36e26fa558..7570887a15 100644 --- a/swift/Sources/ZeroVirtual/VirtualList.swift +++ b/swift/Sources/ZeroVirtual/VirtualList.swift @@ -59,18 +59,23 @@ contentOffset: g.offset, containerHeight: g.container, contentHeight: g.content, insetTop: g.top, insetBottom: g.bottom) } - .onChange(of: Rendered(snapshot), initial: true) { [host] in - host.render(snapshot.items.map { (key: $0.key, index: $0.index) }) - } .onAppear { + let render = { [host, list] in + host.render(list.snapshot.items.map { (key: $0.key, index: $0.index) }) + } + list.onSnapshot = render host.requestScroll = { y in position.scrollTo(y: y) } host.didScroll = { [list] in list.didScroll() } host.viewportDidResize = { [list] in list.viewportDidResize() } host.afterUpdate = { [list] in list.afterUpdate() } list.anchoring = .manual list.attach(host) + render() + } + .onDisappear { + list.onSnapshot = nil + list.detach() } - .onDisappear { list.detach() } } private static var rows: String { "ZeroVirtual.VirtualList.rows" } @@ -88,16 +93,4 @@ bottom = g.contentInsets.bottom } } - - /// What a render of the snapshot laid out: a change needs measuring. - private struct Rendered: Equatable { - var keys: [AnyHashable] - var before, after: Double - - init(_ s: VirtualSnapshot) { - keys = s.items.map { AnyHashable($0.key) } - before = s.spaceBefore - after = s.spaceAfter - } - } #endif diff --git a/swift/Sources/ZeroVirtual/ZeroVirtualizer.swift b/swift/Sources/ZeroVirtual/ZeroVirtualizer.swift index fdcfa72565..2062f86c94 100644 --- a/swift/Sources/ZeroVirtual/ZeroVirtualizer.swift +++ b/swift/Sources/ZeroVirtual/ZeroVirtualizer.swift @@ -48,6 +48,10 @@ public final class ZeroVirtualizer< @ObservationIgnored private var after = Slot<[Row]>() @ObservationIgnored private var syncing = false @ObservationIgnored private var dirty = false + /// Called with each snapshot published: the adapter re-renders and calls + /// `afterUpdate` once it's laid out, as a React render does, even when + /// the rows are the same ones. + @ObservationIgnored package var onSnapshot: (() -> Void)? /// - Parameters: /// - listContext: What the list shows, such as its filter and sort. A @@ -224,6 +228,7 @@ public final class ZeroVirtualizer< } while dirty error = single.error ?? main.error ?? after.error snapshot = core.snapshot + onSnapshot?() } /// The slot for `request`: nil when `slot` already runs it. diff --git a/swift/TestHost/App/Screens.swift b/swift/TestHost/App/Screens.swift index 3021476da1..03fc18d360 100644 --- a/swift/TestHost/App/Screens.swift +++ b/swift/TestHost/App/Screens.swift @@ -18,10 +18,14 @@ struct ChatScreen: View { var body: some View { VStack(spacing: 0) { + // Off the safe area's edges, the scroll view's frame is its viewport, + // which the UI tests measure rows against. + Divider() VirtualList(list) { item in MessageRow(message: item.row) } .accessibilityIdentifier("list") + Divider() HStack { Button("Receive") { backend.receive() }.accessibilityIdentifier("receive") Button("Grow last") { backend.growLast() }.accessibilityIdentifier("grow") @@ -65,6 +69,14 @@ struct IssuesScreen: View { } var body: some View { + VStack(spacing: 0) { + Divider() + issues + Divider() + } + } + + private var issues: some View { VirtualList(list) { item in VStack(alignment: .leading, spacing: 2) { Text(item.row.title) diff --git a/swift/Tests/AppleTests/VirtualListTests.swift b/swift/Tests/AppleTests/VirtualListTests.swift new file mode 100644 index 0000000000..2337042e70 --- /dev/null +++ b/swift/Tests/AppleTests/VirtualListTests.swift @@ -0,0 +1,121 @@ +#if canImport(SwiftUI) + import Foundation + import SwiftUI + import Testing + import ZeroTesting + + @testable import Zero + @testable import ZeroVirtual + + #if canImport(UIKit) + import UIKit + #else + import AppKit + #endif + + /// `VirtualList` in a real window, on a `LoopbackServer`. + @Suite(.serialized) @MainActor struct VirtualListTests { + struct Page: Codable, Sendable { + var limit: Int + var start: String? + var backward: Bool + } + + static let page = defineQuery("notes.page") { (a: Page) in + var q = Notes.table.orderBy(\.id, a.backward ? .desc : .asc) + if let s = a.start { q = q.start([\.id: s]) } + return q.limit(a.limit) + } + static let byID = defineQuery("notes.byID") { (id: String) in + Notes.table.where(\.id, id).one() + } + + typealias List = ZeroVirtualizer + + static func id(_ i: Int) -> String { String(format: "n%03d", i) } + + func makeList() throws -> (Zero, List) { + let server = LoopbackServer() + server.put( + "note", (0..<300).map { ["id": .string(Self.id($0)), "text": .string("note \($0)")] }) + var options = ZeroOptions( + cacheURL: "http://loopback.test", userID: "u", storageKey: randomID(), storage: .memory) + options.logSink = RecordingLogSink() + let zero = try Zero(schema: .notes, options: options, socketFactory: server) + let list = List( + zero: zero, listContext: "", estimatedRowHeight: 30, rowKey: \.id, toStartRow: \.id, + pageQuery: { r, _ in + Self.page(Page(limit: r.limit, start: r.start, backward: r.direction == .backward)) + }, + rowQuery: { r, _ in Self.byID(r.id) }, minPageSize: 20) + return (zero, list) + } + + /// A 300 × 400 window showing `view`. + func show(_ view: some View) -> AnyObject { + #if canImport(UIKit) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 300, height: 400)) + window.rootViewController = UIHostingController(rootView: view) + window.makeKeyAndVisible() + return window + #else + let window = NSWindow( + contentRect: CGRect(x: 0, y: 0, width: 300, height: 400), styleMask: [.titled], + backing: .buffered, defer: false) + window.contentView = NSHostingView(rootView: view) + window.makeKeyAndOrderFront(nil) + return window + #endif + } + + /// Runs the main run loop until `done`, for at most `seconds`. + func run(_ seconds: Double, until done: () -> Bool) { + let end = Date().addingTimeInterval(seconds) + while Date() < end, !done() { RunLoop.main.run(until: Date().addingTimeInterval(0.02)) } + } + + /// The scroll view's offset from the content's top and the height it + /// shows content in, insets aside, as the platform has them. + func platformGeometry(_ window: AnyObject) -> (offset: Double, viewport: Double)? { + #if canImport(UIKit) + func find(_ v: UIView) -> UIScrollView? { + (v as? UIScrollView) ?? v.subviews.lazy.compactMap(find).first + } + return (window as? UIWindow).flatMap(find).map { + let inset = $0.adjustedContentInset + return ( + Double($0.contentOffset.y + inset.top), + Double($0.bounds.height - inset.top - inset.bottom) + ) + } + #else + func find(_ v: NSView) -> NSScrollView? { + (v as? NSScrollView) ?? v.subviews.lazy.compactMap(find).first + } + return (window as? NSWindow)?.contentView.flatMap(find).map { + let inset = $0.contentInsets + return ( + Double($0.contentView.bounds.origin.y + inset.top), + Double($0.contentView.bounds.height - inset.top - inset.bottom) + ) + } + #endif + } + + @Test func scrollsToARowFarBelow() throws { + let (zero, list) = try makeList() + let window = show(VirtualList(list) { Text($0.row.text).frame(height: 30) }) + run(5) { list.snapshot.complete && !list.snapshot.items.isEmpty } + #expect(list.snapshot.items.first?.key == Self.id(0)) + list.scrollToItem(Self.id(250), align: .end) + run(5) { list.lastVisibleItem()?.key == Self.id(250) } + run(0.5) { false } + #expect(list.lastVisibleItem()?.key == Self.id(250)) + // The scroll view itself moved there, not just the virtualizer's idea + // of it: n250's bottom (30-point rows) at the viewport's. + let g = try #require(platformGeometry(window)) + #expect(abs(g.offset - (251 * 30 - g.viewport)) <= 1) + withExtendedLifetime((zero, window)) {} + } + } +#endif diff --git a/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift b/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift index be661be4b4..deeba4a74a 100644 --- a/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift +++ b/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift @@ -45,10 +45,9 @@ final class GeometryScrollView { /// One body evaluation and layout pass, then the work it deferred. func pass() { h.core.setRows(h.answer(h.core.queryInputs)) - let next = h.core.snapshot - let changed = next.items.map(\.key) != snapshot.items.map(\.key) - snapshot = next - if changed { host.render(snapshot.items.map { (key: $0.key, index: $0.index) }) } + // Every snapshot is rendered, as `ZeroVirtualizer.onSnapshot` has it. + snapshot = h.core.snapshot + host.render(snapshot.items.map { (key: $0.key, index: $0.index) }) var y = snapshot.spaceBefore for item in snapshot.items { host.rowFrame(item.key, minY: y, maxY: y + height(item.key)) @@ -222,3 +221,16 @@ final class GeometryScrollView { #expect(view.host.scrollOffset == offset) } } + +extension ScrollGeometryHostTests { + @Test("scrolls to a row not loaded yet, under a top inset") + func scrollToItemFarBelow() { + let h = Harness(rowCount: 200) { $0.anchoring = .manual } + let view = GeometryScrollView(h, containerHeight: 459, insetTop: 59) + view.settle() + h.core.scrollToItem("r199", align: .end) + view.settle() + #expect(h.core.lastVisibleItem()?.key == "r199") + #expect(view.bottomGap == 0) + } +} From 44cf9f435d76d2f7cea7662408e7a4046c4d58c9 Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 20 Oct 2026 02:33:04 +0000 Subject: [PATCH 060/104] 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 --- .../ZeroVirtual/ScrollGeometryHost.swift | 4 +++- swift/TestHost/App/Screens.swift | 7 +++++-- swift/Tests/AppleTests/VirtualListTests.swift | 17 ++++++++++------- .../ScrollGeometryHostTests.swift | 7 +++++++ 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/swift/Sources/ZeroVirtual/ScrollGeometryHost.swift b/swift/Sources/ZeroVirtual/ScrollGeometryHost.swift index ea935ef790..edf3ddc0a7 100644 --- a/swift/Sources/ZeroVirtual/ScrollGeometryHost.swift +++ b/swift/Sources/ZeroVirtual/ScrollGeometryHost.swift @@ -107,8 +107,10 @@ package final class ScrollGeometryHost: VirtualHost { // A row not measured yet is laid out on a pass still coming; so is // content that doesn't reach the last row yet. Rows can report before // the scroll view does, and the core scrolls within `contentHeight`. + // A point of slack: the scroll view rounds its content size to pixels, + // and rows of text have fractional heights. guard items.allSatisfy({ heights[$0.key] != nil }), - contentHeight >= (rowFrames().last?.maxY ?? 0) + contentHeight + 1 >= (rowFrames().last?.maxY ?? 0) else { return } afterUpdate() } diff --git a/swift/TestHost/App/Screens.swift b/swift/TestHost/App/Screens.swift index 03fc18d360..ac1e94c0bf 100644 --- a/swift/TestHost/App/Screens.swift +++ b/swift/TestHost/App/Screens.swift @@ -55,7 +55,9 @@ struct MessageRow: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 12) .padding(.vertical, 6) - .accessibilityElement(children: .combine) + // One element with the padded row's frame (`.combine`'s is its texts'). + .accessibilityElement(children: .ignore) + .accessibilityLabel(message.text) .accessibilityIdentifier("row-\(message.id)") } } @@ -87,7 +89,8 @@ struct IssuesScreen: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 12) .padding(.vertical, 8) - .accessibilityElement(children: .combine) + .accessibilityElement(children: .ignore) + .accessibilityLabel(item.row.title) .accessibilityIdentifier("row-\(item.row.id)") } .accessibilityIdentifier("list") diff --git a/swift/Tests/AppleTests/VirtualListTests.swift b/swift/Tests/AppleTests/VirtualListTests.swift index 2337042e70..a75409bb05 100644 --- a/swift/Tests/AppleTests/VirtualListTests.swift +++ b/swift/Tests/AppleTests/VirtualListTests.swift @@ -68,10 +68,13 @@ #endif } - /// Runs the main run loop until `done`, for at most `seconds`. - func run(_ seconds: Double, until done: () -> Bool) { + /// Waits until `done`, for at most `seconds`, leaving the main actor + /// free: the server's deliveries and SwiftUI's layout run on it. (A + /// nested run loop wouldn't drain the main queue from a test running on + /// it.) + func run(_ seconds: Double, until done: () -> Bool) async throws { let end = Date().addingTimeInterval(seconds) - while Date() < end, !done() { RunLoop.main.run(until: Date().addingTimeInterval(0.02)) } + while Date() < end, !done() { try await Task.sleep(for: .milliseconds(20)) } } /// The scroll view's offset from the content's top and the height it @@ -102,14 +105,14 @@ #endif } - @Test func scrollsToARowFarBelow() throws { + @Test func scrollsToARowFarBelow() async throws { let (zero, list) = try makeList() let window = show(VirtualList(list) { Text($0.row.text).frame(height: 30) }) - run(5) { list.snapshot.complete && !list.snapshot.items.isEmpty } + try await run(5) { list.snapshot.complete && !list.snapshot.items.isEmpty } #expect(list.snapshot.items.first?.key == Self.id(0)) list.scrollToItem(Self.id(250), align: .end) - run(5) { list.lastVisibleItem()?.key == Self.id(250) } - run(0.5) { false } + try await run(5) { list.lastVisibleItem()?.key == Self.id(250) } + try await run(0.5) { false } #expect(list.lastVisibleItem()?.key == Self.id(250)) // The scroll view itself moved there, not just the virtualizer's idea // of it: n250's bottom (30-point rows) at the viewport's. diff --git a/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift b/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift index deeba4a74a..1d87189904 100644 --- a/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift +++ b/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift @@ -121,6 +121,13 @@ final class GeometryScrollView { run() #expect(updates == 1) #expect(host.rowFrames().map(\.maxY) == [20, 50]) + + // Content the view rounded to the pixel below the rows' fractional end. + host.rowFrame("b", minY: 20, maxY: 60.33) + host.geometry( + contentOffset: 0, containerHeight: 100, contentHeight: 60, insetTop: 0, insetBottom: 0) + run() + #expect(updates == 2) } @Test("its own scroll lands and is reported; the user's is reported once") From 71f3b5801e908d9f8e9016364777383679e27e5b Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 20 Oct 2026 02:52:40 +0000 Subject: [PATCH 061/104] 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 --- .../Sources/ZeroVirtual/VirtualizerCore.swift | 3 +++ swift/TestHost/App/Screens.swift | 5 ++++- swift/Tests/AppleTests/VirtualListTests.swift | 7 ++++-- .../ZeroVirtualTests/StickToBottomTests.swift | 22 +++++++++++++++++++ 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/swift/Sources/ZeroVirtual/VirtualizerCore.swift b/swift/Sources/ZeroVirtual/VirtualizerCore.swift index 2e89b9e11a..c946cbe7f1 100644 --- a/swift/Sources/ZeroVirtual/VirtualizerCore.swift +++ b/swift/Sources/ZeroVirtual/VirtualizerCore.swift @@ -691,6 +691,9 @@ package final class VirtualizerCore< private func landed() { if !anchorSuppressed { refreshAnchor() } + // A scroll that lands at the bottom, such as to the latest row, follows + // from there; one that lands above it doesn't. + measureStuck() persist() } diff --git a/swift/TestHost/App/Screens.swift b/swift/TestHost/App/Screens.swift index ac1e94c0bf..c7909bf087 100644 --- a/swift/TestHost/App/Screens.swift +++ b/swift/TestHost/App/Screens.swift @@ -55,7 +55,9 @@ struct MessageRow: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 12) .padding(.vertical, 6) - // One element with the padded row's frame (`.combine`'s is its texts'). + // One element with the padded row's frame: without the shape, it's + // its texts'. + .contentShape(.accessibility, Rectangle()) .accessibilityElement(children: .ignore) .accessibilityLabel(message.text) .accessibilityIdentifier("row-\(message.id)") @@ -89,6 +91,7 @@ struct IssuesScreen: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 12) .padding(.vertical, 8) + .contentShape(.accessibility, Rectangle()) .accessibilityElement(children: .ignore) .accessibilityLabel(item.row.title) .accessibilityIdentifier("row-\(item.row.id)") diff --git a/swift/Tests/AppleTests/VirtualListTests.swift b/swift/Tests/AppleTests/VirtualListTests.swift index a75409bb05..db112ef1a1 100644 --- a/swift/Tests/AppleTests/VirtualListTests.swift +++ b/swift/Tests/AppleTests/VirtualListTests.swift @@ -115,9 +115,12 @@ try await run(0.5) { false } #expect(list.lastVisibleItem()?.key == Self.id(250)) // The scroll view itself moved there, not just the virtualizer's idea - // of it: n250's bottom (30-point rows) at the viewport's. + // of it: n250's bottom at the viewport's. Its rows are 30 points, after + // the space that stands in for the rows before the window. let g = try #require(platformGeometry(window)) - #expect(abs(g.offset - (251 * 30 - g.viewport)) <= 1) + let s = list.snapshot + let first = try #require(s.items.first?.index) + #expect(abs(g.offset - (s.spaceBefore + Double(250 - first + 1) * 30 - g.viewport)) <= 1) withExtendedLifetime((zero, window)) {} } } diff --git a/swift/Tests/ZeroVirtualTests/StickToBottomTests.swift b/swift/Tests/ZeroVirtualTests/StickToBottomTests.swift index 6eddce00b9..f7af5512b8 100644 --- a/swift/Tests/ZeroVirtualTests/StickToBottomTests.swift +++ b/swift/Tests/ZeroVirtualTests/StickToBottomTests.swift @@ -90,3 +90,25 @@ import Testing #expect(bottomGap(h) == 0) } } + +extension StickToBottomTests { + @Test("scrolled up, scrolling to the last row with .end follows again") + func rearmsAfterScrollToItem() { + let h = chat(rows: 200) + h.core.scrollToItem("r199", align: .end) + h.settle() + #expect(bottomGap(h) == 0) + h.userScroll(h.scrollOffset - 300) + h.settle() + append(h, 1) + h.settle() + #expect(bottomGap(h) > 300) + h.core.scrollToItem("r200", align: .end) + h.settle() + #expect(bottomGap(h) == 0) + append(h, 1) + h.settle() + #expect(h.core.lastVisibleItem()?.key == "r201") + #expect(bottomGap(h) == 0) + } +} From 3a69780ee800d20439130258f52c49617a292323 Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 20 Oct 2026 02:57:20 +0000 Subject: [PATCH 062/104] 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 --- swift/Tests/ZeroVirtualTests/ZeroVirtualizerTests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/swift/Tests/ZeroVirtualTests/ZeroVirtualizerTests.swift b/swift/Tests/ZeroVirtualTests/ZeroVirtualizerTests.swift index 3ead4ed644..9072a5b0ea 100644 --- a/swift/Tests/ZeroVirtualTests/ZeroVirtualizerTests.swift +++ b/swift/Tests/ZeroVirtualTests/ZeroVirtualizerTests.swift @@ -175,7 +175,9 @@ import ZeroTesting // Rows the server adds later sync, and the list pages to them. server.put("issues", (150..<160).map(issue)) view.scroll(list, to: .infinity) - for _ in 0..<20 where list.snapshot.items.last?.key != Self.id(159) { + // Until the last page is there and its query confirmed. + for _ in 0..<40 + where list.snapshot.items.last?.key != Self.id(159) || !list.snapshot.complete { view.render(list) list.afterUpdate() view.scroll(list, to: .infinity) From 061458efb0a102958958b60859b10dca86ce48ca Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 20 Oct 2026 03:01:14 +0000 Subject: [PATCH 063/104] 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 --- swift/Tests/AppleTests/VirtualListTests.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/swift/Tests/AppleTests/VirtualListTests.swift b/swift/Tests/AppleTests/VirtualListTests.swift index db112ef1a1..f12f85c7b0 100644 --- a/swift/Tests/AppleTests/VirtualListTests.swift +++ b/swift/Tests/AppleTests/VirtualListTests.swift @@ -119,8 +119,10 @@ // the space that stands in for the rows before the window. let g = try #require(platformGeometry(window)) let s = list.snapshot - let first = try #require(s.items.first?.index) - #expect(abs(g.offset - (s.spaceBefore + Double(250 - first + 1) * 30 - g.viewport)) <= 1) + // Its place in the window: under a permalink anchor, `index` counts + // from the window's anchor, not the list's start. + let position = try #require(s.items.firstIndex { $0.key == Self.id(250) }) + #expect(abs(g.offset - (s.spaceBefore + Double(position + 1) * 30 - g.viewport)) <= 1) withExtendedLifetime((zero, window)) {} } } From cc9716e6fc64fa46ebdd5bf8aa0a2908a8f50d91 Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 20 Oct 2026 03:29:31 +0000 Subject: [PATCH 064/104] 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 --- .../TestHost/UITests/VirtualListUITests.swift | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/swift/TestHost/UITests/VirtualListUITests.swift b/swift/TestHost/UITests/VirtualListUITests.swift index add61b03d8..d27c214f2b 100644 --- a/swift/TestHost/UITests/VirtualListUITests.swift +++ b/swift/TestHost/UITests/VirtualListUITests.swift @@ -22,10 +22,20 @@ final class VirtualListUITests: XCTestCase { /// The rows on screen, top to bottom. func visibleRows() -> [(id: String, frame: CGRect)] { - let bounds = list.frame - return app.descendants(matching: .any) - .matching(NSPredicate(format: "identifier BEGINSWITH 'row-'")).allElementsBoundByIndex - .map { (String($0.identifier.dropFirst(4)), $0.frame) } + // One snapshot of the list, not a query per row: each element's + // identifier and frame is a round trip to the device. + guard let snapshot = try? list.snapshot() else { return [] } + let bounds = snapshot.frame + var rows: [(id: String, frame: CGRect)] = [] + func walk(_ e: any XCUIElementSnapshot) { + if e.identifier.hasPrefix("row-") { + rows.append((String(e.identifier.dropFirst(4)), e.frame)) + } + e.children.forEach(walk) + } + walk(snapshot) + return + rows .filter { $0.frame.maxY > bounds.minY + 1 && $0.frame.minY < bounds.maxY - 1 } .sorted { $0.frame.minY < $1.frame.minY } } @@ -140,8 +150,9 @@ final class VirtualListUITests: XCTestCase { waitUntil("the first issue is at the top") { row("i0000").exists && abs(row("i0000").frame.minY - list.frame.minY) <= 1.5 } + // A swipe moves about 14 issue rows; stop at row 400, not a count. var deepest = 0 - for _ in 0..<30 where deepest < 400 { + for _ in 0..<60 where deepest < 400 { scrollTowardBottom() rest(1) deepest = visibleRows().compactMap { Int($0.id.dropFirst()) }.max() ?? deepest From 77ff24444d9c5d73dfa64cc9882ede651e12d0e3 Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 20 Oct 2026 03:51:35 +0000 Subject: [PATCH 065/104] 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 --- swift/Sources/ZeroVirtual/ScrollGeometryHost.swift | 4 ++-- swift/Sources/ZeroVirtual/VirtualList.swift | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/swift/Sources/ZeroVirtual/ScrollGeometryHost.swift b/swift/Sources/ZeroVirtual/ScrollGeometryHost.swift index edf3ddc0a7..155acc5838 100644 --- a/swift/Sources/ZeroVirtual/ScrollGeometryHost.swift +++ b/swift/Sources/ZeroVirtual/ScrollGeometryHost.swift @@ -73,8 +73,8 @@ package final class ScrollGeometryHost: VirtualHost { } /// The scroll view's geometry: `contentOffset` as UIKit reports it (the - /// top inset is negative space above the content), the size it shows - /// content in, and its insets. + /// top inset is negative space above the content), its full height, + /// insets included (UIKit's bounds), and its insets. package func geometry( contentOffset: Double, containerHeight: Double, contentHeight: Double, insetTop: Double, insetBottom: Double diff --git a/swift/Sources/ZeroVirtual/VirtualList.swift b/swift/Sources/ZeroVirtual/VirtualList.swift index 7570887a15..b451a0fc98 100644 --- a/swift/Sources/ZeroVirtual/VirtualList.swift +++ b/swift/Sources/ZeroVirtual/VirtualList.swift @@ -87,7 +87,9 @@ init(_ g: ScrollGeometry) { offset = g.contentOffset.y - container = g.containerSize.height + // The scroll view's full height, as UIKit's bounds: `containerSize` + // already leaves out the insets, which the host takes off itself. + container = g.visibleRect.height content = g.contentSize.height top = g.contentInsets.top bottom = g.contentInsets.bottom From 15db990649231ef0d1ccaa46cd5428f3e2d61419 Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 20 Oct 2026 04:23:18 +0000 Subject: [PATCH 066/104] 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 --- .../ZeroVirtual/ScrollGeometryHost.swift | 12 ++- swift/Sources/ZeroVirtual/VirtualList.swift | 4 +- .../Sources/ZeroVirtual/VirtualizerCore.swift | 102 +++++++++++++++++- swift/Tests/ZeroVirtualTests/Harness.swift | 7 ++ .../ScrollGeometryHostTests.swift | 69 ++++++++++-- .../Tests/ZeroVirtualTests/ScrollTests.swift | 28 +++++ .../ZeroVirtualizerTests.swift | 7 ++ 7 files changed, 216 insertions(+), 13 deletions(-) diff --git a/swift/Sources/ZeroVirtual/ScrollGeometryHost.swift b/swift/Sources/ZeroVirtual/ScrollGeometryHost.swift index 155acc5838..1f9bb1a3b6 100644 --- a/swift/Sources/ZeroVirtual/ScrollGeometryHost.swift +++ b/swift/Sources/ZeroVirtual/ScrollGeometryHost.swift @@ -33,6 +33,7 @@ package final class ScrollGeometryHost: VirtualHost { /// The rendered rows, in list order. private var items: [(key: AnyHashable, index: Int)] = [] + private var spaceBefore = 0.0 private var heights: [AnyHashable: (minY: Double, maxY: Double)] = [:] private var pendingScroll: Double? private var flushScheduled = false @@ -57,10 +58,17 @@ package final class ScrollGeometryHost: VirtualHost { /// The view rendered a snapshot with these rows: every snapshot, even one /// with the same rows, since the core waits on `afterUpdate` for it. - package func render(_ keys: [(key: Key, index: Int)]) { + package func render(_ keys: [(key: Key, index: Int)], spaceBefore: Double) { items = keys.map { (AnyHashable($0.key), $0.index) } let live = Set(items.map(\.key)) - heights = heights.filter { live.contains($0.key) } + // A new space before the rows moves every row: frames from before are + // stale, and the core would take the move it asked for as a new one. + if spaceBefore != self.spaceBefore { + self.spaceBefore = spaceBefore + heights = [:] + } else { + heights = heights.filter { live.contains($0.key) } + } scheduleFlush() } diff --git a/swift/Sources/ZeroVirtual/VirtualList.swift b/swift/Sources/ZeroVirtual/VirtualList.swift index b451a0fc98..866a0469f1 100644 --- a/swift/Sources/ZeroVirtual/VirtualList.swift +++ b/swift/Sources/ZeroVirtual/VirtualList.swift @@ -61,7 +61,9 @@ } .onAppear { let render = { [host, list] in - host.render(list.snapshot.items.map { (key: $0.key, index: $0.index) }) + host.render( + list.snapshot.items.map { (key: $0.key, index: $0.index) }, + spaceBefore: list.snapshot.spaceBefore) } list.onSnapshot = render host.requestScroll = { y in position.scrollTo(y: y) } diff --git a/swift/Sources/ZeroVirtual/VirtualizerCore.swift b/swift/Sources/ZeroVirtual/VirtualizerCore.swift index c946cbe7f1..f4dd41e346 100644 --- a/swift/Sources/ZeroVirtual/VirtualizerCore.swift +++ b/swift/Sources/ZeroVirtual/VirtualizerCore.swift @@ -264,6 +264,15 @@ package final class VirtualizerCore< private var anchorOffset = 0.0 // From a reset until the new window's rows are in. private var anchorSuppressed = false + // How far the space before the rows is off its estimate. Rows above the + // reference row that load, go, or come out another height than estimated + // would move it; the space takes up the difference instead of a scroll, + // which would stop a fling. Space short of the estimate is given back with + // a scroll once the list rests. + private var spaceAdjust = 0.0 + private var idleTimer: (any ScheduledTimer)? + // The space is being given back: compensation scrolls. + private var reconciling = false private var settleTimer: (any ScheduledTimer)? private var target: Target? private var permalinkMissing = 0 @@ -324,8 +333,45 @@ package final class VirtualizerCore< /// Takes new rows without notifying. package func setRows(_ new: RowsSnapshot) { + let old = rows + let oldSpace = manual && host != nil ? snapshot.spaceBefore : nil rows = new version += 1 + if let oldSpace { carrySpace(from: old, oldSpace) } + } + + /// Keeps the space before the rows where it was through new rows: less + /// the rows added above the first one, by estimate, or more the rows gone + /// from above it, as laid out. Their index, which paging renumbers, and + /// the anchor's kind don't move what's on screen. + private func carrySpace(from old: RowsSnapshot, _ oldSpace: Double) { + guard isListContextCurrent, !rows.atStart, !old.rowsEmpty, !rows.rowsEmpty, + let oldFirst = old.rowAt(old.firstRowIndex).map(options.rowKey), + let newFirst = rows.rowAt(rows.firstRowIndex).map(options.rowKey) + else { return } + func position(of key: Key, in r: RowsSnapshot) -> Int? { + for i in 0.. VirtualSnapshot { let total = effectiveEstimatedTotal let end = rows.firstRowIndex + rows.rowsLength - let before = max(0, rows.firstRowIndex) let after = rows.atEnd ? 0 : max(0, total - end) var items: [VirtualItem] = [] items.reserveCapacity(rows.rowsLength) @@ -555,7 +618,8 @@ package final class VirtualizerCore< } var s = VirtualSnapshot() s.items = items - s.spaceBefore = rows.atStart ? 0 : Double(before) * rowEstimate + // At the start, nothing stands before the first row. + s.spaceBefore = rows.atStart ? 0 : max(0, estimatedSpaceBefore + spaceAdjust) s.spaceAfter = Double(after) * rowEstimate s.complete = rows.complete s.rowsEmpty = rows.rowsEmpty @@ -666,10 +730,36 @@ package final class VirtualizerCore< return } let delta = ref.minY - anchorOffset + defer { reconciling = false } if abs(delta) < 0.5 { return } + // Taken up by the space before the rows, which moves them back, when + // it can be: the scroll view isn't touched, so a fling carries on. + let space = snapshot.spaceBefore - delta + if !reconciling, !rows.atStart, space >= 0 { + spaceAdjust = space - estimatedSpaceBefore + version += 1 + return + } compensate(delta) } + // Once scrolling stops, space short of its estimate goes back to it, and + // the list scrolls by the difference so nothing on screen moves. + private func resetIdleTimer() { + idleTimer?.cancel() + idleTimer = scheduler.schedule(after: Self.idleTime) { [weak self] in + // Space short of its estimate is given back, for the next change to + // come out of. Space over it stays: taking it back shrinks the content + // under the offset, which the view clamps before the list can scroll. + guard let self, self.spaceAdjust < 0 else { return } + self.withNotify { + self.spaceAdjust = 0 + self.reconciling = true + self.version += 1 + } + } + } + private func liftAnchorSuppression() { if anchorSuppressed && rowsSettled { anchorSuppressed = false @@ -706,6 +796,7 @@ package final class VirtualizerCore< if manual && !programmatic { refreshAnchor() } withNotify { resetSettleTimer() + if manual { resetIdleTimer() } evaluate() persist() } @@ -817,6 +908,7 @@ package final class VirtualizerCore< anchorKey = nil anchorSuppressed = true } + spaceAdjust = 0 if case .offset = target { setTarget(nil) } if let restore { @@ -1012,7 +1104,11 @@ package final class VirtualizerCore< guard let host, isListContextCurrent, let report = options.onScrollStateChange else { return } lastPersisted = next let state = ScrollState( - anchor: next.anchor, scrollOffset: host.scrollOffset, estimatedTotal: next.estimatedTotal, + // Where the list would be with the space as estimated, as a restore + // lays it out. + anchor: next.anchor, + scrollOffset: host.scrollOffset - (snapshot.spaceBefore - estimatedSpaceBefore), + estimatedTotal: next.estimatedTotal, hasReachedStart: next.hasReachedStart, hasReachedEnd: next.hasReachedEnd, listContext: options.listContext) lastWritten = state diff --git a/swift/Tests/ZeroVirtualTests/Harness.swift b/swift/Tests/ZeroVirtualTests/Harness.swift index 03702dd422..7d2e836e88 100644 --- a/swift/Tests/ZeroVirtualTests/Harness.swift +++ b/swift/Tests/ZeroVirtualTests/Harness.swift @@ -23,6 +23,7 @@ final class FakeScrollView: VirtualHost { private(set) var items: [VirtualItem] = [] private var spaceBefore = 0.0 + var renderedSpaceBefore: Double { spaceBefore } private var spaceAfter = 0.0 /// Space above the rows that isn't theirs: other content over the list. var marginTop = 0.0 @@ -206,6 +207,12 @@ final class Harness { view.render(core.snapshot) core.attach(view) core.afterUpdate() + // A new space before the rows (taking up a change above them) renders + // again at once, as the view re-renders on every snapshot. + for _ in 0..<3 where core.snapshot.spaceBefore != view.renderedSpaceBefore { + view.render(core.snapshot) + core.afterUpdate() + } if scroll { view.deliverScroll() } } diff --git a/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift b/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift index 1d87189904..f52ac1a81f 100644 --- a/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift +++ b/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift @@ -18,6 +18,7 @@ final class GeometryScrollView { private var deferred: [@MainActor () -> Void] = [] private var snapshot = VirtualSnapshot() private(set) var afterUpdates = 0 + private(set) var scrollRequests = 0 init(_ h: Harness, containerHeight: Double = 400, insetTop: Double = 0) { self.h = h @@ -25,7 +26,10 @@ final class GeometryScrollView { self.insetTop = insetTop // At rest at the top, the inset is above the content. offset = -insetTop - host.requestScroll = { [unowned self] in requested = $0 } + host.requestScroll = { [unowned self] in + requested = $0 + scrollRequests += 1 + } host.defer_ = { [unowned self] in deferred.append($0) } host.didScroll = { [unowned self] in self.h.core.didScroll() } host.viewportDidResize = { [unowned self] in self.h.core.viewportDidResize() } @@ -47,7 +51,8 @@ final class GeometryScrollView { h.core.setRows(h.answer(h.core.queryInputs)) // Every snapshot is rendered, as `ZeroVirtualizer.onSnapshot` has it. snapshot = h.core.snapshot - host.render(snapshot.items.map { (key: $0.key, index: $0.index) }) + host.render( + snapshot.items.map { (key: $0.key, index: $0.index) }, spaceBefore: snapshot.spaceBefore) var y = snapshot.spaceBefore for item in snapshot.items { host.rowFrame(item.key, minY: y, maxY: y + height(item.key)) @@ -69,9 +74,10 @@ final class GeometryScrollView { func settle(sourceLocation: SourceLocation = #_sourceLocation) { for _ in 0..<30 { - let before = (h.queryKey(h.core.queryInputs), offset, requested) + let before = (h.queryKey(h.core.queryInputs), offset, requested, snapshot.spaceBefore) pass() - if (h.queryKey(h.core.queryInputs), offset, requested) == before, deferred.isEmpty, + if (h.queryKey(h.core.queryInputs), offset, requested, h.core.snapshot.spaceBefore) == before, + deferred.isEmpty, h.core.snapshot.complete { return @@ -108,7 +114,7 @@ final class GeometryScrollView { work.forEach { $0() } } - host.render([(key: "a", index: 0), (key: "b", index: 1)]) + host.render([(key: "a", index: 0), (key: "b", index: 1)], spaceBefore: 0) host.rowFrame("a", minY: 0, maxY: 20) run() #expect(updates == 0) @@ -120,14 +126,25 @@ final class GeometryScrollView { contentOffset: 0, containerHeight: 100, contentHeight: 50, insetTop: 0, insetBottom: 0) run() #expect(updates == 1) - #expect(host.rowFrames().map(\.maxY) == [20, 50]) + + // A new space before the rows: their old frames don't count. + host.render([(key: "a", index: 0), (key: "b", index: 1)], spaceBefore: 30) + run() + #expect(updates == 1) + host.rowFrame("a", minY: 30, maxY: 50) + host.rowFrame("b", minY: 50, maxY: 80) + host.geometry( + contentOffset: 0, containerHeight: 100, contentHeight: 80, insetTop: 0, insetBottom: 0) + run() + #expect(updates == 2) + #expect(host.rowFrames().map(\.maxY) == [50, 80]) // Content the view rounded to the pixel below the rows' fractional end. host.rowFrame("b", minY: 20, maxY: 60.33) host.geometry( contentOffset: 0, containerHeight: 100, contentHeight: 60, insetTop: 0, insetBottom: 0) run() - #expect(updates == 2) + #expect(updates == 3) } @Test("its own scroll lands and is reported; the user's is reported once") @@ -241,3 +258,41 @@ extension ScrollGeometryHostTests { #expect(view.bottomGap == 0) } } + +extension ScrollGeometryHostTests { + @Test( + "a fling up through pages of taller rows isn't scrolled under; at rest the space is given back") + func flingKeepsGoing() { + let h = Harness(rowCount: 400) { $0.anchoring = .manual } + let view = GeometryScrollView(h) + for i in 0..<400 where i % 3 == 0 { view.heights["r\(i)"] = 60 } + view.settle() + h.core.scrollToItem("r300", align: .start) + view.settle() + h.scheduler.advance(by: 200) + view.settle() + let before = view.scrollRequests + + // A fling: the offset moves every frame, and pages load under it. + var expected = view.host.scrollOffset + var key = h.core.firstVisibleItem()!.key + for _ in 0..<60 { + let top = view.rowTop(key)! + view.userScroll(view.host.scrollOffset - 40) + // Within the frame: the pages' rows, and the space that takes them up. + view.settle() + expected -= 40 + // Each frame moves what's on screen by the fling's 40 points, no more. + #expect(view.rowTop(key) == top + 40) + key = h.core.firstVisibleItem()!.key + } + #expect(view.scrollRequests == before) + #expect(view.host.scrollOffset == max(0, expected)) + + // At rest, the space goes back to its estimate, nothing on screen moving. + let top = view.rowTop(key)! + h.scheduler.advance(by: 200) + view.settle() + #expect(view.rowTop(key) == top) + } +} diff --git a/swift/Tests/ZeroVirtualTests/ScrollTests.swift b/swift/Tests/ZeroVirtualTests/ScrollTests.swift index 60c679703c..6161dd31dc 100644 --- a/swift/Tests/ZeroVirtualTests/ScrollTests.swift +++ b/swift/Tests/ZeroVirtualTests/ScrollTests.swift @@ -157,6 +157,34 @@ struct ManualAnchoringTests { #expect(h.rowTop("r50") == 0) } + @Test("away from the start, the space before the rows takes up growth; at rest it's given back") + func spaceTakesUpChange() { + let h = Harness(rowCount: 1000) { $0.anchoring = .manual } + h.settle() + for _ in 0..<12 { + h.userScroll(h.scrollOffset + 400) + h.settle() + } + let first = h.items.first!.index + #expect(first > 0) + let space = h.core.snapshot.spaceBefore + let offset = h.scrollOffset + let key = h.core.firstVisibleItem()!.key + let top = h.rowTop(key) + h.setRowHeight("r\(h.core.firstVisibleItem()!.index - 2)", 50) + h.tick() + // No scroll while the list may be moving: a scroll would stop a fling. + #expect(h.scrollOffset == offset) + #expect(h.core.snapshot.spaceBefore == space - 30) + #expect(h.rowTop(key) == top) + + h.scheduler.advance(by: 150) + h.tick() + #expect(h.core.snapshot.spaceBefore == space) + #expect(h.scrollOffset == offset + 30) + #expect(h.rowTop(key) == top) + } + @Test("growth at scroll offset 0 is revealed, not compensated away") func growthAtTop() { let h = Harness(rowCount: 500) { $0.anchoring = .manual } diff --git a/swift/Tests/ZeroVirtualTests/ZeroVirtualizerTests.swift b/swift/Tests/ZeroVirtualTests/ZeroVirtualizerTests.swift index 9072a5b0ea..85efa77757 100644 --- a/swift/Tests/ZeroVirtualTests/ZeroVirtualizerTests.swift +++ b/swift/Tests/ZeroVirtualTests/ZeroVirtualizerTests.swift @@ -59,6 +59,11 @@ import ZeroTesting view.render(list) let before = list.core.queryInputs list.afterUpdate() + // A new space before the rows renders again, as a view would. + for _ in 0..<3 where list.snapshot.spaceBefore != view.spaceBefore { + view.render(list) + list.afterUpdate() + } server.serve(sockets, scheduler) if list.core.queryInputs == before && list.snapshot.complete { return } } @@ -256,6 +261,7 @@ final class ListView: VirtualHost { var offset = 0.0 var frames: [VirtualRowFrame] = [] var height = 0.0 + var spaceBefore = 0.0 let viewportHeight = 400.0 let viewportInsets: (top: Double, bottom: Double) = (0, 0) @@ -274,6 +280,7 @@ final class ListView: VirtualHost { func render(_ list: ZeroVirtualizerTests.List) { self.list = list let s = list.snapshot + spaceBefore = s.spaceBefore var y = s.spaceBefore frames = s.items.map { item in defer { y += 20 } From 25def688c46dfdd5583072dc5b028cfd36b04565 Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 20 Oct 2026 04:36:49 +0000 Subject: [PATCH 067/104] 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 --- .../ZeroVirtual/ScrollGeometryHost.swift | 16 +++++----- .../ScrollGeometryHostTests.swift | 31 +++++++++++++++---- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/swift/Sources/ZeroVirtual/ScrollGeometryHost.swift b/swift/Sources/ZeroVirtual/ScrollGeometryHost.swift index 1f9bb1a3b6..c918b58136 100644 --- a/swift/Sources/ZeroVirtual/ScrollGeometryHost.swift +++ b/swift/Sources/ZeroVirtual/ScrollGeometryHost.swift @@ -59,15 +59,17 @@ package final class ScrollGeometryHost: VirtualHost { /// The view rendered a snapshot with these rows: every snapshot, even one /// with the same rows, since the core waits on `afterUpdate` for it. package func render(_ keys: [(key: Key, index: Int)], spaceBefore: Double) { + let oldFirst = items.first?.key items = keys.map { (AnyHashable($0.key), $0.index) } let live = Set(items.map(\.key)) - // A new space before the rows moves every row: frames from before are - // stale, and the core would take the move it asked for as a new one. - if spaceBefore != self.spaceBefore { - self.spaceBefore = spaceBefore - heights = [:] - } else { - heights = heights.filter { live.contains($0.key) } + heights = heights.filter { live.contains($0.key) } + // The view reports only frames that change, so frames are kept. A new + // space over the same first row moves every row by the difference; over + // a new first row, the core sized it to keep the rows where they were. + let shift = spaceBefore - self.spaceBefore + self.spaceBefore = spaceBefore + if shift != 0, oldFirst != nil, oldFirst == items.first?.key { + heights = heights.mapValues { ($0.minY + shift, $0.maxY + shift) } } scheduleFlush() } diff --git a/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift b/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift index f52ac1a81f..d0e9c6bd90 100644 --- a/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift +++ b/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift @@ -19,6 +19,7 @@ final class GeometryScrollView { private var snapshot = VirtualSnapshot() private(set) var afterUpdates = 0 private(set) var scrollRequests = 0 + private var reported: [String: (minY: Double, maxY: Double)] = [:] init(_ h: Harness, containerHeight: Double = 400, insetTop: Double = 0) { self.h = h @@ -53,9 +54,17 @@ final class GeometryScrollView { snapshot = h.core.snapshot host.render( snapshot.items.map { (key: $0.key, index: $0.index) }, spaceBefore: snapshot.spaceBefore) + // Only frames that changed are reported, as `onGeometryChange` does, + // and a row's first: one that left and came back is a new view. + let live = Set(snapshot.items.map(\.key)) + reported = reported.filter { live.contains($0.key) } var y = snapshot.spaceBefore for item in snapshot.items { - host.rowFrame(item.key, minY: y, maxY: y + height(item.key)) + let frame = (minY: y, maxY: y + height(item.key)) + if reported[item.key].map({ $0 != frame }) ?? true { + reported[item.key] = frame + host.rowFrame(item.key, minY: frame.minY, maxY: frame.maxY) + } y += height(item.key) } let maxOffset = max(0, contentHeight - (containerHeight - insetTop)) - insetTop @@ -127,24 +136,34 @@ final class GeometryScrollView { run() #expect(updates == 1) - // A new space before the rows: their old frames don't count. + // A new space over the same first row moves the rows by its change, + // with no new reports (the view reports a frame only when it changes; + // here the update waits on the content reaching the moved rows). host.render([(key: "a", index: 0), (key: "b", index: 1)], spaceBefore: 30) run() #expect(updates == 1) - host.rowFrame("a", minY: 30, maxY: 50) - host.rowFrame("b", minY: 50, maxY: 80) + #expect(host.rowFrames().map(\.maxY) == [50, 80]) host.geometry( contentOffset: 0, containerHeight: 100, contentHeight: 80, insetTop: 0, insetBottom: 0) run() #expect(updates == 2) - #expect(host.rowFrames().map(\.maxY) == [50, 80]) + + // A new first row: the space was sized to keep the rows in place, so + // the ones still there keep their frames and only the new one waits. + host.render([(key: "z", index: 0), (key: "a", index: 1), (key: "b", index: 2)], spaceBefore: 0) + run() + #expect(updates == 2) + host.rowFrame("z", minY: 0, maxY: 30) + run() + #expect(updates == 3) + #expect(host.rowFrames().map(\.maxY) == [30, 50, 80]) // Content the view rounded to the pixel below the rows' fractional end. host.rowFrame("b", minY: 20, maxY: 60.33) host.geometry( contentOffset: 0, containerHeight: 100, contentHeight: 60, insetTop: 0, insetBottom: 0) run() - #expect(updates == 3) + #expect(updates == 4) } @Test("its own scroll lands and is reported; the user's is reported once") From 7a4d539aa1dbba8154331b2fe0257c7d793c819d Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 20 Oct 2026 05:03:26 +0000 Subject: [PATCH 068/104] 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 --- .../ZeroVirtual/ScrollGeometryHost.swift | 25 +++++- .../Sources/ZeroVirtual/VirtualizerCore.swift | 37 ++++++--- .../ScrollGeometryHostTests.swift | 79 +++++++++++++++++++ .../Tests/ZeroVirtualTests/ScrollTests.swift | 10 ++- 4 files changed, 135 insertions(+), 16 deletions(-) diff --git a/swift/Sources/ZeroVirtual/ScrollGeometryHost.swift b/swift/Sources/ZeroVirtual/ScrollGeometryHost.swift index c918b58136..4db071d617 100644 --- a/swift/Sources/ZeroVirtual/ScrollGeometryHost.swift +++ b/swift/Sources/ZeroVirtual/ScrollGeometryHost.swift @@ -35,11 +35,15 @@ package final class ScrollGeometryHost: VirtualHost { private var items: [(key: AnyHashable, index: Int)] = [] private var spaceBefore = 0.0 private var heights: [AnyHashable: (minY: Double, maxY: Double)] = [:] - private var pendingScroll: Double? + /// A scroll asked for and not seen yet: the target, unclamped, and how + /// many reports have contradicted it. + private var pendingScroll: (target: Double, contradicted: Int)? private var flushScheduled = false package init() {} + private static var retries: Int { 3 } + package func rowFrames() -> [VirtualRowFrame] { items.compactMap { item in heights[item.key].map { @@ -49,13 +53,15 @@ package final class ScrollGeometryHost: VirtualHost { } package func scroll(to target: Double) { - let offset = max(0, min(target, contentHeight - viewportHeight)) + let offset = clamp(target) guard offset != scrollOffset else { return } scrollOffset = offset - pendingScroll = offset + pendingScroll = (target, 0) requestScroll(offset) } + private func clamp(_ y: Double) -> Double { max(0, min(y, contentHeight - viewportHeight)) } + /// The view rendered a snapshot with these rows: every snapshot, even one /// with the same rows, since the core waits on `afterUpdate` for it. package func render(_ keys: [(key: Key, index: Int)], spaceBefore: Double) { @@ -95,6 +101,19 @@ package final class ScrollGeometryHost: VirtualHost { viewportHeight = viewport self.contentHeight = contentHeight let offset = contentOffset + insetTop + // A report from before the scroll took, or a fling's deceleration + // writing over it: the scroll is asked for again, against the content + // as it is now, for a few reports. + if let pending = pendingScroll, abs(offset - clamp(pending.target)) > 1, + pending.contradicted < Self.retries + { + pendingScroll = (pending.target, pending.contradicted + 1) + scrollOffset = clamp(pending.target) + requestScroll(scrollOffset) + if resized { viewportDidResize() } + if grew { scheduleFlush() } + return + } let moved = offset != scrollOffset let landed = pendingScroll != nil pendingScroll = nil diff --git a/swift/Sources/ZeroVirtual/VirtualizerCore.swift b/swift/Sources/ZeroVirtual/VirtualizerCore.swift index f4dd41e346..21274c6753 100644 --- a/swift/Sources/ZeroVirtual/VirtualizerCore.swift +++ b/swift/Sources/ZeroVirtual/VirtualizerCore.swift @@ -270,6 +270,9 @@ package final class VirtualizerCore< // which would stop a fling. Space short of the estimate is given back with // a scroll once the list rests. private var spaceAdjust = 0.0 + // The list opened away from its start (a permalink, a restore) and hasn't + // been there since: its indexes count from where it opened. + private var openedAway: Bool private var idleTimer: (any ScheduledTimer)? // The space is being given back: compensation scrolls. private var reconciling = false @@ -305,6 +308,7 @@ package final class VirtualizerCore< estimatedTotal: skeletonRows, hasReachedStart: false, hasReachedEnd: false, anchor: .top, listContext: options.listContext) } + openedAway = restore.map { !$0.hasReachedStart } ?? (options.permalinkID != nil) if let id = options.permalinkID, restore == nil { target = .row(PendingScroll(id: id, align: .start, source: .option)) } @@ -345,7 +349,7 @@ package final class VirtualizerCore< /// from above it, as laid out. Their index, which paging renumbers, and /// the anchor's kind don't move what's on screen. private func carrySpace(from old: RowsSnapshot, _ oldSpace: Double) { - guard isListContextCurrent, !rows.atStart, !old.rowsEmpty, !rows.rowsEmpty, + guard isListContextCurrent, !old.rowsEmpty, !rows.rowsEmpty, let oldFirst = old.rowAt(old.firstRowIndex).map(options.rowKey), let newFirst = rows.rowAt(rows.firstRowIndex).map(options.rowKey) else { return } @@ -587,14 +591,14 @@ package final class VirtualizerCore< private var rowEstimate: Double { max(1, options.estimatedRowHeight) } - // The space before the rows, as their count estimates it. Under a - // permalink, whose indexes count from the window and estimate nothing, - // at least a reserve for the space to take changes above the rows out - // of. None once the rows reach the start. + // The space before the rows, as their count estimates it. A list that + // opened away from its start counts from where it opened, which estimates + // nothing, so it keeps at least a reserve for the space to take changes + // above the rows out of. None at the start. private var estimatedSpaceBefore: Double { if rows.atStart { return 0 } let estimate = Double(max(0, rows.firstRowIndex)) * rowEstimate - guard manual, rows.anchor.isPermalink, !rows.rowsEmpty else { return estimate } + guard manual, openedAway, !rows.rowsEmpty else { return estimate } return max(estimate, Double(3 * pageSize) * rowEstimate) } @@ -618,8 +622,9 @@ package final class VirtualizerCore< } var s = VirtualSnapshot() s.items = items - // At the start, nothing stands before the first row. - s.spaceBefore = rows.atStart ? 0 : max(0, estimatedSpaceBefore + spaceAdjust) + // At the start, space left over from above stands before the first row + // until the list rests. + s.spaceBefore = max(0, estimatedSpaceBefore + spaceAdjust) s.spaceAfter = Double(after) * rowEstimate s.complete = rows.complete s.rowsEmpty = rows.rowsEmpty @@ -651,6 +656,11 @@ package final class VirtualizerCore< private func setPaging(_ next: Paging) { paging = next + if next.anchor == .top || next.hasReachedStart { + openedAway = false + } else if next.anchor.isPermalink { + openedAway = true + } version += 1 } @@ -735,7 +745,7 @@ package final class VirtualizerCore< // Taken up by the space before the rows, which moves them back, when // it can be: the scroll view isn't touched, so a fling carries on. let space = snapshot.spaceBefore - delta - if !reconciling, !rows.atStart, space >= 0 { + if !reconciling, space >= 0 { spaceAdjust = space - estimatedSpaceBefore version += 1 return @@ -749,9 +759,11 @@ package final class VirtualizerCore< idleTimer?.cancel() idleTimer = scheduler.schedule(after: Self.idleTime) { [weak self] in // Space short of its estimate is given back, for the next change to - // come out of. Space over it stays: taking it back shrinks the content - // under the offset, which the view clamps before the list can scroll. - guard let self, self.spaceAdjust < 0 else { return } + // come out of. Space over it stays, as taking it back shrinks the + // content under the offset, which the view clamps before the list can + // scroll; but at the start, where nothing stands before the first row. + guard let self, self.spaceAdjust < 0 || (self.rows.atStart && self.spaceAdjust > 0) + else { return } self.withNotify { self.spaceAdjust = 0 self.reconciling = true @@ -919,6 +931,7 @@ package final class VirtualizerCore< estimatedTotal: restore.estimatedTotal, hasReachedStart: restore.hasReachedStart, hasReachedEnd: restore.hasReachedEnd, anchor: restore.anchor, listContext: options.listContext)) + openedAway = !restore.hasReachedStart } else if let permalinkID { // A visible permalink row stays put; any other is scrolled to. let targetFrame = host.flatMap { frame($0, key: AnyHashable(permalinkID)) } diff --git a/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift b/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift index d0e9c6bd90..e083fed4f7 100644 --- a/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift +++ b/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift @@ -315,3 +315,82 @@ extension ScrollGeometryHostTests { #expect(view.rowTop(key) == top) } } + +extension ScrollGeometryHostTests { + @Test( + "opened at its end, flings up to the top through rows 3× their estimate, never scrolled under") + func flingsUpFromAPermalink() { + let h = Harness(rowCount: 400) { + $0.anchoring = .manual + $0.permalinkID = "r399" + } + let view = GeometryScrollView(h) + for i in 0..<400 { view.heights["r\(i)"] = 60 } + view.settle() + h.scheduler.advance(by: 200) + view.settle() + #expect(h.core.lastVisibleItem()?.key == "r399") + + var flings = 0 + while view.host.scrollOffset > 0, flings < 200 { + flings += 1 + let before = view.scrollRequests + // A fling of 20 frames, pages loading under it. + for _ in 0..<20 where view.host.scrollOffset > 0 { + guard let key = h.core.firstVisibleItem()?.key, let top = view.rowTop(key) else { + Issue.record("no rows on screen") + return + } + let offset = view.host.scrollOffset + view.userScroll(offset - 40) + view.settle() + let moved = offset - max(0, offset - 40) + #expect(view.rowTop(key) == top + moved, "fling \(flings) at \(offset)") + } + #expect(view.scrollRequests == before, "scrolled under fling \(flings)") + // At rest: the space is given back, nothing on screen moving. + guard let key = h.core.firstVisibleItem()?.key, let top = view.rowTop(key) else { return } + h.scheduler.advance(by: 200) + view.settle() + #expect(view.rowTop(key) == top, "the give-back after fling \(flings) moved the rows") + } + #expect(h.core.snapshot.items.first?.key == "r0") + #expect(view.host.scrollOffset == 0) + #expect(h.core.snapshot.spaceBefore == 0) + } + + @Test("a scroll a report contradicts is asked for again, a few times") + func reissuesAContradictedScroll() { + let host = ScrollGeometryHost() + var requested: [Double] = [] + var scrolls = 0 + host.requestScroll = { requested.append($0) } + host.didScroll = { scrolls += 1 } + func report(_ offset: Double, content: Double = 2000) { + host.geometry( + contentOffset: offset, containerHeight: 400, contentHeight: content, insetTop: 0, + insetBottom: 0) + } + report(500) + scrolls = 0 + host.scroll(to: 1200) + // A fling's deceleration writes over it: asked for again, the core not + // told of an offset it didn't ask for. + report(490) + #expect(requested == [1200, 1200]) + #expect(host.scrollOffset == 1200) + #expect(scrolls == 0) + // Against the content as it is now. + report(480, content: 1500) + #expect(requested == [1200, 1200, 1100]) + report(1100, content: 1500) + #expect(scrolls == 1) + #expect(host.scrollOffset == 1100) + + // One that never takes is given up after three reports. + host.scroll(to: 200) + for y in [1090.0, 1080, 1070, 1060] { report(y, content: 1500) } + #expect(requested.count == 3 + 1 + 3) + #expect(host.scrollOffset == 1060) + } +} diff --git a/swift/Tests/ZeroVirtualTests/ScrollTests.swift b/swift/Tests/ZeroVirtualTests/ScrollTests.swift index 6161dd31dc..5cc4f5550c 100644 --- a/swift/Tests/ZeroVirtualTests/ScrollTests.swift +++ b/swift/Tests/ZeroVirtualTests/ScrollTests.swift @@ -148,11 +148,19 @@ struct ManualAnchoringTests { #expect(h.rowTop("r50") == 0) } - @Test("shrinkage above the anchor row is compensated too") + @Test("shrinkage above the anchor row is taken up by space at the start, given back at rest") func shrinkageAbove() { let h = scrolled() h.setRowHeight("r30", 5) h.tick() + // No scroll while the list may be moving: a scroll would stop a fling. + #expect(h.scrollOffset == 1000) + #expect(h.core.snapshot.spaceBefore == 15) + #expect(h.rowTop("r50") == 0) + + h.scheduler.advance(by: 150) + h.tick() + #expect(h.core.snapshot.spaceBefore == 0) #expect(h.scrollOffset == 985) #expect(h.rowTop("r50") == 0) } From 16de39f6a564a326129841d8228619aed107398f Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 20 Oct 2026 05:17:40 +0000 Subject: [PATCH 069/104] 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 --- swift/Tests/AppleTests/VirtualListTests.swift | 5 +++-- .../Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift | 9 +++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/swift/Tests/AppleTests/VirtualListTests.swift b/swift/Tests/AppleTests/VirtualListTests.swift index f12f85c7b0..401b38dfd5 100644 --- a/swift/Tests/AppleTests/VirtualListTests.swift +++ b/swift/Tests/AppleTests/VirtualListTests.swift @@ -108,10 +108,11 @@ @Test func scrollsToARowFarBelow() async throws { let (zero, list) = try makeList() let window = show(VirtualList(list) { Text($0.row.text).frame(height: 30) }) - try await run(5) { list.snapshot.complete && !list.snapshot.items.isEmpty } + // Generous: other tests in the process can hold the main actor for seconds. + try await run(30) { list.snapshot.complete && !list.snapshot.items.isEmpty } #expect(list.snapshot.items.first?.key == Self.id(0)) list.scrollToItem(Self.id(250), align: .end) - try await run(5) { list.lastVisibleItem()?.key == Self.id(250) } + try await run(30) { list.lastVisibleItem()?.key == Self.id(250) } try await run(0.5) { false } #expect(list.lastVisibleItem()?.key == Self.id(250)) // The scroll view itself moved there, not just the virtualizer's idea diff --git a/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift b/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift index e083fed4f7..c31a40cb76 100644 --- a/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift +++ b/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift @@ -335,16 +335,17 @@ extension ScrollGeometryHostTests { while view.host.scrollOffset > 0, flings < 200 { flings += 1 let before = view.scrollRequests - // A fling of 20 frames, pages loading under it. - for _ in 0..<20 where view.host.scrollOffset > 0 { + // A fling of 8 coarse frames, pages loading under it. (Kept cheap: it + // runs on the main actor alongside the other targets' tests.) + for _ in 0..<8 where view.host.scrollOffset > 0 { guard let key = h.core.firstVisibleItem()?.key, let top = view.rowTop(key) else { Issue.record("no rows on screen") return } let offset = view.host.scrollOffset - view.userScroll(offset - 40) + view.userScroll(offset - 100) view.settle() - let moved = offset - max(0, offset - 40) + let moved = offset - max(0, offset - 100) #expect(view.rowTop(key) == top + moved, "fling \(flings) at \(offset)") } #expect(view.scrollRequests == before, "scrolled under fling \(flings)") From 1fdb2eacdee63cece3443b4b470e174e79b0cf5a Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 20 Oct 2026 05:32:09 +0000 Subject: [PATCH 070/104] 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 --- swift/TestHost/UITests/VirtualListUITests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/swift/TestHost/UITests/VirtualListUITests.swift b/swift/TestHost/UITests/VirtualListUITests.swift index d27c214f2b..6c81560c0c 100644 --- a/swift/TestHost/UITests/VirtualListUITests.swift +++ b/swift/TestHost/UITests/VirtualListUITests.swift @@ -133,7 +133,8 @@ final class VirtualListUITests: XCTestCase { launch("chat") waitUntil("the latest message is at the bottom") { atBottom("m0199") } var reachedStart = false - for _ in 0..<40 where !reachedStart { + // About 4 messages a swipe, 200 messages: stops once m0000 is in place. + for _ in 0..<70 where !reachedStart { scrollTowardTop() rest() assertHoldsStill() From 9e2e1fe05bf2a47d08c2d53cf3f36659c21709d5 Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 20 Oct 2026 16:10:02 +0000 Subject: [PATCH 071/104] 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 --- swift/Sources/ZeroVirtual/VirtualizerCore.swift | 13 +++++++++++++ .../ZeroVirtualTests/ScrollGeometryHostTests.swift | 9 +++++++++ 2 files changed, 22 insertions(+) diff --git a/swift/Sources/ZeroVirtual/VirtualizerCore.swift b/swift/Sources/ZeroVirtual/VirtualizerCore.swift index 21274c6753..bd2d994b26 100644 --- a/swift/Sources/ZeroVirtual/VirtualizerCore.swift +++ b/swift/Sources/ZeroVirtual/VirtualizerCore.swift @@ -765,9 +765,22 @@ package final class VirtualizerCore< guard let self, self.spaceAdjust < 0 || (self.rows.atStart && self.spaceAdjust > 0) else { return } self.withNotify { + let removed = + max(0, self.estimatedSpaceBefore + self.spaceAdjust) + - max(0, self.estimatedSpaceBefore) self.spaceAdjust = 0 self.reconciling = true self.version += 1 + // Space taken out goes with its scroll, so the view applies both in + // one pass; scrolling once the rows report would draw a pass of the + // shorter content at the old offset. (Within the space of the top, + // the rows move up with it.) Space put back is compensated once the + // rows report: the view can't scroll past content it doesn't have. + guard removed > 0, self.manual, !self.anchorSuppressed, self.isListContextCurrent, + !self.landing, let host = self.host, host.scrollOffset > 0 + else { return } + self.anchorOffset -= removed + self.setScrollOffset(host.scrollOffset - removed) } } } diff --git a/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift b/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift index c31a40cb76..93f78c6960 100644 --- a/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift +++ b/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift @@ -105,6 +105,9 @@ final class GeometryScrollView { host.rowFrames().first { $0.key == AnyHashable(key) }.map { $0.minY - host.scrollOffset } } + /// Where the row was drawn on the last pass, from the viewport's top. + func drawnTop(_ key: String) -> Double? { reported[key].map { $0.minY - offset - insetTop } } + var bottomGap: Double { host.contentHeight - host.scrollOffset - host.viewportHeight } } @@ -351,7 +354,13 @@ extension ScrollGeometryHostTests { #expect(view.scrollRequests == before, "scrolled under fling \(flings)") // At rest: the space is given back, nothing on screen moving. guard let key = h.core.firstVisibleItem()?.key, let top = view.rowTop(key) else { return } + let (space, first) = (h.core.snapshot.spaceBefore, h.core.snapshot.items.first?.key) h.scheduler.advance(by: 200) + // Space taken out goes with its scroll: no pass drawn at the old offset. + view.pass() + if h.core.snapshot.spaceBefore < space, h.core.snapshot.items.first?.key == first { + #expect(view.drawnTop(key) == top, "the give-back after fling \(flings) drew rows moved") + } view.settle() #expect(view.rowTop(key) == top, "the give-back after fling \(flings) moved the rows") } From bf4117627adaa750c86c6a1d4416764c76a290f8 Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 20 Oct 2026 16:45:57 +0000 Subject: [PATCH 072/104] 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 --- swift/Sources/ZeroVirtual/VirtualizerCore.swift | 13 ------------- .../ZeroVirtualTests/ScrollGeometryHostTests.swift | 9 --------- 2 files changed, 22 deletions(-) diff --git a/swift/Sources/ZeroVirtual/VirtualizerCore.swift b/swift/Sources/ZeroVirtual/VirtualizerCore.swift index bd2d994b26..21274c6753 100644 --- a/swift/Sources/ZeroVirtual/VirtualizerCore.swift +++ b/swift/Sources/ZeroVirtual/VirtualizerCore.swift @@ -765,22 +765,9 @@ package final class VirtualizerCore< guard let self, self.spaceAdjust < 0 || (self.rows.atStart && self.spaceAdjust > 0) else { return } self.withNotify { - let removed = - max(0, self.estimatedSpaceBefore + self.spaceAdjust) - - max(0, self.estimatedSpaceBefore) self.spaceAdjust = 0 self.reconciling = true self.version += 1 - // Space taken out goes with its scroll, so the view applies both in - // one pass; scrolling once the rows report would draw a pass of the - // shorter content at the old offset. (Within the space of the top, - // the rows move up with it.) Space put back is compensated once the - // rows report: the view can't scroll past content it doesn't have. - guard removed > 0, self.manual, !self.anchorSuppressed, self.isListContextCurrent, - !self.landing, let host = self.host, host.scrollOffset > 0 - else { return } - self.anchorOffset -= removed - self.setScrollOffset(host.scrollOffset - removed) } } } diff --git a/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift b/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift index 93f78c6960..c31a40cb76 100644 --- a/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift +++ b/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift @@ -105,9 +105,6 @@ final class GeometryScrollView { host.rowFrames().first { $0.key == AnyHashable(key) }.map { $0.minY - host.scrollOffset } } - /// Where the row was drawn on the last pass, from the viewport's top. - func drawnTop(_ key: String) -> Double? { reported[key].map { $0.minY - offset - insetTop } } - var bottomGap: Double { host.contentHeight - host.scrollOffset - host.viewportHeight } } @@ -354,13 +351,7 @@ extension ScrollGeometryHostTests { #expect(view.scrollRequests == before, "scrolled under fling \(flings)") // At rest: the space is given back, nothing on screen moving. guard let key = h.core.firstVisibleItem()?.key, let top = view.rowTop(key) else { return } - let (space, first) = (h.core.snapshot.spaceBefore, h.core.snapshot.items.first?.key) h.scheduler.advance(by: 200) - // Space taken out goes with its scroll: no pass drawn at the old offset. - view.pass() - if h.core.snapshot.spaceBefore < space, h.core.snapshot.items.first?.key == first { - #expect(view.drawnTop(key) == top, "the give-back after fling \(flings) drew rows moved") - } view.settle() #expect(view.rowTop(key) == top, "the give-back after fling \(flings) moved the rows") } From ca6cec0e3307ca4815868e37852956d67eb60f61 Mon Sep 17 00:00:00 2001 From: tmm Date: Tue, 20 Oct 2026 13:29:26 -0400 Subject: [PATCH 073/104] 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. --- swift/TestHost/App/FrameProbe.swift | 431 ++++++++++++++++++ swift/TestHost/App/ProbeScript.swift | 85 ++++ swift/TestHost/App/Screens.swift | 25 +- swift/TestHost/Info.plist | 24 + .../TestHost/UITests/FrameProbeUITests.swift | 105 +++++ swift/TestHost/project.yml | 6 + 6 files changed, 672 insertions(+), 4 deletions(-) create mode 100644 swift/TestHost/App/FrameProbe.swift create mode 100644 swift/TestHost/App/ProbeScript.swift create mode 100644 swift/TestHost/Info.plist create mode 100644 swift/TestHost/UITests/FrameProbeUITests.swift diff --git a/swift/TestHost/App/FrameProbe.swift b/swift/TestHost/App/FrameProbe.swift new file mode 100644 index 0000000000..2f82c43205 --- /dev/null +++ b/swift/TestHost/App/FrameProbe.swift @@ -0,0 +1,431 @@ +import Observation +import SwiftUI + +#if os(iOS) + import UIKit + typealias PlatformView = UIView + typealias PlatformScrollView = UIScrollView +#else + import AppKit + typealias PlatformView = NSView + typealias PlatformScrollView = NSScrollView +#endif + +/// Checks every display frame for a wrong position: with `-probe`, each row +/// carries a marker view, and on every frame the probe reads where the rows +/// are on screen. A frame is wrong when the rows move by anything but the +/// scroll that drove them: a page loading, space changing, or a jump drawn +/// at a position the list never meant to show. +/// +/// - Driven by the probe itself (`drive`), the scroll is known exactly: the +/// rows must move by it, to the half point. +/// - Flung by a finger (iOS), the momentum is smooth: a frame whose speed +/// departs from both of its neighbors' is a jump in it. +/// - At rest, nothing moves. +/// - A jump (Latest) shows the rows as they were, then as they end up, and +/// nothing in between. +/// +/// Every frame also checks that the rows move together. The summary is the +/// `probe` element's value, for the UI tests. +@MainActor @Observable +final class FrameProbe { + static let shared = FrameProbe() + static let enabled = ProcessInfo.processInfo.arguments.contains("-probe") + static var script: String? { UserDefaults.standard.string(forKey: "probe-script") } + + /// The summary the UI tests read. + private(set) var summary = "wrong=0" + + @ObservationIgnored private var markers: [String: WeakView] = [:] + @ObservationIgnored private(set) weak var scrollView: PlatformScrollView? + @ObservationIgnored private var previous: Sample? + @ObservationIgnored private var smooth: [(v: Double, real: Bool, sample: Sample, dy: Double)] = + [] + @ObservationIgnored private var applied: Double? + @ObservationIgnored private var drive: (velocity: Double, done: CheckedContinuation)? + @ObservationIgnored private var jump: [Sample]? + @ObservationIgnored private var jumpStill = 0.0 + @ObservationIgnored private var flingStart: (t: Double, moved: Double)? + @ObservationIgnored private(set) var phase = "start" + @ObservationIgnored private var wrongByPhase: [(String, Int)] = [] + @ObservationIgnored private(set) var wrong = 0 + @ObservationIgnored private var frames = 0 + @ObservationIgnored private var flings: [String] = [] + @ObservationIgnored private var done = false + @ObservationIgnored private var ticker: AnyObject? + + struct Sample { + var t: Double + /// Rows on screen: top and bottom from the viewport's top. + var rows: [String: (minY: Double, maxY: Double)] + var tracking = false + var decelerating = false + var atEdge = false + + /// Top to bottom. + var ids: [String] { rows.sorted { $0.value.minY < $1.value.minY }.map(\.key) } + } + + /// The rows on screen in the last frame, top to bottom. + var visible: [String] { previous?.ids ?? [] } + func top(of id: String) -> Double? { previous?.rows[id]?.minY } + func bottom(of id: String) -> Double? { previous?.rows[id]?.maxY } + var viewportHeight: Double { + #if os(iOS) + guard let s = scrollView else { return 0 } + return s.bounds.height - s.adjustedContentInset.top - s.adjustedContentInset.bottom + #else + return Double(scrollView?.contentView.bounds.height ?? 0) + #endif + } + + func register(_ id: String, _ view: PlatformView) { + markers[id] = WeakView(view: view) + if scrollView == nil { scrollView = Self.enclosingScrollView(view) } + if ticker == nil, let scrollView { start(scrollView) } + } + + func unregister(_ id: String, _ view: PlatformView) { + if markers[id]?.view === view { markers[id] = nil } + } + + func setPhase(_ name: String) { + finishJump() + log("PHASE \(name)") + phase = name + if !wrongByPhase.contains(where: { $0.0 == name }) { wrongByPhase.append((name, 0)) } + publish() + } + + func finish() { + finishJump() + done = true + log("DONE \(summary)") + publish() + } + + /// A programmatic jump starts: the frames until it settles are checked + /// as a jump. + func beginJump() { + guard Self.enabled else { return } + finishJump() + jump = previous.map { [$0] } ?? [] + jumpStill = 0 + log("JUMP begin top=\(previous?.ids.first ?? "-")") + } + + /// Scrolls as a fling would: `velocity` points per second toward the + /// content's end (negative: toward its start), decaying as UIKit's does. + func drive(velocity: Double) async { + await withCheckedContinuation { drive = (velocity, $0) } + } + + // MARK: Frames + + private func start(_ view: PlatformView) { + #if os(iOS) + let link = CADisplayLink(target: Tick(self), selector: #selector(Tick.tick)) + link.preferredFrameRateRange = CAFrameRateRange(minimum: 80, maximum: 120, preferred: 120) + link.add(to: .main, forMode: .common) + ticker = link + #else + let link = view.displayLink(target: Tick(self), selector: #selector(Tick.tick)) + link.add(to: .main, forMode: .common) + ticker = link + #endif + } + + fileprivate func tick(_ t: Double) { + guard let scrollView else { return } + if scrollView.window == nil { return } + let sample = sample(t, scrollView) + frames += 1 + if let previous { check(previous, sample) } + previous = sample + step(t, scrollView) + } + + private func sample(_ t: Double, _ scrollView: PlatformScrollView) -> Sample { + var s = Sample(t: t, rows: [:]) + #if os(iOS) + let port = scrollView.convert(scrollView.bounds, to: nil) + .inset(by: scrollView.adjustedContentInset) + for (id, m) in markers { + guard let v = m.view, v.window != nil, v.bounds.height > 0 else { continue } + let r = v.convert(v.bounds, to: nil) + guard r.maxY > port.minY + 0.5, r.minY < port.maxY - 0.5 else { continue } + s.rows[id] = (Double(r.minY - port.minY), Double(r.maxY - port.minY)) + } + s.tracking = scrollView.isTracking + s.decelerating = scrollView.isDecelerating + let y = scrollView.contentOffset.y + scrollView.adjustedContentInset.top + s.atEdge = y <= 0.5 || y >= scrollView.contentSize.height - viewportHeight - 0.5 + #else + let clip = scrollView.contentView + let port = clip.convert(clip.bounds, to: nil) + for (id, m) in markers { + guard let v = m.view, v.window != nil, v.bounds.height > 0 else { continue } + let r = v.convert(v.bounds, to: nil) + // Window coordinates count up from the bottom. + let minY = port.maxY - r.maxY + let maxY = port.maxY - r.minY + guard maxY > 0.5, minY < port.height - 0.5 else { continue } + s.rows[id] = (Double(minY), Double(maxY)) + } + let y = clip.bounds.origin.y + let height = scrollView.documentView?.frame.height ?? 0 + s.atEdge = y <= 0.5 || y >= height - clip.bounds.height - 0.5 + #endif + return s + } + + private func check(_ a: Sample, _ b: Sample) { + let driven = applied + applied = nil + if jump != nil { + jump!.append(b) + if moved(a, b) { + jumpStill = 0 + } else { + jumpStill += b.t - a.t + } + if jumpStill >= 0.5 { finishJump() } + return + } + let common = a.rows.keys.filter { b.rows[$0] != nil } + guard !common.isEmpty else { + if !a.rows.isEmpty && !b.rows.isEmpty { flag(b, "no row in both frames", a) } + return + } + let deltas = common.map { (id: $0, dy: b.rows[$0]!.minY - a.rows[$0]!.minY) } + let spread = (deltas.map(\.dy).max() ?? 0) - (deltas.map(\.dy).min() ?? 0) + if spread > 1 { flag(b, "rows moved apart by \(fmt(spread))", a) } + // The anchor: the top row on screen in both frames. + let anchor = common.min { a.rows[$0]!.minY < a.rows[$1]!.minY }! + let dy = b.rows[anchor]!.minY - a.rows[anchor]!.minY + let real = b.tracking || b.decelerating || a.tracking || a.decelerating + if let driven { + if abs(dy + driven) > 0.75 { + flag(b, "\(anchor) moved \(fmt(dy)), the drive \(fmt(-driven))", a) + } + } else if real { + smoothCheck(dy: dy, a: a, b: b) + } else if abs(dy) > 0.5 { + flag(b, "\(anchor) moved \(fmt(dy)) at rest", a) + } + trackFling(a, b, dy) + } + + /// Momentum is smooth: a frame whose speed departs from both neighbors' + /// is a jump. Checked one frame late, once the next frame is in. + private func smoothCheck(dy: Double, a: Sample, b: Sample) { + let dt = max(0.001, b.t - a.t) + smooth.append((dy / dt, !(a.tracking || b.tracking), b, dy)) + if smooth.count > 3 { smooth.removeFirst() } + guard smooth.count == 3, smooth.allSatisfy(\.real) else { return } + let (p, m, n) = (smooth[0], smooth[1], smooth[2]) + let limit = 300.0 + if abs(m.v - p.v) > limit && abs(m.v - n.v) > limit { + flag(m.sample, "speed \(fmt(m.v)) between \(fmt(p.v)) and \(fmt(n.v)) (moved \(fmt(m.dy)))") + } + } + + private func trackFling(_ a: Sample, _ b: Sample, _ dy: Double) { + if b.decelerating && !b.tracking { + if flingStart == nil { flingStart = (b.t, 0) } + flingStart!.moved += dy + } else if let start = flingStart, !b.decelerating { + flingStart = nil + let entry = "\(fmt(b.t - start.t))s/\(fmt(abs(start.moved)))pt\(b.atEdge ? "/edge" : "")" + flings.append(entry) + log("FLING \(entry)") + publish() + } + } + + private func finishJump() { + guard let frames = jump, let first = frames.first, let last = frames.last else { + jump = nil + return + } + jump = nil + var bad = 0 + // Each state the jump showed, once. + for (i, f) in frames.enumerated() where i == 0 || !same(f, frames[i - 1]) { + let top = f.ids.first.map { "\($0)@\(fmt(f.rows[$0]!.minY))" } ?? "-" + let ms = fmt((f.t - first.t) * 1000) + log("JUMP frame \(i) +\(ms)ms \(top)…\(f.ids.last ?? "-") rows=\(f.rows.count)") + } + for f in frames.dropFirst() where !same(f, first) && !same(f, last) { + bad += 1 + flag(f, "jump frame neither where it was (\(first.ids.first ?? "-")) nor where it lands") + } + log( + "JUMP end frames=\(frames.count) wrong=\(bad) from \(first.ids.first ?? "-")…\(first.ids.last ?? "-") to \(last.ids.first ?? "-")…\(last.ids.last ?? "-")" + ) + } + + private func moved(_ a: Sample, _ b: Sample) -> Bool { !same(a, b) } + + private func same(_ a: Sample, _ b: Sample) -> Bool { + guard Set(a.rows.keys) == Set(b.rows.keys) else { return false } + return a.rows.allSatisfy { abs($0.value.minY - b.rows[$0.key]!.minY) <= 0.5 } + } + + private func flag(_ s: Sample, _ why: String, _ before: Sample? = nil) { + wrong += 1 + if let i = wrongByPhase.firstIndex(where: { $0.0 == phase }) { wrongByPhase[i].1 += 1 } + let rows = s.ids.prefix(2).map { "\($0)@\(fmt(s.rows[$0]!.minY))" }.joined(separator: " ") + let was = before.map { b in + " was " + b.ids.prefix(2).map { "\($0)@\(fmt(b.rows[$0]!.minY))" }.joined(separator: " ") + } + log("WRONG [\(phase)] \(why): \(rows)\(was ?? "")") + publish() + } + + private func publish() { + let phases = wrongByPhase.map { "\($0.0):\($0.1)" }.joined(separator: ",") + summary = + "wrong=\(wrong) frames=\(frames) phases=[\(phases)] flings=[\(flings.suffix(8).joined(separator: ","))]\(done ? " done" : "")" + } + + private func log(_ s: String) { + print("PROBE \(String(format: "%.3f", Date().timeIntervalSince1970)) \(s)") + fflush(stdout) + } + + private func fmt(_ x: Double) -> String { String(format: "%.1f", x) } + + // MARK: Driving + + /// Moves the scroll view by this frame's share of the drive, after the + /// frame was checked: the next frame must show the rows moved by it. + private func step(_ t: Double, _ scrollView: PlatformScrollView) { + guard let d = drive else { return } + let dt = 1.0 / 120 + let move = d.velocity * dt + #if os(iOS) + let inset = scrollView.adjustedContentInset + let lo = -inset.top + let hi = max(lo, scrollView.contentSize.height + inset.bottom - scrollView.bounds.height) + let from = scrollView.contentOffset.y + let to = min(hi, max(lo, from + move)) + scrollView.contentOffset.y = to + #else + let clip = scrollView.contentView + let height = scrollView.documentView?.frame.height ?? 0 + let from = clip.bounds.origin.y + let to = min(max(0, height - clip.bounds.height), max(0, from + move)) + clip.scroll(to: CGPoint(x: clip.bounds.origin.x, y: to)) + scrollView.reflectScrolledClipView(clip) + #endif + applied = Double(to - from) + // UIKit's deceleration: 0.998 a millisecond. + let v = d.velocity * pow(0.998, dt * 1000) + if abs(v) < 20 || to == from { + drive = nil + d.done.resume() + } else { + drive = (v, d.done) + } + } + + private static func enclosingScrollView(_ view: PlatformView) -> PlatformScrollView? { + #if os(iOS) + var v: UIView? = view.superview + while let s = v { + if let scroll = s as? UIScrollView { return scroll } + v = s.superview + } + return nil + #else + return view.enclosingScrollView + #endif + } +} + +private struct WeakView { + weak var view: PlatformView? +} + +/// The display link's target: a link retains it, not the probe. +private final class Tick: NSObject { + weak var probe: FrameProbe? + init(_ probe: FrameProbe) { self.probe = probe } + + @MainActor @objc func tick(_ link: CADisplayLink) { probe?.tick(link.timestamp) } +} + +/// A row's marker: a view the size of the row, which the probe reads. +struct ProbeMarker: View { + let id: String + + var body: some View { + if FrameProbe.enabled { MarkerView(id: id) } + } +} + +#if os(iOS) + private struct MarkerView: UIViewRepresentable { + let id: String + func makeUIView(context: Context) -> Marker { Marker(id) } + func updateUIView(_ view: Marker, context: Context) {} + } + + private final class Marker: UIView { + let id: String + init(_ id: String) { + self.id = id + super.init(frame: .zero) + isUserInteractionEnabled = false + } + required init?(coder: NSCoder) { fatalError() } + override func didMoveToWindow() { + super.didMoveToWindow() + if window != nil { + FrameProbe.shared.register(id, self) + } else { + FrameProbe.shared.unregister(id, self) + } + } + } +#else + private struct MarkerView: NSViewRepresentable { + let id: String + func makeNSView(context: Context) -> Marker { Marker(id) } + func updateNSView(_ view: Marker, context: Context) {} + } + + private final class Marker: NSView { + let id: String + init(_ id: String) { + self.id = id + super.init(frame: .zero) + } + required init?(coder: NSCoder) { fatalError() } + override func hitTest(_ point: NSPoint) -> NSView? { nil } + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window != nil { + FrameProbe.shared.register(id, self) + } else { + FrameProbe.shared.unregister(id, self) + } + } + } +#endif + +/// The probe's summary, as an element the UI tests read. +struct ProbeSummary: View { + @State private var probe = FrameProbe.shared + + var body: some View { + if FrameProbe.enabled { + Text(probe.summary.prefix(40)) + .font(.system(size: 8)) + .lineLimit(1) + .accessibilityIdentifier("probe") + .accessibilityValue(probe.summary) + } + } +} diff --git a/swift/TestHost/App/ProbeScript.swift b/swift/TestHost/App/ProbeScript.swift new file mode 100644 index 0000000000..c363c5edf7 --- /dev/null +++ b/swift/TestHost/App/ProbeScript.swift @@ -0,0 +1,85 @@ +import Foundation + +/// The transitions `FrameProbe` checks, driven by the app itself +/// (`-probe -probe-script chat|issues`), so they run the same with or +/// without UI tests. Each is a phase of the probe's summary. +@MainActor +enum ProbeScript { + static var probe: FrameProbe { .shared } + + static func chat( + latestID: () -> String?, toLatest: () -> Void, receive: () -> Void + ) async { + probe.setPhase("open") + await until(10) { latestID().map { probe.visible.last == $0 } ?? false } + await sleep(1) + + // Older pages of many heights load under each fling; at each rest the + // space before the rows is given back; at the top, it's taken away. + probe.setPhase("older") + for _ in 0..<80 where !atTop("m0000") { + await probe.drive(velocity: -2000) + await sleep(0.6) + } + await sleep(1) + + probe.setPhase("latest") + toLatest() + await sleep(1.5) + + // Far up, then Latest: with a message received while away, and without. + for round in 0..<6 { + let withReceive = round % 2 == 1 + probe.setPhase("far-up") + for _ in 0..<10 { + await probe.drive(velocity: -2500) + await sleep(0.4) + } + await sleep(1) + probe.setPhase(withReceive ? "receive-latest" : "latest") + if withReceive { + receive() + await sleep(1) + } + toLatest() + await sleep(1.5) + } + probe.finish() + } + + static func issues() async { + probe.setPhase("open") + await until(10) { probe.visible.contains("i0000") } + await sleep(1) + + probe.setPhase("down") + for _ in 0..<60 where deepest() < 400 { + await probe.drive(velocity: 2000) + await sleep(0.5) + } + probe.setPhase("up") + for _ in 0..<80 where !atTop("i0000") { + await probe.drive(velocity: -2000) + await sleep(0.5) + } + await sleep(1) + probe.finish() + } + + static func atTop(_ id: String) -> Bool { + probe.top(of: id).map { abs($0) <= 0.5 } ?? false + } + + static func deepest() -> Int { + probe.visible.compactMap { Int($0.dropFirst()) }.max() ?? 0 + } + + static func until(_ seconds: Double, _ done: () -> Bool) async { + let end = Date().addingTimeInterval(seconds) + while Date() < end, !done() { await sleep(0.05) } + } + + static func sleep(_ seconds: Double) async { + try? await Task.sleep(for: .seconds(seconds)) + } +} diff --git a/swift/TestHost/App/Screens.swift b/swift/TestHost/App/Screens.swift index c7909bf087..80dd99f5f3 100644 --- a/swift/TestHost/App/Screens.swift +++ b/swift/TestHost/App/Screens.swift @@ -29,18 +29,28 @@ struct ChatScreen: View { HStack { Button("Receive") { backend.receive() }.accessibilityIdentifier("receive") Button("Grow last") { backend.growLast() }.accessibilityIdentifier("grow") - Button("Latest") { - if let latest { list.scrollToItem(latest.id, align: .end) } - } - .accessibilityIdentifier("latest") + Button("Latest") { toLatest() } + .accessibilityIdentifier("latest") } .padding(8) } + .overlay(alignment: .topTrailing) { ProbeSummary() } .onChange(of: latest?.id, initial: true) { guard !started, let id = latest?.id else { return } started = true list.scrollToItem(id, align: .end) } + .task { + guard FrameProbe.script == "chat" else { return } + await ProbeScript.chat( + latestID: { latest?.id }, toLatest: toLatest, receive: { backend.receive() }) + } + } + + private func toLatest() { + guard let latest else { return } + FrameProbe.shared.beginJump() + list.scrollToItem(latest.id, align: .end) } } @@ -61,6 +71,7 @@ struct MessageRow: View { .accessibilityElement(children: .ignore) .accessibilityLabel(message.text) .accessibilityIdentifier("row-\(message.id)") + .background(ProbeMarker(id: message.id)) } } @@ -78,6 +89,11 @@ struct IssuesScreen: View { issues Divider() } + .overlay(alignment: .topTrailing) { ProbeSummary() } + .task { + guard FrameProbe.script == "issues" else { return } + await ProbeScript.issues() + } } private var issues: some View { @@ -95,6 +111,7 @@ struct IssuesScreen: View { .accessibilityElement(children: .ignore) .accessibilityLabel(item.row.title) .accessibilityIdentifier("row-\(item.row.id)") + .background(ProbeMarker(id: item.row.id)) } .accessibilityIdentifier("list") } diff --git a/swift/TestHost/Info.plist b/swift/TestHost/Info.plist new file mode 100644 index 0000000000..a6f0539fa0 --- /dev/null +++ b/swift/TestHost/Info.plist @@ -0,0 +1,24 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/swift/TestHost/UITests/FrameProbeUITests.swift b/swift/TestHost/UITests/FrameProbeUITests.swift new file mode 100644 index 0000000000..60e7d9dfbe --- /dev/null +++ b/swift/TestHost/UITests/FrameProbeUITests.swift @@ -0,0 +1,105 @@ +import XCTest + +/// Every display frame of the list's transitions, as `FrameProbe` checks +/// them in the app: no frame may show the rows anywhere but where the +/// scroll put them. The app drives the transitions itself +/// (`-probe-script`); the probe's summary is the `probe` element's value. +@MainActor +final class FrameProbeUITests: XCTestCase { + var app: XCUIApplication! + + override func setUp() async throws { + continueAfterFailure = true + } + + func launch(_ screen: String, script: Bool) { + app = XCUIApplication() + app.launchArguments = + ["-screen", screen] + (script ? ["-probe-script", screen] : []) + ["-probe", "1"] + app.launch() + } + + var summary: String { app.staticTexts["probe"].value as? String ?? "" } + + /// Wrong frames by phase, from `phases=[name:count,…]`. + func phases(_ s: String) -> [String: Int] { + guard let open = s.range(of: "phases=["), let close = s[open.upperBound...].firstIndex(of: "]") + else { return [:] } + var out: [String: Int] = [:] + for part in s[open.upperBound.. Int? { + s.split(separator: " ").first { $0.hasPrefix("wrong=") }.flatMap { Int($0.dropFirst(6)) } + } + + /// `flings=[2.7s/702.0pt,…]`: each fling's length, and whether it ended + /// at an edge. + func flings(_ s: String) -> [(seconds: Double, points: Double, edge: Bool)] { + guard let open = s.range(of: "flings=["), let close = s[open.upperBound...].firstIndex(of: "]") + else { return [] } + return s[open.upperBound..= 2, let t = Double(p[0].dropLast()), let d = Double(p[1].dropLast(2)) + else { return nil } + return (t, d, p.count > 2) + } + } + + func runScript(_ screen: String, phases expected: [String]) { + launch(screen, script: true) + let end = Date().addingTimeInterval(900) + while Date() < end, !summary.hasSuffix(" done") { + RunLoop.current.run(until: Date().addingTimeInterval(2)) + } + let s = summary + XCTAssertTrue(s.hasSuffix(" done"), "the script didn't finish: \(s)") + let byPhase = phases(s) + for name in expected { + XCTAssertEqual(byPhase[name], 0, "wrong frames in \(name): \(s)") + } + XCTAssertEqual(wrong(s), 0, s) + } + + func testChatShowsNoWrongFrame() { + runScript("chat", phases: ["open", "older", "latest", "far-up", "receive-latest"]) + } + + func testIssuesShowNoWrongFrame() { + runScript("issues", phases: ["open", "down", "up"]) + } + + #if os(iOS) + /// Real flings, by a finger: each runs to its natural end (about 2.7 s), + /// pages loading under it, with no wrong frame. + func flingThrough(_ screen: String, _ moves: [Bool]) { + launch(screen, script: false) + let list = app.scrollViews.firstMatch + XCTAssertTrue(list.waitForExistence(timeout: 10)) + RunLoop.current.run(until: Date().addingTimeInterval(2)) + for up in moves { + if up { list.swipeDown(velocity: .fast) } else { list.swipeUp(velocity: .fast) } + RunLoop.current.run(until: Date().addingTimeInterval(3.5)) + } + let s = summary + XCTAssertEqual(wrong(s), 0, s) + let all = flings(s) + XCTAssertFalse(all.isEmpty, s) + for f in all where !f.edge { + XCTAssertGreaterThan(f.seconds, 2.2, "a fling cut short: \(s)") + } + } + + /// Up from the latest message, older pages of many heights loading. + func testChatFlingsUpRunTheirLength() { flingThrough("chat", Array(repeating: true, count: 8)) } + + /// Down a few hundred rows, then back up as earlier pages load. + func testIssuesFlingsRunTheirLength() { + flingThrough("issues", Array(repeating: false, count: 10) + Array(repeating: true, count: 8)) + } + #endif +} diff --git a/swift/TestHost/project.yml b/swift/TestHost/project.yml index 29653fea76..6019a9c7f1 100644 --- a/swift/TestHost/project.yml +++ b/swift/TestHost/project.yml @@ -20,6 +20,12 @@ targets: testTargets: [TestHostUITests] sources: - path: App + # ProMotion: frames at up to 120 Hz, which the frame probe checks. Merged + # with the generated Info.plist. + info: + path: Info.plist + properties: + CADisableMinimumFrameDurationOnPhone: true dependencies: - package: Zero product: ZeroVirtual From d026dc589465b0e37d9c7547cc120949ded29868 Mon Sep 17 00:00:00 2001 From: tmm Date: Tue, 20 Oct 2026 16:23:26 -0400 Subject: [PATCH 074/104] fix(zero-swift): paging waits for the space that keeps rows in place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Sources/ZeroVirtual/VirtualizerCore.swift | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/swift/Sources/ZeroVirtual/VirtualizerCore.swift b/swift/Sources/ZeroVirtual/VirtualizerCore.swift index 21274c6753..c8a232adb4 100644 --- a/swift/Sources/ZeroVirtual/VirtualizerCore.swift +++ b/swift/Sources/ZeroVirtual/VirtualizerCore.swift @@ -529,7 +529,9 @@ package final class VirtualizerCore< lastSettleContext = options.listContext resetSettleTimer() } - measureAndCompensate() + // Space that takes up rows moving moves them back on the next layout: + // the rows aren't where paging would read them until then. + let respaced = measureAndCompensate() restoreOrReset() updatePageSize() land() @@ -539,7 +541,7 @@ package final class VirtualizerCore< updatePaging() resolveProbe() repinToBottom() - evaluatePaging() + if !respaced { evaluatePaging() } persist(onlyIfChanged: true) } @@ -728,29 +730,35 @@ package final class VirtualizerCore< setScrollOffset(host.scrollOffset + delta) } - private func measureAndCompensate() { - guard manual, !anchorSuppressed, isListContextCurrent, !landing, let host else { return } + /// Keeps the anchor row in place; true when the space before the rows + /// took up its move, which the next layout applies. + @discardableResult + private func measureAndCompensate() -> Bool { + guard manual, !anchorSuppressed, isListContextCurrent, !landing, let host else { + return false + } // At the top, rows added above are revealed, not compensated away. if host.scrollOffset <= 0 { refreshAnchor() - return + return false } guard let key = anchorKey, let ref = frame(host, key: key) else { refreshAnchor() - return + return false } let delta = ref.minY - anchorOffset defer { reconciling = false } - if abs(delta) < 0.5 { return } + if abs(delta) < 0.5 { return false } // Taken up by the space before the rows, which moves them back, when // it can be: the scroll view isn't touched, so a fling carries on. let space = snapshot.spaceBefore - delta if !reconciling, space >= 0 { spaceAdjust = space - estimatedSpaceBefore version += 1 - return + return true } compensate(delta) + return false } // Once scrolling stops, space short of its estimate goes back to it, and From a018d9855b9c0c592c8e48d17be4c29387b0e746 Mon Sep 17 00:00:00 2001 From: tmm Date: Tue, 20 Oct 2026 16:23:49 -0400 Subject: [PATCH 075/104] 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). --- swift/Sources/ZeroVirtual/LayoutHost.swift | 104 ++++++ .../Sources/ZeroVirtual/RowsScrollView.swift | 310 ++++++++++++++++++ swift/Sources/ZeroVirtual/VirtualList.swift | 116 +++---- .../Sources/ZeroVirtual/VirtualizerCore.swift | 2 + .../Sources/ZeroVirtual/ZeroVirtualizer.swift | 3 + 5 files changed, 480 insertions(+), 55 deletions(-) create mode 100644 swift/Sources/ZeroVirtual/LayoutHost.swift create mode 100644 swift/Sources/ZeroVirtual/RowsScrollView.swift diff --git a/swift/Sources/ZeroVirtual/LayoutHost.swift b/swift/Sources/ZeroVirtual/LayoutHost.swift new file mode 100644 index 0000000000..49322c0df3 --- /dev/null +++ b/swift/Sources/ZeroVirtual/LayoutHost.swift @@ -0,0 +1,104 @@ +/// A `VirtualHost` for a scroll view that lays its rows out itself, in one +/// layout pass: the rows' frames, the content's height and the offset are +/// worked out together, and the view applies them before it draws. The core +/// runs inside the pass: what it does about a pass (a scroll that keeps the +/// rows in place, a new snapshot) is in the same pass, so no frame shows the +/// content moved and the offset not yet. +/// +/// The view calls `layout(offset:viewport:)` from its layout pass, with the +/// offset it's at (the user's scroll, since the last pass) and its +/// viewport's height. Rows are measured with `measure` and stacked from the +/// snapshot's space before them. +@MainActor +package final class LayoutHost: VirtualHost { + package private(set) var scrollOffset = 0.0 + package private(set) var viewportHeight = 0.0 + package var viewportInsets: (top: Double, bottom: Double) { (0, 0) } + package private(set) var contentHeight = 0.0 + + /// The rows to lay out, and the space before and after them: the + /// snapshot's. + package var rows: () -> (keys: [(key: AnyHashable, index: Int)], before: Double, after: Double) = + { ([], 0, 0) } + /// A row's height, at the view's width. + package var measure: (AnyHashable) -> Double = { _ in 0 } + package var didScroll: () -> Void = {} + package var viewportDidResize: () -> Void = {} + package var afterUpdate: () -> Void = {} + /// A jump or a restore hasn't landed yet. + package var isLanding: () -> Bool = { false } + /// Asks the view for a layout pass. + package var setNeedsLayout: () -> Void = {} + + private var frames: [VirtualRowFrame] = [] + /// A snapshot, or a row's height, not laid out yet. + private var stale = true + /// The core scrolled outside a pass: the next pass applies its offset, + /// not the view's. + private var scrolled = false + private var inPass = false + + package init() {} + + package func rowFrames() -> [VirtualRowFrame] { frames } + + /// A new snapshot, or rows that changed height. + package func invalidate() { + stale = true + if !inPass { setNeedsLayout() } + } + + package func scroll(to target: Double) { + let offset = max(0, min(target, contentHeight - viewportHeight)) + guard offset != scrollOffset else { return } + scrollOffset = offset + if !inPass { + scrolled = true + setNeedsLayout() + } + didScroll() + } + + /// One layout pass. `offset` is where the view is scrolled (the + /// viewport's top in content coordinates), `viewport` its height. Returns + /// the rows' frames, the content's height and the offset to apply. + package func layout( + offset: Double, viewport: Double + ) -> ( + frames: [VirtualRowFrame], contentHeight: Double, offset: Double + ) { + inPass = true + defer { inPass = false } + if viewport != viewportHeight { + viewportHeight = viewport + viewportDidResize() + } + if scrolled { + scrolled = false + } else if offset != scrollOffset { + // The user's scroll, reported with the rows where they were: the core + // takes its anchor from them before new rows move it. + scrollOffset = offset + didScroll() + } + // What the core does about the update can be a new snapshot; it's laid + // out in this pass too. + for _ in 0..<8 where stale { + stale = false + place() + afterUpdate() + } + return (frames, contentHeight, scrollOffset) + } + + private func place() { + let r = rows() + var y = r.before + frames = r.keys.map { item in + let height = measure(item.key) + defer { y += height } + return VirtualRowFrame(index: item.index, key: item.key, minY: y, maxY: y + height) + } + contentHeight = y + r.after + } +} diff --git a/swift/Sources/ZeroVirtual/RowsScrollView.swift b/swift/Sources/ZeroVirtual/RowsScrollView.swift new file mode 100644 index 0000000000..3eae5b968a --- /dev/null +++ b/swift/Sources/ZeroVirtual/RowsScrollView.swift @@ -0,0 +1,310 @@ +#if canImport(SwiftUI) && (canImport(UIKit) || canImport(AppKit)) + import SwiftUI + + #if canImport(UIKit) + import UIKit + #else + import AppKit + #endif + + /// A row to draw: its key and index, the row (to tell a changed row from + /// the same one), and its view. + struct RowsItem { + var key: AnyHashable + var index: Int + var row: Any + var view: () -> AnyView + } + + /// The rows the view draws: the snapshot's. + struct RowsSource { + var items: [RowsItem] = [] + var spaceBefore = 0.0 + var spaceAfter = 0.0 + } + + /// A row's hosted SwiftUI view and its measured height. + @MainActor + final class RowCell { + #if canImport(UIKit) + let controller = UIHostingController(rootView: AnyView(EmptyView())) + var view: UIView { controller.view } + #else + let controller = NSHostingController(rootView: AnyView(EmptyView())) + var view: NSView { controller.view } + #endif + var height: Double? + var row: Any? + + init() { + #if canImport(UIKit) + controller.safeAreaRegions = [] + controller.view.backgroundColor = .clear + #else + controller.sizingOptions = [] + #endif + } + + func measure(width: Double) -> Double { + if let height { return height } + let size = controller.sizeThatFits( + in: CGSize(width: width, height: .greatestFiniteMagnitude)) + let h = Double(size.height) + height = h + return h + } + } + + /// What the platform scroll views share: the rows' cells, kept and + /// reused, and the layout host that places them. + @MainActor + final class RowsLayout { + let host = LayoutHost() + var source = RowsSource() + private var cells: [AnyHashable: RowCell] = [:] + private var width = 0.0 + /// Changes with each snapshot: rows are compared with their cells' then. + private var generation = 0 + private var seen: [AnyHashable: Int] = [:] + /// The list has drawn rows. Until then, it draws none while it opens at + /// a jump: its first frame is where the jump lands, not where it starts. + private(set) var shown = false + var addCell: (RowCell) -> Void = { _ in } + + init() { + host.rows = { [unowned self] in + (source.items.map { (key: $0.key, index: $0.index) }, source.spaceBefore, source.spaceAfter) + } + host.measure = { [unowned self] key in measure(key) } + } + + /// A new snapshot to draw. + func update(_ source: RowsSource) { + self.source = source + generation += 1 + host.invalidate() + } + + /// One layout pass at `width`: the cells to place, the content's height + /// and the offset. + func layout( + width: Double, offset: Double, viewport: Double + ) -> ( + frames: [(cell: RowCell, minY: Double, maxY: Double)], contentHeight: Double, offset: Double + ) { + if width != self.width { + self.width = width + for cell in cells.values { cell.height = nil } + host.invalidate() + } + let r = host.layout(offset: offset, viewport: viewport) + if !shown, !r.frames.isEmpty, !host.isLanding() { shown = true } + let live = Set(r.frames.map(\.key)) + // A row's view isn't reused for another row: it would show the row it + // had until its next layout. + for (key, cell) in cells where !live.contains(key) { + cells[key] = nil + seen[key] = nil + cell.view.removeFromSuperview() + } + return ( + r.frames.compactMap { f in cells[f.key].map { ($0, f.minY, f.maxY) } }, r.contentHeight, + r.offset + ) + } + + private func measure(_ key: AnyHashable) -> Double { + guard let item = source.items.first(where: { $0.key == key }) else { return 0 } + let cell: RowCell + if let existing = cells[key] { + cell = existing + } else { + cell = RowCell() + addCell(cell) + cells[key] = cell + } + if seen[key] != generation { + seen[key] = generation + if let old = cell.row, sameRow(old, item.row) { + // The same row: its view and height stand. + } else { + cell.row = item.row + cell.height = nil + cell.controller.rootView = AnyView( + item.view() + .fixedSize(horizontal: false, vertical: true) + .onGeometryChange(for: Double.self) { + Double($0.size.height) + } action: { + [weak self, weak cell] h in + // The row changed height on its own, as a view inside it + // loaded. + guard let self, let cell, let old = cell.height, abs(old - h) > 0.5 else { return } + cell.height = nil + self.host.invalidate() + }) + } + } + return cell.measure(width: width) + } + } + + /// Rows of an `Equatable` type that compare equal are the same row. + private func sameRow(_ a: Any, _ b: Any) -> Bool { + guard let a = a as? any Equatable else { return false } + return a.isEqual(to: b) + } + + extension Equatable { + fileprivate func isEqual(to other: Any) -> Bool { (other as? Self) == self } + } + + #if canImport(UIKit) + /// A scroll view that lays out the rows itself: their frames, its + /// content size and its offset, all in `layoutSubviews`. + final class RowsScrollView: UIScrollView { + let rows = RowsLayout() + private var laying = false + + override init(frame: CGRect) { + super.init(frame: frame) + alwaysBounceVertical = true + rows.host.setNeedsLayout = { [weak self] in self?.setNeedsLayout() } + rows.addCell = { [weak self] cell in self?.addSubview(cell.view) } + } + + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + override func layoutSubviews() { + super.layoutSubviews() + // Applying the offset below lays the view out again. + guard !laying else { return } + laying = true + defer { laying = false } + let inset = adjustedContentInset + let width = Double(bounds.width - inset.left - inset.right) + let r = rows.layout( + width: width, offset: Double(contentOffset.y + inset.top), + viewport: Double(bounds.height - inset.top - inset.bottom)) + let scale = max(1, window?.screen.scale ?? traitCollection.displayScale) + func align(_ y: Double) -> CGFloat { (CGFloat(y) * scale).rounded() / scale } + for f in r.frames { + let frame = CGRect( + x: 0, y: align(f.minY), width: width, height: align(f.maxY) - align(f.minY)) + f.cell.view.isHidden = !rows.shown + // Set only when it moves: a frame set lays the row's view out again. + guard f.cell.view.frame != frame else { continue } + f.cell.view.frame = frame + f.cell.view.layoutIfNeeded() + } + let size = CGSize(width: width, height: r.contentHeight) + if contentSize != size { contentSize = size } + let y = CGFloat(r.offset) - inset.top + if contentOffset.y != y { contentOffset.y = y } + } + } + #else + /// A scroll view that lays out the rows itself: their frames, its + /// content's size and its offset, all in one pass. The pass runs when + /// the clip view moves or resizes (the user's scroll, before it's + /// drawn) and in the document view's `layout` (a new snapshot). + final class RowsScrollView: NSScrollView { + let rows = RowsLayout() + private let document = RowsDocumentView() + private var laying = false + + override init(frame: CGRect) { + super.init(frame: frame) + hasVerticalScroller = true + drawsBackground = false + documentView = document + document.owner = self + contentView.postsBoundsChangedNotifications = true + contentView.postsFrameChangedNotifications = true + for name in [NSView.boundsDidChangeNotification, NSView.frameDidChangeNotification] { + NotificationCenter.default.addObserver( + self, selector: #selector(clipChanged), name: name, object: contentView) + } + rows.host.setNeedsLayout = { [weak self] in self?.document.needsLayout = true } + rows.addCell = { [weak self] cell in self?.document.addSubview(cell.view) } + } + + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + @objc private func clipChanged() { layoutRows() } + + fileprivate func layoutRows() { + // Applying the size and offset below moves the clip view again. + guard !laying, window != nil else { return } + laying = true + defer { laying = false } + let clip = contentView + let inset = clip.contentInsets + let width = Double(clip.bounds.width) + let r = rows.layout( + width: width, offset: Double(clip.bounds.origin.y + inset.top), + viewport: Double(clip.bounds.height - inset.top - inset.bottom)) + for f in r.frames { + let frame = document.backingAlignedRect( + CGRect(x: 0, y: f.minY, width: width, height: f.maxY - f.minY), + options: .alignAllEdgesNearest) + f.cell.view.isHidden = !rows.shown + // Set only when it moves: a frame set lays the row's view out again. + guard f.cell.view.frame != frame else { continue } + f.cell.view.frame = frame + f.cell.view.layoutSubtreeIfNeeded() + } + let size = NSSize(width: width, height: r.contentHeight) + if document.frame.size != size { document.setFrameSize(size) } + let y = CGFloat(r.offset) - inset.top + if clip.bounds.origin.y != y { + clip.scroll(to: NSPoint(x: clip.bounds.origin.x, y: y)) + reflectScrolledClipView(clip) + } + } + } + + final class RowsDocumentView: NSView { + weak var owner: RowsScrollView? + + override var isFlipped: Bool { true } + + override func layout() { + super.layout() + owner?.layoutRows() + } + } + #endif + + /// `VirtualList`'s scroll view. + @MainActor + struct RowsScrollViewRepresentable { + let source: () -> RowsSource + let model: VirtualListModel + + func make() -> RowsScrollView { + let view = RowsScrollView(frame: .zero) + model.source = source + model.connect(view) + view.rows.update(source()) + return view + } + + func update(_ view: RowsScrollView) { + model.source = source + view.rows.update(source()) + } + } + + #if canImport(UIKit) + extension RowsScrollViewRepresentable: UIViewRepresentable { + func makeUIView(context: Context) -> RowsScrollView { make() } + func updateUIView(_ view: RowsScrollView, context: Context) { update(view) } + } + #else + extension RowsScrollViewRepresentable: NSViewRepresentable { + func makeNSView(context: Context) -> RowsScrollView { make() } + func updateNSView(_ view: RowsScrollView, context: Context) { update(view) } + } + #endif +#endif diff --git a/swift/Sources/ZeroVirtual/VirtualList.swift b/swift/Sources/ZeroVirtual/VirtualList.swift index 866a0469f1..6662a91321 100644 --- a/swift/Sources/ZeroVirtual/VirtualList.swift +++ b/swift/Sources/ZeroVirtual/VirtualList.swift @@ -1,4 +1,4 @@ -#if canImport(SwiftUI) +#if canImport(SwiftUI) && (canImport(UIKit) || canImport(AppKit)) import SwiftUI import Zero @@ -14,6 +14,14 @@ /// /// Rows can be any height, and change it. The list draws only the rows /// near the screen, with space standing in for the rest. + /// + /// Underneath is the platform's scroll view (`UIScrollView`, + /// `NSScrollView`), which lays the rows out itself: their frames, its + /// content's size and its offset are set in one layout pass, so no frame + /// shows rows moved by a page loading before the offset that keeps them + /// in place. Each row is hosted in its own hosting view: it gets the + /// platform's traits (color scheme, Dynamic Type, layout direction), but + /// not the list's custom environment values. Apply those in `row`. public struct VirtualList< Context: Sendable, Row: ZeroRow, Key: Hashable, StartRow: Equatable & Sendable, ListContext: Equatable & Sendable, RowContent: View @@ -22,8 +30,7 @@ private let list: Virtualizer private let row: (VirtualItem) -> RowContent - @State private var host = ScrollGeometryHost() - @State private var position = ScrollPosition() + @State private var model = VirtualListModel() public init( _ list: Virtualizer, @ViewBuilder row: @escaping (VirtualItem) -> RowContent @@ -33,68 +40,67 @@ } public var body: some View { - let snapshot = list.snapshot - ScrollView { - // Not lazy: the snapshot is already the rows to draw, and each must - // be measured (`ScrollGeometryHost`). - VStack(spacing: 0) { - Color.clear.frame(height: snapshot.spaceBefore) - ForEach(snapshot.items, id: \.key) { item in - row(item) - .onGeometryChange(for: CGRect.self) { - $0.frame(in: .named(Self.rows)) - } action: { [host] frame in - host.rowFrame(item.key, minY: frame.minY, maxY: frame.maxY) - } - } - Color.clear.frame(height: snapshot.spaceAfter) - } - .coordinateSpace(.named(Self.rows)) - } - .scrollPosition($position) - .onScrollGeometryChange(for: Geometry.self) { - Geometry($0) - } action: { [host] _, g in - host.geometry( - contentOffset: g.offset, containerHeight: g.container, contentHeight: g.content, - insetTop: g.top, insetBottom: g.bottom) + // Read here, so the list renders each snapshot. + _ = list.snapshot + return RowsScrollViewRepresentable(source: source, model: model) + .onAppear { model.attach(list) } + .onDisappear { model.detach(list) } + } + + /// The snapshot's rows, as the scroll view draws them. + private var source: () -> RowsSource { + { [list, row] in + let snapshot = list.snapshot + return RowsSource( + items: snapshot.items.map { item in + RowsItem( + key: AnyHashable(item.key), index: item.index, row: item.row, + view: { AnyView(row(item)) }) + }, + spaceBefore: snapshot.spaceBefore, spaceAfter: snapshot.spaceAfter) } - .onAppear { - let render = { [host, list] in - host.render( - list.snapshot.items.map { (key: $0.key, index: $0.index) }, - spaceBefore: list.snapshot.spaceBefore) - } - list.onSnapshot = render - host.requestScroll = { y in position.scrollTo(y: y) } + } + } + + /// Ties the virtualizer to the scroll view: attached while the list is + /// on screen. + @MainActor + final class VirtualListModel { + weak var view: RowsScrollView? + var source: () -> RowsSource = { RowsSource() } + private var attachAs: ((RowsScrollView) -> Void)? + private var attached = false + + func attach(_ list: ZeroVirtualizer) { + attachAs = { [unowned self] view in + let host = view.rows.host host.didScroll = { [list] in list.didScroll() } host.viewportDidResize = { [list] in list.viewportDidResize() } host.afterUpdate = { [list] in list.afterUpdate() } + host.isLanding = { [list] in list.isLanding } list.anchoring = .manual + // Each snapshot, in the layout pass or out of it, goes to the view + // at once: a pass lays out what the core publishes during it. + list.onSnapshot = { [weak view, unowned self] in view?.rows.update(source()) } list.attach(host) - render() - } - .onDisappear { - list.onSnapshot = nil - list.detach() + view.rows.update(source()) } + if let view { connect(view) } } - private static var rows: String { "ZeroVirtual.VirtualList.rows" } - } - - /// What the host needs of `ScrollGeometry`. - private struct Geometry: Equatable { - var offset, container, content, top, bottom: Double + func detach(_ list: ZeroVirtualizer) { + attachAs = nil + attached = false + list.onSnapshot = nil + list.detach() + } - init(_ g: ScrollGeometry) { - offset = g.contentOffset.y - // The scroll view's full height, as UIKit's bounds: `containerSize` - // already leaves out the insets, which the host takes off itself. - container = g.visibleRect.height - content = g.contentSize.height - top = g.contentInsets.top - bottom = g.contentInsets.bottom + /// The view exists: attach to it if the list is on screen. + func connect(_ view: RowsScrollView) { + self.view = view + guard !attached, let attachAs else { return } + attached = true + attachAs(view) } } #endif diff --git a/swift/Sources/ZeroVirtual/VirtualizerCore.swift b/swift/Sources/ZeroVirtual/VirtualizerCore.swift index c8a232adb4..d230246c0e 100644 --- a/swift/Sources/ZeroVirtual/VirtualizerCore.swift +++ b/swift/Sources/ZeroVirtual/VirtualizerCore.swift @@ -278,6 +278,8 @@ package final class VirtualizerCore< private var reconciling = false private var settleTimer: (any ScheduledTimer)? private var target: Target? + /// A jump or a restore hasn't landed: the position is still on its way. + package var isLanding: Bool { target != nil } private var permalinkMissing = 0 // A restore not yet applied: from `scrollState` at first, then diff --git a/swift/Sources/ZeroVirtual/ZeroVirtualizer.swift b/swift/Sources/ZeroVirtual/ZeroVirtualizer.swift index 2062f86c94..1d99905f58 100644 --- a/swift/Sources/ZeroVirtual/ZeroVirtualizer.swift +++ b/swift/Sources/ZeroVirtual/ZeroVirtualizer.swift @@ -195,6 +195,9 @@ public final class ZeroVirtualizer< /// The content moved. public func didScroll() { core.didScroll() } + /// A jump or a restore hasn't landed yet. + package var isLanding: Bool { core.isLanding } + /// The viewport changed size. public func viewportDidResize() { core.viewportDidResize() } From ce30a2fafe0a40b9bfbcb097a3e802cf940ec51b Mon Sep 17 00:00:00 2001 From: tmm Date: Tue, 20 Oct 2026 16:23:49 -0400 Subject: [PATCH 076/104] 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. --- swift/TestHost/App/FrameProbe.swift | 83 +++++++++++++++++++++++------ swift/TestHost/App/Screens.swift | 12 +++-- 2 files changed, 75 insertions(+), 20 deletions(-) diff --git a/swift/TestHost/App/FrameProbe.swift b/swift/TestHost/App/FrameProbe.swift index 2f82c43205..b150e85145 100644 --- a/swift/TestHost/App/FrameProbe.swift +++ b/swift/TestHost/App/FrameProbe.swift @@ -19,8 +19,8 @@ import SwiftUI /// /// - Driven by the probe itself (`drive`), the scroll is known exactly: the /// rows must move by it, to the half point. -/// - Flung by a finger (iOS), the momentum is smooth: a frame whose speed -/// departs from both of its neighbors' is a jump in it. +/// - Flung by a finger (iOS), the momentum is smooth: two frames whose +/// speed departs from both neighboring pairs' are a jump in it. /// - At rest, nothing moves. /// - A jump (Latest) shows the rows as they were, then as they end up, and /// nothing in between. @@ -39,7 +39,7 @@ final class FrameProbe { @ObservationIgnored private var markers: [String: WeakView] = [:] @ObservationIgnored private(set) weak var scrollView: PlatformScrollView? @ObservationIgnored private var previous: Sample? - @ObservationIgnored private var smooth: [(v: Double, real: Bool, sample: Sample, dy: Double)] = + @ObservationIgnored private var smooth: [(dy: Double, dt: Double, real: Bool, sample: Sample)] = [] @ObservationIgnored private var applied: Double? @ObservationIgnored private var drive: (velocity: Double, done: CheckedContinuation)? @@ -50,6 +50,17 @@ final class FrameProbe { @ObservationIgnored private var wrongByPhase: [(String, Int)] = [] @ObservationIgnored private(set) var wrong = 0 @ObservationIgnored private var frames = 0 + @ObservationIgnored private var moving = 0 + @ObservationIgnored private var dropped = 0 + /// The last wrong frames' reasons, for a failing test's message. + @ObservationIgnored private var lastWrong: [String] = [] + private static var frameInterval: Double { + #if os(iOS) + 1 / Double(UIScreen.main.maximumFramesPerSecond) + #else + 1 / Double(NSScreen.main?.maximumFramesPerSecond ?? 60) + #endif + } @ObservationIgnored private var flings: [String] = [] @ObservationIgnored private var done = false @ObservationIgnored private var ticker: AnyObject? @@ -100,8 +111,8 @@ final class FrameProbe { func finish() { finishJump() done = true - log("DONE \(summary)") publish() + log("DONE \(summary)") } /// A programmatic jump starts: the frames until it settles are checked @@ -140,6 +151,12 @@ final class FrameProbe { if scrollView.window == nil { return } let sample = sample(t, scrollView) frames += 1 + // A frame interval over 1.5 of the display's: frames dropped while the + // list moves. + if let previous, drive != nil || sample.decelerating { + moving += 1 + if t - previous.t > 1.5 * Self.frameInterval { dropped += 1 } + } if let previous { check(previous, sample) } previous = sample step(t, scrollView) @@ -151,7 +168,9 @@ final class FrameProbe { let port = scrollView.convert(scrollView.bounds, to: nil) .inset(by: scrollView.adjustedContentInset) for (id, m) in markers { - guard let v = m.view, v.window != nil, v.bounds.height > 0 else { continue } + guard let v = m.view, v.window != nil, v.bounds.height > 0, !Self.hidden(v) else { + continue + } let r = v.convert(v.bounds, to: nil) guard r.maxY > port.minY + 0.5, r.minY < port.maxY - 0.5 else { continue } s.rows[id] = (Double(r.minY - port.minY), Double(r.maxY - port.minY)) @@ -164,7 +183,9 @@ final class FrameProbe { let clip = scrollView.contentView let port = clip.convert(clip.bounds, to: nil) for (id, m) in markers { - guard let v = m.view, v.window != nil, v.bounds.height > 0 else { continue } + guard let v = m.view, v.window != nil, v.bounds.height > 0, !Self.hidden(v) else { + continue + } let r = v.convert(v.bounds, to: nil) // Window coordinates count up from the bottom. let minY = port.maxY - r.maxY @@ -216,17 +237,29 @@ final class FrameProbe { trackFling(a, b, dy) } - /// Momentum is smooth: a frame whose speed departs from both neighbors' - /// is a jump. Checked one frame late, once the next frame is in. + /// Momentum is smooth: two frames whose speed departs from both + /// neighboring pairs' is a jump in it. Checked over pairs, as UIKit moves + /// a slow fling every other frame; and not across a late frame, after + /// which momentum catches up (counted as dropped instead). private func smoothCheck(dy: Double, a: Sample, b: Sample) { - let dt = max(0.001, b.t - a.t) - smooth.append((dy / dt, !(a.tracking || b.tracking), b, dy)) - if smooth.count > 3 { smooth.removeFirst() } - guard smooth.count == 3, smooth.allSatisfy(\.real) else { return } - let (p, m, n) = (smooth[0], smooth[1], smooth[2]) + smooth.append((dy, b.t - a.t, !(a.tracking || b.tracking), b)) + if smooth.count > 6 { smooth.removeFirst() } + guard smooth.count == 6, smooth.allSatisfy(\.real), + smooth.allSatisfy({ $0.dt <= 1.5 * Self.frameInterval }) + else { return } + func speed(_ i: Int) -> Double { + (smooth[i].dy + smooth[i + 1].dy) / (smooth[i].dt + smooth[i + 1].dt) + } + let (p, m, n) = (speed(0), speed(2), speed(4)) let limit = 300.0 - if abs(m.v - p.v) > limit && abs(m.v - n.v) > limit { - flag(m.sample, "speed \(fmt(m.v)) between \(fmt(p.v)) and \(fmt(n.v)) (moved \(fmt(m.dy)))") + let sample = smooth[3].sample + let moved = smooth[2].dy + smooth[3].dy + if abs(m - p) > limit && abs(m - n) > limit { + // Once per jump: the next check starts after it. + smooth.removeAll() + flag( + sample, + "speed \(fmt(m)) between \(fmt(p)) and \(fmt(n)) (moved \(fmt(moved)))") } } @@ -279,14 +312,16 @@ final class FrameProbe { let was = before.map { b in " was " + b.ids.prefix(2).map { "\($0)@\(fmt(b.rows[$0]!.minY))" }.joined(separator: " ") } - log("WRONG [\(phase)] \(why): \(rows)\(was ?? "")") + let line = "WRONG [\(phase)] \(why): \(rows)\(was ?? "")" + lastWrong = Array((lastWrong + [line]).suffix(4)) + log(line) publish() } private func publish() { let phases = wrongByPhase.map { "\($0.0):\($0.1)" }.joined(separator: ",") summary = - "wrong=\(wrong) frames=\(frames) phases=[\(phases)] flings=[\(flings.suffix(8).joined(separator: ","))]\(done ? " done" : "")" + "wrong=\(wrong) frames=\(frames) moving=\(moving) dropped=\(dropped) phases=[\(phases)] flings=[\(flings.suffix(8).joined(separator: ","))] last=[\(lastWrong.joined(separator: "; "))]\(done ? " done" : "")" } private func log(_ s: String) { @@ -330,6 +365,20 @@ final class FrameProbe { } } + /// A row's view the list hides: drawn rows only count. + private static func hidden(_ view: PlatformView) -> Bool { + #if os(iOS) + var v: UIView? = view + while let s = v { + if s.isHidden { return true } + v = s.superview + } + return false + #else + return view.isHiddenOrHasHiddenAncestor + #endif + } + private static func enclosingScrollView(_ view: PlatformView) -> PlatformScrollView? { #if os(iOS) var v: UIView? = view.superview diff --git a/swift/TestHost/App/Screens.swift b/swift/TestHost/App/Screens.swift index 80dd99f5f3..fef44f664a 100644 --- a/swift/TestHost/App/Screens.swift +++ b/swift/TestHost/App/Screens.swift @@ -21,10 +21,16 @@ struct ChatScreen: View { // Off the safe area's edges, the scroll view's frame is its viewport, // which the UI tests measure rows against. Divider() - VirtualList(list) { item in - MessageRow(message: item.row) + // On screen once it knows where to open, the latest message: shown + // before, it would draw the top of the chat for a frame. + if started { + VirtualList(list) { item in + MessageRow(message: item.row) + } + .accessibilityIdentifier("list") + } else { + Spacer() } - .accessibilityIdentifier("list") Divider() HStack { Button("Receive") { backend.receive() }.accessibilityIdentifier("receive") From 45276f2016509f04595517c7a3e235b57632e8e7 Mon Sep 17 00:00:00 2001 From: tmm Date: Tue, 20 Oct 2026 17:34:23 -0400 Subject: [PATCH 077/104] 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. --- swift/Sources/ZeroVirtual/LayoutHost.swift | 1 + .../Sources/ZeroVirtual/VirtualizerCore.swift | 30 ++++++++++++++++++- swift/TestHost/App/FrameProbe.swift | 20 ++++++++++++- .../TestHost/UITests/FrameProbeUITests.swift | 26 ++++++++++++++++ swift/Tests/ZeroVirtualTests/Harness.swift | 1 + .../Tests/ZeroVirtualTests/ScrollTests.swift | 13 ++++++++ 6 files changed, 89 insertions(+), 2 deletions(-) diff --git a/swift/Sources/ZeroVirtual/LayoutHost.swift b/swift/Sources/ZeroVirtual/LayoutHost.swift index 49322c0df3..e5305893f4 100644 --- a/swift/Sources/ZeroVirtual/LayoutHost.swift +++ b/swift/Sources/ZeroVirtual/LayoutHost.swift @@ -15,6 +15,7 @@ package final class LayoutHost: VirtualHost { package private(set) var viewportHeight = 0.0 package var viewportInsets: (top: Double, bottom: Double) { (0, 0) } package private(set) var contentHeight = 0.0 + package var scrollsWithLayout: Bool { true } /// The rows to lay out, and the space before and after them: the /// snapshot's. diff --git a/swift/Sources/ZeroVirtual/VirtualizerCore.swift b/swift/Sources/ZeroVirtual/VirtualizerCore.swift index d230246c0e..e5fe8498a0 100644 --- a/swift/Sources/ZeroVirtual/VirtualizerCore.swift +++ b/swift/Sources/ZeroVirtual/VirtualizerCore.swift @@ -101,6 +101,14 @@ public protocol VirtualHost: AnyObject { var contentHeight: Double { get } /// The rendered rows, in list order. func rowFrames() -> [VirtualRowFrame] + /// A scroll and a new snapshot are applied in the same layout pass, and a + /// scroll doesn't stop a fling: the list can scroll while it may be + /// moving. False by default. + var scrollsWithLayout: Bool { get } +} + +extension VirtualHost { + public var scrollsWithLayout: Bool { false } } /// What the list renders (`VirtualizerSnapshot`). @@ -533,7 +541,8 @@ package final class VirtualizerCore< } // Space that takes up rows moving moves them back on the next layout: // the rows aren't where paging would read them until then. - let respaced = measureAndCompensate() + var respaced = measureAndCompensate() + if dropSpaceAtStart() { respaced = true } restoreOrReset() updatePageSize() land() @@ -763,6 +772,25 @@ package final class VirtualizerCore< return false } + /// At the start nothing stands before the first row: on a host that + /// scrolls with its layout, space left over there (from rows that came + /// out shorter, or carried over as the first page arrived) comes out at + /// once, with a scroll by as much in the same pass, so no frame shows a + /// gap above the first row, even mid-fling. What the offset can't take, + /// near the top, waits for the list to rest. + private func dropSpaceAtStart() -> Bool { + guard manual, rows.atStart, spaceAdjust > 0, isListContextCurrent, !landing, let host, + host.scrollsWithLayout + else { return false } + let removed = min(spaceAdjust, max(0, host.scrollOffset)) + guard removed >= 0.5 else { return false } + spaceAdjust -= removed + anchorOffset -= removed + version += 1 + setScrollOffset(host.scrollOffset - removed) + return true + } + // Once scrolling stops, space short of its estimate goes back to it, and // the list scrolls by the difference so nothing on screen moves. private func resetIdleTimer() { diff --git a/swift/TestHost/App/FrameProbe.swift b/swift/TestHost/App/FrameProbe.swift index b150e85145..9074320f2c 100644 --- a/swift/TestHost/App/FrameProbe.swift +++ b/swift/TestHost/App/FrameProbe.swift @@ -72,6 +72,8 @@ final class FrameProbe { var tracking = false var decelerating = false var atEdge = false + /// The viewport's top in content coordinates: below 0 in a bounce. + var offset = 0.0 /// Top to bottom. var ids: [String] { rows.sorted { $0.value.minY < $1.value.minY }.map(\.key) } @@ -178,6 +180,7 @@ final class FrameProbe { s.tracking = scrollView.isTracking s.decelerating = scrollView.isDecelerating let y = scrollView.contentOffset.y + scrollView.adjustedContentInset.top + s.offset = Double(y) s.atEdge = y <= 0.5 || y >= scrollView.contentSize.height - viewportHeight - 0.5 #else let clip = scrollView.contentView @@ -194,6 +197,7 @@ final class FrameProbe { s.rows[id] = (Double(minY), Double(maxY)) } let y = clip.bounds.origin.y + s.offset = Double(y) let height = scrollView.documentView?.frame.height ?? 0 s.atEdge = y <= 0.5 || y >= height - clip.bounds.height - 0.5 #endif @@ -235,6 +239,18 @@ final class FrameProbe { flag(b, "\(anchor) moved \(fmt(dy)) at rest", a) } trackFling(a, b, dy) + // At rest, the list's first row is at its top or above: space below + // it is a gap the list shows, not a bounce (which only moves). + if !real, driven == nil, drive == nil, let first = Self.firstID, + let y = b.rows[first]?.minY, y > 0.5 + { + flag(b, "gap of \(fmt(y)) above \(first) at rest (offset \(fmt(b.offset)))") + } + } + + /// The list's first row: where its top is the list's. + private static var firstID: String? { + ["chat": "m0000", "issues": "i0000"][UserDefaults.standard.string(forKey: "screen") ?? ""] } /// Momentum is smooth: two frames whose speed departs from both @@ -242,7 +258,9 @@ final class FrameProbe { /// a slow fling every other frame; and not across a late frame, after /// which momentum catches up (counted as dropped instead). private func smoothCheck(dy: Double, a: Sample, b: Sample) { - smooth.append((dy, b.t - a.t, !(a.tracking || b.tracking), b)) + // A bounce past the top springs back at its own pace: not momentum. + let bouncing = a.offset < -0.5 || b.offset < -0.5 + smooth.append((dy, b.t - a.t, !(a.tracking || b.tracking || bouncing), b)) if smooth.count > 6 { smooth.removeFirst() } guard smooth.count == 6, smooth.allSatisfy(\.real), smooth.allSatisfy({ $0.dt <= 1.5 * Self.frameInterval }) diff --git a/swift/TestHost/UITests/FrameProbeUITests.swift b/swift/TestHost/UITests/FrameProbeUITests.swift index 60e7d9dfbe..30e9f8ac49 100644 --- a/swift/TestHost/UITests/FrameProbeUITests.swift +++ b/swift/TestHost/UITests/FrameProbeUITests.swift @@ -97,6 +97,32 @@ final class FrameProbeUITests: XCTestCase { /// Up from the latest message, older pages of many heights loading. func testChatFlingsUpRunTheirLength() { flingThrough("chat", Array(repeating: true, count: 8)) } + /// Flung back to the top from a few distances at a few speeds: at rest + /// the first issue is at the top, with no gap above it. + func testIssuesFlingsToTheTopLeaveNoGap() { + launch("issues", script: false) + let list = app.scrollViews.firstMatch + XCTAssertTrue(list.waitForExistence(timeout: 10)) + let first = app.descendants(matching: .any)["row-i0000"].firstMatch + RunLoop.current.run(until: Date().addingTimeInterval(2)) + for (away, speed) in [ + (2, 6000.0), (4, 800.0), (6, 3000.0), (10, 1500.0), (3, 3000.0), (8, 6000.0), + ] { + for _ in 0..? private(set) var items: [VirtualItem] = [] diff --git a/swift/Tests/ZeroVirtualTests/ScrollTests.swift b/swift/Tests/ZeroVirtualTests/ScrollTests.swift index 5cc4f5550c..6cae09c5fa 100644 --- a/swift/Tests/ZeroVirtualTests/ScrollTests.swift +++ b/swift/Tests/ZeroVirtualTests/ScrollTests.swift @@ -165,6 +165,19 @@ struct ManualAnchoringTests { #expect(h.rowTop("r50") == 0) } + @Test( + "on a host that scrolls with its layout, shrinkage at the start comes out with a scroll at once" + ) + func shrinkageAboveScrollsWithLayout() { + let h = scrolled() + h.view.scrollsWithLayout = true + h.setRowHeight("r30", 5) + h.tick() + #expect(h.core.snapshot.spaceBefore == 0) + #expect(h.scrollOffset == 985) + #expect(h.rowTop("r50") == 0) + } + @Test("away from the start, the space before the rows takes up growth; at rest it's given back") func spaceTakesUpChange() { let h = Harness(rowCount: 1000) { $0.anchoring = .manual } From fd386d3165bd368521538a194ff39ff17beb4736 Mon Sep 17 00:00:00 2001 From: tmm Date: Tue, 20 Oct 2026 18:08:29 -0400 Subject: [PATCH 078/104] 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. --- swift/Sources/ZeroVirtual/RowsScrollView.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/swift/Sources/ZeroVirtual/RowsScrollView.swift b/swift/Sources/ZeroVirtual/RowsScrollView.swift index 3eae5b968a..8934e6a422 100644 --- a/swift/Sources/ZeroVirtual/RowsScrollView.swift +++ b/swift/Sources/ZeroVirtual/RowsScrollView.swift @@ -244,10 +244,14 @@ let r = rows.layout( width: width, offset: Double(clip.bounds.origin.y + inset.top), viewport: Double(clip.bounds.height - inset.top - inset.bottom)) + // On whole pixels of the content, not of the window: aligned in the + // window, a row would snap differently at each offset and move by a + // pixel against the content as it scrolls. + let scale = max(1, window?.backingScaleFactor ?? 1) + func align(_ y: Double) -> CGFloat { (CGFloat(y) * scale).rounded() / scale } for f in r.frames { - let frame = document.backingAlignedRect( - CGRect(x: 0, y: f.minY, width: width, height: f.maxY - f.minY), - options: .alignAllEdgesNearest) + let frame = CGRect( + x: 0, y: align(f.minY), width: width, height: align(f.maxY) - align(f.minY)) f.cell.view.isHidden = !rows.shown // Set only when it moves: a frame set lays the row's view out again. guard f.cell.view.frame != frame else { continue } From 461a9b4c91f33ee2f4a91bc5294bac7f2a61311c Mon Sep 17 00:00:00 2001 From: tmm Date: Tue, 20 Oct 2026 18:08:29 -0400 Subject: [PATCH 079/104] 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. --- .github/workflows/zbugs-screenshots.yml | 35 +++++++++++++++---------- swift/TestHost/App/FrameProbe.swift | 12 ++++++++- 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/.github/workflows/zbugs-screenshots.yml b/.github/workflows/zbugs-screenshots.yml index df51c61b9b..39cc100e86 100644 --- a/.github/workflows/zbugs-screenshots.yml +++ b/.github/workflows/zbugs-screenshots.yml @@ -136,9 +136,16 @@ jobs: cat "$RUNNER_TEMP/pg.log" || true virtual-list: - name: Virtual list UI tests (iPhone and macOS) + # Each platform and suite in its own job: together they outrun a job's + # timeout (the frame probe's scripts drive minutes of scrolling). + name: Virtual list UI tests (${{ matrix.platform }}, ${{ matrix.suite }}) runs-on: macos-26 timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + platform: [iPhone, macOS] + suite: [VirtualListUITests, FrameProbeUITests] defaults: run: working-directory: swift/TestHost @@ -149,25 +156,25 @@ jobs: - name: Generate the Xcode project run: brew install xcodegen && xcodegen generate - name: UI tests + env: + PLATFORM: ${{ matrix.platform }} + SUITE: ${{ matrix.suite }} run: | simulator() { xcrun simctl list devices available -j | 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(sys.argv[1])))' "$1" } - # Both, even after one fails. - status=0 - uitests() { - name="$1" - shift - xcodebuild test -project TestHost.xcodeproj -scheme TestHost \ - -resultBundlePath "$RUNNER_TEMP/results/$name.xcresult" \ - -skipPackagePluginValidation "$@" || status=1 - } - uitests iPhone -destination "id=$(simulator iPhone)" CODE_SIGNING_ALLOWED=NO - uitests macOS -destination platform=macOS CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY=- - exit "$status" + if [ "$PLATFORM" = macOS ]; then + set -- -destination platform=macOS CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY=- + else + set -- -destination "id=$(simulator "$PLATFORM")" CODE_SIGNING_ALLOWED=NO + fi + xcodebuild test -project TestHost.xcodeproj -scheme TestHost \ + -only-testing:"TestHostUITests/$SUITE" \ + -resultBundlePath "$RUNNER_TEMP/results/$PLATFORM-$SUITE.xcresult" \ + -skipPackagePluginValidation "$@" - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: failure() with: - name: virtual-list-results + name: virtual-list-results-${{ matrix.platform }}-${{ matrix.suite }} path: ${{ runner.temp }}/results diff --git a/swift/TestHost/App/FrameProbe.swift b/swift/TestHost/App/FrameProbe.swift index 9074320f2c..308eb3d9b1 100644 --- a/swift/TestHost/App/FrameProbe.swift +++ b/swift/TestHost/App/FrameProbe.swift @@ -45,6 +45,9 @@ final class FrameProbe { @ObservationIgnored private var drive: (velocity: Double, done: CheckedContinuation)? @ObservationIgnored private var jump: [Sample]? @ObservationIgnored private var jumpStill = 0.0 + /// Since the jump began, and whether it has moved yet. + @ObservationIgnored private var jumpTime = 0.0 + @ObservationIgnored private var jumpMoved = false @ObservationIgnored private var flingStart: (t: Double, moved: Double)? @ObservationIgnored private(set) var phase = "start" @ObservationIgnored private var wrongByPhase: [(String, Int)] = [] @@ -124,6 +127,8 @@ final class FrameProbe { finishJump() jump = previous.map { [$0] } ?? [] jumpStill = 0 + jumpTime = 0 + jumpMoved = false log("JUMP begin top=\(previous?.ids.first ?? "-")") } @@ -209,12 +214,17 @@ final class FrameProbe { applied = nil if jump != nil { jump!.append(b) + jumpTime += b.t - a.t if moved(a, b) { jumpStill = 0 + jumpMoved = true } else { jumpStill += b.t - a.t } - if jumpStill >= 0.5 { finishJump() } + // Settled half a second after it last moved. Its rows can take longer + // than that to load (a simulator's can), so it waits for its first + // move; a jump to where the list already is never moves. + if (jumpMoved && jumpStill >= 0.5) || jumpTime >= 5 { finishJump() } return } let common = a.rows.keys.filter { b.rows[$0] != nil } From a8d7227571392798107dc63811cc1cbcc15fc1ba Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 21 Oct 2026 00:16:38 +0000 Subject: [PATCH 080/104] 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 --- .github/workflows/zbugs-screenshots.yml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/workflows/zbugs-screenshots.yml b/.github/workflows/zbugs-screenshots.yml index 39cc100e86..3d6c7243b3 100644 --- a/.github/workflows/zbugs-screenshots.yml +++ b/.github/workflows/zbugs-screenshots.yml @@ -89,11 +89,22 @@ jobs: xcrun simctl list devices available -j | 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(sys.argv[1])))' "$1" } + # Booted, and done booting, before the test launches the app: a + # simulator xcodebuild boots itself can still be starting up when + # the first launch times out. One at a time, as the runner is small. + boot() { + xcrun simctl shutdown all + xcrun simctl bootstatus "$1" -b + } # Every run, even after one fails, so each keeps its screenshots. status=0 screenshots() { name="$1" shift + case "$1 $2" in + "-destination id="*) boot "${2#id=}" ;; + *) xcrun simctl shutdown all ;; + esac xcodebuild test -project ZBugs.xcodeproj -scheme ZBugsApp \ -resultBundlePath "$RUNNER_TEMP/results/$name.xcresult" \ -skipPackagePluginValidation "$@" || status=1 @@ -167,7 +178,11 @@ jobs: if [ "$PLATFORM" = macOS ]; then set -- -destination platform=macOS CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY=- else - set -- -destination "id=$(simulator "$PLATFORM")" CODE_SIGNING_ALLOWED=NO + device="$(simulator "$PLATFORM")" + # Booted, and done booting, before the first test launches the + # app, which otherwise can time out on a simulator still starting. + xcrun simctl bootstatus "$device" -b + set -- -destination "id=$device" CODE_SIGNING_ALLOWED=NO fi xcodebuild test -project TestHost.xcodeproj -scheme TestHost \ -only-testing:"TestHostUITests/$SUITE" \ From 0ff3f12be1f0566450a7d871a085dc1c1c8e3ae3 Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 21 Oct 2026 00:19:32 +0000 Subject: [PATCH 081/104] 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 --- swift/Package.swift | 2 +- swift/Sources/ZeroVirtual/VirtualList.swift | 12 +++++--- swift/Tests/AppleTests/VirtualListTests.swift | 30 +++++++++++++++++++ 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/swift/Package.swift b/swift/Package.swift index 7ad8de3fcf..5681d1c3e0 100644 --- a/swift/Package.swift +++ b/swift/Package.swift @@ -22,7 +22,7 @@ let package = Package( .target(name: "Zero", dependencies: ["CSQLite"]), .target(name: "ZeroUI", dependencies: ["Zero"]), // Virtual lists that page their rows in (`@rocicorp/zero-virtual`). - .target(name: "ZeroVirtual", dependencies: ["Zero"]), + .target(name: "ZeroVirtual", dependencies: ["Zero", "ZeroUI"]), .target(name: "ZeroTesting", dependencies: ["Zero"]), // A plain ws:// transport for end-to-end tests on Linux. .target( diff --git a/swift/Sources/ZeroVirtual/VirtualList.swift b/swift/Sources/ZeroVirtual/VirtualList.swift index 6662a91321..be988dd3a3 100644 --- a/swift/Sources/ZeroVirtual/VirtualList.swift +++ b/swift/Sources/ZeroVirtual/VirtualList.swift @@ -1,6 +1,7 @@ #if canImport(SwiftUI) && (canImport(UIKit) || canImport(AppKit)) import SwiftUI import Zero + import ZeroUI /// A scroll view of a `ZeroVirtualizer`'s rows: they page in as it /// scrolls, and what's on screen stays put as rows load, go, and change @@ -20,8 +21,10 @@ /// content's size and its offset are set in one layout pass, so no frame /// shows rows moved by a page loading before the offset that keeps them /// in place. Each row is hosted in its own hosting view: it gets the - /// platform's traits (color scheme, Dynamic Type, layout direction), but - /// not the list's custom environment values. Apply those in `row`. + /// platform's traits (color scheme, Dynamic Type, layout direction) and + /// the list's Zero client (`.zero(_:)`), so a row's `@ZeroQuery` reads + /// from it, but not the list's other environment values. Apply those in + /// `row`. public struct VirtualList< Context: Sendable, Row: ZeroRow, Key: Hashable, StartRow: Equatable & Sendable, ListContext: Equatable & Sendable, RowContent: View @@ -31,6 +34,7 @@ private let list: Virtualizer private let row: (VirtualItem) -> RowContent @State private var model = VirtualListModel() + @Environment(\.zero) private var zero public init( _ list: Virtualizer, @ViewBuilder row: @escaping (VirtualItem) -> RowContent @@ -49,13 +53,13 @@ /// The snapshot's rows, as the scroll view draws them. private var source: () -> RowsSource { - { [list, row] in + { [list, row, zero] in let snapshot = list.snapshot return RowsSource( items: snapshot.items.map { item in RowsItem( key: AnyHashable(item.key), index: item.index, row: item.row, - view: { AnyView(row(item)) }) + view: { AnyView(row(item).environment(\.zero, zero)) }) }, spaceBefore: snapshot.spaceBefore, spaceAfter: snapshot.spaceAfter) } diff --git a/swift/Tests/AppleTests/VirtualListTests.swift b/swift/Tests/AppleTests/VirtualListTests.swift index 401b38dfd5..fefa29270c 100644 --- a/swift/Tests/AppleTests/VirtualListTests.swift +++ b/swift/Tests/AppleTests/VirtualListTests.swift @@ -3,6 +3,7 @@ import SwiftUI import Testing import ZeroTesting + import ZeroUI @testable import Zero @testable import ZeroVirtual @@ -105,6 +106,35 @@ #endif } + final class Seen { + var texts: Set = [] + } + + /// A row that runs its own query. + struct QueryRow: View { + @ZeroQuery var note: Note? + let seen: Seen + + init(id: String, seen: Seen) { + _note = ZeroQuery(VirtualListTests.byID(id)) + self.seen = seen + } + + var body: some View { + let _ = note.map { seen.texts.insert($0.text) } + Text(note?.text ?? "").frame(height: 30) + } + } + + @Test func aRowsQueryReadsTheListsClient() async throws { + let (zero, list) = try makeList() + let seen = Seen() + let window = show(VirtualList(list) { QueryRow(id: $0.row.id, seen: seen) }.zero(zero)) + try await run(30) { seen.texts.contains("note 0") } + #expect(seen.texts.contains("note 0")) + withExtendedLifetime((zero, window)) {} + } + @Test func scrollsToARowFarBelow() async throws { let (zero, list) = try makeList() let window = show(VirtualList(list) { Text($0.row.text).frame(height: 30) }) From e238d3fa391ec67ed465ecf6a1f45e629852afa7 Mon Sep 17 00:00:00 2001 From: tmm Date: Tue, 20 Oct 2026 20:23:27 -0400 Subject: [PATCH 082/104] 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. --- swift/TestHost/App/FrameProbe.swift | 45 +++++++++++++++++------------ 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/swift/TestHost/App/FrameProbe.swift b/swift/TestHost/App/FrameProbe.swift index 308eb3d9b1..4f0342e45e 100644 --- a/swift/TestHost/App/FrameProbe.swift +++ b/swift/TestHost/App/FrameProbe.swift @@ -20,7 +20,7 @@ import SwiftUI /// - Driven by the probe itself (`drive`), the scroll is known exactly: the /// rows must move by it, to the half point. /// - Flung by a finger (iOS), the momentum is smooth: two frames whose -/// speed departs from both neighboring pairs' are a jump in it. +/// speed departs from their neighboring pairs' are a jump in it. /// - At rest, nothing moves. /// - A jump (Latest) shows the rows as they were, then as they end up, and /// nothing in between. @@ -39,8 +39,9 @@ final class FrameProbe { @ObservationIgnored private var markers: [String: WeakView] = [:] @ObservationIgnored private(set) weak var scrollView: PlatformScrollView? @ObservationIgnored private var previous: Sample? - @ObservationIgnored private var smooth: [(dy: Double, dt: Double, real: Bool, sample: Sample)] = - [] + @ObservationIgnored private var smooth: + [(dy: Double, dt: Double, regular: Bool, sample: Sample)] = + [] @ObservationIgnored private var applied: Double? @ObservationIgnored private var drive: (velocity: Double, done: CheckedContinuation)? @ObservationIgnored private var jump: [Sample]? @@ -77,6 +78,8 @@ final class FrameProbe { var atEdge = false /// The viewport's top in content coordinates: below 0 in a bounce. var offset = 0.0 + /// How long after the display link's timestamp the frame was sampled. + var latency = 0.0 /// Top to bottom. var ids: [String] { rows.sorted { $0.value.minY < $1.value.minY }.map(\.key) } @@ -171,6 +174,7 @@ final class FrameProbe { private func sample(_ t: Double, _ scrollView: PlatformScrollView) -> Sample { var s = Sample(t: t, rows: [:]) + s.latency = CACurrentMediaTime() - t #if os(iOS) let port = scrollView.convert(scrollView.bounds, to: nil) .inset(by: scrollView.adjustedContentInset) @@ -263,26 +267,29 @@ final class FrameProbe { ["chat": "m0000", "issues": "i0000"][UserDefaults.standard.string(forKey: "screen") ?? ""] } - /// Momentum is smooth: two frames whose speed departs from both - /// neighboring pairs' is a jump in it. Checked over pairs, as UIKit moves - /// a slow fling every other frame; and not across a late frame, after - /// which momentum catches up (counted as dropped instead). + /// Momentum is smooth: two frames whose speed departs from what their + /// neighboring pairs' speeds make it, by more than deceleration does, are + /// a jump in it. Checked over pairs, as UIKit moves a slow fling every + /// other frame; and not within two frames of a late frame, nor of one + /// whose callback came a frame later in its interval than the last (a + /// simulator's are irregular): after either, momentum catches up, and the + /// frame drawn may not be the one sampled. private func smoothCheck(dy: Double, a: Sample, b: Sample) { // A bounce past the top springs back at its own pace: not momentum. let bouncing = a.offset < -0.5 || b.offset < -0.5 - smooth.append((dy, b.t - a.t, !(a.tracking || b.tracking || bouncing), b)) - if smooth.count > 6 { smooth.removeFirst() } - guard smooth.count == 6, smooth.allSatisfy(\.real), - smooth.allSatisfy({ $0.dt <= 1.5 * Self.frameInterval }) - else { return } - func speed(_ i: Int) -> Double { - (smooth[i].dy + smooth[i + 1].dy) / (smooth[i].dt + smooth[i + 1].dt) - } + let late = + b.t - a.t > 1.5 * Self.frameInterval + || abs(b.latency - a.latency) > 0.5 * Self.frameInterval + smooth.append((dy, b.t - a.t, !(a.tracking || b.tracking || bouncing || late), b)) + if smooth.count > 8 { smooth.removeFirst() } + guard smooth.count == 8, smooth.allSatisfy(\.regular) else { return } + let w = smooth.suffix(6).map { $0 } + func speed(_ i: Int) -> Double { (w[i].dy + w[i + 1].dy) / (w[i].dt + w[i + 1].dt) } let (p, m, n) = (speed(0), speed(2), speed(4)) - let limit = 300.0 - let sample = smooth[3].sample - let moved = smooth[2].dy + smooth[3].dy - if abs(m - p) > limit && abs(m - n) > limit { + let expected = (p + n) / 2 + let sample = w[3].sample + let moved = w[2].dy + w[3].dy + if abs(m - expected) > max(300, 0.15 * abs(expected)) { // Once per jump: the next check starts after it. smooth.removeAll() flag( From 60be75caa0b94b9119cece4933f0d91b3d34f3f8 Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 21 Oct 2026 00:47:02 +0000 Subject: [PATCH 083/104] 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 --- swift/Package.swift | 2 +- swift/Sources/ZeroVirtual/VirtualList.swift | 14 +++++--------- swift/Tests/AppleTests/VirtualListTests.swift | 1 + 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/swift/Package.swift b/swift/Package.swift index 5681d1c3e0..7ad8de3fcf 100644 --- a/swift/Package.swift +++ b/swift/Package.swift @@ -22,7 +22,7 @@ let package = Package( .target(name: "Zero", dependencies: ["CSQLite"]), .target(name: "ZeroUI", dependencies: ["Zero"]), // Virtual lists that page their rows in (`@rocicorp/zero-virtual`). - .target(name: "ZeroVirtual", dependencies: ["Zero", "ZeroUI"]), + .target(name: "ZeroVirtual", dependencies: ["Zero"]), .target(name: "ZeroTesting", dependencies: ["Zero"]), // A plain ws:// transport for end-to-end tests on Linux. .target( diff --git a/swift/Sources/ZeroVirtual/VirtualList.swift b/swift/Sources/ZeroVirtual/VirtualList.swift index be988dd3a3..50ef07d0f0 100644 --- a/swift/Sources/ZeroVirtual/VirtualList.swift +++ b/swift/Sources/ZeroVirtual/VirtualList.swift @@ -1,7 +1,6 @@ #if canImport(SwiftUI) && (canImport(UIKit) || canImport(AppKit)) import SwiftUI import Zero - import ZeroUI /// A scroll view of a `ZeroVirtualizer`'s rows: they page in as it /// scrolls, and what's on screen stays put as rows load, go, and change @@ -20,11 +19,9 @@ /// `NSScrollView`), which lays the rows out itself: their frames, its /// content's size and its offset are set in one layout pass, so no frame /// shows rows moved by a page loading before the offset that keeps them - /// in place. Each row is hosted in its own hosting view: it gets the - /// platform's traits (color scheme, Dynamic Type, layout direction) and - /// the list's Zero client (`.zero(_:)`), so a row's `@ZeroQuery` reads - /// from it, but not the list's other environment values. Apply those in - /// `row`. + /// in place. Each row is hosted in its own hosting view, which inherits + /// the list's environment: a row's `@ZeroQuery` reads the list's client + /// (`.zero(_:)`), and its `@Environment` values are the list's. public struct VirtualList< Context: Sendable, Row: ZeroRow, Key: Hashable, StartRow: Equatable & Sendable, ListContext: Equatable & Sendable, RowContent: View @@ -34,7 +31,6 @@ private let list: Virtualizer private let row: (VirtualItem) -> RowContent @State private var model = VirtualListModel() - @Environment(\.zero) private var zero public init( _ list: Virtualizer, @ViewBuilder row: @escaping (VirtualItem) -> RowContent @@ -53,13 +49,13 @@ /// The snapshot's rows, as the scroll view draws them. private var source: () -> RowsSource { - { [list, row, zero] in + { [list, row] in let snapshot = list.snapshot return RowsSource( items: snapshot.items.map { item in RowsItem( key: AnyHashable(item.key), index: item.index, row: item.row, - view: { AnyView(row(item).environment(\.zero, zero)) }) + view: { AnyView(row(item)) }) }, spaceBefore: snapshot.spaceBefore, spaceAfter: snapshot.spaceAfter) } diff --git a/swift/Tests/AppleTests/VirtualListTests.swift b/swift/Tests/AppleTests/VirtualListTests.swift index fefa29270c..3db5b4287d 100644 --- a/swift/Tests/AppleTests/VirtualListTests.swift +++ b/swift/Tests/AppleTests/VirtualListTests.swift @@ -126,6 +126,7 @@ } } + // Rows are hosted views of their own, which inherit the list's environment. @Test func aRowsQueryReadsTheListsClient() async throws { let (zero, list) = try makeList() let seen = Seen() From 93af68b7cf8a705cf85a21fdfd5948668fef10e0 Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 21 Oct 2026 01:35:20 +0000 Subject: [PATCH 084/104] 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 --- .../ZeroVirtual/ScrollGeometryHost.swift | 146 ------------------ ...yHostTests.swift => LayoutHostTests.swift} | 0 2 files changed, 146 deletions(-) delete mode 100644 swift/Sources/ZeroVirtual/ScrollGeometryHost.swift rename swift/Tests/ZeroVirtualTests/{ScrollGeometryHostTests.swift => LayoutHostTests.swift} (100%) diff --git a/swift/Sources/ZeroVirtual/ScrollGeometryHost.swift b/swift/Sources/ZeroVirtual/ScrollGeometryHost.swift deleted file mode 100644 index 4db071d617..0000000000 --- a/swift/Sources/ZeroVirtual/ScrollGeometryHost.swift +++ /dev/null @@ -1,146 +0,0 @@ -/// A `VirtualHost` for a scroll view that reports its geometry after the -/// fact, as SwiftUI's does: its offset with `onScrollGeometryChange`, and -/// each row's frame with `onGeometryChange`. -/// -/// It keeps what the core asks of a scroll view as of the last report: row -/// frames in content coordinates, by key; the offset from the content's top, -/// insets aside. A scroll the core asks for takes effect here at once, and -/// goes to the view with `requestScroll`. The core hears of a new snapshot -/// (`afterUpdate`) once every one of its rows has been measured. -/// -/// The snapshot is already the window to draw, so the view lays its rows -/// out eagerly (`VStack`, not `LazyVStack`): a lazy stack might never -/// measure a row out of sight, and the update would wait for it forever. -/// Frames are in the rows' stack's coordinate space, which doesn't move as -/// the view scrolls. -@MainActor -package final class ScrollGeometryHost: VirtualHost { - package private(set) var scrollOffset = 0.0 - package private(set) var viewportHeight = 0.0 - package private(set) var contentHeight = 0.0 - package var viewportInsets: (top: Double, bottom: Double) { (0, 0) } - - /// Scrolls the view so content `y` is at the viewport's top. - package var requestScroll: (Double) -> Void = { _ in } - /// Runs `action` once the view has reported a layout pass: later on the - /// main actor. - package var defer_: (@escaping @MainActor () -> Void) -> Void = { action in - Task { @MainActor in action() } - } - package var didScroll: () -> Void = {} - package var viewportDidResize: () -> Void = {} - package var afterUpdate: () -> Void = {} - - /// The rendered rows, in list order. - private var items: [(key: AnyHashable, index: Int)] = [] - private var spaceBefore = 0.0 - private var heights: [AnyHashable: (minY: Double, maxY: Double)] = [:] - /// A scroll asked for and not seen yet: the target, unclamped, and how - /// many reports have contradicted it. - private var pendingScroll: (target: Double, contradicted: Int)? - private var flushScheduled = false - - package init() {} - - private static var retries: Int { 3 } - - package func rowFrames() -> [VirtualRowFrame] { - items.compactMap { item in - heights[item.key].map { - VirtualRowFrame(index: item.index, key: item.key, minY: $0.minY, maxY: $0.maxY) - } - } - } - - package func scroll(to target: Double) { - let offset = clamp(target) - guard offset != scrollOffset else { return } - scrollOffset = offset - pendingScroll = (target, 0) - requestScroll(offset) - } - - private func clamp(_ y: Double) -> Double { max(0, min(y, contentHeight - viewportHeight)) } - - /// The view rendered a snapshot with these rows: every snapshot, even one - /// with the same rows, since the core waits on `afterUpdate` for it. - package func render(_ keys: [(key: Key, index: Int)], spaceBefore: Double) { - let oldFirst = items.first?.key - items = keys.map { (AnyHashable($0.key), $0.index) } - let live = Set(items.map(\.key)) - heights = heights.filter { live.contains($0.key) } - // The view reports only frames that change, so frames are kept. A new - // space over the same first row moves every row by the difference; over - // a new first row, the core sized it to keep the rows where they were. - let shift = spaceBefore - self.spaceBefore - self.spaceBefore = spaceBefore - if shift != 0, oldFirst != nil, oldFirst == items.first?.key { - heights = heights.mapValues { ($0.minY + shift, $0.maxY + shift) } - } - scheduleFlush() - } - - /// A row's frame, in content coordinates. - package func rowFrame(_ key: Key, minY: Double, maxY: Double) { - let key = AnyHashable(key) - if let old = heights[key], old == (minY, maxY) { return } - heights[key] = (minY, maxY) - scheduleFlush() - } - - /// The scroll view's geometry: `contentOffset` as UIKit reports it (the - /// top inset is negative space above the content), its full height, - /// insets included (UIKit's bounds), and its insets. - package func geometry( - contentOffset: Double, containerHeight: Double, contentHeight: Double, insetTop: Double, - insetBottom: Double - ) { - let viewport = max(0, containerHeight - insetTop - insetBottom) - let resized = viewport != viewportHeight - let grew = contentHeight != self.contentHeight - viewportHeight = viewport - self.contentHeight = contentHeight - let offset = contentOffset + insetTop - // A report from before the scroll took, or a fling's deceleration - // writing over it: the scroll is asked for again, against the content - // as it is now, for a few reports. - if let pending = pendingScroll, abs(offset - clamp(pending.target)) > 1, - pending.contradicted < Self.retries - { - pendingScroll = (pending.target, pending.contradicted + 1) - scrollOffset = clamp(pending.target) - requestScroll(scrollOffset) - if resized { viewportDidResize() } - if grew { scheduleFlush() } - return - } - let moved = offset != scrollOffset - let landed = pendingScroll != nil - pendingScroll = nil - scrollOffset = offset - if resized { viewportDidResize() } - // The core's own scroll is reported too, landed or not: that's how it - // tells its scrolls from the user's. - if moved || landed { didScroll() } - if grew { scheduleFlush() } - } - - private func scheduleFlush() { - guard !flushScheduled else { return } - flushScheduled = true - defer_ { [weak self] in self?.flush() } - } - - private func flush() { - flushScheduled = false - // A row not measured yet is laid out on a pass still coming; so is - // content that doesn't reach the last row yet. Rows can report before - // the scroll view does, and the core scrolls within `contentHeight`. - // A point of slack: the scroll view rounds its content size to pixels, - // and rows of text have fractional heights. - guard items.allSatisfy({ heights[$0.key] != nil }), - contentHeight + 1 >= (rowFrames().last?.maxY ?? 0) - else { return } - afterUpdate() - } -} diff --git a/swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift b/swift/Tests/ZeroVirtualTests/LayoutHostTests.swift similarity index 100% rename from swift/Tests/ZeroVirtualTests/ScrollGeometryHostTests.swift rename to swift/Tests/ZeroVirtualTests/LayoutHostTests.swift From e3509740860a4cfe912b3b2fa1595c14d07d0af4 Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 21 Oct 2026 01:38:53 +0000 Subject: [PATCH 085/104] 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 --- .../ZeroVirtualTests/LayoutHostTests.swift | 365 ++++++------------ 1 file changed, 115 insertions(+), 250 deletions(-) diff --git a/swift/Tests/ZeroVirtualTests/LayoutHostTests.swift b/swift/Tests/ZeroVirtualTests/LayoutHostTests.swift index c31a40cb76..5c0720492c 100644 --- a/swift/Tests/ZeroVirtualTests/LayoutHostTests.swift +++ b/swift/Tests/ZeroVirtualTests/LayoutHostTests.swift @@ -1,92 +1,68 @@ import Testing +import ZeroTesting @testable import ZeroVirtual -/// A SwiftUI scroll view as `ScrollGeometryHost` sees it: geometry reported -/// after each layout pass, row frames before the scroll view's own, a -/// requested scroll applied on the next pass, and deferred work run between -/// passes. +/// `RowsScrollView` without the platform view: each layout pass calls +/// `LayoutHost.layout` with the offset the user left it at, and draws the +/// frames and offset that come back. Query results arrive between passes, as +/// `ZeroVirtualizer.onSnapshot` delivers them. @MainActor -final class GeometryScrollView { - let host = ScrollGeometryHost() +final class LayoutScrollView { + let host = LayoutHost() let h: Harness - var containerHeight: Double - let insetTop: Double + let viewportHeight: Double var heights: [String: Double] = [:] private var offset = 0.0 - private var requested: Double? - private var deferred: [@MainActor () -> Void] = [] - private var snapshot = VirtualSnapshot() - private(set) var afterUpdates = 0 - private(set) var scrollRequests = 0 - private var reported: [String: (minY: Double, maxY: Double)] = [:] - - init(_ h: Harness, containerHeight: Double = 400, insetTop: Double = 0) { + /// Passes that applied an offset other than the user's: the core + /// scrolled, which can stop a fling. + private(set) var scrolls = 0 + /// Each drawn row's top, from the viewport's top, as of the last pass. + private var drawn: [AnyHashable: Double] = [:] + private var unsubscribe: (() -> Void)? + + init(_ h: Harness, viewportHeight: Double = 400) { self.h = h - self.containerHeight = containerHeight - self.insetTop = insetTop - // At rest at the top, the inset is above the content. - offset = -insetTop - host.requestScroll = { [unowned self] in - requested = $0 - scrollRequests += 1 + self.viewportHeight = viewportHeight + host.rows = { [unowned self] in + let s = self.h.core.snapshot + return (s.items.map { (AnyHashable($0.key), $0.index) }, s.spaceBefore, s.spaceAfter) + } + host.measure = { [unowned self] key in + self.heights[key.base as! String] ?? self.h.view.rowHeight } - host.defer_ = { [unowned self] in deferred.append($0) } host.didScroll = { [unowned self] in self.h.core.didScroll() } host.viewportDidResize = { [unowned self] in self.h.core.viewportDidResize() } - host.afterUpdate = { [unowned self] in - afterUpdates += 1 - self.h.core.afterUpdate() - } + host.afterUpdate = { [unowned self] in self.h.core.afterUpdate() } + host.isLanding = { [unowned self] in self.h.core.isLanding } + unsubscribe = h.core.subscribe { [unowned self] in self.host.invalidate() } h.core.attach(host) } - private func height(_ key: String) -> Double { heights[key] ?? h.view.rowHeight } + var scrollOffset: Double { offset } - private var contentHeight: Double { - snapshot.spaceBefore + snapshot.items.reduce(0) { $0 + height($1.key) } + snapshot.spaceAfter - } - - /// One body evaluation and layout pass, then the work it deferred. + /// The query's rows as they are now, then one layout pass. func pass() { h.core.setRows(h.answer(h.core.queryInputs)) - // Every snapshot is rendered, as `ZeroVirtualizer.onSnapshot` has it. - snapshot = h.core.snapshot - host.render( - snapshot.items.map { (key: $0.key, index: $0.index) }, spaceBefore: snapshot.spaceBefore) - // Only frames that changed are reported, as `onGeometryChange` does, - // and a row's first: one that left and came back is a new view. - let live = Set(snapshot.items.map(\.key)) - reported = reported.filter { live.contains($0.key) } - var y = snapshot.spaceBefore - for item in snapshot.items { - let frame = (minY: y, maxY: y + height(item.key)) - if reported[item.key].map({ $0 != frame }) ?? true { - reported[item.key] = frame - host.rowFrame(item.key, minY: frame.minY, maxY: frame.maxY) - } - y += height(item.key) - } - let maxOffset = max(0, contentHeight - (containerHeight - insetTop)) - insetTop - if let target = requested { - requested = nil - offset = target - insetTop - } - offset = max(-insetTop, min(offset, maxOffset)) - host.geometry( - contentOffset: offset, containerHeight: containerHeight, contentHeight: contentHeight, - insetTop: insetTop, insetBottom: 0) - let work = deferred - deferred = [] - work.forEach { $0() } + host.invalidate() + let r = host.layout(offset: offset, viewport: viewportHeight) + if r.offset != offset { scrolls += 1 } + offset = r.offset + drawn = [:] + for f in r.frames { drawn[f.key] = f.minY - r.offset } } - func settle(sourceLocation: SourceLocation = #_sourceLocation) { + /// Passes until the query and the layout stop changing. With `holding`, + /// that row, while drawn, is drawn in the same place on every pass. + func settle(holding key: String? = nil, sourceLocation: SourceLocation = #_sourceLocation) { + let top = key.flatMap(rowTop) for _ in 0..<30 { - let before = (h.queryKey(h.core.queryInputs), offset, requested, snapshot.spaceBefore) + let before = (h.queryKey(h.core.queryInputs), offset, h.core.snapshot.spaceBefore) pass() - if (h.queryKey(h.core.queryInputs), offset, requested, h.core.snapshot.spaceBefore) == before, - deferred.isEmpty, + if let key, let top, let now = rowTop(key) { + #expect(now == top, "\(key) drawn moved", sourceLocation: sourceLocation) + } + if (h.queryKey(h.core.queryInputs), offset, h.core.snapshot.spaceBefore) == before, h.core.snapshot.complete { return @@ -95,144 +71,69 @@ final class GeometryScrollView { Issue.record("did not settle", sourceLocation: sourceLocation) } + /// The user scrolls to `y`, within the content, and the view lays out. func userScroll(_ y: Double) { - offset = y - insetTop + offset = max(0, min(y, host.contentHeight - viewportHeight)) pass() } - /// Where the row is from the viewport's top. - func rowTop(_ key: String) -> Double? { - host.rowFrames().first { $0.key == AnyHashable(key) }.map { $0.minY - host.scrollOffset } - } + /// Where the row was drawn on the last pass, from the viewport's top. + func rowTop(_ key: String) -> Double? { drawn[AnyHashable(key)] } - var bottomGap: Double { host.contentHeight - host.scrollOffset - host.viewportHeight } + var bottomGap: Double { host.contentHeight - offset - viewportHeight } } @MainActor -@Suite("SwiftUI geometry host") struct ScrollGeometryHostTests { - @Test("the update waits for every row to be measured and the content to reach them") - func waitsForMeasurement() { - let host = ScrollGeometryHost() - var deferred: [@MainActor () -> Void] = [] - var updates = 0 - host.defer_ = { deferred.append($0) } - host.afterUpdate = { updates += 1 } - func run() { - let work = deferred - deferred = [] - work.forEach { $0() } - } - - host.render([(key: "a", index: 0), (key: "b", index: 1)], spaceBefore: 0) - host.rowFrame("a", minY: 0, maxY: 20) - run() - #expect(updates == 0) - host.rowFrame("b", minY: 20, maxY: 50) - run() - // The rows are in, the scroll view hasn't reported content that fits them. - #expect(updates == 0) - host.geometry( - contentOffset: 0, containerHeight: 100, contentHeight: 50, insetTop: 0, insetBottom: 0) - run() - #expect(updates == 1) - - // A new space over the same first row moves the rows by its change, - // with no new reports (the view reports a frame only when it changes; - // here the update waits on the content reaching the moved rows). - host.render([(key: "a", index: 0), (key: "b", index: 1)], spaceBefore: 30) - run() - #expect(updates == 1) - #expect(host.rowFrames().map(\.maxY) == [50, 80]) - host.geometry( - contentOffset: 0, containerHeight: 100, contentHeight: 80, insetTop: 0, insetBottom: 0) - run() - #expect(updates == 2) - - // A new first row: the space was sized to keep the rows in place, so - // the ones still there keep their frames and only the new one waits. - host.render([(key: "z", index: 0), (key: "a", index: 1), (key: "b", index: 2)], spaceBefore: 0) - run() - #expect(updates == 2) - host.rowFrame("z", minY: 0, maxY: 30) - run() - #expect(updates == 3) - #expect(host.rowFrames().map(\.maxY) == [30, 50, 80]) - - // Content the view rounded to the pixel below the rows' fractional end. - host.rowFrame("b", minY: 20, maxY: 60.33) - host.geometry( - contentOffset: 0, containerHeight: 100, contentHeight: 60, insetTop: 0, insetBottom: 0) - run() - #expect(updates == 4) - } - - @Test("its own scroll lands and is reported; the user's is reported once") - func reportsScrolls() { - let host = ScrollGeometryHost() - var requested: [Double] = [] - var scrolls = 0 - host.requestScroll = { requested.append($0) } - host.didScroll = { scrolls += 1 } - host.geometry( - contentOffset: -10, containerHeight: 110, contentHeight: 1000, insetTop: 10, insetBottom: 0) - #expect(host.scrollOffset == 0) - #expect(host.viewportHeight == 100) - #expect(scrolls == 0) - - host.scroll(to: 2000) - #expect(requested == [900]) - #expect(host.scrollOffset == 900) - // Landing where it already says it is still tells the core. - host.geometry( - contentOffset: 890, containerHeight: 110, contentHeight: 1000, insetTop: 10, insetBottom: 0) - #expect(scrolls == 1) - host.geometry( - contentOffset: 890, containerHeight: 110, contentHeight: 1000, insetTop: 10, insetBottom: 0) - #expect(scrolls == 1) - host.geometry( - contentOffset: 490, containerHeight: 110, contentHeight: 1000, insetTop: 10, insetBottom: 0) - #expect(scrolls == 2) - #expect(host.scrollOffset == 500) - } - +@Suite("Layout host") struct LayoutHostTests { @Test("pages down and back up, the top row holding still as pages come and go") func pages() { let h = Harness(rowCount: 1000) { $0.anchoring = .manual } - let view = GeometryScrollView(h, containerHeight: 430, insetTop: 30) + let view = LayoutScrollView(h) view.settle() #expect(h.core.snapshot.items.first?.key == "r0") for _ in 0..<40 { - view.userScroll(view.host.scrollOffset + 300) - view.settle() + view.userScroll(view.scrollOffset + 300) + view.settle(holding: h.core.firstVisibleItem()?.key) } - let key = "r600" - #expect(view.rowTop(key) != nil) - let top = view.rowTop(key) - view.settle() - #expect(view.rowTop(key) == top) + #expect(view.rowTop("r600") != nil) for _ in 0..<40 { - view.userScroll(view.host.scrollOffset - 300) - view.settle() + view.userScroll(view.scrollOffset - 300) + view.settle(holding: h.core.firstVisibleItem()?.key) } #expect(h.core.snapshot.items.first?.key == "r0") - #expect(view.host.scrollOffset == 0) + #expect(view.scrollOffset == 0) } @Test("rows taller than estimated above the viewport don't move it") func dynamicHeights() { let h = Harness(rowCount: 300) { $0.anchoring = .manual } - let view = GeometryScrollView(h) + let view = LayoutScrollView(h) view.settle() view.userScroll(2000) view.settle() let key = h.core.firstVisibleItem()!.key - let top = view.rowTop(key)! let index = h.core.firstVisibleItem()!.index // Rows above it, rendered as overscan, turn out taller. for i in max(0, index - 4).. 0 { let top = view.rowTop(key)! - view.userScroll(view.host.scrollOffset - 40) - // Within the frame: the pages' rows, and the space that takes them up. - view.settle() - expected -= 40 - // Each frame moves what's on screen by the fling's 40 points, no more. - #expect(view.rowTop(key) == top + 40) + let offset = view.scrollOffset + view.userScroll(offset - 40) + // Each frame moves what's on screen by the fling's distance, no more, + // through every pass the pages it loads take. + #expect(view.rowTop(key) == top + (offset - view.scrollOffset)) + view.settle(holding: key) key = h.core.firstVisibleItem()!.key } - #expect(view.scrollRequests == before) - #expect(view.host.scrollOffset == max(0, expected)) + // Rows that load or come out taller are taken up by the space before + // them, not a scroll. + #expect(view.scrolls == scrolls) // At rest, the space goes back to its estimate, nothing on screen moving. - let top = view.rowTop(key)! h.scheduler.advance(by: 200) - view.settle() - #expect(view.rowTop(key) == top) + view.settle(holding: key) } -} -extension ScrollGeometryHostTests { @Test( - "opened at its end, flings up to the top through rows 3× their estimate, never scrolled under") + "opened at its end, flings up to the top through rows 3× their estimate, never drawn moved") func flingsUpFromAPermalink() { let h = Harness(rowCount: 400) { $0.anchoring = .manual $0.permalinkID = "r399" } - let view = GeometryScrollView(h) + let view = LayoutScrollView(h) for i in 0..<400 { view.heights["r\(i)"] = 60 } view.settle() h.scheduler.advance(by: 200) @@ -332,66 +224,39 @@ extension ScrollGeometryHostTests { #expect(h.core.lastVisibleItem()?.key == "r399") var flings = 0 - while view.host.scrollOffset > 0, flings < 200 { + while view.scrollOffset > 0, flings < 200 { flings += 1 - let before = view.scrollRequests + let scrolls = view.scrolls // A fling of 8 coarse frames, pages loading under it. (Kept cheap: it // runs on the main actor alongside the other targets' tests.) - for _ in 0..<8 where view.host.scrollOffset > 0 { + for _ in 0..<8 where view.scrollOffset > 0 { guard let key = h.core.firstVisibleItem()?.key, let top = view.rowTop(key) else { Issue.record("no rows on screen") return } - let offset = view.host.scrollOffset + let offset = view.scrollOffset view.userScroll(offset - 100) - view.settle() - let moved = offset - max(0, offset - 100) - #expect(view.rowTop(key) == top + moved, "fling \(flings) at \(offset)") + #expect( + view.rowTop(key) == top + (offset - view.scrollOffset), "fling \(flings) at \(offset)") + view.settle(holding: key) + // At the top, no space stands above the first row. + if view.scrollOffset == 0, h.core.snapshot.items.first?.key == "r0" { + #expect(view.rowTop("r0") == 0, "a gap above r0 after fling \(flings)") + } + } + // Away from the start, the space takes the pages up: no scroll under + // the fling. (At the start, leftover space goes with a scroll in the + // same pass.) + if h.core.snapshot.items.first?.key != "r0" { + #expect(view.scrolls == scrolls, "scrolled under fling \(flings)") } - #expect(view.scrollRequests == before, "scrolled under fling \(flings)") // At rest: the space is given back, nothing on screen moving. - guard let key = h.core.firstVisibleItem()?.key, let top = view.rowTop(key) else { return } + guard let key = h.core.firstVisibleItem()?.key else { return } h.scheduler.advance(by: 200) - view.settle() - #expect(view.rowTop(key) == top, "the give-back after fling \(flings) moved the rows") + view.settle(holding: key) } #expect(h.core.snapshot.items.first?.key == "r0") - #expect(view.host.scrollOffset == 0) + #expect(view.scrollOffset == 0) #expect(h.core.snapshot.spaceBefore == 0) } - - @Test("a scroll a report contradicts is asked for again, a few times") - func reissuesAContradictedScroll() { - let host = ScrollGeometryHost() - var requested: [Double] = [] - var scrolls = 0 - host.requestScroll = { requested.append($0) } - host.didScroll = { scrolls += 1 } - func report(_ offset: Double, content: Double = 2000) { - host.geometry( - contentOffset: offset, containerHeight: 400, contentHeight: content, insetTop: 0, - insetBottom: 0) - } - report(500) - scrolls = 0 - host.scroll(to: 1200) - // A fling's deceleration writes over it: asked for again, the core not - // told of an offset it didn't ask for. - report(490) - #expect(requested == [1200, 1200]) - #expect(host.scrollOffset == 1200) - #expect(scrolls == 0) - // Against the content as it is now. - report(480, content: 1500) - #expect(requested == [1200, 1200, 1100]) - report(1100, content: 1500) - #expect(scrolls == 1) - #expect(host.scrollOffset == 1100) - - // One that never takes is given up after three reports. - host.scroll(to: 200) - for y in [1090.0, 1080, 1070, 1060] { report(y, content: 1500) } - #expect(requested.count == 3 + 1 + 3) - #expect(host.scrollOffset == 1060) - } } From 80939cb63522a06cdf7f3d94c3ed2b873553a895 Mon Sep 17 00:00:00 2001 From: tmm Date: Tue, 20 Oct 2026 22:46:48 -0400 Subject: [PATCH 086/104] 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. --- .../Sources/ZeroVirtual/RowsScrollView.swift | 280 +++++++++++++----- swift/Sources/ZeroVirtual/VirtualList.swift | 28 +- .../Sources/ZeroVirtual/VirtualListView.swift | 124 ++++++++ .../AppleTests/VirtualListViewTests.swift | 128 ++++++++ 4 files changed, 475 insertions(+), 85 deletions(-) create mode 100644 swift/Sources/ZeroVirtual/VirtualListView.swift create mode 100644 swift/Tests/AppleTests/VirtualListViewTests.swift diff --git a/swift/Sources/ZeroVirtual/RowsScrollView.swift b/swift/Sources/ZeroVirtual/RowsScrollView.swift index 8934e6a422..cc35728820 100644 --- a/swift/Sources/ZeroVirtual/RowsScrollView.swift +++ b/swift/Sources/ZeroVirtual/RowsScrollView.swift @@ -7,13 +7,19 @@ import AppKit #endif + #if canImport(UIKit) + typealias PlatformView = UIView + #else + typealias PlatformView = NSView + #endif + /// A row to draw: its key and index, the row (to tell a changed row from - /// the same one), and its view. + /// the same one), and the snapshot's item, for the row's view. struct RowsItem { var key: AnyHashable var index: Int var row: Any - var view: () -> AnyView + var item: Any } /// The rows the view draws: the snapshot's. @@ -23,45 +29,46 @@ var spaceAfter = 0.0 } - /// A row's hosted SwiftUI view and its measured height. + /// How a row's view is made and measured: hosted SwiftUI + /// (`HostedRowViews`), or the app's own (`AppRowViews`). The rest of the + /// layout is the same for both. + @MainActor + protocol RowViews: AnyObject { + /// Views of rows that leave are kept, hidden, for rows that come. + var reusesViews: Bool { get } + /// The view showing `item`: `view` shown anew (the row's own when its + /// row changed, or, when views are reused, one a row left), or a new + /// one when it's nil. + func view( + for item: RowsItem, reusing view: PlatformView?, in layout: RowsLayout + ) + -> PlatformView + /// The view's height at `width`. + func height(of view: PlatformView, width: Double) -> Double + /// The view's row left and the view isn't reused. + func discard(_ view: PlatformView) + } + + /// A row on screen: its view, its measured height, and the row it shows. @MainActor final class RowCell { - #if canImport(UIKit) - let controller = UIHostingController(rootView: AnyView(EmptyView())) - var view: UIView { controller.view } - #else - let controller = NSHostingController(rootView: AnyView(EmptyView())) - var view: NSView { controller.view } - #endif + var view: PlatformView var height: Double? var row: Any? - init() { - #if canImport(UIKit) - controller.safeAreaRegions = [] - controller.view.backgroundColor = .clear - #else - controller.sizingOptions = [] - #endif - } - - func measure(width: Double) -> Double { - if let height { return height } - let size = controller.sizeThatFits( - in: CGSize(width: width, height: .greatestFiniteMagnitude)) - let h = Double(size.height) - height = h - return h - } + init(view: PlatformView) { self.view = view } } - /// What the platform scroll views share: the rows' cells, kept and - /// reused, and the layout host that places them. + /// What the platform scroll views share: the rows' cells, and the layout + /// host that places them. @MainActor final class RowsLayout { let host = LayoutHost() + let views: any RowViews var source = RowsSource() private var cells: [AnyHashable: RowCell] = [:] + /// Views rows left, hidden, for other rows (when `views` reuses them). + private var pool: [PlatformView] = [] private var width = 0.0 /// Changes with each snapshot: rows are compared with their cells' then. private var generation = 0 @@ -69,9 +76,10 @@ /// The list has drawn rows. Until then, it draws none while it opens at /// a jump: its first frame is where the jump lands, not where it starts. private(set) var shown = false - var addCell: (RowCell) -> Void = { _ in } + var addView: (PlatformView) -> Void = { _ in } - init() { + init(views: any RowViews) { + self.views = views host.rows = { [unowned self] in (source.items.map { (key: $0.key, index: $0.index) }, source.spaceBefore, source.spaceAfter) } @@ -85,6 +93,14 @@ host.invalidate() } + /// The row with `key` changed height on its own: it's measured again in + /// the next layout pass. + func rowHeightDidChange(_ key: AnyHashable) { + guard let cell = cells[key] else { return } + cell.height = nil + host.invalidate() + } + /// One layout pass at `width`: the cells to place, the content's height /// and the offset. func layout( @@ -100,12 +116,16 @@ let r = host.layout(offset: offset, viewport: viewport) if !shown, !r.frames.isEmpty, !host.isLanding() { shown = true } let live = Set(r.frames.map(\.key)) - // A row's view isn't reused for another row: it would show the row it - // had until its next layout. for (key, cell) in cells where !live.contains(key) { cells[key] = nil seen[key] = nil - cell.view.removeFromSuperview() + if views.reusesViews { + cell.view.isHidden = true + pool.append(cell.view) + } else { + cell.view.removeFromSuperview() + views.discard(cell.view) + } } return ( r.frames.compactMap { f in cells[f.key].map { ($0, f.minY, f.maxY) } }, r.contentHeight, @@ -115,37 +135,53 @@ private func measure(_ key: AnyHashable) -> Double { guard let item = source.items.first(where: { $0.key == key }) else { return 0 } - let cell: RowCell - if let existing = cells[key] { - cell = existing - } else { - cell = RowCell() - addCell(cell) - cells[key] = cell - } - if seen[key] != generation { - seen[key] = generation - if let old = cell.row, sameRow(old, item.row) { + if let cell = cells[key] { + if seen[key] != generation { + seen[key] = generation // The same row: its view and height stand. - } else { - cell.row = item.row - cell.height = nil - cell.controller.rootView = AnyView( - item.view() - .fixedSize(horizontal: false, vertical: true) - .onGeometryChange(for: Double.self) { - Double($0.size.height) - } action: { - [weak self, weak cell] h in - // The row changed height on its own, as a view inside it - // loaded. - guard let self, let cell, let old = cell.height, abs(old - h) > 0.5 else { return } - cell.height = nil - self.host.invalidate() - }) + if let old = cell.row, !sameRow(old, item.row) { + show(item, in: cell) + } } + return measure(cell) + } + let reused = views.reusesViews ? pool.popLast() : nil + let view = views.view(for: item, reusing: reused, in: self) + if view !== reused { + if let reused { pool.append(reused) } + addView(view) } - return cell.measure(width: width) + let cell = RowCell(view: view) + cell.row = item.row + cells[key] = cell + seen[key] = generation + return measure(cell) + } + + private func show(_ item: RowsItem, in cell: RowCell) { + cell.row = item.row + cell.height = nil + let view = views.view(for: item, reusing: cell.view, in: self) + guard view !== cell.view else { return } + // A new view in place of the row's own. + cell.view.removeFromSuperview() + views.discard(cell.view) + cell.view = view + addView(view) + } + + private func measure(_ cell: RowCell) -> Double { + if let height = cell.height { return height } + let h = views.height(of: cell.view, width: width) + cell.height = h + return h + } + + /// The row's height as it laid itself out: when it's another than the + /// one measured, it's measured again. + func row(_ key: AnyHashable, laidOutAt height: Double) { + guard let old = cells[key]?.height, abs(old - height) > 0.5 else { return } + rowHeightDidChange(key) } } @@ -159,18 +195,117 @@ fileprivate func isEqual(to other: Any) -> Bool { (other as? Self) == self } } + /// SwiftUI rows, each in its own hosting controller. A row's controller + /// isn't reused for another row: it would show the row it had until its + /// next layout. + @MainActor + final class HostedRowViews: RowViews { + #if canImport(UIKit) + typealias Controller = UIHostingController + #else + typealias Controller = NSHostingController + #endif + var row: (Any) -> AnyView + private var controllers: [ObjectIdentifier: Controller] = [:] + + init(row: @escaping (Any) -> AnyView) { self.row = row } + + var reusesViews: Bool { false } + + func view( + for item: RowsItem, reusing view: PlatformView?, in layout: RowsLayout + ) + -> PlatformView + { + let controller: Controller + if let view, let existing = controllers[ObjectIdentifier(view)] { + controller = existing + } else { + controller = Controller(rootView: AnyView(EmptyView())) + #if canImport(UIKit) + controller.safeAreaRegions = [] + controller.view.backgroundColor = .clear + #else + controller.sizingOptions = [] + #endif + controllers[ObjectIdentifier(controller.view)] = controller + } + let key = item.key + controller.rootView = AnyView( + row(item.item) + .fixedSize(horizontal: false, vertical: true) + .onGeometryChange(for: Double.self) { + Double($0.size.height) + } action: { [weak layout] h in + // The row changed height on its own, as a view inside it loaded. + layout?.row(key, laidOutAt: h) + }) + return controller.view + } + + func height(of view: PlatformView, width: Double) -> Double { + guard let controller = controllers[ObjectIdentifier(view)] else { return 0 } + return Double( + controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)).height) + } + + func discard(_ view: PlatformView) { + controllers[ObjectIdentifier(view)] = nil + } + } + + /// The app's own row views, measured with Auto Layout at the list's + /// width, and reused. + @MainActor + final class AppRowViews: RowViews { + let make: (Any, PlatformView?) -> PlatformView + + init(make: @escaping (Any, PlatformView?) -> PlatformView) { self.make = make } + + var reusesViews: Bool { true } + + func view( + for item: RowsItem, reusing view: PlatformView?, in layout: RowsLayout + ) + -> PlatformView + { + make(item.item, view) + } + + func height(of view: PlatformView, width: Double) -> Double { + #if canImport(UIKit) + let size = view.systemLayoutSizeFitting( + CGSize(width: width, height: UIView.layoutFittingCompressedSize.height), + withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel) + return Double(size.height) + #else + let constraint = view.widthAnchor.constraint(equalToConstant: width) + constraint.isActive = true + defer { constraint.isActive = false } + // Laid out at the width first: a wrapping label wraps at the width + // it was laid out at, and its height follows. + view.setFrameSize(NSSize(width: width, height: view.frame.height)) + view.layoutSubtreeIfNeeded() + return Double(view.fittingSize.height) + #endif + } + + func discard(_ view: PlatformView) {} + } + #if canImport(UIKit) /// A scroll view that lays out the rows itself: their frames, its /// content size and its offset, all in `layoutSubviews`. final class RowsScrollView: UIScrollView { - let rows = RowsLayout() + let rows: RowsLayout private var laying = false - override init(frame: CGRect) { - super.init(frame: frame) + init(views: any RowViews) { + rows = RowsLayout(views: views) + super.init(frame: .zero) alwaysBounceVertical = true rows.host.setNeedsLayout = { [weak self] in self?.setNeedsLayout() } - rows.addCell = { [weak self] cell in self?.addSubview(cell.view) } + rows.addView = { [weak self] view in self?.addSubview(view) } } required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } @@ -209,12 +344,13 @@ /// the clip view moves or resizes (the user's scroll, before it's /// drawn) and in the document view's `layout` (a new snapshot). final class RowsScrollView: NSScrollView { - let rows = RowsLayout() + let rows: RowsLayout private let document = RowsDocumentView() private var laying = false - override init(frame: CGRect) { - super.init(frame: frame) + init(views: any RowViews) { + rows = RowsLayout(views: views) + super.init(frame: .zero) hasVerticalScroller = true drawsBackground = false documentView = document @@ -226,7 +362,7 @@ self, selector: #selector(clipChanged), name: name, object: contentView) } rows.host.setNeedsLayout = { [weak self] in self?.document.needsLayout = true } - rows.addCell = { [weak self] cell in self?.document.addSubview(cell.view) } + rows.addView = { [weak self] view in self?.document.addSubview(view) } } required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } @@ -284,10 +420,11 @@ @MainActor struct RowsScrollViewRepresentable { let source: () -> RowsSource + let row: (Any) -> AnyView let model: VirtualListModel func make() -> RowsScrollView { - let view = RowsScrollView(frame: .zero) + let view = RowsScrollView(views: HostedRowViews(row: row)) model.source = source model.connect(view) view.rows.update(source()) @@ -296,6 +433,7 @@ func update(_ view: RowsScrollView) { model.source = source + (view.rows.views as? HostedRowViews)?.row = row view.rows.update(source()) } } diff --git a/swift/Sources/ZeroVirtual/VirtualList.swift b/swift/Sources/ZeroVirtual/VirtualList.swift index 50ef07d0f0..5815e3614d 100644 --- a/swift/Sources/ZeroVirtual/VirtualList.swift +++ b/swift/Sources/ZeroVirtual/VirtualList.swift @@ -42,23 +42,23 @@ public var body: some View { // Read here, so the list renders each snapshot. _ = list.snapshot - return RowsScrollViewRepresentable(source: source, model: model) - .onAppear { model.attach(list) } - .onDisappear { model.detach(list) } + return RowsScrollViewRepresentable( + source: { [list] in RowsSource(list.snapshot) }, + row: { [row] in AnyView(row($0 as! VirtualItem)) }, model: model + ) + .onAppear { model.attach(list) } + .onDisappear { model.detach(list) } } + } + extension RowsSource { /// The snapshot's rows, as the scroll view draws them. - private var source: () -> RowsSource { - { [list, row] in - let snapshot = list.snapshot - return RowsSource( - items: snapshot.items.map { item in - RowsItem( - key: AnyHashable(item.key), index: item.index, row: item.row, - view: { AnyView(row(item)) }) - }, - spaceBefore: snapshot.spaceBefore, spaceAfter: snapshot.spaceAfter) - } + init(_ snapshot: VirtualSnapshot) { + self.init( + items: snapshot.items.map { + RowsItem(key: AnyHashable($0.key), index: $0.index, row: $0.row, item: $0) + }, + spaceBefore: snapshot.spaceBefore, spaceAfter: snapshot.spaceAfter) } } diff --git a/swift/Sources/ZeroVirtual/VirtualListView.swift b/swift/Sources/ZeroVirtual/VirtualListView.swift new file mode 100644 index 0000000000..c9c48309c0 --- /dev/null +++ b/swift/Sources/ZeroVirtual/VirtualListView.swift @@ -0,0 +1,124 @@ +#if canImport(SwiftUI) && (canImport(UIKit) || canImport(AppKit)) + #if canImport(UIKit) + import UIKit + #else + import AppKit + #endif + import Zero + + #if canImport(UIKit) + /// What `VirtualListView` is: a `UIView`. + public typealias VirtualListViewBase = UIView + #else + /// What `VirtualListView` is: an `NSView`. + public typealias VirtualListViewBase = NSView + #endif + + /// `VirtualList` for UIKit and AppKit: a scroll view of a + /// `ZeroVirtualizer`'s rows, which are the app's own views. They page in + /// as it scrolls, and what's on screen stays put as rows load, go, and + /// change height. + /// + /// ```swift + /// let list = VirtualListView(messages) { item, reusing in + /// let row = reusing as? MessageRowView ?? MessageRowView() + /// row.show(item.row) + /// return row + /// } + /// ``` + /// + /// `row` makes the view of a row, or shows the row in `reusing`, a view + /// another row left; it's nil when there's none. A row whose data + /// changes (rows of an `Equatable` type that no longer compare equal) is + /// shown again in its own view. A row's height is its view's, laid out + /// with Auto Layout at the list's width: its constraints must determine + /// it. A row that changes height on its own, not with its data, says so + /// with `rowHeightDidChange(_:)`. + /// + /// The list lays the rows out in its `scrollView` itself: their frames, + /// the content's size and the offset are set in one layout pass, so no + /// frame shows rows moved by a page loading before the offset that keeps + /// them in place. Set the scroll view's insets and delegate as for any + /// other, but not its content size or offset; scroll with the + /// virtualizer (`scrollToItem`). The list follows the virtualizer while + /// it's in a window. + public final class VirtualListView< + Context: Sendable, Row: ZeroRow, Key: Hashable, StartRow: Equatable & Sendable, + ListContext: Equatable & Sendable + >: VirtualListViewBase { + public typealias Virtualizer = ZeroVirtualizer + + private let list: Virtualizer + private let rows: RowsScrollView + private let model = VirtualListModel() + + #if canImport(UIKit) + /// The scroll view the rows are in. + public var scrollView: UIScrollView { rows } + #else + /// The scroll view the rows are in. + public var scrollView: NSScrollView { rows } + #endif + + #if canImport(UIKit) + public convenience init( + _ list: Virtualizer, + row: @escaping @MainActor (_ item: VirtualItem, _ reusing: UIView?) -> UIView + ) { + self.init(list, make: row) + } + #else + public convenience init( + _ list: Virtualizer, + row: @escaping @MainActor (_ item: VirtualItem, _ reusing: NSView?) -> NSView + ) { + self.init(list, make: row) + } + #endif + + private init( + _ list: Virtualizer, + make row: @escaping @MainActor (VirtualItem, PlatformView?) -> PlatformView + ) { + self.list = list + rows = RowsScrollView( + views: AppRowViews { item, reusing in row(item as! VirtualItem, reusing) }) + super.init(frame: .zero) + #if canImport(UIKit) + rows.autoresizingMask = [.flexibleWidth, .flexibleHeight] + #else + rows.autoresizingMask = [.width, .height] + #endif + rows.frame = bounds + addSubview(rows) + model.source = { [list] in RowsSource(list.snapshot) } + model.connect(rows) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + /// The row with `key` changed height on its own: its view is measured + /// again, and the rows below it move, with what's on screen staying + /// put. + public func rowHeightDidChange(_ key: Key) { + rows.rows.rowHeightDidChange(AnyHashable(key)) + } + + #if canImport(UIKit) + public override func didMoveToWindow() { + super.didMoveToWindow() + follow(window != nil) + } + #else + public override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + follow(window != nil) + } + #endif + + private func follow(_ inWindow: Bool) { + if inWindow { model.attach(list) } else { model.detach(list) } + } + } +#endif diff --git a/swift/Tests/AppleTests/VirtualListViewTests.swift b/swift/Tests/AppleTests/VirtualListViewTests.swift new file mode 100644 index 0000000000..dee0bd957e --- /dev/null +++ b/swift/Tests/AppleTests/VirtualListViewTests.swift @@ -0,0 +1,128 @@ +#if canImport(SwiftUI) + import Foundation + import Testing + import ZeroTesting + + @testable import Zero + @testable import ZeroVirtual + + #if canImport(UIKit) + import UIKit + typealias TestView = UIView + #else + import AppKit + typealias TestView = NSView + #endif + + /// `VirtualListView` in a real window, on a `LoopbackServer`, with rows + /// that are plain views. + @Suite(.serialized) @MainActor struct VirtualListViewTests { + /// A 30-point row showing a note's id. + final class NoteView: TestView { + var id = "" + init() { + super.init(frame: .zero) + translatesAutoresizingMaskIntoConstraints = true + let height = heightAnchor.constraint(equalToConstant: 30) + height.isActive = true + } + required init?(coder: NSCoder) { fatalError() } + } + + /// A 300 × 400 window showing `view`. + func show(_ view: TestView) -> AnyObject { + #if canImport(UIKit) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 300, height: 400)) + let controller = UIViewController() + controller.view = view + window.rootViewController = controller + window.makeKeyAndVisible() + return window + #else + let window = NSWindow( + contentRect: CGRect(x: 0, y: 0, width: 300, height: 400), styleMask: [.titled], + backing: .buffered, defer: false) + window.contentView = view + window.makeKeyAndOrderFront(nil) + return window + #endif + } + + @Test func scrollsToARowFarBelow() async throws { + let t = VirtualListTests() + let (zero, list) = try t.makeList() + let view = VirtualListView(list) { item, reusing in + let row = reusing as? NoteView ?? NoteView() + row.id = item.row.id + return row + } + let window = show(view) + try await t.run(30) { list.snapshot.complete && !list.snapshot.items.isEmpty } + #expect(list.snapshot.items.first?.key == VirtualListTests.id(0)) + list.scrollToItem(VirtualListTests.id(250), align: .end) + try await t.run(30) { list.lastVisibleItem()?.key == VirtualListTests.id(250) } + try await t.run(0.5) { false } + #expect(list.lastVisibleItem()?.key == VirtualListTests.id(250)) + // The scroll view moved there: n250's bottom at the viewport's. + let g = try #require(t.platformGeometry(window)) + let s = list.snapshot + let position = try #require(s.items.firstIndex { $0.key == VirtualListTests.id(250) }) + #expect(abs(g.offset - (s.spaceBefore + Double(position + 1) * 30 - g.viewport)) <= 1) + withExtendedLifetime((zero, window)) {} + } + + @Test func aReusedViewShowsItsNewRow() async throws { + let t = VirtualListTests() + let (zero, list) = try t.makeList() + var reused = 0 + let view = VirtualListView(list) { item, reusing in + let row = reusing as? NoteView ?? NoteView() + if reusing != nil { reused += 1 } + row.id = item.row.id + return row + } + let window = show(view) + try await t.run(30) { list.snapshot.complete && !list.snapshot.items.isEmpty } + // Scrolled down a few screens, as a user would: rows leave at the + // top, and their views come back below. + for _ in 0..<12 { + scroll(view.scrollView, by: 100) + try await t.run(0.05) { false } + } + try await t.run(1) { false } + #expect(reused > 0) + // Each view on screen shows the row at its place. + let s = list.snapshot + let shown = rowViews(view.scrollView) + #expect(!shown.isEmpty) + for row in shown { + let i = Int(((Double(row.frame.minY) - s.spaceBefore) / 30).rounded()) + #expect(s.items.indices.contains(i) && s.items[i].key == row.id, "\(row.id) at \(i)") + } + withExtendedLifetime((zero, window)) {} + } + + func scroll(_ scrollView: AnyObject, by dy: Double) { + #if canImport(UIKit) + let s = scrollView as! UIScrollView + s.contentOffset.y += dy + s.layoutIfNeeded() + #else + let s = scrollView as! NSScrollView + let clip = s.contentView + clip.scroll(to: NSPoint(x: 0, y: clip.bounds.origin.y + dy)) + s.reflectScrolledClipView(clip) + #endif + } + + /// The row views drawn. + func rowViews(_ scrollView: AnyObject) -> [NoteView] { + #if canImport(UIKit) + let views = (scrollView as! UIScrollView).subviews + #else + let views = (scrollView as! NSScrollView).documentView?.subviews ?? [] + #endif + return views.compactMap { $0 as? NoteView }.filter { !$0.isHidden } + } + } +#endif From fa8f6987ed1978d1570cd8e033898573a8aa001a Mon Sep 17 00:00:00 2001 From: tmm Date: Tue, 20 Oct 2026 22:46:55 -0400 Subject: [PATCH 087/104] 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. --- .github/workflows/zbugs-screenshots.yml | 5 +- swift/TestHost/App/FrameProbe.swift | 22 ++- swift/TestHost/App/NativeRows.swift | 150 ++++++++++++++++++ swift/TestHost/App/Screens.swift | 21 ++- .../TestHost/UITests/FrameProbeUITests.swift | 10 +- .../TestHost/UITests/VirtualListUITests.swift | 11 +- 6 files changed, 209 insertions(+), 10 deletions(-) create mode 100644 swift/TestHost/App/NativeRows.swift diff --git a/.github/workflows/zbugs-screenshots.yml b/.github/workflows/zbugs-screenshots.yml index 3d6c7243b3..a2f13c1891 100644 --- a/.github/workflows/zbugs-screenshots.yml +++ b/.github/workflows/zbugs-screenshots.yml @@ -156,7 +156,10 @@ jobs: fail-fast: false matrix: platform: [iPhone, macOS] - suite: [VirtualListUITests, FrameProbeUITests] + # The Native suites run the same tests on the screens' UIKit and + # AppKit lists (VirtualListView). + suite: + [VirtualListUITests, FrameProbeUITests, NativeVirtualListUITests, NativeFrameProbeUITests] defaults: run: working-directory: swift/TestHost diff --git a/swift/TestHost/App/FrameProbe.swift b/swift/TestHost/App/FrameProbe.swift index 4f0342e45e..2e86966925 100644 --- a/swift/TestHost/App/FrameProbe.swift +++ b/swift/TestHost/App/FrameProbe.swift @@ -456,8 +456,16 @@ struct ProbeMarker: View { func updateUIView(_ view: Marker, context: Context) {} } - private final class Marker: UIView { - let id: String + /// A marker in a row's own view (`NativeRowView`): its id changes with + /// the row the view shows. + final class Marker: UIView { + var id: String { + didSet { + guard id != oldValue, window != nil else { return } + FrameProbe.shared.unregister(oldValue, self) + FrameProbe.shared.register(id, self) + } + } init(_ id: String) { self.id = id super.init(frame: .zero) @@ -480,8 +488,14 @@ struct ProbeMarker: View { func updateNSView(_ view: Marker, context: Context) {} } - private final class Marker: NSView { - let id: String + final class Marker: NSView { + var id: String { + didSet { + guard id != oldValue, window != nil else { return } + FrameProbe.shared.unregister(oldValue, self) + FrameProbe.shared.register(id, self) + } + } init(_ id: String) { self.id = id super.init(frame: .zero) diff --git a/swift/TestHost/App/NativeRows.swift b/swift/TestHost/App/NativeRows.swift new file mode 100644 index 0000000000..b995067604 --- /dev/null +++ b/swift/TestHost/App/NativeRows.swift @@ -0,0 +1,150 @@ +import SwiftUI +import Zero +import ZeroVirtual + +#if os(iOS) + import UIKit +#else + import AppKit +#endif + +/// The screens' lists as UIKit or AppKit views (`-native 1`): a +/// `VirtualListView` whose rows are `NativeRowView`s. +enum Native { + static var enabled: Bool { UserDefaults.standard.bool(forKey: "native") } +} + +/// A row of two lines of text, the second in a caption style (or the +/// first, with `captionFirst`), one accessibility element like the SwiftUI +/// rows. +final class NativeRowView: PlatformView { + #if os(iOS) + private let first = UILabel() + private let second = UILabel() + private let stack: UIStackView + #else + private let first = NSTextField(wrappingLabelWithString: "") + private let second = NSTextField(wrappingLabelWithString: "") + private let stack: NSStackView + #endif + private var marker: Marker? + private var padding: [NSLayoutConstraint] = [] + + init() { + #if os(iOS) + stack = UIStackView(arrangedSubviews: [first, second]) + stack.axis = .vertical + stack.alignment = .fill + for label in [first, second] { + label.numberOfLines = 0 + label.adjustsFontForContentSizeCategory = true + } + #else + stack = NSStackView(views: [first, second]) + stack.orientation = .vertical + stack.alignment = .leading + for label in [first, second] { + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + } + #endif + stack.spacing = 2 + super.init(frame: .zero) + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + padding = [ + stack.topAnchor.constraint(equalTo: topAnchor), + bottomAnchor.constraint(equalTo: stack.bottomAnchor), + ] + NSLayoutConstraint.activate( + padding + [ + stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12), + stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12), + ]) + #if os(iOS) + isAccessibilityElement = true + #else + setAccessibilityElement(true) + setAccessibilityRole(.group) + #endif + } + + required init?(coder: NSCoder) { fatalError() } + + /// Shows a row: its id, its two lines (the second hidden when empty) and + /// its vertical padding. + func show(id: String, first a: String, second b: String, captionFirst: Bool, padding: Double) { + #if os(iOS) + first.text = a + second.text = b + first.font = .preferredFont(forTextStyle: captionFirst ? .caption1 : .body) + second.font = .preferredFont(forTextStyle: captionFirst ? .body : .caption1) + first.textColor = captionFirst ? .secondaryLabel : .label + second.textColor = captionFirst ? .label : .secondaryLabel + accessibilityIdentifier = "row-\(id)" + accessibilityLabel = captionFirst ? b : a + #else + first.stringValue = a + second.stringValue = b + first.font = .preferredFont(forTextStyle: captionFirst ? .caption1 : .body) + second.font = .preferredFont(forTextStyle: captionFirst ? .body : .caption1) + first.textColor = captionFirst ? .secondaryLabelColor : .labelColor + second.textColor = captionFirst ? .labelColor : .secondaryLabelColor + setAccessibilityIdentifier("row-\(id)") + setAccessibilityLabel(captionFirst ? b : a) + #endif + second.isHidden = b.isEmpty + for c in self.padding { c.constant = padding } + if FrameProbe.enabled { + if let marker { + marker.id = id + } else { + let m = Marker(id) + m.frame = bounds + #if os(iOS) + m.autoresizingMask = [.flexibleWidth, .flexibleHeight] + #else + m.autoresizingMask = [.width, .height] + #endif + addSubview(m) + marker = m + } + } + } + +} + +/// A `VirtualListView` in a SwiftUI screen, its rows `NativeRowView`s. Its +/// scroll view is the `list` element the UI tests read. +@MainActor +struct NativeVirtualList< + C: Sendable, R: ZeroRow, K: Hashable, S: Equatable & Sendable, L: Equatable & Sendable +> { + let list: ZeroVirtualizer + let show: (VirtualItem, NativeRowView) -> Void + + func make() -> VirtualListView { + let view = VirtualListView(list) { [show] item, reusing in + let row = reusing as? NativeRowView ?? NativeRowView() + show(item, row) + return row + } + #if os(iOS) + view.scrollView.accessibilityIdentifier = "list" + #else + view.scrollView.setAccessibilityIdentifier("list") + #endif + return view + } +} + +#if os(iOS) + extension NativeVirtualList: UIViewRepresentable { + func makeUIView(context: Context) -> VirtualListView { make() } + func updateUIView(_ view: VirtualListView, context: Context) {} + } +#else + extension NativeVirtualList: NSViewRepresentable { + func makeNSView(context: Context) -> VirtualListView { make() } + func updateNSView(_ view: VirtualListView, context: Context) {} + } +#endif diff --git a/swift/TestHost/App/Screens.swift b/swift/TestHost/App/Screens.swift index fef44f664a..905b6b7767 100644 --- a/swift/TestHost/App/Screens.swift +++ b/swift/TestHost/App/Screens.swift @@ -23,7 +23,12 @@ struct ChatScreen: View { Divider() // On screen once it knows where to open, the latest message: shown // before, it would draw the top of the chat for a frame. - if started { + if started, Native.enabled { + NativeVirtualList(list: list) { item, row in + let m = item.row + row.show(id: m.id, first: m.authorId, second: m.text, captionFirst: true, padding: 6) + } + } else if started { VirtualList(list) { item in MessageRow(message: item.row) } @@ -102,7 +107,19 @@ struct IssuesScreen: View { } } - private var issues: some View { + @ViewBuilder private var issues: some View { + if Native.enabled { + NativeVirtualList(list: list) { item, row in + let i = item.row + row.show( + id: i.id, first: i.title, second: i.description, captionFirst: false, padding: 8) + } + } else { + swiftUIIssues + } + } + + private var swiftUIIssues: some View { VirtualList(list) { item in VStack(alignment: .leading, spacing: 2) { Text(item.row.title) diff --git a/swift/TestHost/UITests/FrameProbeUITests.swift b/swift/TestHost/UITests/FrameProbeUITests.swift index 30e9f8ac49..de262b8c27 100644 --- a/swift/TestHost/UITests/FrameProbeUITests.swift +++ b/swift/TestHost/UITests/FrameProbeUITests.swift @@ -5,8 +5,10 @@ import XCTest /// scroll put them. The app drives the transitions itself /// (`-probe-script`); the probe's summary is the `probe` element's value. @MainActor -final class FrameProbeUITests: XCTestCase { +class FrameProbeUITests: XCTestCase { var app: XCUIApplication! + /// The screens' lists as UIKit or AppKit views (`VirtualListView`). + var native: Bool { false } override func setUp() async throws { continueAfterFailure = true @@ -16,6 +18,7 @@ final class FrameProbeUITests: XCTestCase { app = XCUIApplication() app.launchArguments = ["-screen", screen] + (script ? ["-probe-script", screen] : []) + ["-probe", "1"] + + ["-native", native ? "1" : "0"] app.launch() } @@ -129,3 +132,8 @@ final class FrameProbeUITests: XCTestCase { } #endif } + +/// The same, with the screens' lists as UIKit or AppKit views. +final class NativeFrameProbeUITests: FrameProbeUITests { + override var native: Bool { true } +} diff --git a/swift/TestHost/UITests/VirtualListUITests.swift b/swift/TestHost/UITests/VirtualListUITests.swift index 6c81560c0c..b4abe8677a 100644 --- a/swift/TestHost/UITests/VirtualListUITests.swift +++ b/swift/TestHost/UITests/VirtualListUITests.swift @@ -3,8 +3,10 @@ import XCTest /// ZeroVirtual's SwiftUI list (`VirtualList`) in the test app: rows checked /// by where they are on screen. @MainActor -final class VirtualListUITests: XCTestCase { +class VirtualListUITests: XCTestCase { var app: XCUIApplication! + /// The screens' lists as UIKit or AppKit views (`VirtualListView`). + var native: Bool { false } override func setUp() async throws { continueAfterFailure = false @@ -12,7 +14,7 @@ final class VirtualListUITests: XCTestCase { func launch(_ screen: String) { app = XCUIApplication() - app.launchArguments = ["-screen", screen] + app.launchArguments = ["-screen", screen, "-native", native ? "1" : "0"] app.launch() } @@ -170,3 +172,8 @@ final class VirtualListUITests: XCTestCase { XCTAssertTrue(top, "never got back to the first issue") } } + +/// The same, with the screens' lists as UIKit or AppKit views. +final class NativeVirtualListUITests: VirtualListUITests { + override var native: Bool { true } +} From 11ce9098bff2c3ac01e960f1dd5ed2638b45f743 Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 21 Oct 2026 05:32:56 +0000 Subject: [PATCH 088/104] 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 --- .github/actions/playwright-install/action.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/actions/playwright-install/action.yml b/.github/actions/playwright-install/action.yml index 2f0af9f0de..03bc885ff3 100644 --- a/.github/actions/playwright-install/action.yml +++ b/.github/actions/playwright-install/action.yml @@ -6,14 +6,18 @@ runs: steps: - name: Get Playwright version id: playwright-version - run: echo "version=$(pnpm exec playwright --version | awk '{print $NF}')" >> "$GITHUB_OUTPUT" + # The OS release too: browsers built for one Ubuntu release don't run + # on the next, which ubuntu-latest moves to. + run: | + echo "version=$(pnpm exec playwright --version | awk '{print $NF}')" >> "$GITHUB_OUTPUT" + echo "os=$(. /etc/os-release && echo "$ID-$VERSION_ID")" >> "$GITHUB_OUTPUT" shell: bash - name: Cache Playwright browsers id: playwright-cache uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ runner.arch }}-${{ steps.playwright-version.outputs.version }} + key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.os }}-${{ runner.arch }}-${{ steps.playwright-version.outputs.version }} - name: Install Playwright browsers and system deps if: steps.playwright-cache.outputs.cache-hit != 'true' run: timeout -k 30s 20m pnpm exec playwright install --with-deps From d67d76c0b7f46eb0d4ff485e80ebcacbc112c14b Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 21 Oct 2026 06:24:02 +0000 Subject: [PATCH 089/104] 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 --- .github/workflows/public-js.yml | 5 +++-- .github/workflows/zbugs-screenshots.yml | 7 ++++++- .syncpackrc | 23 +++++++++++++++++++++++ packages/zero-swift-cli/package.json | 2 +- 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/.github/workflows/public-js.yml b/.github/workflows/public-js.yml index 85ab6e02d0..9ec324be90 100644 --- a/.github/workflows/public-js.yml +++ b/.github/workflows/public-js.yml @@ -79,8 +79,9 @@ jobs: test: name: Public Test if: github.repository == 'rocicorp/mono' || github.repository == 'rocicorp/mono-internal' || github.repository == 'wevm/zero' - # See Public Checks. x64, since this runs WebKit and Firefox too. - runs-on: ${{ github.repository == 'rocicorp/mono-internal' && 'ubuntu-x64-8-core' || 'ubuntu-latest' }} + # See Public Checks. x64, since this runs WebKit and Firefox too. Ubuntu + # 24.04, not ubuntu-latest: Playwright 1.60 has no browsers for 26.04. + runs-on: ${{ github.repository == 'rocicorp/mono-internal' && 'ubuntu-x64-8-core' || 'ubuntu-24.04' }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: diff --git a/.github/workflows/zbugs-screenshots.yml b/.github/workflows/zbugs-screenshots.yml index a2f13c1891..241e9f4f6c 100644 --- a/.github/workflows/zbugs-screenshots.yml +++ b/.github/workflows/zbugs-screenshots.yml @@ -159,7 +159,12 @@ jobs: # The Native suites run the same tests on the screens' UIKit and # AppKit lists (VirtualListView). suite: - [VirtualListUITests, FrameProbeUITests, NativeVirtualListUITests, NativeFrameProbeUITests] + [ + VirtualListUITests, + FrameProbeUITests, + NativeVirtualListUITests, + NativeFrameProbeUITests, + ] defaults: run: working-directory: swift/TestHost diff --git a/.syncpackrc b/.syncpackrc index 5aeae05581..da0b6dfb39 100644 --- a/.syncpackrc +++ b/.syncpackrc @@ -14,6 +14,29 @@ ], "isIgnored": true }, + { + "label": "zero-swift's CLI (packages/zero-swift-cli) reads schemas with the TypeScript compiler API at runtime, which TypeScript 7 does not have", + "dependencies": [ + "typescript" + ], + "packages": [ + "zero-swift" + ], + "pinVersion": "6.0.3" + }, + { + "label": "zero-swift's CLI takes the app's @rocicorp/zero, any release it supports, not the workspace's", + "dependencies": [ + "@rocicorp/zero" + ], + "dependencyTypes": [ + "peer" + ], + "packages": [ + "zero-swift" + ], + "isIgnored": true + }, { "label": "replicache-doc stays on TypeScript 6 because typedoc does not support TypeScript 7", "dependencies": [ diff --git a/packages/zero-swift-cli/package.json b/packages/zero-swift-cli/package.json index 0548e30760..6ec323b078 100644 --- a/packages/zero-swift-cli/package.json +++ b/packages/zero-swift-cli/package.json @@ -31,7 +31,7 @@ "devDependencies": { "@rocicorp/zero": "workspace:*", "@types/node": "^22.10.5", - "esbuild": "0.27.7", + "esbuild": "0.28.2", "oxfmt": "^0.65.0", "vitest": "^5.0.0", "zero-schema": "workspace:*", From 3bb5ac5fd5baae8ae42519c958381316cbf62cae Mon Sep 17 00:00:00 2001 From: tmm Date: Wed, 21 Oct 2026 00:09:04 -0400 Subject: [PATCH 090/104] 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. --- swift/Sources/ZeroVirtual/LayoutHost.swift | 20 +- .../Sources/ZeroVirtual/RowsScrollView.swift | 529 ++++++++++++++++-- swift/Sources/ZeroVirtual/VirtualList.swift | 83 ++- .../Sources/ZeroVirtual/VirtualListView.swift | 95 +++- swift/Tests/AppleTests/VirtualListTests.swift | 143 +++++ .../AppleTests/VirtualListViewTests.swift | 36 ++ .../ZeroVirtualTests/LayoutHostTests.swift | 65 +++ 7 files changed, 901 insertions(+), 70 deletions(-) diff --git a/swift/Sources/ZeroVirtual/LayoutHost.swift b/swift/Sources/ZeroVirtual/LayoutHost.swift index e5305893f4..429dca6bc7 100644 --- a/swift/Sources/ZeroVirtual/LayoutHost.swift +++ b/swift/Sources/ZeroVirtual/LayoutHost.swift @@ -8,7 +8,9 @@ /// The view calls `layout(offset:viewport:)` from its layout pass, with the /// offset it's at (the user's scroll, since the last pass) and its /// viewport's height. Rows are measured with `measure` and stacked from the -/// snapshot's space before them. +/// snapshot's space before them, after the header: the content is the +/// header, the space before the rows, the rows, the space after them, and +/// the footer. @MainActor package final class LayoutHost: VirtualHost { package private(set) var scrollOffset = 0.0 @@ -23,6 +25,13 @@ package final class LayoutHost: VirtualHost { { ([], 0, 0) } /// A row's height, at the view's width. package var measure: (AnyHashable) -> Double = { _ in 0 } + /// The heights of the views above and below the rows, at the view's + /// width: 0 for none. + package var header: () -> Double = { 0 } + package var footer: () -> Double = { 0 } + /// The header's and the footer's heights, as last laid out. + package private(set) var headerHeight = 0.0 + package private(set) var footerHeight = 0.0 package var didScroll: () -> Void = {} package var viewportDidResize: () -> Void = {} package var afterUpdate: () -> Void = {} @@ -43,7 +52,8 @@ package final class LayoutHost: VirtualHost { package func rowFrames() -> [VirtualRowFrame] { frames } - /// A new snapshot, or rows that changed height. + /// A new snapshot, or rows (or the header or footer) that changed + /// height. package func invalidate() { stale = true if !inPass { setNeedsLayout() } @@ -94,12 +104,14 @@ package final class LayoutHost: VirtualHost { private func place() { let r = rows() - var y = r.before + headerHeight = header() + var y = headerHeight + r.before frames = r.keys.map { item in let height = measure(item.key) defer { y += height } return VirtualRowFrame(index: item.index, key: item.key, minY: y, maxY: y + height) } - contentHeight = y + r.after + footerHeight = footer() + contentHeight = y + r.after + footerHeight } } diff --git a/swift/Sources/ZeroVirtual/RowsScrollView.swift b/swift/Sources/ZeroVirtual/RowsScrollView.swift index cc35728820..2df0e39467 100644 --- a/swift/Sources/ZeroVirtual/RowsScrollView.swift +++ b/swift/Sources/ZeroVirtual/RowsScrollView.swift @@ -29,6 +29,28 @@ var spaceAfter = 0.0 } + /// How a row is shown: selected, and whether its highlight is + /// emphasized, as while the list has focus on macOS. + struct RowState: Equatable { + var selected = false + var emphasized = false + } + + /// A view above or below the rows, laid out with them: a header or a + /// footer. + @MainActor + final class RowsAccessory { + let view: PlatformView + /// Its height at a width. + let measure: (Double) -> Double + var height: Double? + + init(view: PlatformView, measure: @escaping (Double) -> Double) { + self.view = view + self.measure = measure + } + } + /// How a row's view is made and measured: hosted SwiftUI /// (`HostedRowViews`), or the app's own (`AppRowViews`). The rest of the /// layout is the same for both. @@ -36,11 +58,11 @@ protocol RowViews: AnyObject { /// Views of rows that leave are kept, hidden, for rows that come. var reusesViews: Bool { get } - /// The view showing `item`: `view` shown anew (the row's own when its - /// row changed, or, when views are reused, one a row left), or a new - /// one when it's nil. + /// The view showing `item` in `state`: `view` shown anew (the row's + /// own when its row or state changed, or, when views are reused, one a + /// row left), or a new one when it's nil. func view( - for item: RowsItem, reusing view: PlatformView?, in layout: RowsLayout + for item: RowsItem, _ state: RowState, reusing view: PlatformView?, in layout: RowsLayout ) -> PlatformView /// The view's height at `width`. @@ -78,12 +100,44 @@ private(set) var shown = false var addView: (PlatformView) -> Void = { _ in } + /// The views above and below the rows. + var header: RowsAccessory? { + didSet { replace(oldValue, with: header) } + } + var footer: RowsAccessory? { + didSet { replace(oldValue, with: footer) } + } + + /// A tap or a key selects a row. + var selectable = false + /// The selected row, which the list highlights. + var selected: AnyHashable? { + didSet { + guard selected != oldValue else { return } + reshow(oldValue) + reshow(selected) + } + } + /// The highlight is emphasized: the list has focus. + var emphasized = false { + didSet { + guard emphasized != oldValue else { return } + reshow(selected) + } + } + /// The user selected a row, with a tap or a key: also the selected one. + var onSelect: (AnyHashable) -> Void = { _ in } + /// Scrolls a loaded row into view. + var scrollToRow: (AnyHashable) -> Void = { _ in } + init(views: any RowViews) { self.views = views host.rows = { [unowned self] in (source.items.map { (key: $0.key, index: $0.index) }, source.spaceBefore, source.spaceAfter) } host.measure = { [unowned self] key in measure(key) } + host.header = { [unowned self] in height(of: header) } + host.footer = { [unowned self] in height(of: footer) } } /// A new snapshot to draw. @@ -101,16 +155,39 @@ host.invalidate() } + /// The header or the footer changed height: it's measured again in the + /// next layout pass. + func accessoryHeightDidChange(_ view: PlatformView) { + for accessory in [header, footer] where accessory?.view === view { + accessory?.height = nil + host.invalidate() + } + } + + /// The header's or the footer's height as it laid itself out: when it's + /// another than the one measured, it's measured again. + func accessory(_ view: PlatformView, laidOutAt height: Double) { + for accessory in [header, footer] where accessory?.view === view { + guard let old = accessory?.height, abs(old - height) > 0.5 else { return } + accessoryHeightDidChange(view) + } + } + /// One layout pass at `width`: the cells to place, the content's height - /// and the offset. + /// and the offset; the header's and the footer's frames, and the + /// selected row's. func layout( width: Double, offset: Double, viewport: Double ) -> ( - frames: [(cell: RowCell, minY: Double, maxY: Double)], contentHeight: Double, offset: Double + frames: [(cell: RowCell, minY: Double, maxY: Double)], contentHeight: Double, offset: Double, + accessories: [(view: PlatformView, minY: Double, maxY: Double)], + selected: (minY: Double, maxY: Double)? ) { if width != self.width { self.width = width for cell in cells.values { cell.height = nil } + header?.height = nil + footer?.height = nil host.invalidate() } let r = host.layout(offset: offset, viewport: viewport) @@ -127,12 +204,80 @@ views.discard(cell.view) } } + var accessories: [(view: PlatformView, minY: Double, maxY: Double)] = [] + if let header { accessories.append((header.view, 0, host.headerHeight)) } + if let footer { + accessories.append((footer.view, r.contentHeight - host.footerHeight, r.contentHeight)) + } + let selected = selected.flatMap { key in r.frames.first { $0.key == key } } return ( r.frames.compactMap { f in cells[f.key].map { ($0, f.minY, f.maxY) } }, r.contentHeight, - r.offset + r.offset, accessories, selected.map { ($0.minY, $0.maxY) } ) } + /// The header and the footer are hidden while the rows are: until the + /// list has landed where it opens. + var accessoriesHidden: Bool { !shown && host.isLanding() } + + // MARK: Selection + + /// The user selected the row with `key`. + func userSelect(_ key: AnyHashable) { + selected = key + onSelect(key) + } + + /// The row a tap at `y`, in content coordinates, selects. + func key(at y: Double) -> AnyHashable? { + host.rowFrames().first { $0.minY <= y && y < $0.maxY }?.key + } + + /// Selects the row `step` rows from the selected one, or the first on + /// screen when there's none, and scrolls it into view. + func moveSelection(by step: Int) { + let items = source.items + let next: AnyHashable + if let selected, let i = items.firstIndex(where: { $0.key == selected }) { + guard items.indices.contains(i + step) else { return } + next = items[i + step].key + } else { + let top = host.scrollOffset + guard let first = host.rowFrames().first(where: { $0.maxY > top + 0.5 }) else { return } + next = first.key + } + userSelect(next) + scrollToRow(next) + } + + private func state(_ key: AnyHashable) -> RowState { + let selected = key == selected + return RowState(selected: selected, emphasized: selected && emphasized) + } + + /// The row with `key` is shown again, in its state now. + private func reshow(_ key: AnyHashable?) { + guard let key, let cell = cells[key], let item = source.items.first(where: { $0.key == key }) + else { return } + show(item, in: cell) + host.invalidate() + } + + private func replace(_ old: RowsAccessory?, with new: RowsAccessory?) { + guard old !== new else { return } + old?.view.removeFromSuperview() + if let new { addView(new.view) } + host.invalidate() + } + + private func height(of accessory: RowsAccessory?) -> Double { + guard let accessory else { return 0 } + if let height = accessory.height { return height } + let h = accessory.measure(width) + accessory.height = h + return h + } + private func measure(_ key: AnyHashable) -> Double { guard let item = source.items.first(where: { $0.key == key }) else { return 0 } if let cell = cells[key] { @@ -146,7 +291,7 @@ return measure(cell) } let reused = views.reusesViews ? pool.popLast() : nil - let view = views.view(for: item, reusing: reused, in: self) + let view = views.view(for: item, state(key), reusing: reused, in: self) if view !== reused { if let reused { pool.append(reused) } addView(view) @@ -161,7 +306,7 @@ private func show(_ item: RowsItem, in cell: RowCell) { cell.row = item.row cell.height = nil - let view = views.view(for: item, reusing: cell.view, in: self) + let view = views.view(for: item, state(item.key), reusing: cell.view, in: self) guard view !== cell.view else { return } // A new view in place of the row's own. cell.view.removeFromSuperview() @@ -205,34 +350,33 @@ #else typealias Controller = NSHostingController #endif - var row: (Any) -> AnyView + var row: (Any, Bool) -> AnyView private var controllers: [ObjectIdentifier: Controller] = [:] - init(row: @escaping (Any) -> AnyView) { self.row = row } + init(row: @escaping (Any, Bool) -> AnyView) { self.row = row } var reusesViews: Bool { false } func view( - for item: RowsItem, reusing view: PlatformView?, in layout: RowsLayout + for item: RowsItem, _ state: RowState, reusing view: PlatformView?, in layout: RowsLayout ) -> PlatformView { - let controller: Controller - if let view, let existing = controllers[ObjectIdentifier(view)] { - controller = existing - } else { - controller = Controller(rootView: AnyView(EmptyView())) - #if canImport(UIKit) - controller.safeAreaRegions = [] - controller.view.backgroundColor = .clear - #else - controller.sizingOptions = [] - #endif - controllers[ObjectIdentifier(controller.view)] = controller - } + let controller = view.flatMap { controllers[ObjectIdentifier($0)] } ?? makeController() let key = item.key + // Text in the system's styles takes the highlight's colors, as in a + // `List`. + let content = row(item.item, state.selected) + .environment(\.backgroundProminence, state.emphasized ? .increased : .standard) + let shown = + layout.selectable + ? AnyView( + content + .accessibilityAddTraits(state.selected ? [.isButton, .isSelected] : .isButton) + .accessibilityAction { [weak layout] in layout?.userSelect(key) }) + : AnyView(content) controller.rootView = AnyView( - row(item.item) + shown .fixedSize(horizontal: false, vertical: true) .onGeometryChange(for: Double.self) { Double($0.size.height) @@ -243,6 +387,45 @@ return controller.view } + /// A header or a footer showing `content`: `current` showing it anew, + /// or a new one. + func accessory( + _ current: RowsAccessory?, showing content: AnyView, in layout: RowsLayout + ) -> RowsAccessory { + let controller = + current.flatMap { controllers[ObjectIdentifier($0.view)] } ?? makeController() + let view: PlatformView = controller.view + controller.rootView = AnyView( + content + .fixedSize(horizontal: false, vertical: true) + .onGeometryChange(for: Double.self) { + Double($0.size.height) + } action: { [weak layout, weak view] h in + if let view { layout?.accessory(view, laidOutAt: h) } + }) + if let current, current.view === view { + // Measured with its new content in the next pass, before it's + // drawn: what it shows can be another height. + current.height = nil + return current + } + return RowsAccessory(view: view) { [unowned self, unowned view] width in + height(of: view, width: width) + } + } + + private func makeController() -> Controller { + let controller = Controller(rootView: AnyView(EmptyView())) + #if canImport(UIKit) + controller.safeAreaRegions = [] + controller.view.backgroundColor = .clear + #else + controller.sizingOptions = [] + #endif + controllers[ObjectIdentifier(controller.view)] = controller + return controller + } + func height(of view: PlatformView, width: Double) -> Double { guard let controller = controllers[ObjectIdentifier(view)] else { return 0 } return Double( @@ -258,21 +441,39 @@ /// width, and reused. @MainActor final class AppRowViews: RowViews { - let make: (Any, PlatformView?) -> PlatformView + let make: (Any, Bool, PlatformView?) -> PlatformView - init(make: @escaping (Any, PlatformView?) -> PlatformView) { self.make = make } + init(make: @escaping (Any, Bool, PlatformView?) -> PlatformView) { self.make = make } var reusesViews: Bool { true } func view( - for item: RowsItem, reusing view: PlatformView?, in layout: RowsLayout + for item: RowsItem, _ state: RowState, reusing view: PlatformView?, in layout: RowsLayout ) -> PlatformView { - make(item.item, view) + let view = make(item.item, state.selected, view) + #if canImport(UIKit) + if state.selected { + view.accessibilityTraits.insert(.selected) + } else { + view.accessibilityTraits.remove(.selected) + } + #else + view.setAccessibilitySelected(state.selected) + // A table cell view draws its text and images in the highlight's + // colors, as in a table. + (view as? NSTableCellView)?.backgroundStyle = state.emphasized ? .emphasized : .normal + #endif + return view } func height(of view: PlatformView, width: Double) -> Double { + Self.fittingHeight(of: view, width: width) + } + + /// The view's height with Auto Layout at `width`. + static func fittingHeight(of view: PlatformView, width: Double) -> Double { #if canImport(UIKit) let size = view.systemLayoutSizeFitting( CGSize(width: width, height: UIView.layoutFittingCompressedSize.height), @@ -294,22 +495,45 @@ } #if canImport(UIKit) - /// A scroll view that lays out the rows itself: their frames, its - /// content size and its offset, all in `layoutSubviews`. - final class RowsScrollView: UIScrollView { + /// A scroll view that lays out the rows itself: their frames, the + /// header's and the footer's, its content size and its offset, all in + /// `layoutSubviews`. + final class RowsScrollView: UIScrollView, UIGestureRecognizerDelegate { let rows: RowsLayout private var laying = false + /// Behind the selected row. + let highlight = UIView() + private lazy var tap = UITapGestureRecognizer(target: self, action: #selector(tapped)) init(views: any RowViews) { rows = RowsLayout(views: views) super.init(frame: .zero) alwaysBounceVertical = true + highlight.isUserInteractionEnabled = false + highlight.isHidden = true + addSubview(highlight) + styleHighlight() + tap.cancelsTouchesInView = false + tap.delegate = self rows.host.setNeedsLayout = { [weak self] in self?.setNeedsLayout() } rows.addView = { [weak self] view in self?.addSubview(view) } } required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + /// A tap or a key selects a row. + var selectable: Bool { + get { rows.selectable } + set { + rows.selectable = newValue + if newValue { + if tap.view == nil { addGestureRecognizer(tap) } + } else { + removeGestureRecognizer(tap) + } + } + } + override func layoutSubviews() { super.layoutSubviews() // Applying the offset below lays the view out again. @@ -323,30 +547,99 @@ viewport: Double(bounds.height - inset.top - inset.bottom)) let scale = max(1, window?.screen.scale ?? traitCollection.displayScale) func align(_ y: Double) -> CGFloat { (CGFloat(y) * scale).rounded() / scale } + func frame(_ minY: Double, _ maxY: Double) -> CGRect { + CGRect(x: 0, y: align(minY), width: width, height: align(maxY) - align(minY)) + } for f in r.frames { - let frame = CGRect( - x: 0, y: align(f.minY), width: width, height: align(f.maxY) - align(f.minY)) - f.cell.view.isHidden = !rows.shown - // Set only when it moves: a frame set lays the row's view out again. - guard f.cell.view.frame != frame else { continue } - f.cell.view.frame = frame - f.cell.view.layoutIfNeeded() + place(f.cell.view, frame(f.minY, f.maxY), hidden: !rows.shown) + } + for a in r.accessories { + place(a.view, frame(a.minY, a.maxY), hidden: rows.accessoriesHidden) + } + if let s = r.selected, rows.shown { + highlight.frame = frame(s.minY, s.maxY) + highlight.isHidden = false + } else { + highlight.isHidden = true } let size = CGSize(width: width, height: r.contentHeight) if contentSize != size { contentSize = size } let y = CGFloat(r.offset) - inset.top if contentOffset.y != y { contentOffset.y = y } } + + private func place(_ view: UIView, _ frame: CGRect, hidden: Bool) { + view.isHidden = hidden + // Set only when it moves: a frame set lays the view out again. + guard view.frame != frame else { return } + view.frame = frame + view.layoutIfNeeded() + } + + // MARK: Selection + + /// A selected list cell's background, as a plain list draws it. + private func styleHighlight() { + var state = UICellConfigurationState(traitCollection: traitCollection) + state.isSelected = true + let background = UIBackgroundConfiguration.listCell().updated(for: state) + highlight.backgroundColor = background.resolvedBackgroundColor(for: tintColor) + highlight.layer.cornerRadius = background.cornerRadius + } + + override func tintColorDidChange() { + super.tintColorDidChange() + styleHighlight() + } + + @objc private func tapped() { select(at: Double(tap.location(in: self).y)) } + + /// A tap at `y`, in content coordinates: the list takes focus, and the + /// row there is selected. + func select(at y: Double) { + guard let key = rows.key(at: y) else { return } + becomeFirstResponder() + rows.userSelect(key) + } + + // The rows' own controls take their taps too. + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer + ) -> Bool { true } + + override var canBecomeFirstResponder: Bool { rows.selectable } + + override var keyCommands: [UIKeyCommand]? { + guard rows.selectable else { return nil } + return [ + (UIKeyCommand.inputDownArrow, #selector(selectNext)), + (UIKeyCommand.inputUpArrow, #selector(selectPrevious)), + ("j", #selector(selectNext)), ("k", #selector(selectPrevious)), + ].map { input, action in + let command = UIKeyCommand(input: input, modifierFlags: [], action: action) + // Before the scroll view's own arrow keys. + command.wantsPriorityOverSystemBehavior = true + return command + } + } + + @objc func selectNext() { rows.moveSelection(by: 1) } + @objc func selectPrevious() { rows.moveSelection(by: -1) } } #else - /// A scroll view that lays out the rows itself: their frames, its - /// content's size and its offset, all in one pass. The pass runs when - /// the clip view moves or resizes (the user's scroll, before it's - /// drawn) and in the document view's `layout` (a new snapshot). + /// A scroll view that lays out the rows itself: their frames, the + /// header's and the footer's, its content's size and its offset, all in + /// one pass. The pass runs when the clip view moves or resizes (the + /// user's scroll, before it's drawn) and in the document view's + /// `layout` (a new snapshot). final class RowsScrollView: NSScrollView { let rows: RowsLayout - private let document = RowsDocumentView() + let document = RowsDocumentView() private var laying = false + /// Behind the selected row. + let highlight = RowsHighlightView() + private lazy var click = NSClickGestureRecognizer(target: self, action: #selector(clicked)) init(views: any RowViews) { rows = RowsLayout(views: views) @@ -355,6 +648,9 @@ drawsBackground = false documentView = document document.owner = self + highlight.isHidden = true + document.addSubview(highlight) + click.delaysPrimaryMouseButtonEvents = false contentView.postsBoundsChangedNotifications = true contentView.postsFrameChangedNotifications = true for name in [NSView.boundsDidChangeNotification, NSView.frameDidChangeNotification] { @@ -367,6 +663,19 @@ required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + /// A click or a key selects a row. + var selectable: Bool { + get { rows.selectable } + set { + rows.selectable = newValue + if newValue { + if click.view == nil { document.addGestureRecognizer(click) } + } else { + document.removeGestureRecognizer(click) + } + } + } + @objc private func clipChanged() { layoutRows() } fileprivate func layoutRows() { @@ -385,14 +694,21 @@ // pixel against the content as it scrolls. let scale = max(1, window?.backingScaleFactor ?? 1) func align(_ y: Double) -> CGFloat { (CGFloat(y) * scale).rounded() / scale } + func frame(_ minY: Double, _ maxY: Double) -> CGRect { + CGRect(x: 0, y: align(minY), width: width, height: align(maxY) - align(minY)) + } for f in r.frames { - let frame = CGRect( - x: 0, y: align(f.minY), width: width, height: align(f.maxY) - align(f.minY)) - f.cell.view.isHidden = !rows.shown - // Set only when it moves: a frame set lays the row's view out again. - guard f.cell.view.frame != frame else { continue } - f.cell.view.frame = frame - f.cell.view.layoutSubtreeIfNeeded() + place(f.cell.view, frame(f.minY, f.maxY), hidden: !rows.shown) + } + for a in r.accessories { + place(a.view, frame(a.minY, a.maxY), hidden: rows.accessoriesHidden) + } + if let s = r.selected, rows.shown { + // Inset, with rounded corners, as an inset table draws it. + highlight.frame = frame(s.minY, s.maxY).insetBy(dx: 8, dy: 0) + highlight.isHidden = false + } else { + highlight.isHidden = true } let size = NSSize(width: width, height: r.contentHeight) if document.frame.size != size { document.setFrameSize(size) } @@ -402,6 +718,45 @@ reflectScrolledClipView(clip) } } + + private func place(_ view: NSView, _ frame: CGRect, hidden: Bool) { + view.isHidden = hidden + // Set only when it moves: a frame set lays the view out again. + guard view.frame != frame else { return } + view.frame = frame + view.layoutSubtreeIfNeeded() + } + + // MARK: Selection + + @objc private func clicked() { select(at: Double(click.location(in: document).y)) } + + /// A click at `y`, in content coordinates: the list takes focus, and + /// the row there is selected. + func select(at y: Double) { + guard let key = rows.key(at: y) else { return } + window?.makeFirstResponder(document) + rows.userSelect(key) + } + + override func viewWillMove(toWindow newWindow: NSWindow?) { + super.viewWillMove(toWindow: newWindow) + let center = NotificationCenter.default + for name in [NSWindow.didBecomeKeyNotification, NSWindow.didResignKeyNotification] { + center.removeObserver(self, name: name, object: window) + if let newWindow { + center.addObserver( + self, selector: #selector(focusChanged), name: name, object: newWindow) + } + } + } + + /// The highlight is emphasized while the list has focus. + @objc fileprivate func focusChanged() { + let focused = window.map { $0.isKeyWindow && $0.firstResponder === document } ?? false + rows.emphasized = focused + highlight.emphasized = focused + } } final class RowsDocumentView: NSView { @@ -413,6 +768,56 @@ super.layout() owner?.layoutRows() } + + override var acceptsFirstResponder: Bool { owner?.rows.selectable ?? false } + + override func becomeFirstResponder() -> Bool { + defer { owner?.focusChanged() } + return super.becomeFirstResponder() + } + + override func resignFirstResponder() -> Bool { + defer { owner?.focusChanged() } + return super.resignFirstResponder() + } + + /// The arrow keys, and j and k, move the selection. + override func keyDown(with event: NSEvent) { + guard let rows = owner?.rows, rows.selectable, + event.modifierFlags.isDisjoint(with: [.command, .control, .option]) + else { return super.keyDown(with: event) } + switch (event.keyCode, event.charactersIgnoringModifiers) { + case (125, _), (_, "j"): rows.moveSelection(by: 1) + case (126, _), (_, "k"): rows.moveSelection(by: -1) + default: super.keyDown(with: event) + } + } + } + + /// The selected row's background: the accent color while the list has + /// focus, gray when it hasn't, as a table draws it. + final class RowsHighlightView: NSView { + var emphasized = false { + didSet { needsDisplay = true } + } + + override init(frame: NSRect) { + super.init(frame: frame) + wantsLayer = true + } + + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + override var wantsUpdateLayer: Bool { true } + + override func updateLayer() { + let color: NSColor = + emphasized ? .selectedContentBackgroundColor : .unemphasizedSelectedContentBackgroundColor + layer?.backgroundColor = color.cgColor + layer?.cornerRadius = 5 + } + + override func hitTest(_ point: NSPoint) -> NSView? { nil } } #endif @@ -420,21 +825,33 @@ @MainActor struct RowsScrollViewRepresentable { let source: () -> RowsSource - let row: (Any) -> AnyView + let row: (Any, Bool) -> AnyView + let header: AnyView? + let footer: AnyView? + /// The selected row, and what a tap or a key sets it to; nil when rows + /// aren't selectable. + let selection: (key: AnyHashable?, set: (AnyHashable) -> Void)? let model: VirtualListModel func make() -> RowsScrollView { let view = RowsScrollView(views: HostedRowViews(row: row)) model.source = source model.connect(view) - view.rows.update(source()) + update(view) return view } func update(_ view: RowsScrollView) { model.source = source - (view.rows.views as? HostedRowViews)?.row = row - view.rows.update(source()) + let rows = view.rows + let views = rows.views as! HostedRowViews + views.row = row + rows.header = header.map { views.accessory(rows.header, showing: $0, in: rows) } + rows.footer = footer.map { views.accessory(rows.footer, showing: $0, in: rows) } + view.selectable = selection != nil + rows.onSelect = selection?.set ?? { _ in } + rows.selected = selection?.key + rows.update(source()) } } diff --git a/swift/Sources/ZeroVirtual/VirtualList.swift b/swift/Sources/ZeroVirtual/VirtualList.swift index 5815e3614d..1165e5d970 100644 --- a/swift/Sources/ZeroVirtual/VirtualList.swift +++ b/swift/Sources/ZeroVirtual/VirtualList.swift @@ -13,13 +13,29 @@ /// ``` /// /// Rows can be any height, and change it. The list draws only the rows - /// near the screen, with space standing in for the rest. + /// near the screen, with space standing in for the rest. A header and a + /// footer scroll with the rows, above and below them: + /// + /// ```swift + /// VirtualList(comments) { item in + /// CommentRow(comment: item.row) + /// } header: { + /// IssueHeader(issue: issue) + /// } footer: { + /// Composer(issue: issue) + /// } + /// ``` + /// + /// With a `selection`, a tap or a key selects a row (see + /// `init(_:selection:row:header:footer:)`). /// /// Underneath is the platform's scroll view (`UIScrollView`, - /// `NSScrollView`), which lays the rows out itself: their frames, its - /// content's size and its offset are set in one layout pass, so no frame + /// `NSScrollView`), which lays the rows out itself: their frames, the + /// header's and the footer's, its content's size and its offset are set + /// in one layout pass, so no frame /// shows rows moved by a page loading before the offset that keeps them - /// in place. Each row is hosted in its own hosting view, which inherits + /// in place. Each row (and the header and the footer) is hosted in its + /// own hosting view, which inherits /// the list's environment: a row's `@ZeroQuery` reads the list's client /// (`.zero(_:)`), and its `@Environment` values are the list's. public struct VirtualList< @@ -29,14 +45,55 @@ public typealias Virtualizer = ZeroVirtualizer private let list: Virtualizer - private let row: (VirtualItem) -> RowContent + private let row: (VirtualItem, Bool) -> RowContent + private let selection: Binding? + private let header: AnyView? + private let footer: AnyView? @State private var model = VirtualListModel() - public init( - _ list: Virtualizer, @ViewBuilder row: @escaping (VirtualItem) -> RowContent + /// - Parameters: + /// - header: Above the rows, scrolling with them. + /// - footer: Below the rows, scrolling with them. + public init( + _ list: Virtualizer, @ViewBuilder row: @escaping (VirtualItem) -> RowContent, + @ViewBuilder header: () -> Header = { EmptyView() }, + @ViewBuilder footer: () -> Footer = { EmptyView() } + ) { + self.list = list + self.row = { item, _ in row(item) } + selection = nil + self.header = Self.accessory(header) + self.footer = Self.accessory(footer) + } + + /// A list whose rows can be selected, one at a time: a tap selects a + /// row, and while the list has focus, the arrow keys and j and k + /// select the next or the previous one and scroll it into view. + /// + /// A tap sets `selection` even to the row it is, so the app can open + /// it again. The list highlights the selected row, as a `List` does; + /// `row` gets whether its row is selected, for anything else it + /// shows differently. + /// + /// - Parameters: + /// - selection: The selected row's key. + /// - header: Above the rows, scrolling with them. + /// - footer: Below the rows, scrolling with them. + public init( + _ list: Virtualizer, selection: Binding, + @ViewBuilder row: @escaping (_ item: VirtualItem, _ isSelected: Bool) -> RowContent, + @ViewBuilder header: () -> Header = { EmptyView() }, + @ViewBuilder footer: () -> Footer = { EmptyView() } ) { self.list = list self.row = row + self.selection = selection + self.header = Self.accessory(header) + self.footer = Self.accessory(footer) + } + + private static func accessory(_ content: () -> V) -> AnyView? { + V.self == EmptyView.self ? nil : AnyView(content()) } public var body: some View { @@ -44,7 +101,14 @@ _ = list.snapshot return RowsScrollViewRepresentable( source: { [list] in RowsSource(list.snapshot) }, - row: { [row] in AnyView(row($0 as! VirtualItem)) }, model: model + row: { [row] in AnyView(row($0 as! VirtualItem, $1)) }, header: header, + footer: footer, + selection: selection.map { selection in + ( + selection.wrappedValue.map { AnyHashable($0) }, + { key in selection.wrappedValue = key.base as? Key } + ) + }, model: model ) .onAppear { model.attach(list) } .onDisappear { model.detach(list) } @@ -78,6 +142,9 @@ host.viewportDidResize = { [list] in list.viewportDidResize() } host.afterUpdate = { [list] in list.afterUpdate() } host.isLanding = { [list] in list.isLanding } + view.rows.scrollToRow = { [list] key in + if let key = key.base as? K { list.scrollToRow(key: key, align: .auto) } + } list.anchoring = .manual // Each snapshot, in the layout pass or out of it, goes to the view // at once: a pass lays out what the core publishes during it. diff --git a/swift/Sources/ZeroVirtual/VirtualListView.swift b/swift/Sources/ZeroVirtual/VirtualListView.swift index c9c48309c0..f0db43ca4a 100644 --- a/swift/Sources/ZeroVirtual/VirtualListView.swift +++ b/swift/Sources/ZeroVirtual/VirtualListView.swift @@ -64,6 +64,18 @@ public convenience init( _ list: Virtualizer, row: @escaping @MainActor (_ item: VirtualItem, _ reusing: UIView?) -> UIView + ) { + self.init(list, make: { item, _, reusing in row(item, reusing) }) + } + + /// With whether the row is selected, for a list whose rows can be + /// (`onSelect`). + public convenience init( + _ list: Virtualizer, + row: + @escaping @MainActor ( + _ item: VirtualItem, _ isSelected: Bool, _ reusing: UIView? + ) -> UIView ) { self.init(list, make: row) } @@ -71,6 +83,18 @@ public convenience init( _ list: Virtualizer, row: @escaping @MainActor (_ item: VirtualItem, _ reusing: NSView?) -> NSView + ) { + self.init(list, make: { item, _, reusing in row(item, reusing) }) + } + + /// With whether the row is selected, for a list whose rows can be + /// (`onSelect`). + public convenience init( + _ list: Virtualizer, + row: + @escaping @MainActor ( + _ item: VirtualItem, _ isSelected: Bool, _ reusing: NSView? + ) -> NSView ) { self.init(list, make: row) } @@ -78,11 +102,13 @@ private init( _ list: Virtualizer, - make row: @escaping @MainActor (VirtualItem, PlatformView?) -> PlatformView + make row: @escaping @MainActor (VirtualItem, Bool, PlatformView?) -> PlatformView ) { self.list = list rows = RowsScrollView( - views: AppRowViews { item, reusing in row(item as! VirtualItem, reusing) }) + views: AppRowViews { item, selected, reusing in + row(item as! VirtualItem, selected, reusing) + }) super.init(frame: .zero) #if canImport(UIKit) rows.autoresizingMask = [.flexibleWidth, .flexibleHeight] @@ -105,6 +131,71 @@ rows.rows.rowHeightDidChange(AnyHashable(key)) } + // MARK: Header and footer + + #if canImport(UIKit) + /// A view above the rows, scrolling with them. Its height is its + /// Auto Layout height at the list's width, like a row's. + public var headerView: UIView? { + didSet { rows.rows.header = headerView.map(Self.accessory) } + } + /// A view below the rows, scrolling with them. + public var footerView: UIView? { + didSet { rows.rows.footer = footerView.map(Self.accessory) } + } + #else + /// A view above the rows, scrolling with them. Its height is its + /// Auto Layout height at the list's width, like a row's. + public var headerView: NSView? { + didSet { rows.rows.header = headerView.map(Self.accessory) } + } + /// A view below the rows, scrolling with them. + public var footerView: NSView? { + didSet { rows.rows.footer = footerView.map(Self.accessory) } + } + #endif + + /// The header or the footer changed height on its own: it's measured + /// again, and the rows move with it, what's on screen staying put + /// unless the header is on screen. + public func headerOrFooterHeightDidChange() { + for view in [headerView, footerView] { + if let view { rows.rows.accessoryHeightDidChange(view) } + } + } + + private static func accessory(_ view: PlatformView) -> RowsAccessory { + RowsAccessory(view: view) { [unowned view] width in + AppRowViews.fittingHeight(of: view, width: width) + } + } + + // MARK: Selection + + /// The selected row's key: the list highlights its row. Setting it + /// doesn't call `onSelect`. + public var selection: Key? { + get { rows.rows.selected?.base as? Key } + set { rows.rows.selected = newValue.map { AnyHashable($0) } } + } + + /// Called with a row the user selects: a tap selects a row (even the + /// selected one), and while the list has focus, the arrow keys and j + /// and k select the next or the previous one and scroll it into view. + /// Rows can be selected while it's set. + /// + /// The list highlights the selected row with the platform's selection + /// color, and marks its view selected for accessibility; on macOS, an + /// `NSTableCellView` row draws its text in the highlight's colors. + public var onSelect: ((Key) -> Void)? { + didSet { + rows.selectable = onSelect != nil + rows.rows.onSelect = { [weak self] key in + if let key = key.base as? Key { self?.onSelect?(key) } + } + } + } + #if canImport(UIKit) public override func didMoveToWindow() { super.didMoveToWindow() diff --git a/swift/Tests/AppleTests/VirtualListTests.swift b/swift/Tests/AppleTests/VirtualListTests.swift index 3db5b4287d..f07321b08e 100644 --- a/swift/Tests/AppleTests/VirtualListTests.swift +++ b/swift/Tests/AppleTests/VirtualListTests.swift @@ -136,6 +136,54 @@ withExtendedLifetime((zero, window)) {} } + @Observable final class Selection { + var key: String? + /// Each key a tap or a key set it to. + var sets: [String] = [] + /// Whether each row was last shown selected. + @ObservationIgnored var shown: [String: Bool] = [:] + + var binding: Binding { + Binding { + self.key + } set: { + self.key = $0 + if let key = $0 { self.sets.append(key) } + } + } + } + + @Test func selectsByTapAndByKeys() async throws { + let (zero, list) = try makeList() + let selection = Selection() + let window = show( + VirtualList(list, selection: selection.binding) { item, isSelected in + let _ = selection.shown[item.row.id] = isSelected + Text(item.row.text).frame(height: 30) + }) + try await run(30) { list.snapshot.complete && !list.snapshot.items.isEmpty } + let view = try #require(rowsScrollView(window)) + try await RowsSelectionCheck(list: list, view: view, window: window) { + (selection.key, selection.sets, selection.shown) + }.run(self) + withExtendedLifetime((zero, window)) {} + } + + /// The `RowsScrollView` in `window`. + func rowsScrollView(_ window: AnyObject) -> RowsScrollView? { + #if canImport(UIKit) + func find(_ v: UIView) -> RowsScrollView? { + (v as? RowsScrollView) ?? v.subviews.lazy.compactMap(find).first + } + return (window as? UIWindow).flatMap(find) + #else + func find(_ v: NSView) -> RowsScrollView? { + (v as? RowsScrollView) ?? v.subviews.lazy.compactMap(find).first + } + return (window as? NSWindow)?.contentView.flatMap(find) + #endif + } + @Test func scrollsToARowFarBelow() async throws { let (zero, list) = try makeList() let window = show(VirtualList(list) { Text($0.row.text).frame(height: 30) }) @@ -158,4 +206,99 @@ withExtendedLifetime((zero, window)) {} } } + + /// Selecting rows of 30 points, ids `n000`…, in a 400-point list: by a + /// tap, twice on the same row, then by the arrow keys and j and k (sent + /// through the window on macOS), past the screen's bottom. + @MainActor struct RowsSelectionCheck { + let list: VirtualListTests.List + let view: RowsScrollView + let window: AnyObject + /// The selection, every key the user set it to, and whether each row + /// was last shown selected. + let state: () -> (key: String?, sets: [String], shown: [String: Bool]) + + func run(_ t: VirtualListTests) async throws { + let id = VirtualListTests.id + // Laid out: the snapshot can complete before the view's next pass. + try await t.run(5) { view.rows.host.rowFrames().count > 5 } + try tap(id(5)) + try await t.run(5) { state().key == id(5) } + #expect(state().key == id(5)) + #expect(state().shown[id(5)] == true) + try await t.run(0.2) { false } + try expectHighlight(on: id(5)) + + // Again: the app hears of it, to open the row again. + try tap(id(5)) + try await t.run(5) { state().sets.count == 2 } + #expect(state().sets == [id(5), id(5)]) + + for _ in 0..<10 { key(down: true, letter: false) } + try await t.run(5) { state().key == id(15) } + #expect(state().key == id(15)) + // Below the screen at first: scrolled into view, its bottom at the + // list's. + try await t.run(5) { list.lastVisibleItem()?.key == id(15) } + #expect(list.lastVisibleItem()?.key == id(15)) + key(down: false, letter: true) + key(down: false, letter: true) + key(down: true, letter: true) + try await t.run(5) { state().sets.count == 15 } + #expect(state().key == id(14)) + #expect(state().shown[id(15)] == false) + #expect(state().shown[id(5)] == false) + #expect(state().shown[id(14)] == true) + try await t.run(0.2) { false } + try expectHighlight(on: id(14)) + } + + func frame(_ key: String) throws -> VirtualRowFrame { + try #require(view.rows.host.rowFrames().first { $0.key == AnyHashable(key) }) + } + + /// A tap's own handler, at the row's middle: UIKit has no way to send + /// a touch, nor AppKit a click to a window that isn't key, as a test's + /// isn't. + func tap(_ key: String) throws { + let f = try frame(key) + view.select(at: (f.minY + f.maxY) / 2) + } + + /// The down or up arrow, or j or k. + func key(down: Bool, letter: Bool) { + #if canImport(UIKit) + let input = + letter + ? (down ? "j" : "k") : (down ? UIKeyCommand.inputDownArrow : UIKeyCommand.inputUpArrow) + let command = view.keyCommands?.first { $0.input == input } + #expect(command != nil, "no key command for \(input)") + if let action = command?.action { view.perform(action) } + #else + let w = window as! NSWindow + let (characters, code): (String, UInt16) = + letter + ? (down ? ("j", 38) : ("k", 40)) + : ( + String(UnicodeScalar(down ? NSDownArrowFunctionKey : NSUpArrowFunctionKey)!), + down ? 125 : 126 + ) + w.sendEvent( + NSEvent.keyEvent( + with: .keyDown, location: .zero, modifierFlags: letter ? [] : [.numericPad, .function], + timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: w.windowNumber, + context: nil, characters: characters, charactersIgnoringModifiers: characters, + isARepeat: false, keyCode: code)!) + #endif + } + + /// The highlight stands behind the row's frame. + func expectHighlight(on key: String) throws { + let f = try frame(key) + let h = view.highlight + #expect(!h.isHidden) + #expect( + abs(Double(h.frame.minY) - f.minY) <= 0.5 && abs(Double(h.frame.maxY) - f.maxY) <= 0.5) + } + } #endif diff --git a/swift/Tests/AppleTests/VirtualListViewTests.swift b/swift/Tests/AppleTests/VirtualListViewTests.swift index dee0bd957e..7ee6c5dc32 100644 --- a/swift/Tests/AppleTests/VirtualListViewTests.swift +++ b/swift/Tests/AppleTests/VirtualListViewTests.swift @@ -102,6 +102,42 @@ withExtendedLifetime((zero, window)) {} } + @Test func selectsByTapAndByKeys() async throws { + let t = VirtualListTests() + let (zero, list) = try t.makeList() + var sets: [String] = [] + var shown: [String: Bool] = [:] + let view = VirtualListView(list) { item, isSelected, reusing in + let row = reusing as? NoteView ?? NoteView() + row.id = item.row.id + shown[item.row.id] = isSelected + return row + } + view.onSelect = { sets.append($0) } + let window = show(view) + try await t.run(30) { list.snapshot.complete && !list.snapshot.items.isEmpty } + let rows = try #require(view.scrollView as? RowsScrollView) + try await RowsSelectionCheck(list: list, view: rows, window: window) { + (view.selection, sets, shown) + }.run(t) + // The selected row's view, and no other, is selected for + // accessibility. + let selected = rowViews(view.scrollView).filter { + #if canImport(UIKit) + $0.accessibilityTraits.contains(.selected) + #else + $0.isAccessibilitySelected() + #endif + } + #expect(selected.map(\.id) == [VirtualListTests.id(14)]) + // Set by the app: highlighted, and not reported back. + view.selection = VirtualListTests.id(3) + try await t.run(0.2) { false } + #expect(shown[VirtualListTests.id(3)] == true && shown[VirtualListTests.id(14)] == false) + #expect(sets.count == 15) + withExtendedLifetime((zero, window)) {} + } + func scroll(_ scrollView: AnyObject, by dy: Double) { #if canImport(UIKit) let s = scrollView as! UIScrollView diff --git a/swift/Tests/ZeroVirtualTests/LayoutHostTests.swift b/swift/Tests/ZeroVirtualTests/LayoutHostTests.swift index 5c0720492c..1a402bad04 100644 --- a/swift/Tests/ZeroVirtualTests/LayoutHostTests.swift +++ b/swift/Tests/ZeroVirtualTests/LayoutHostTests.swift @@ -13,6 +13,9 @@ final class LayoutScrollView { let h: Harness let viewportHeight: Double var heights: [String: Double] = [:] + /// The heights of the views above and below the rows. + var header = 0.0 + var footer = 0.0 private var offset = 0.0 /// Passes that applied an offset other than the user's: the core /// scrolled, which can stop a fling. @@ -31,6 +34,8 @@ final class LayoutScrollView { host.measure = { [unowned self] key in self.heights[key.base as! String] ?? self.h.view.rowHeight } + host.header = { [unowned self] in self.header } + host.footer = { [unowned self] in self.footer } host.didScroll = { [unowned self] in self.h.core.didScroll() } host.viewportDidResize = { [unowned self] in self.h.core.viewportDidResize() } host.afterUpdate = { [unowned self] in self.h.core.afterUpdate() } @@ -137,6 +142,66 @@ final class LayoutScrollView { #expect(view.rowTop(key) == top) } + @Test( + "a header changing height above the viewport doesn't move the rows; at the top it pushes them") + func headerChangesHeight() { + let h = Harness(rowCount: 300) { $0.anchoring = .manual } + let view = LayoutScrollView(h) + view.header = 100 + view.settle() + #expect(view.rowTop("r0") == 100) + view.userScroll(2000) + view.settle() + let key = h.core.firstVisibleItem()!.key + let top = view.rowTop(key) + for height in [250.0, 40, 0, 180] { + view.header = height + view.settle(holding: key) + } + h.scheduler.advance(by: 200) + view.settle(holding: key) + #expect(view.rowTop(key) == top) + + // At the top, the header is on screen: it grows, and the rows go down + // with it. + for _ in 0..<40 where view.scrollOffset > 0 { + view.userScroll(view.scrollOffset - 300) + view.settle() + } + #expect(view.scrollOffset == 0) + view.header = 260 + view.settle() + #expect(view.scrollOffset == 0) + #expect(view.rowTop("r0") == 260) + } + + @Test("scrolls to rows past the header and up to the footer") + func scrollsAroundAHeaderAndAFooter() { + let h = Harness(rowCount: 200) { $0.anchoring = .manual } + let view = LayoutScrollView(h) + view.header = 120 + view.footer = 80 + view.settle() + #expect(view.rowTop("r0") == 120) + + h.core.scrollToItem("r199", align: .end) + view.settle() + // The last row's bottom at the viewport's, the footer below it. + let last = try! #require(view.rowTop("r199")) + #expect(last + (view.heights["r199"] ?? h.view.rowHeight) == view.viewportHeight) + #expect(view.bottomGap == 80) + + h.core.scrollToItem("r0", align: .start) + view.settle() + #expect(view.rowTop("r0") == 0) + #expect(view.scrollOffset == 120) + + h.core.scrollToItem("r100", align: .center) + view.settle() + let middle = try! #require(view.rowTop("r100")) + #expect(abs(middle + h.view.rowHeight / 2 - view.viewportHeight / 2) <= 0.5) + } + @Test("stuck to the bottom, it follows new rows and a last row growing taller") func sticks() { let h = Harness(rowCount: 60) { From e7c59192c96d9ca73365bfbcc0bca7004b0f19ec Mon Sep 17 00:00:00 2001 From: tmm Date: Wed, 21 Oct 2026 00:09:15 -0400 Subject: [PATCH 091/104] 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. --- swift/TestHost/App/FrameProbe.swift | 55 ++++++++++-- swift/TestHost/App/NativeRows.swift | 59 +++++++++++-- swift/TestHost/App/ProbeScript.swift | 35 +++++++- swift/TestHost/App/Screens.swift | 57 ++++++++++++- .../TestHost/UITests/FrameProbeUITests.swift | 12 ++- .../TestHost/UITests/VirtualListUITests.swift | 83 ++++++++++++++++++- 6 files changed, 272 insertions(+), 29 deletions(-) diff --git a/swift/TestHost/App/FrameProbe.swift b/swift/TestHost/App/FrameProbe.swift index 2e86966925..2cb096996c 100644 --- a/swift/TestHost/App/FrameProbe.swift +++ b/swift/TestHost/App/FrameProbe.swift @@ -22,8 +22,10 @@ import SwiftUI /// - Flung by a finger (iOS), the momentum is smooth: two frames whose /// speed departs from their neighboring pairs' are a jump in it. /// - At rest, nothing moves. -/// - A jump (Latest) shows the rows as they were, then as they end up, and -/// nothing in between. +/// - A jump (Latest, or a key moving the selection) shows the rows as they +/// were, then as they end up, and nothing in between. +/// - The header changing height on screen pushes the rows by as much, its +/// top holding still; off screen, nothing moves. /// /// Every frame also checks that the rows move together. The summary is the /// `probe` element's value, for the UI tests. @@ -135,6 +137,27 @@ final class FrameProbe { log("JUMP begin top=\(previous?.ids.first ?? "-")") } + #if os(macOS) + /// Presses the down arrow (or the up arrow) in the list, as the user + /// would: the list takes focus first. + func pressArrow(down: Bool) { + guard let scrollView, let window = scrollView.window, + let event = NSEvent.keyEvent( + with: .keyDown, location: .zero, modifierFlags: [.numericPad, .function], + timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, + context: nil, + characters: String(UnicodeScalar(down ? NSDownArrowFunctionKey : NSUpArrowFunctionKey)!), + charactersIgnoringModifiers: String( + UnicodeScalar(down ? NSDownArrowFunctionKey : NSUpArrowFunctionKey)!), + isARepeat: false, keyCode: down ? 125 : 126) + else { return } + if window.firstResponder !== scrollView.documentView { + window.makeFirstResponder(scrollView.documentView) + } + window.sendEvent(event) + } + #endif + /// Scrolls as a fling would: `velocity` points per second toward the /// content's end (negative: toward its start), decaying as UIKit's does. func drive(velocity: Double) async { @@ -231,9 +254,12 @@ final class FrameProbe { if (jumpMoved && jumpStill >= 0.5) || jumpTime >= 5 { finishJump() } return } - let common = a.rows.keys.filter { b.rows[$0] != nil } + // Rows, not the header or the footer, which the checks below place. + let common = a.rows.keys.filter { b.rows[$0] != nil && !Self.accessories.contains($0) } guard !common.isEmpty else { - if !a.rows.isEmpty && !b.rows.isEmpty { flag(b, "no row in both frames", a) } + if a.rows.keys.contains(where: Self.isRow) && b.rows.keys.contains(where: Self.isRow) { + flag(b, "no row in both frames", a) + } return } let deltas = common.map { (id: $0, dy: b.rows[$0]!.minY - a.rows[$0]!.minY) } @@ -249,19 +275,34 @@ final class FrameProbe { } } else if real { smoothCheck(dy: dy, a: a, b: b) - } else if abs(dy) > 0.5 { + } else if abs(dy) > 0.5, !pushedByHeader(a, b, dy) { flag(b, "\(anchor) moved \(fmt(dy)) at rest", a) } trackFling(a, b, dy) // At rest, the list's first row is at its top or above: space below // it is a gap the list shows, not a bounce (which only moves). + // Below the header, when it's on screen. if !real, driven == nil, drive == nil, let first = Self.firstID, - let y = b.rows[first]?.minY, y > 0.5 + let y = b.rows[first]?.minY, y - (b.rows["header"]?.maxY ?? 0) > 0.5 { - flag(b, "gap of \(fmt(y)) above \(first) at rest (offset \(fmt(b.offset)))") + let gap = y - (b.rows["header"]?.maxY ?? 0) + flag(b, "gap of \(fmt(gap)) above \(first) at rest (offset \(fmt(b.offset)))") } } + /// The header and the footer: placed with the rows, but not rows. + private static let accessories: Set = ["header", "footer"] + private static func isRow(_ id: String) -> Bool { !accessories.contains(id) } + + /// At rest, the rows moved because the header, on screen, changed + /// height: its top held still, and the rows moved with its bottom. + private func pushedByHeader(_ a: Sample, _ b: Sample, _ dy: Double) -> Bool { + guard let ha = a.rows["header"], let hb = b.rows["header"], + abs((hb.maxY - hb.minY) - (ha.maxY - ha.minY)) > 0.5 + else { return false } + return abs(hb.minY - ha.minY) <= 0.5 && abs(dy - (hb.maxY - ha.maxY)) <= 0.75 + } + /// The list's first row: where its top is the list's. private static var firstID: String? { ["chat": "m0000", "issues": "i0000"][UserDefaults.standard.string(forKey: "screen") ?? ""] diff --git a/swift/TestHost/App/NativeRows.swift b/swift/TestHost/App/NativeRows.swift index b995067604..4b8a10c56a 100644 --- a/swift/TestHost/App/NativeRows.swift +++ b/swift/TestHost/App/NativeRows.swift @@ -29,6 +29,8 @@ final class NativeRowView: PlatformView { #endif private var marker: Marker? private var padding: [NSLayoutConstraint] = [] + /// The first line's text. + private(set) var text = "" init() { #if os(iOS) @@ -71,8 +73,12 @@ final class NativeRowView: PlatformView { required init?(coder: NSCoder) { fatalError() } /// Shows a row: its id, its two lines (the second hidden when empty) and - /// its vertical padding. - func show(id: String, first a: String, second b: String, captionFirst: Bool, padding: Double) { + /// its vertical padding. Its element's identifier is `row-`, or + /// `element`. + func show( + id: String, first a: String, second b: String, captionFirst: Bool, padding: Double, + element: String? = nil + ) { #if os(iOS) first.text = a second.text = b @@ -80,7 +86,7 @@ final class NativeRowView: PlatformView { second.font = .preferredFont(forTextStyle: captionFirst ? .body : .caption1) first.textColor = captionFirst ? .secondaryLabel : .label second.textColor = captionFirst ? .label : .secondaryLabel - accessibilityIdentifier = "row-\(id)" + accessibilityIdentifier = element ?? "row-\(id)" accessibilityLabel = captionFirst ? b : a #else first.stringValue = a @@ -89,9 +95,10 @@ final class NativeRowView: PlatformView { second.font = .preferredFont(forTextStyle: captionFirst ? .body : .caption1) first.textColor = captionFirst ? .secondaryLabelColor : .labelColor second.textColor = captionFirst ? .labelColor : .secondaryLabelColor - setAccessibilityIdentifier("row-\(id)") + setAccessibilityIdentifier(element ?? "row-\(id)") setAccessibilityLabel(captionFirst ? b : a) #endif + text = a second.isHidden = b.isEmpty for c in self.padding { c.constant = padding } if FrameProbe.enabled { @@ -113,17 +120,32 @@ final class NativeRowView: PlatformView { } -/// A `VirtualListView` in a SwiftUI screen, its rows `NativeRowView`s. Its -/// scroll view is the `list` element the UI tests read. +/// A `VirtualListView` in a SwiftUI screen, its rows `NativeRowView`s, +/// with a header and a footer of text, and a selection, when they're +/// given. Its scroll view is the `list` element the UI tests read. @MainActor struct NativeVirtualList< C: Sendable, R: ZeroRow, K: Hashable, S: Equatable & Sendable, L: Equatable & Sendable > { let list: ZeroVirtualizer + var selection: Binding? + var header: String? + var footer: String? let show: (VirtualItem, NativeRowView) -> Void + init( + list: ZeroVirtualizer, selection: Binding? = nil, header: String? = nil, + footer: String? = nil, show: @escaping (VirtualItem, NativeRowView) -> Void + ) { + self.list = list + self.selection = selection + self.header = header + self.footer = footer + self.show = show + } + func make() -> VirtualListView { - let view = VirtualListView(list) { [show] item, reusing in + let view = VirtualListView(list) { [show] item, _, reusing in let row = reusing as? NativeRowView ?? NativeRowView() show(item, row) return row @@ -133,18 +155,37 @@ struct NativeVirtualList< #else view.scrollView.setAccessibilityIdentifier("list") #endif + if header != nil { view.headerView = NativeRowView() } + if footer != nil { view.footerView = NativeRowView() } + update(view) return view } + + func update(_ view: VirtualListView) { + var changed = false + for (text, id, accessory) in [ + (header, "header", view.headerView), (footer, "footer", view.footerView), + ] { + guard let text, let row = accessory as? NativeRowView, row.text != text else { continue } + row.show(id: id, first: text, second: "", captionFirst: false, padding: 12, element: id) + changed = true + } + if changed { view.headerOrFooterHeightDidChange() } + if let selection { + view.selection = selection.wrappedValue + view.onSelect = { selection.wrappedValue = $0 } + } + } } #if os(iOS) extension NativeVirtualList: UIViewRepresentable { func makeUIView(context: Context) -> VirtualListView { make() } - func updateUIView(_ view: VirtualListView, context: Context) {} + func updateUIView(_ view: VirtualListView, context: Context) { update(view) } } #else extension NativeVirtualList: NSViewRepresentable { func makeNSView(context: Context) -> VirtualListView { make() } - func updateNSView(_ view: VirtualListView, context: Context) {} + func updateNSView(_ view: VirtualListView, context: Context) { update(view) } } #endif diff --git a/swift/TestHost/App/ProbeScript.swift b/swift/TestHost/App/ProbeScript.swift index c363c5edf7..0e75a7364b 100644 --- a/swift/TestHost/App/ProbeScript.swift +++ b/swift/TestHost/App/ProbeScript.swift @@ -47,25 +47,56 @@ enum ProbeScript { probe.finish() } - static func issues() async { + static func issues(growHeader: () -> Void) async { probe.setPhase("open") await until(10) { probe.visible.contains("i0000") } await sleep(1) + // The header changes height: on screen, at the top, the rows go down + // and up with it; above the screen, nothing on screen moves. + probe.setPhase("header") + for _ in 0..<5 { + growHeader() + await sleep(0.6) + } + await probe.drive(velocity: 1500) + await sleep(0.6) + for _ in 0..<5 { + growHeader() + await sleep(0.6) + } + probe.setPhase("down") for _ in 0..<60 where deepest() < 400 { await probe.drive(velocity: 2000) await sleep(0.5) } probe.setPhase("up") - for _ in 0..<80 where !atTop("i0000") { + for _ in 0..<80 where !atStart() { await probe.drive(velocity: -2000) await sleep(0.5) } await sleep(1) + + #if os(macOS) + // The selection moves down by the arrow key past the screen's + // bottom, the list scrolling a row at a time, then back up. + probe.setPhase("keys") + for down in Array(repeating: true, count: 30) + Array(repeating: false, count: 12) { + probe.beginJump() + probe.pressArrow(down: down) + await sleep(0.15) + } + await sleep(1) + #endif probe.finish() } + /// The list's start is at its top: the header, or the first row. + static func atStart() -> Bool { + atTop("header") || atTop("i0000") + } + static func atTop(_ id: String) -> Bool { probe.top(of: id).map { abs($0) <= 0.5 } ?? false } diff --git a/swift/TestHost/App/Screens.swift b/swift/TestHost/App/Screens.swift index 905b6b7767..6a0063b297 100644 --- a/swift/TestHost/App/Screens.swift +++ b/swift/TestHost/App/Screens.swift @@ -86,9 +86,13 @@ struct MessageRow: View { } } -/// A long list to page through, both ways. +/// A long list to page through, both ways, with a header that grows and a +/// footer, and rows to select. struct IssuesScreen: View { @State private var list: IssueList + @State private var selection: String? + /// The header's lines of text. + @State private var headerLines = 2 init(backend: Backend) { _list = State(initialValue: backend.issueList()) @@ -99,17 +103,31 @@ struct IssuesScreen: View { Divider() issues Divider() + HStack { + Button("Grow header") { growHeader() }.accessibilityIdentifier("grow-header") + Button("End") { list.scrollToItem(Backend.issueID(1999), align: .end) } + .accessibilityIdentifier("end") + Text(selection ?? "none").accessibilityIdentifier("selection") + } + .padding(8) } .overlay(alignment: .topTrailing) { ProbeSummary() } .task { guard FrameProbe.script == "issues" else { return } - await ProbeScript.issues() + await ProbeScript.issues(growHeader: growHeader) } } + /// Taller by two lines, up to 8, then back to 1. + private func growHeader() { + headerLines = headerLines >= 8 ? 1 : headerLines + 2 + } + @ViewBuilder private var issues: some View { if Native.enabled { - NativeVirtualList(list: list) { item, row in + NativeVirtualList( + list: list, selection: $selection, header: Self.header(headerLines), footer: Self.footer + ) { item, row in let i = item.row row.show( id: i.id, first: i.title, second: i.description, captionFirst: false, padding: 8) @@ -119,8 +137,15 @@ struct IssuesScreen: View { } } + /// The header's text: `lines` lines. + static func header(_ lines: Int) -> String { + (1...lines).map { "Header line \($0)" }.joined(separator: "\n") + } + + static let footer = "The end of the issues" + private var swiftUIIssues: some View { - VirtualList(list) { item in + VirtualList(list, selection: $selection) { item, _ in VStack(alignment: .leading, spacing: 2) { Text(item.row.title) if !item.row.description.isEmpty { @@ -135,7 +160,31 @@ struct IssuesScreen: View { .accessibilityLabel(item.row.title) .accessibilityIdentifier("row-\(item.row.id)") .background(ProbeMarker(id: item.row.id)) + } header: { + Accessory(text: Self.header(headerLines), id: "header") + } footer: { + Accessory(text: Self.footer, id: "footer") } .accessibilityIdentifier("list") } } + +/// The header or the footer: its text, as one element, with a probe +/// marker. +struct Accessory: View { + let text: String + let id: String + + var body: some View { + Text(text) + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(.quaternary) + .contentShape(.accessibility, Rectangle()) + .accessibilityElement(children: .ignore) + .accessibilityLabel(text) + .accessibilityIdentifier(id) + .background(ProbeMarker(id: id)) + } +} diff --git a/swift/TestHost/UITests/FrameProbeUITests.swift b/swift/TestHost/UITests/FrameProbeUITests.swift index de262b8c27..9e75a74edb 100644 --- a/swift/TestHost/UITests/FrameProbeUITests.swift +++ b/swift/TestHost/UITests/FrameProbeUITests.swift @@ -73,7 +73,11 @@ class FrameProbeUITests: XCTestCase { } func testIssuesShowNoWrongFrame() { - runScript("issues", phases: ["open", "down", "up"]) + #if os(macOS) + runScript("issues", phases: ["open", "header", "down", "up", "keys"]) + #else + runScript("issues", phases: ["open", "header", "down", "up"]) + #endif } #if os(iOS) @@ -101,12 +105,14 @@ class FrameProbeUITests: XCTestCase { func testChatFlingsUpRunTheirLength() { flingThrough("chat", Array(repeating: true, count: 8)) } /// Flung back to the top from a few distances at a few speeds: at rest - /// the first issue is at the top, with no gap above it. + /// the header is at the top, the first issue right below it, with no + /// gap above it. func testIssuesFlingsToTheTopLeaveNoGap() { launch("issues", script: false) let list = app.scrollViews.firstMatch XCTAssertTrue(list.waitForExistence(timeout: 10)) - let first = app.descendants(matching: .any)["row-i0000"].firstMatch + // The list's start: the header at the top. + let first = app.descendants(matching: .any)["header"].firstMatch RunLoop.current.run(until: Date().addingTimeInterval(2)) for (away, speed) in [ (2, 6000.0), (4, 800.0), (6, 3000.0), (10, 1500.0), (3, 3000.0), (8, 6000.0), diff --git a/swift/TestHost/UITests/VirtualListUITests.swift b/swift/TestHost/UITests/VirtualListUITests.swift index b4abe8677a..38f41fa83a 100644 --- a/swift/TestHost/UITests/VirtualListUITests.swift +++ b/swift/TestHost/UITests/VirtualListUITests.swift @@ -148,11 +148,18 @@ class VirtualListUITests: XCTestCase { // MARK: A long list + /// The list's start at its top: the header, and the first issue right + /// below it. + func issuesAtStart() -> Bool { + let header = app.descendants(matching: .any)["header"].firstMatch + return header.exists && row("i0000").exists + && abs(header.frame.minY - list.frame.minY) <= 1.5 + && abs(row("i0000").frame.minY - header.frame.maxY) <= 1.5 + } + func testIssuesPageDownAndBackUp() { launch("issues") - waitUntil("the first issue is at the top") { - row("i0000").exists && abs(row("i0000").frame.minY - list.frame.minY) <= 1.5 - } + waitUntil("the header and the first issue are at the top") { issuesAtStart() } // A swipe moves about 14 issue rows; stop at row 400, not a count. var deepest = 0 for _ in 0..<60 where deepest < 400 { @@ -167,10 +174,78 @@ class VirtualListUITests: XCTestCase { for _ in 0..<60 where !top { scrollTowardTop() rest(1) - top = row("i0000").exists && abs(row("i0000").frame.minY - list.frame.minY) <= 1.5 + top = issuesAtStart() } XCTAssertTrue(top, "never got back to the first issue") } + + /// The header scrolls with the rows: on screen it pushes them as it + /// grows; above the screen, they hold still. The footer follows the last + /// row. + func testIssuesHeaderAndFooterScrollWithTheRows() { + launch("issues") + waitUntil("the header and the first issue are at the top") { issuesAtStart() } + let header = app.descendants(matching: .any)["header"].firstMatch + let height = header.frame.height + app.buttons["grow-header"].tap() + waitUntil("the header is taller, the first issue below it") { + header.frame.height > height + 10 && issuesAtStart() + } + + for _ in 0..<3 { + scrollTowardBottom() + rest(1) + } + guard let top = visibleRows().first else { return XCTFail("no rows on screen") } + XCTAssertFalse(header.isHittable, "the header is still on screen") + app.buttons["grow-header"].tap() + rest() + XCTAssertEqual(row(top.id).frame.minY, top.frame.minY, accuracy: 1, "\(top.id) moved") + + app.buttons["end"].tap() + waitUntil("the last issue is at the bottom") { atBottom("i1999") } + scrollTowardBottom() + rest() + let footer = app.descendants(matching: .any)["footer"].firstMatch + XCTAssertTrue(footer.exists) + XCTAssertEqual(footer.frame.minY, row("i1999").frame.maxY, accuracy: 1.5) + XCTAssertEqual(footer.frame.maxY, list.frame.maxY, accuracy: 1.5) + } + + /// A tap selects an issue; on macOS, the arrow keys and j and k move the + /// selection, scrolling it into view. + func testIssuesSelectByTapAndByKeys() { + launch("issues") + waitUntil("the header and the first issue are at the top") { issuesAtStart() } + let selection = app.staticTexts["selection"] + // A text's string is its label on iOS, its value on macOS. + func selected() -> String { + selection.label.isEmpty ? selection.value as? String ?? "" : selection.label + } + #if os(macOS) + row("i0002").click() + #else + row("i0002").tap() + #endif + waitUntil("i0002 is selected") { selected() == "i0002" } + XCTAssertTrue(row("i0002").isSelected) + #if os(macOS) + for _ in 0..<3 { app.typeKey(.downArrow, modifierFlags: []) } + waitUntil("i0005 is selected") { selected() == "i0005" } + app.typeKey("j", modifierFlags: []) + waitUntil("i0006 is selected") { selected() == "i0006" } + app.typeKey("k", modifierFlags: []) + waitUntil("i0005 is selected again") { selected() == "i0005" } + // Past the screen's bottom: the list follows the selection. + for _ in 0..<25 { app.typeKey(.downArrow, modifierFlags: []) } + waitUntil("i0030 is selected") { selected() == "i0030" } + rest(0.5) + XCTAssertTrue(row("i0030").isSelected) + XCTAssertLessThanOrEqual(row("i0030").frame.maxY, list.frame.maxY + 1) + XCTAssertGreaterThanOrEqual(row("i0030").frame.minY, list.frame.minY - 1) + XCTAssertFalse(row("i0002").exists && row("i0002").isSelected) + #endif + } } /// The same, with the screens' lists as UIKit or AppKit views. From 4f0b146612af2db002d94d924076a88b25fb3bf3 Mon Sep 17 00:00:00 2001 From: tmm Date: Wed, 21 Oct 2026 00:13:08 -0400 Subject: [PATCH 092/104] 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. --- swift/Sources/ZeroVirtual/VirtualList.swift | 4 ++- .../Sources/ZeroVirtual/VirtualizerCore.swift | 2 ++ .../Sources/ZeroVirtual/ZeroVirtualizer.swift | 9 +++++ .../AppleTests/VirtualListViewTests.swift | 34 +++++++++++++++++++ 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/swift/Sources/ZeroVirtual/VirtualList.swift b/swift/Sources/ZeroVirtual/VirtualList.swift index 1165e5d970..18b602776f 100644 --- a/swift/Sources/ZeroVirtual/VirtualList.swift +++ b/swift/Sources/ZeroVirtual/VirtualList.swift @@ -155,11 +155,13 @@ if let view { connect(view) } } + /// Detaches from `list`, unless another list has taken it over since, + /// as when one list replaces another with the same virtualizer. func detach(_ list: ZeroVirtualizer) { attachAs = nil attached = false + guard list.detach(view?.rows.host) else { return } list.onSnapshot = nil - list.detach() } /// The view exists: attach to it if the list is on screen. diff --git a/swift/Sources/ZeroVirtual/VirtualizerCore.swift b/swift/Sources/ZeroVirtual/VirtualizerCore.swift index e5fe8498a0..a4202e198d 100644 --- a/swift/Sources/ZeroVirtual/VirtualizerCore.swift +++ b/swift/Sources/ZeroVirtual/VirtualizerCore.swift @@ -422,6 +422,8 @@ package final class VirtualizerCore< withNotify { resetSettleTimer() } } + package func isAttached(to host: (any VirtualHost)?) -> Bool { self.host === host } + package func detach() { host = nil settleTimer?.cancel() diff --git a/swift/Sources/ZeroVirtual/ZeroVirtualizer.swift b/swift/Sources/ZeroVirtual/ZeroVirtualizer.swift index 1d99905f58..43d69ed4f7 100644 --- a/swift/Sources/ZeroVirtual/ZeroVirtualizer.swift +++ b/swift/Sources/ZeroVirtual/ZeroVirtualizer.swift @@ -189,6 +189,15 @@ public final class ZeroVirtualizer< public func detach() { core.detach() } + /// Stops driving `host`, if it's the host it drives (nil: a host that's + /// gone): another list may have taken the virtualizer over. Whether it + /// was. + package func detach(_ host: (any VirtualHost)?) -> Bool { + guard core.isAttached(to: host) else { return false } + core.detach() + return true + } + /// After the host rendered `snapshot`, or its rows changed height. public func afterUpdate() { core.afterUpdate() } diff --git a/swift/Tests/AppleTests/VirtualListViewTests.swift b/swift/Tests/AppleTests/VirtualListViewTests.swift index 7ee6c5dc32..453e085d16 100644 --- a/swift/Tests/AppleTests/VirtualListViewTests.swift +++ b/swift/Tests/AppleTests/VirtualListViewTests.swift @@ -102,6 +102,40 @@ withExtendedLifetime((zero, window)) {} } + // Switching from one list to another on the same virtualizer: the first + // one leaving after the second came doesn't detach the second. + @Test func aSecondListTakesTheVirtualizerOver() async throws { + let t = VirtualListTests() + let (zero, list) = try t.makeList() + func make() -> VirtualListView { + VirtualListView(list) { item, reusing in + let row = reusing as? NoteView ?? NoteView() + row.id = item.row.id + return row + } + } + let first = make() + var firstWindow: AnyObject? = show(first) + try await t.run(30) { list.snapshot.complete && !list.snapshot.items.isEmpty } + let second = make() + let window = show(second) + try await t.run(0.2) { false } + #if canImport(UIKit) + (firstWindow as? UIWindow)?.isHidden = true + first.removeFromSuperview() + #else + (firstWindow as? NSWindow)?.orderOut(nil) + (firstWindow as? NSWindow)?.contentView = nil + #endif + firstWindow = nil + try await t.run(0.2) { false } + list.scrollToItem(VirtualListTests.id(250), align: .end) + try await t.run(30) { list.lastVisibleItem()?.key == VirtualListTests.id(250) } + #expect(list.lastVisibleItem()?.key == VirtualListTests.id(250)) + #expect(rowViews(second.scrollView).contains { $0.id == VirtualListTests.id(250) }) + withExtendedLifetime((zero, window)) {} + } + @Test func selectsByTapAndByKeys() async throws { let t = VirtualListTests() let (zero, list) = try t.makeList() From 11bcd1b41d3e1629484f554802288d6f36c83247 Mon Sep 17 00:00:00 2001 From: tmm Date: Wed, 21 Oct 2026 00:22:35 -0400 Subject: [PATCH 093/104] 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. --- .../App/UITests/ScreenshotTests.swift | 60 +++++ apps/zbugs-swift/Package.swift | 1 + apps/zbugs-swift/README.md | 7 +- apps/zbugs-swift/Sources/ZBugs/Queries.swift | 11 +- .../Sources/ZBugsUI/IssueDetail.swift | 64 +++++- .../Sources/ZBugsUI/IssueList.swift | 69 ++++-- .../Sources/ZBugsUI/IssueTable.swift | 211 ------------------ .../Sources/ZBugsUI/NativeIssueList.swift | 172 ++++++++++++++ .../Sources/ZBugsUI/RootView.swift | 87 +++++++- 9 files changed, 429 insertions(+), 253 deletions(-) delete mode 100644 apps/zbugs-swift/Sources/ZBugsUI/IssueTable.swift create mode 100644 apps/zbugs-swift/Sources/ZBugsUI/NativeIssueList.swift diff --git a/apps/zbugs-swift/App/UITests/ScreenshotTests.swift b/apps/zbugs-swift/App/UITests/ScreenshotTests.swift index fa74d5b273..c4509f74a7 100644 --- a/apps/zbugs-swift/App/UITests/ScreenshotTests.swift +++ b/apps/zbugs-swift/App/UITests/ScreenshotTests.swift @@ -92,6 +92,66 @@ final class ScreenshotTests: XCTestCase { } } + /// All the issues, more than the 200 the list's first page had before it + /// paged: scrolled down past 200 of them, they keep coming. + @MainActor + func testListPagesPastTheFirst200() throws { + let environment = ProcessInfo.processInfo.environment + let token = try XCTUnwrap(environment["ZBUGS_TOKEN"], "Set TEST_RUNNER_ZBUGS_TOKEN") + let app = XCUIApplication() + app.launchEnvironment["ZBUGS_TOKEN"] = token + for key in ["ZBUGS_CACHE_URL", "ZBUGS_API_URL"] { + app.launchEnvironment[key] = environment[key] + } + app.launch() + let rows = app.descendants(matching: .any).matching(identifier: "IssueRow") + guard expect(app, rows.firstMatch, "an issue in the list", timeout: 120) else { return } + + // Status: All. + if isPhone { + back(app) + } + #if os(macOS) + let status = app.popUpButtons.matching(NSPredicate(format: "value == 'Open'")).firstMatch + let all = app.menuItems["All"] + #else + let status = app.buttons.matching(NSPredicate(format: "label BEGINSWITH 'Status,'")) + .firstMatch + let all = app.buttons["All"] + #endif + guard expect(app, status, "the Status picker") else { return } + press(status) + guard expect(app, all, "the All status") else { return } + press(all) + if isPhone { + let issues = app.buttons["Issues"] + if expect(app, issues, "the Issues button") { press(issues) } + } + guard expect(app, rows.firstMatch, "an issue in the list of all issues") else { return } + settle() + + let list = app.scrollViews.containing(NSPredicate(format: "identifier == 'IssueRow'")) + .firstMatch + var seen: Set = [] + for _ in 0..<120 where seen.count <= 200 { + if let snapshot = try? list.snapshot() { + func walk(_ e: any XCUIElementSnapshot) { + if e.identifier == "IssueRow" { seen.insert(e.label) } + e.children.forEach(walk) + } + walk(snapshot) + } + #if os(macOS) + list.scroll(byDeltaX: 0, deltaY: -800) + #else + list.swipeUp(velocity: .fast) + #endif + Thread.sleep(forTimeInterval: 0.5) + } + snapshot(app, "6 Issues past 200") + XCTAssertGreaterThan(seen.count, 200, "the list stopped at \(seen.count) issues") + } + @MainActor private var isPhone: Bool { #if os(iOS) diff --git a/apps/zbugs-swift/Package.swift b/apps/zbugs-swift/Package.swift index bb1fea943c..315be05491 100644 --- a/apps/zbugs-swift/Package.swift +++ b/apps/zbugs-swift/Package.swift @@ -23,6 +23,7 @@ let package = Package( "ZBugs", .product(name: "Zero", package: "Zero"), .product(name: "ZeroUI", package: "Zero"), + .product(name: "ZeroVirtual", package: "Zero"), ]), .testTarget( name: "ZBugsTests", diff --git a/apps/zbugs-swift/README.md b/apps/zbugs-swift/README.md index 3f3df1a0de..652de5a96e 100644 --- a/apps/zbugs-swift/README.md +++ b/apps/zbugs-swift/README.md @@ -4,8 +4,11 @@ - `ZBugs`: zbugs' schema, queries, and mutators (`apps/zbugs/shared`), ported to Swift. Builds on Linux too. -- `ZBugsUI`: the app's screens in SwiftUI, with the issue list also in UIKit - (iOS) and AppKit (macOS) through `DiffableAdapter`. +- `ZBugsUI`: the app's screens in SwiftUI. The issue list and an issue's + comments page in as they scroll, through `ZeroVirtual`'s `VirtualList`, as + zbugs' list page and comments do (`useZeroVirtualizer`); the issue list is + also in UIKit (iOS) and AppKit (macOS), through `VirtualListView`. A + relaunch opens the list at the selected issue, or else where it was. - `App/`: the app target, as an [XcodeGen](https://github.com/yonaskolb/XcodeGen) project. - `server/server.ts`: zbugs' backend from this repository's sources, for the diff --git a/apps/zbugs-swift/Sources/ZBugs/Queries.swift b/apps/zbugs-swift/Sources/ZBugs/Queries.swift index 11ce8245da..37c8aded38 100644 --- a/apps/zbugs-swift/Sources/ZBugs/Queries.swift +++ b/apps/zbugs-swift/Sources/ZBugs/Queries.swift @@ -84,6 +84,13 @@ public enum IssueKey: Codable, Sendable, Hashable { case .number(let n): .number(n) } } + + /// An issue's id, or its short id when it's all digits + /// (`getIDFromString` in `shared/issue-id.ts`). + public static func parse(_ s: String) -> (field: IDField, value: IssueKey) { + if s.contains(where: { !("0"..."9").contains($0) }) { return (.id, .string(s)) } + return (.shortID, .number(Double(s) ?? .nan)) + } } public struct ProjectArgs: Codable, Sendable, Hashable { @@ -424,7 +431,9 @@ private func crew(_ user: RowExpression) -> Condition { user.and(user.role == .crew, user.not(user.login.like("rocibot%"))) } -func applyIssuePermissions(_ q: Query, _ role: Role?) +func applyIssuePermissions( + _ q: Query, _ role: Role? +) -> Query where Result.Row == Issue { role == .crew ? q : q.where(\.visibility, .public) diff --git a/apps/zbugs-swift/Sources/ZBugsUI/IssueDetail.swift b/apps/zbugs-swift/Sources/ZBugsUI/IssueDetail.swift index 812b4f8a52..d957b63c6a 100644 --- a/apps/zbugs-swift/Sources/ZBugsUI/IssueDetail.swift +++ b/apps/zbugs-swift/Sources/ZBugsUI/IssueDetail.swift @@ -4,19 +4,48 @@ import ZBugs import Zero import ZeroUI + import ZeroVirtual + + /// An issue's comments, paged in as they scroll. + typealias CommentVirtualizer = ZeroVirtualizer< + AuthData?, Comment, String, CommentsPageArgs.Cursor, String + > + + extension CommentVirtualizer { + /// The issue's comments, oldest first, as zbugs' issue page sets them + /// up: pages of `commentsPage` from a comment's time, its list context + /// the issue's id. + static func comments(_ zero: Zero, issueID: String) -> CommentVirtualizer { + CommentVirtualizer( + zero: zero, listContext: issueID, estimatedRowHeight: 200, rowKey: \.id, + toStartRow: { CommentsPageArgs.Cursor(id: $0.id, created: $0.created) }, + pageQuery: { page, issueID in + Queries.commentsPage( + CommentsPageArgs( + issueID: issueID, limit: page.limit, start: page.start, + dir: page.direction == .forward ? .forward : .backward, + inclusive: page.start == nil)) + }, + rowQuery: { row, _ in Queries.comment(row.id) }, + // Comments are tall: about three screens of them. + minPageSize: 20) + } + } /// One issue with its comments, as zbugs' issue page shows it. struct IssueDetail: View { @ZeroQuery var issue: Issue? + @State private var comments: CommentVirtualizer @Environment(AppModel.self) private var model - init(id: String) { + init(id: String, zero: Zero) { _issue = ZeroQuery(Queries.issueDetail(IssueDetailArgs(idField: .id, id: .string(id)))) + _comments = State(initialValue: .comments(zero, issueID: id)) } var body: some View { if let issue { - IssueContent(issue: issue) + IssueContent(issue: issue, comments: comments) } else if $issue.resultType == .complete { ContentUnavailableView("Issue Not Found", systemImage: "questionmark.circle") } else { @@ -27,23 +56,29 @@ private struct IssueContent: View { let issue: Issue + let comments: CommentVirtualizer @Environment(AppModel.self) private var model @State private var comment = "" @State private var editing = false var body: some View { - ScrollView { + // The header and the description above the comments, the composer + // below them, in one list. + VirtualList(comments) { item in + CommentView(comment: item.row).column().padding(.bottom, 16) + } header: { VStack(alignment: .leading, spacing: 16) { header if !issue.description.isEmpty { Text(markdown(issue.description)).textSelection(.enabled) } Divider() - ForEach(comments, id: \.id) { CommentView(comment: $0) } - composer } - .padding() - .frame(maxWidth: 760, alignment: .leading) + .column() + .padding(.top) + .padding(.bottom, 16) + } footer: { + composer.column().padding(.bottom) } // String(_:), or the title formats the number for the locale (3,000). .navigationTitle(issue.shortID.map { "Issue #" + String($0) } ?? "Issue") @@ -116,11 +151,6 @@ } } - /// The query has the latest 50, newest first. - private var comments: [Comment] { - (issue.comments ?? []).sorted { ($0.created, $0.id) < ($1.created, $1.id) } - } - private var canEdit: Bool { guard let auth = model.auth else { return false } return auth.role == .crew || auth.sub == issue.creatorID @@ -149,6 +179,16 @@ } } + extension View { + /// In the issue page's column: at most 760 points wide, centered, with + /// the standard margins. + func column() -> some View { + frame(maxWidth: 760, alignment: .leading) + .padding(.horizontal) + .frame(maxWidth: .infinity) + } + } + /// Edits an issue's title and description. struct IssueEditor: View { let issue: Issue diff --git a/apps/zbugs-swift/Sources/ZBugsUI/IssueList.swift b/apps/zbugs-swift/Sources/ZBugsUI/IssueList.swift index d21a8c827c..3091ba58d5 100644 --- a/apps/zbugs-swift/Sources/ZBugsUI/IssueList.swift +++ b/apps/zbugs-swift/Sources/ZBugsUI/IssueList.swift @@ -4,6 +4,7 @@ import ZBugs import Zero import ZeroUI + import ZeroVirtual public enum IssueStatus: String, CaseIterable, Identifiable, Sendable { case open, closed, all @@ -29,32 +30,58 @@ textFilter: text.isEmpty ? nil : text, sortField: sortField, sortDirection: sortDirection) } + } + + /// The issue list's rows, paged in as it scrolls. + typealias IssueVirtualizer = ZeroVirtualizer< + AuthData?, Issue, String, IssueRowSort, ListContextParams + > + + /// Where the issue list was, to put it back. + typealias IssueScrollState = VirtualScrollState - /// The first page of the list. - var query: QueryRequest { - Queries.issueListV2( - IssueListV2Args( - listContext: listContext, limit: 200, start: nil, dir: .forward, inclusive: true)) + extension IssueVirtualizer { + /// The issue list, as zbugs' list page sets it up: pages of + /// `issueListV2` from a row's sort keys, and an issue looked up by its + /// id or short id. Opened at `permalinkID`, or else where + /// `scrollState` says. + static func issues( + _ zero: Zero, filter: IssueFilter, permalinkID: String?, + scrollState: IssueScrollState? + ) -> IssueVirtualizer { + IssueVirtualizer( + zero: zero, listContext: filter.listContext, estimatedRowHeight: 56, rowKey: \.id, + toStartRow: { IssueRowSort(id: $0.id, created: $0.created, modified: $0.modified) }, + pageQuery: { page, listContext in + Queries.issueListV2( + IssueListV2Args( + listContext: listContext, limit: page.limit, start: page.start, + dir: page.direction == .forward ? .forward : .backward, + inclusive: page.start == nil)) + }, + rowQuery: { row, listContext in + let (field, value) = IssueKey.parse(row.id) + return Queries.listIssueByID( + ListIssueByIDArgs(listContext: listContext, idField: field, idValue: value)) + }, permalinkID: permalinkID, scrollState: scrollState) } } /// The issue list in SwiftUI. struct IssueList: View { - @ZeroQuery var issues: [Issue] + let list: IssueVirtualizer + /// The issue the list highlights. A tap or a key sets it, even to the + /// highlighted one. @Binding var selection: String? - init(filter: IssueFilter, selection: Binding) { - _issues = ZeroQuery(filter.query) - _selection = selection - } - var body: some View { - List(issues, selection: $selection) { issue in - IssueRow(issue: issue).tag(issue.id) + VirtualList(list, selection: $selection) { item, isSelected in + IssueRow(issue: item.row, isSelected: isSelected) } .overlay { - if issues.isEmpty { - if $issues.resultType == .complete { + let s = list.snapshot + if s.rowsEmpty { + if s.complete { ContentUnavailableView("No Issues", systemImage: "checkmark.circle") } else { ProgressView() @@ -66,6 +93,7 @@ struct IssueRow: View { let issue: Issue + var isSelected = false var body: some View { VStack(alignment: .leading, spacing: 4) { @@ -94,7 +122,16 @@ .font(.caption) .foregroundStyle(.secondary) } - .padding(.vertical, 2) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 8) + #if os(iOS) + // A plain list's separator. + .overlay(alignment: .bottom) { + if !isSelected { Divider().padding(.leading, 16) } + } + #endif + .contentShape(.accessibility, Rectangle()) .accessibilityElement(children: .combine) .accessibilityIdentifier("IssueRow") } diff --git a/apps/zbugs-swift/Sources/ZBugsUI/IssueTable.swift b/apps/zbugs-swift/Sources/ZBugsUI/IssueTable.swift deleted file mode 100644 index 80ba0abab7..0000000000 --- a/apps/zbugs-swift/Sources/ZBugsUI/IssueTable.swift +++ /dev/null @@ -1,211 +0,0 @@ -#if canImport(SwiftUI) - import SwiftUI - import ZBugs - import Zero - import ZeroUI - - #if canImport(UIKit) - import UIKit - - /// The issue list in UIKit: a table view fed by `DiffableAdapter`. - final class IssueTableController: UITableViewController { - var onSelect: (String) -> Void = { _ in } - private var adapter: DiffableAdapter? - private var dataSource: UITableViewDiffableDataSource? - private var key: (zero: ObjectIdentifier, filter: IssueFilter)? - - override func viewDidLoad() { - super.viewDidLoad() - // It would clear the highlight `select` sets before the list appears. - clearsSelectionOnViewWillAppear = false - tableView.register(UITableViewCell.self, forCellReuseIdentifier: "issue") - dataSource = UITableViewDiffableDataSource(tableView: tableView) { - [unowned self] tableView, indexPath, id in - let cell = tableView.dequeueReusableCell(withIdentifier: "issue", for: indexPath) - var content = UIListContentConfiguration.subtitleCell() - if let issue = adapter?.row(for: id) { - content.text = issue.title - content.secondaryText = issue.subtitle - content.image = UIImage(systemName: issue.open ? "circle" : "checkmark.circle.fill") - content.imageProperties.tintColor = issue.open ? .systemGreen : .systemPurple - } - cell.contentConfiguration = content - cell.accessibilityIdentifier = "IssueCell" - return cell - } - } - - /// Shows `filter`'s issues; nothing to do when they are showing. - func show(_ zero: Zero, _ filter: IssueFilter) { - loadViewIfNeeded() - if let key, key.zero == ObjectIdentifier(zero), key.filter == filter { return } - key = (ObjectIdentifier(zero), filter) - guard let dataSource, let view = try? zero.materialize(filter.query) else { return } - adapter = DiffableAdapter(view, section: 0, dataSource: dataSource) - } - - /// Highlights the issue the detail shows. - func select(_ issueID: String?) { - let indexPath = issueID.flatMap { issueID in - adapter?.rows.first { $0.value.id == issueID } - .flatMap { dataSource?.indexPath(for: $0.key) } - } - if indexPath != tableView.indexPathForSelectedRow { - tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none) - } - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - guard let id = dataSource?.itemIdentifier(for: indexPath), - let issue = adapter?.row(for: id) - else { return } - onSelect(issue.id) - } - } - - struct IssueTable: UIViewControllerRepresentable { - let zero: Zero - let filter: IssueFilter - /// The issue the detail shows. - let selection: String? - /// Opens an issue, even the one the detail shows. - let open: (String) -> Void - - func makeUIViewController(context: Context) -> IssueTableController { - IssueTableController(style: .plain) - } - - func updateUIViewController(_ controller: IssueTableController, context: Context) { - controller.onSelect = open - controller.show(zero, filter) - controller.select(selection) - } - } - #elseif canImport(AppKit) - import AppKit - - /// The issue list in AppKit: a table view fed by `DiffableAdapter`. - final class IssueTableController: NSViewController, NSTableViewDelegate { - var onSelect: (String) -> Void = { _ in } - private let tableView = NSTableView() - private var adapter: DiffableAdapter? - private var dataSource: NSTableViewDiffableDataSource? - private var key: (zero: ObjectIdentifier, filter: IssueFilter)? - /// Whether `select` is changing the selection, which isn't the user - /// choosing an issue. - private var selecting = false - - override func loadView() { - let column = NSTableColumn(identifier: .init("issue")) - column.title = "Issue" - tableView.addTableColumn(column) - tableView.headerView = nil - tableView.usesAutomaticRowHeights = true - tableView.style = .inset - tableView.delegate = self - dataSource = NSTableViewDiffableDataSource(tableView: tableView) { - [unowned self] _, _, _, id in - let cell = NSTableCellView() - let icon = NSImageView() - icon.symbolConfiguration = .init(textStyle: .headline) - let title = NSTextField(labelWithString: "") - title.font = .preferredFont(forTextStyle: .headline) - title.lineBreakMode = .byTruncatingTail - // The cell view's identifier doesn't reach the accessibility tree. - title.setAccessibilityIdentifier("IssueCell") - let subtitle = NSTextField(labelWithString: "") - subtitle.font = .preferredFont(forTextStyle: .caption1) - subtitle.textColor = .secondaryLabelColor - subtitle.lineBreakMode = .byTruncatingTail - // Truncated, not clipped, in a column narrower than the text. - for label in [title, subtitle] { - label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - } - if let issue = adapter?.row(for: id) { - icon.image = NSImage( - systemSymbolName: issue.open ? "circle" : "checkmark.circle.fill", - accessibilityDescription: issue.open ? "Open" : "Closed") - icon.contentTintColor = issue.open ? .systemGreen : .systemPurple - title.stringValue = issue.title - subtitle.stringValue = issue.subtitle - } - // So a selected row draws them in its colors. - cell.imageView = icon - cell.textField = title - let heading = NSStackView(views: [icon, title]) - heading.spacing = 6 - let stack = NSStackView(views: [heading, subtitle]) - stack.orientation = .vertical - stack.alignment = .leading - stack.spacing = 2 - stack.edgeInsets = NSEdgeInsets(top: 4, left: 0, bottom: 4, right: 0) - stack.translatesAutoresizingMaskIntoConstraints = false - cell.addSubview(stack) - NSLayoutConstraint.activate([ - stack.leadingAnchor.constraint(equalTo: cell.leadingAnchor), - stack.trailingAnchor.constraint(equalTo: cell.trailingAnchor), - stack.topAnchor.constraint(equalTo: cell.topAnchor), - stack.bottomAnchor.constraint(equalTo: cell.bottomAnchor), - ]) - return cell - } - let scroll = NSScrollView() - scroll.documentView = tableView - scroll.hasVerticalScroller = true - view = scroll - } - - /// Shows `filter`'s issues; nothing to do when they are showing. - func show(_ zero: Zero, _ filter: IssueFilter) { - loadViewIfNeeded() - if let key, key.zero == ObjectIdentifier(zero), key.filter == filter { return } - key = (ObjectIdentifier(zero), filter) - guard let dataSource, let view = try? zero.materialize(filter.query) else { return } - adapter = DiffableAdapter(view, section: 0, dataSource: dataSource) - } - - /// Highlights the issue the detail shows. - func select(_ issueID: String?) { - let row = issueID.flatMap { issueID in - adapter?.rows.first { $0.value.id == issueID } - .flatMap { dataSource?.snapshot().indexOfItem($0.key) } - } - guard (row ?? -1) != tableView.selectedRow else { return } - selecting = true - defer { selecting = false } - if let row { - tableView.selectRowIndexes([row], byExtendingSelection: false) - } else { - tableView.deselectAll(nil) - } - } - - func tableViewSelectionDidChange(_ notification: Notification) { - guard !selecting, tableView.selectedRow >= 0, - let id = dataSource?.itemIdentifier(forRow: tableView.selectedRow), - let issue = adapter?.row(for: id) - else { return } - onSelect(issue.id) - } - } - - struct IssueTable: NSViewControllerRepresentable { - let zero: Zero - let filter: IssueFilter - /// The issue the detail shows. - let selection: String? - /// Opens an issue. - let open: (String) -> Void - - func makeNSViewController(context: Context) -> IssueTableController { - IssueTableController() - } - - func updateNSViewController(_ controller: IssueTableController, context: Context) { - controller.onSelect = open - controller.show(zero, filter) - controller.select(selection) - } - } - #endif -#endif diff --git a/apps/zbugs-swift/Sources/ZBugsUI/NativeIssueList.swift b/apps/zbugs-swift/Sources/ZBugsUI/NativeIssueList.swift new file mode 100644 index 0000000000..ddade012fd --- /dev/null +++ b/apps/zbugs-swift/Sources/ZBugsUI/NativeIssueList.swift @@ -0,0 +1,172 @@ +#if canImport(SwiftUI) + import SwiftUI + import ZBugs + import Zero + import ZeroVirtual + + #if canImport(UIKit) + import UIKit + + /// An issue in the UIKit list: its state's icon, its title and its + /// subtitle. + final class IssueCell: UIView { + private let icon = UIImageView() + private let title = UILabel() + private let subtitle = UILabel() + private let separator = UIView() + + init() { + super.init(frame: .zero) + title.font = .preferredFont(forTextStyle: .headline) + title.numberOfLines = 2 + subtitle.font = .preferredFont(forTextStyle: .caption1) + subtitle.textColor = .secondaryLabel + icon.preferredSymbolConfiguration = .init(textStyle: .headline) + icon.setContentHuggingPriority(.required, for: .horizontal) + separator.backgroundColor = .separator + let heading = UIStackView(arrangedSubviews: [icon, title]) + heading.spacing = 6 + heading.alignment = .firstBaseline + let stack = UIStackView(arrangedSubviews: [heading, subtitle]) + stack.axis = .vertical + stack.spacing = 4 + for view in [stack, separator] { + view.translatesAutoresizingMaskIntoConstraints = false + addSubview(view) + } + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), + trailingAnchor.constraint(equalTo: stack.trailingAnchor, constant: 16), + stack.topAnchor.constraint(equalTo: topAnchor, constant: 10), + bottomAnchor.constraint(equalTo: stack.bottomAnchor, constant: 10), + // A plain list's separator. + separator.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), + separator.trailingAnchor.constraint(equalTo: trailingAnchor), + separator.bottomAnchor.constraint(equalTo: bottomAnchor), + separator.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale), + ]) + isAccessibilityElement = true + accessibilityIdentifier = "IssueCell" + accessibilityTraits = .button + } + + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + func show(_ issue: Issue, selected: Bool) { + icon.image = UIImage(systemName: issue.open ? "circle" : "checkmark.circle.fill") + icon.tintColor = issue.open ? .systemGreen : .systemPurple + title.text = issue.title + subtitle.text = issue.subtitle + separator.isHidden = selected + accessibilityLabel = "\(issue.open ? "Open" : "Closed"), \(issue.title), \(issue.subtitle)" + } + } + #elseif canImport(AppKit) + import AppKit + + /// An issue in the AppKit list: its state's icon, its title and its + /// subtitle. A table cell view, so a selected row draws them in its + /// colors. + final class IssueCell: NSTableCellView { + private let icon = NSImageView() + private let title = NSTextField(labelWithString: "") + private let subtitle = NSTextField(labelWithString: "") + + init() { + super.init(frame: .zero) + icon.symbolConfiguration = .init(textStyle: .headline) + title.font = .preferredFont(forTextStyle: .headline) + title.lineBreakMode = .byTruncatingTail + subtitle.font = .preferredFont(forTextStyle: .caption1) + subtitle.textColor = .secondaryLabelColor + subtitle.lineBreakMode = .byTruncatingTail + // Truncated, not clipped, in a column narrower than the text. + for label in [title, subtitle] { + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + } + imageView = icon + textField = title + let heading = NSStackView(views: [icon, title]) + heading.spacing = 6 + let stack = NSStackView(views: [heading, subtitle]) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 2 + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), + trailingAnchor.constraint(greaterThanOrEqualTo: stack.trailingAnchor, constant: 16), + stack.topAnchor.constraint(equalTo: topAnchor, constant: 6), + bottomAnchor.constraint(equalTo: stack.bottomAnchor, constant: 6), + ]) + setAccessibilityElement(true) + setAccessibilityRole(.group) + setAccessibilityIdentifier("IssueCell") + } + + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + // The subtitle too, which isn't the cell's text field. + override var backgroundStyle: NSView.BackgroundStyle { + didSet { subtitle.cell?.backgroundStyle = backgroundStyle } + } + + func show(_ issue: Issue, selected: Bool) { + icon.image = NSImage( + systemSymbolName: issue.open ? "circle" : "checkmark.circle.fill", + accessibilityDescription: issue.open ? "Open" : "Closed") + icon.contentTintColor = issue.open ? .systemGreen : .systemPurple + title.stringValue = issue.title + subtitle.stringValue = issue.subtitle + setAccessibilityLabel( + "\(issue.open ? "Open" : "Closed"), \(issue.title), \(issue.subtitle)") + } + } + #endif + + typealias IssueListView = VirtualListView< + AuthData?, Issue, String, IssueRowSort, ListContextParams + > + + /// The issue list in UIKit or AppKit: a `VirtualListView` of `IssueCell`s. + @MainActor + struct NativeIssueList { + let list: IssueVirtualizer + /// The issue the list highlights. + let selection: String? + /// Opens an issue, even the highlighted one. + let open: (String) -> Void + + func make() -> IssueListView { + VirtualListView(list) { item, selected, reusing in + let cell = reusing as? IssueCell ?? IssueCell() + cell.show(item.row, selected: selected) + return cell + } + } + + func update(_ view: IssueListView) { + view.selection = selection + view.onSelect = open + } + } + + #if canImport(UIKit) + extension NativeIssueList: UIViewRepresentable { + func makeUIView(context: Context) -> IssueListView { make() } + func updateUIView( + _ view: IssueListView, + context: Context + ) { update(view) } + } + #elseif canImport(AppKit) + extension NativeIssueList: NSViewRepresentable { + func makeNSView(context: Context) -> IssueListView { make() } + func updateNSView( + _ view: IssueListView, + context: Context + ) { update(view) } + } + #endif +#endif diff --git a/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift b/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift index fa4563f071..9d0d260ce8 100644 --- a/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift +++ b/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift @@ -3,6 +3,7 @@ import ZBugs import Zero import ZeroUI + import ZeroVirtual /// Which UI layer draws the issue list. public enum ListLayer: String, CaseIterable, Identifiable, Sendable { @@ -24,6 +25,13 @@ let model: AppModel @State private var filter = IssueFilter() @State private var selection: String? + /// The issue list's rows, for the client. + @State private var issues: IssueVirtualizer? + /// The selected issue and where the list was, kept with the window: a + /// relaunch opens the list at the issue, or else where it was. + @SceneStorage("selection") private var savedSelection: String? + @SceneStorage("issueList") private var savedScroll: Data? + @State private var saving = Debouncer() @State private var listLayer = ListLayer.swiftUI @State private var sheet: Sheet? /// All three columns, on an iPad or a Mac: the filters are how you get @@ -77,16 +85,26 @@ .toolbar { toolbar } } detail: { if let selection { - IssueDetail(id: selection).id(selection) + IssueDetail(id: selection, zero: model.zero) + // Its comments' virtualizer is of the client. + .id([selection, String(describing: ObjectIdentifier(model.zero))]) } else { ContentUnavailableView("No Issue Selected", systemImage: "list.bullet.rectangle") } } - .onAppear { compactColumn = .content } + .onAppear { + compactColumn = .content + if selection == nil { selection = savedSelection } + } + // A new client, as the user logs in: the list starts over, at the + // selected issue. + .onChange(of: ObjectIdentifier(model.zero), initial: true) { makeIssues() } + .onChange(of: filter) { _, filter in issues?.listContext = filter.listContext } // A new issue is selected outside a List, which a collapsed split // view doesn't follow on its own. .onChange(of: selection) { _, selection in if selection != nil { compactColumn = .detail } + savedSelection = selection } .zero(model.zero) .environment(model) @@ -115,19 +133,51 @@ } @ViewBuilder private var list: some View { - switch listLayer { - case .swiftUI: - IssueList(filter: filter, selection: $selection) - case .native: - IssueTable(zero: model.zero, filter: filter, selection: highlighted) { id in - selection = id - // Also when it's the issue the detail shows, which doesn't change - // the selection. - compactColumn = .detail + if let issues { + switch listLayer { + case .swiftUI: + IssueList(list: issues, selection: listSelection) + case .native: + NativeIssueList(list: issues, selection: highlighted) { open($0) } + // A view of another virtualizer, for another client. + .id(ObjectIdentifier(issues)) } } } + /// The list's selection: the highlighted issue, which a tap or a key + /// opens. + private var listSelection: Binding { + Binding { + highlighted + } set: { id in + if let id { open(id) } + } + } + + private func open(_ id: String) { + selection = id + // Also when it's the issue the detail shows, which doesn't change + // the selection. + compactColumn = .detail + } + + /// The list for the client, opened at the selected issue, as the web + /// app's list permalink opens it, or else where it was. + private func makeIssues() { + let permalinkID = selection ?? savedSelection + let scroll = + permalinkID == nil + ? savedScroll.flatMap { try? JSONDecoder().decode(IssueScrollState.self, from: $0) } : nil + let list = IssueVirtualizer.issues( + model.zero, filter: filter, permalinkID: permalinkID, scrollState: scroll) + let save = $savedScroll + list.onScrollStateChange = { [saving] state in + saving.run { save.wrappedValue = try? JSONEncoder().encode(state) } + } + issues = list + } + /// The issue the list highlights: the selected one, when the list is /// beside it (not on an iPhone). The columns themselves are compact on an /// iPad too. @@ -220,6 +270,21 @@ } } + /// Runs the last of the actions it's given half a second after it, as + /// the list reports where it is on every scroll. + @MainActor + final class Debouncer { + private var task: Task? + + func run(_ action: @escaping @MainActor () -> Void) { + task?.cancel() + task = Task { + try? await Task.sleep(for: .milliseconds(500)) + if !Task.isCancelled { action() } + } + } + } + /// The connection state, as a dot. private struct ConnectionBadge: View { let connection: Connection From cdb2a80546b55e312f2e5590856581786f0f3fb7 Mon Sep 17 00:00:00 2001 From: tmm Date: Wed, 21 Oct 2026 00:27:58 -0400 Subject: [PATCH 094/104] docs(zero-swift): VirtualListView's header, footer, and selection, by example --- swift/Sources/ZeroVirtual/VirtualListView.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/swift/Sources/ZeroVirtual/VirtualListView.swift b/swift/Sources/ZeroVirtual/VirtualListView.swift index f0db43ca4a..6b6d0f61d5 100644 --- a/swift/Sources/ZeroVirtual/VirtualListView.swift +++ b/swift/Sources/ZeroVirtual/VirtualListView.swift @@ -42,6 +42,19 @@ /// other, but not its content size or offset; scroll with the /// virtualizer (`scrollToItem`). The list follows the virtualizer while /// it's in a window. + /// + /// A `headerView` and a `footerView` scroll with the rows, above and below + /// them. With an `onSelect`, a tap or a key selects a row; `row` then + /// takes whether its row is selected: + /// + /// ```swift + /// let list = VirtualListView(issues) { item, isSelected, reusing in + /// let row = reusing as? IssueRowView ?? IssueRowView() + /// row.show(item.row, selected: isSelected) + /// return row + /// } + /// list.onSelect = { id in open(id) } + /// ``` public final class VirtualListView< Context: Sendable, Row: ZeroRow, Key: Hashable, StartRow: Equatable & Sendable, ListContext: Equatable & Sendable From e26bfa67f0e501929ba7c639357da8e367061b5d Mon Sep 17 00:00:00 2001 From: tmm Date: Wed, 21 Oct 2026 00:30:15 -0400 Subject: [PATCH 095/104] fix(zero-swift): a VirtualList header or footer that goes lets go of its hosting controller --- swift/Sources/ZeroVirtual/RowsScrollView.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/swift/Sources/ZeroVirtual/RowsScrollView.swift b/swift/Sources/ZeroVirtual/RowsScrollView.swift index 2df0e39467..07ea56d021 100644 --- a/swift/Sources/ZeroVirtual/RowsScrollView.swift +++ b/swift/Sources/ZeroVirtual/RowsScrollView.swift @@ -265,7 +265,10 @@ private func replace(_ old: RowsAccessory?, with new: RowsAccessory?) { guard old !== new else { return } - old?.view.removeFromSuperview() + if let old { + old.view.removeFromSuperview() + views.discard(old.view) + } if let new { addView(new.view) } host.invalidate() } From 984674b7c49925972ce84afd29e212d88cc5adfe Mon Sep 17 00:00:00 2001 From: tmm Date: Wed, 21 Oct 2026 01:22:51 -0400 Subject: [PATCH 096/104] 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. --- .../Sources/ZBugsUI/NativeIssueList.swift | 26 ++++++++++--------- .../Sources/ZBugsUI/RootView.swift | 6 ++++- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/apps/zbugs-swift/Sources/ZBugsUI/NativeIssueList.swift b/apps/zbugs-swift/Sources/ZBugsUI/NativeIssueList.swift index ddade012fd..5179707fda 100644 --- a/apps/zbugs-swift/Sources/ZBugsUI/NativeIssueList.swift +++ b/apps/zbugs-swift/Sources/ZBugsUI/NativeIssueList.swift @@ -22,23 +22,25 @@ subtitle.font = .preferredFont(forTextStyle: .caption1) subtitle.textColor = .secondaryLabel icon.preferredSymbolConfiguration = .init(textStyle: .headline) - icon.setContentHuggingPriority(.required, for: .horizontal) + icon.contentMode = .scaleAspectFit separator.backgroundColor = .separator - let heading = UIStackView(arrangedSubviews: [icon, title]) - heading.spacing = 6 - heading.alignment = .firstBaseline - let stack = UIStackView(arrangedSubviews: [heading, subtitle]) - stack.axis = .vertical - stack.spacing = 4 - for view in [stack, separator] { + for view in [icon, title, subtitle, separator] { view.translatesAutoresizingMaskIntoConstraints = false addSubview(view) } + // The icon beside the title's first line, the title wrapping beside + // it, and the subtitle below them. NSLayoutConstraint.activate([ - stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), - trailingAnchor.constraint(equalTo: stack.trailingAnchor, constant: 16), - stack.topAnchor.constraint(equalTo: topAnchor, constant: 10), - bottomAnchor.constraint(equalTo: stack.bottomAnchor, constant: 10), + icon.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), + icon.widthAnchor.constraint(equalToConstant: 22), + icon.centerYAnchor.constraint(equalTo: title.firstBaselineAnchor, constant: -6), + title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 6), + trailingAnchor.constraint(equalTo: title.trailingAnchor, constant: 16), + title.topAnchor.constraint(equalTo: topAnchor, constant: 10), + subtitle.leadingAnchor.constraint(equalTo: icon.leadingAnchor), + trailingAnchor.constraint(equalTo: subtitle.trailingAnchor, constant: 16), + subtitle.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 4), + bottomAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 10), // A plain list's separator. separator.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), separator.trailingAnchor.constraint(equalTo: trailingAnchor), diff --git a/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift b/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift index 9d0d260ce8..1c17fcaecc 100644 --- a/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift +++ b/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift @@ -99,7 +99,11 @@ // A new client, as the user logs in: the list starts over, at the // selected issue. .onChange(of: ObjectIdentifier(model.zero), initial: true) { makeIssues() } - .onChange(of: filter) { _, filter in issues?.listContext = filter.listContext } + // Another list: from its top, not the issue it opened at. + .onChange(of: filter) { _, filter in + issues?.permalinkID = nil + issues?.listContext = filter.listContext + } // A new issue is selected outside a List, which a collapsed split // view doesn't follow on its own. .onChange(of: selection) { _, selection in From 60fb8133b0c32c1a40c87da9d4695c5aadeac15c Mon Sep 17 00:00:00 2001 From: tmm Date: Wed, 21 Oct 2026 10:00:15 -0400 Subject: [PATCH 097/104] 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. --- .github/workflows/zbugs-screenshots.yml | 9 +++-- .../Sources/ZeroVirtual/VirtualizerCore.swift | 10 ++++-- swift/Tests/ZeroVirtualTests/Harness.swift | 12 ++++--- .../ZeroVirtualTests/LayoutHostTests.swift | 34 +++++++++++++++++++ 4 files changed, 56 insertions(+), 9 deletions(-) diff --git a/.github/workflows/zbugs-screenshots.yml b/.github/workflows/zbugs-screenshots.yml index 241e9f4f6c..df9a3486d8 100644 --- a/.github/workflows/zbugs-screenshots.yml +++ b/.github/workflows/zbugs-screenshots.yml @@ -124,9 +124,12 @@ jobs: echo "zero-cache is still serving" exit 1 fi - screenshots iPhone-offline -destination "id=$(simulator iPhone)" CODE_SIGNING_ALLOWED=NO - screenshots iPad-offline -destination "id=$(simulator iPad)" CODE_SIGNING_ALLOWED=NO - screenshots macOS-offline -destination platform=macOS CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY=- + # Not the paging test: a list pages on once its page's query has the + # server's answer, as zero-virtual's does. + online=-skip-testing:ZBugsUITests/ScreenshotTests/testListPagesPastTheFirst200 + screenshots iPhone-offline -destination "id=$(simulator iPhone)" CODE_SIGNING_ALLOWED=NO "$online" + screenshots iPad-offline -destination "id=$(simulator iPad)" CODE_SIGNING_ALLOWED=NO "$online" + screenshots macOS-offline -destination platform=macOS CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY=- "$online" exit "$status" - name: Export screenshots if: always() diff --git a/swift/Sources/ZeroVirtual/VirtualizerCore.swift b/swift/Sources/ZeroVirtual/VirtualizerCore.swift index a4202e198d..8524fd7730 100644 --- a/swift/Sources/ZeroVirtual/VirtualizerCore.swift +++ b/swift/Sources/ZeroVirtual/VirtualizerCore.swift @@ -286,8 +286,12 @@ package final class VirtualizerCore< private var reconciling = false private var settleTimer: (any ScheduledTimer)? private var target: Target? - /// A jump or a restore hasn't landed: the position is still on its way. - package var isLanding: Bool { target != nil } + // The target's position has been applied once: the viewport is where it + // goes, though its window may not have settled (offline, it never does). + private var placed = false + /// A jump or a restore hasn't put the viewport where it goes yet: the + /// view holds off drawing the rows it opens with until it has. + package var isLanding: Bool { target != nil && !placed } private var permalinkMissing = 0 // A restore not yet applied: from `scrollState` at first, then @@ -508,6 +512,7 @@ package final class VirtualizerCore< private func setTarget(_ new: Target?) { if target?.isProbe == true || new?.isProbe == true { version += 1 } target = new + placed = false } private func resolveProbe() { @@ -1050,6 +1055,7 @@ package final class VirtualizerCore< case .row(let pending): guard landRow(pending) else { return } } + placed = true if rowsSettled { setTarget(nil) landed() diff --git a/swift/Tests/ZeroVirtualTests/Harness.swift b/swift/Tests/ZeroVirtualTests/Harness.swift index a8baa7a93f..a60539f8d6 100644 --- a/swift/Tests/ZeroVirtualTests/Harness.swift +++ b/swift/Tests/ZeroVirtualTests/Harness.swift @@ -111,6 +111,9 @@ final class Harness { let core: Core var options: Core.Options private var partial: (skip: Int, from: String, key: String?)? + /// Every answer is incomplete, as Zero answers from the client's store + /// with no server. + var offline = false private var permalinkBeforePending = false init( @@ -168,12 +171,13 @@ final class Harness { return assembleRows( inputs, RowsQueryResults( - singleRow: single, singleComplete: true, + singleRow: single, singleComplete: !offline, mainRows: single.map { beforePending ? [] : page($0, .backward, inputs.pageSize / 2 + 1) }, - mainComplete: !beforePending, - afterRows: single.map { page($0, .forward, inputs.pageSize / 2) }, afterComplete: true)) + mainComplete: !beforePending && !offline, + afterRows: single.map { page($0, .forward, inputs.pageSize / 2) }, + afterComplete: !offline)) } let probe = inputs.probeID.flatMap { id in data.first { $0.id == id } } let key = Self.pageKey(inputs.anchor) @@ -185,7 +189,7 @@ final class Harness { case .permalink: (nil, .forward) } var main = page(start, direction, limit) - var complete = true + var complete = !offline if var p = partial, key != p.from, (p.key ?? key) == key { if p.key == nil { p.key = key diff --git a/swift/Tests/ZeroVirtualTests/LayoutHostTests.swift b/swift/Tests/ZeroVirtualTests/LayoutHostTests.swift index 1a402bad04..c57445ce0f 100644 --- a/swift/Tests/ZeroVirtualTests/LayoutHostTests.swift +++ b/swift/Tests/ZeroVirtualTests/LayoutHostTests.swift @@ -202,6 +202,40 @@ final class LayoutScrollView { #expect(abs(middle + h.view.rowHeight / 2 - view.viewportHeight / 2) <= 0.5) } + @Test( + "offline, a list opened at a permalink or where it was shows its rows once they're in place") + func opensOffline() { + // Where the list was, as it reported it. + let online = Harness(rowCount: 300) { $0.anchoring = .manual } + var saved: VirtualScrollState? + online.setOptions { $0.onScrollStateChange = { saved = $0 } } + let first = LayoutScrollView(online) + first.settle() + first.userScroll(2400) + first.settle() + let top = try! #require(online.core.firstVisibleItem()?.key) + let state = try! #require(saved) + + let opens: [(key: String, open: (inout Harness.Core.Options) -> Void)] = [ + ("r150", { $0.permalinkID = "r150" }), + (top, { $0.scrollState = state }), + ] + for (key, open) in opens { + let h = Harness(rowCount: 300) { + $0.anchoring = .manual + open(&$0) + } + // No server: the queries never complete. + h.offline = true + let view = LayoutScrollView(h) + for _ in 0..<10 { view.pass() } + #expect(!h.core.snapshot.complete) + // The view draws the rows only once the list isn't landing. + #expect(!h.core.isLanding) + #expect(h.core.firstVisibleItem()?.key == key) + } + } + @Test("stuck to the bottom, it follows new rows and a last row growing taller") func sticks() { let h = Harness(rowCount: 60) { From 022e88424a7c6f82a1c8e13d02738284e4025dcc Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 21 Oct 2026 14:58:32 +0000 Subject: [PATCH 098/104] 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 --- apps/zbugs/api/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/zbugs/api/index.ts b/apps/zbugs/api/index.ts index be6280b8b9..da4f8e2f61 100644 --- a/apps/zbugs/api/index.ts +++ b/apps/zbugs/api/index.ts @@ -119,6 +119,9 @@ fastify.get<{ // The Swift app (apps/zbugs-swift) logs in through a web authentication // session, which can't read the cookie, so its callback carries the token. + // That's safe because the session hands the callback only to the app that + // started it, even if another app registers zbugs-swift://. The app doesn't + // register the scheme with the system, so the link isn't opened any other way. if (request.query.redirect === nativeAppRedirect) { reply.redirect(`${nativeAppRedirect}?jwt=${encodeURIComponent(jwt)}`); return; From d7e76072b0be8155cc981fd5a4f2383ce948569b Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 21 Oct 2026 15:02:14 +0000 Subject: [PATCH 099/104] 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 --- .github/workflows/swift-mirror.yml | 44 ----------- .github/workflows/swift-release.yml | 111 +++++++++++++++++++++++++++ packages/zero-swift-cli/package.json | 1 - swift/DESIGN.md | 14 ++-- 4 files changed, 119 insertions(+), 51 deletions(-) delete mode 100644 .github/workflows/swift-mirror.yml create mode 100644 .github/workflows/swift-release.yml diff --git a/.github/workflows/swift-mirror.yml b/.github/workflows/swift-mirror.yml deleted file mode 100644 index f903196c0c..0000000000 --- a/.github/workflows/swift-mirror.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Swift Mirror - -# Publishes swift/ to the SwiftPM mirror (wevm/zero-swift) and tags the version -# in swift/VERSION. SwiftPM needs Package.swift at a repository root and clones -# whole repositories, so consumers install from the mirror, not this monorepo. -# -# The swift-release environment requires approval, and the job is skipped -# until the ZERO_SWIFT_MIRROR_TOKEN secret (push access to the mirror) exists. - -on: - push: - branches: [main] - paths: ['swift/VERSION'] - workflow_dispatch: - -permissions: - contents: read - -jobs: - mirror: - name: Publish mirror - runs-on: ubuntu-latest - environment: swift-release - env: - MIRROR_TOKEN: ${{ secrets.ZERO_SWIFT_MIRROR_TOKEN }} - MIRROR_REPO: wevm/zero-swift - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - fetch-depth: 0 - persist-credentials: false - - name: Split and push - if: env.MIRROR_TOKEN != '' - run: | - version="$(tr -d '[:space:]' < swift/VERSION)" - git subtree split --prefix=swift -b mirror - remote="https://x-access-token:${MIRROR_TOKEN}@github.com/${MIRROR_REPO}.git" - git push "$remote" mirror:main - if git ls-remote --exit-code --tags "$remote" "refs/tags/$version" >/dev/null; then - echo "Tag $version already exists on the mirror; not retagging." - else - git tag "$version" mirror - git push "$remote" "refs/tags/$version" - fi diff --git a/.github/workflows/swift-release.yml b/.github/workflows/swift-release.yml new file mode 100644 index 0000000000..68af4831f0 --- /dev/null +++ b/.github/workflows/swift-release.yml @@ -0,0 +1,111 @@ +name: Swift Release + +# Releases the version in swift/VERSION: +# - Publishes swift/ to the SwiftPM mirror (wevm/zero-swift) and tags the +# version there. SwiftPM needs Package.swift at a repository root and clones +# whole repositories, so consumers install from the mirror, not this monorepo. +# - Publishes the zero-swift CLI (packages/zero-swift-cli) to npm with trusted +# publishing. Its version must match swift/VERSION. +# +# The swift-release environment requires approval. The mirror job is skipped +# until the ZERO_SWIFT_MIRROR_TOKEN secret (push access to the mirror) exists, +# and the npm jobs until the ZERO_SWIFT_NPM_PUBLISH variable is 'true'. + +on: + push: + branches: [main] + paths: ['swift/VERSION'] + workflow_dispatch: + +permissions: + contents: read + +jobs: + mirror: + name: Publish mirror + runs-on: ubuntu-latest + environment: swift-release + env: + MIRROR_TOKEN: ${{ secrets.ZERO_SWIFT_MIRROR_TOKEN }} + MIRROR_REPO: wevm/zero-swift + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + - name: Split and push + if: env.MIRROR_TOKEN != '' + run: | + version="$(tr -d '[:space:]' < swift/VERSION)" + git subtree split --prefix=swift -b mirror + remote="https://x-access-token:${MIRROR_TOKEN}@github.com/${MIRROR_REPO}.git" + git push "$remote" mirror:main + if git ls-remote --exit-code --tags "$remote" "refs/tags/$version" >/dev/null; then + echo "Tag $version already exists on the mirror; not retagging." + else + git tag "$version" mirror + git push "$remote" "refs/tags/$version" + fi + + npm-build: + name: Build npm package + runs-on: ubuntu-latest + if: vars.ZERO_SWIFT_NPM_PUBLISH == 'true' + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + version: 11.11.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .nvmrc + package-manager-cache: false + - run: pnpm install --frozen-lockfile + - name: Check the version matches swift/VERSION + run: | + version="$(tr -d '[:space:]' < swift/VERSION)" + package="$(node -p "require('./packages/zero-swift-cli/package.json').version")" + if [ "$package" != "$version" ]; then + echo "packages/zero-swift-cli is $package but swift/VERSION is $version." >&2 + exit 1 + fi + - name: Build and pack + run: | + pnpm --filter zero-swift build + pnpm --filter zero-swift pack --pack-destination "$GITHUB_WORKSPACE/dist/npm" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: zero-swift-npm + path: dist/npm/*.tgz + if-no-files-found: error + + npm-publish: + name: Publish npm package + runs-on: ubuntu-latest + needs: [mirror, npm-build] + environment: swift-release + permissions: + contents: read + id-token: write # For npm trusted publishing. + steps: + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.x + registry-url: 'https://registry.npmjs.org' + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: zero-swift-npm + path: dist/npm + - name: Publish + run: | + set -euo pipefail + tarball="$(ls dist/npm/zero-swift-*.tgz)" + version="${tarball##*/zero-swift-}" + version="${version%.tgz}" + if npm view "zero-swift@$version" version >/dev/null 2>&1; then + echo "zero-swift@$version is already on npm; not republishing." + else + npm publish "$tarball" --provenance --access public + fi diff --git a/packages/zero-swift-cli/package.json b/packages/zero-swift-cli/package.json index 6ec323b078..7c1b983c3a 100644 --- a/packages/zero-swift-cli/package.json +++ b/packages/zero-swift-cli/package.json @@ -1,7 +1,6 @@ { "name": "zero-swift", "version": "0.1.0", - "private": true, "description": "Generates the Swift row types and schema of a Zero app for the Zero Swift client.", "license": "Apache-2.0", "bin": { diff --git a/swift/DESIGN.md b/swift/DESIGN.md index e3338e703c..b324b83cd1 100644 --- a/swift/DESIGN.md +++ b/swift/DESIGN.md @@ -74,8 +74,8 @@ apps/zbugs-swift/ dogfood app (iOS + macOS) distributed through a mirror repository, `wevm/zero-swift`. CI fills it with a `git subtree split` of `swift/` and tags releases there. Nobody commits to the mirror directly. -- The mirror is private until launch, then public. The Swift source is visible - in this public monorepo either way. +- The mirror is public. The Swift source is visible in this public monorepo + either way. - SwiftPM products: `Zero`, `ZeroUI`, `ZeroTesting`, and `ZeroNIOTesting` (test-only; the only product that depends on SwiftNIO). - The public API is what apps and the generated code use. The engine (IVM @@ -87,8 +87,8 @@ apps/zbugs-swift/ dogfood app (iOS + macOS) `@rocicorp` scope) with a peer dependency on `@rocicorp/zero` (1.11 or later: `drift record` must run the app's own copy). It is versioned with the Swift releases. `pnpm --filter zero-swift build` bundles it with - esbuild, inlining the workspace sources it imports. The package stays - `private` until the first publish is approved. + esbuild, inlining the workspace sources it imports. CI publishes it with npm + trusted publishing. - License: Apache-2.0, like this repository. - Docs: a DocC catalog per library product, with a getting-started article and a JS→Swift parity matrix (`Zero.docc/JSParity.md`). Oxfmt skips the @@ -98,8 +98,10 @@ apps/zbugs-swift/ dogfood app (iOS + macOS) - Independent semver starting at `0.1.0`. `1.0` once zbugs dogfooding shows the API is stable. -- A PR bumps `swift/VERSION`. On merge, CI pushes the subtree split to the - mirror and tags it. +- A PR bumps `swift/VERSION` and the CLI's `package.json` version together. On + merge, `swift-release.yml` pushes the subtree split to the mirror, tags it, + and publishes the CLI to npm, after approval in the `swift-release` + environment. - The README carries a compatibility table: Swift version → sync protocol version → minimum `zero-cache` version → the Zero JS release whose vectors it passes. From 8fa5d41221507adcd686b948b1d780cf6f15be52 Mon Sep 17 00:00:00 2001 From: tmm Date: Wed, 21 Oct 2026 11:18:47 -0400 Subject: [PATCH 100/104] 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. --- swift/TestHost/App/FrameProbe.swift | 37 +++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/swift/TestHost/App/FrameProbe.swift b/swift/TestHost/App/FrameProbe.swift index 2cb096996c..2073509aba 100644 --- a/swift/TestHost/App/FrameProbe.swift +++ b/swift/TestHost/App/FrameProbe.swift @@ -75,6 +75,8 @@ final class FrameProbe { var t: Double /// Rows on screen: top and bottom from the viewport's top. var rows: [String: (minY: Double, maxY: Double)] + /// Every laid-out row's top in content coordinates, on screen or not. + var content: [String: Double] = [:] var tracking = false var decelerating = false var atEdge = false @@ -201,22 +203,25 @@ final class FrameProbe { #if os(iOS) let port = scrollView.convert(scrollView.bounds, to: nil) .inset(by: scrollView.adjustedContentInset) + let y = scrollView.contentOffset.y + scrollView.adjustedContentInset.top + s.offset = Double(y) for (id, m) in markers { guard let v = m.view, v.window != nil, v.bounds.height > 0, !Self.hidden(v) else { continue } let r = v.convert(v.bounds, to: nil) + s.content[id] = Double(r.minY - port.minY) + s.offset guard r.maxY > port.minY + 0.5, r.minY < port.maxY - 0.5 else { continue } s.rows[id] = (Double(r.minY - port.minY), Double(r.maxY - port.minY)) } s.tracking = scrollView.isTracking s.decelerating = scrollView.isDecelerating - let y = scrollView.contentOffset.y + scrollView.adjustedContentInset.top - s.offset = Double(y) s.atEdge = y <= 0.5 || y >= scrollView.contentSize.height - viewportHeight - 0.5 #else let clip = scrollView.contentView let port = clip.convert(clip.bounds, to: nil) + let y = clip.bounds.origin.y + s.offset = Double(y) for (id, m) in markers { guard let v = m.view, v.window != nil, v.bounds.height > 0, !Self.hidden(v) else { continue @@ -225,11 +230,10 @@ final class FrameProbe { // Window coordinates count up from the bottom. let minY = port.maxY - r.maxY let maxY = port.maxY - r.minY + s.content[id] = Double(minY) + s.offset guard maxY > 0.5, minY < port.height - 0.5 else { continue } s.rows[id] = (Double(minY), Double(maxY)) } - let y = clip.bounds.origin.y - s.offset = Double(y) let height = scrollView.documentView?.frame.height ?? 0 s.atEdge = y <= 0.5 || y >= height - clip.bounds.height - 0.5 #endif @@ -258,7 +262,7 @@ final class FrameProbe { let common = a.rows.keys.filter { b.rows[$0] != nil && !Self.accessories.contains($0) } guard !common.isEmpty else { if a.rows.keys.contains(where: Self.isRow) && b.rows.keys.contains(where: Self.isRow) { - flag(b, "no row in both frames", a) + noCommonRow(a, b) } return } @@ -290,6 +294,29 @@ final class FrameProbe { } } + /// No row on screen in both frames: a jump, unless the offset moved by + /// about as much as the rows did, as when a stalled frame (a slow + /// simulator's) lets a fling run on, or the scroll view catches up on the + /// next frame. Rows laid out in both frames, on screen or not, show it: a + /// scroll leaves their content positions where they were, or moves them + /// by what the list compensated in the same pass, as when the stalled + /// frame brought a page whose rows came out taller than estimated (113 pt + /// for 23 rows, against a 1,188 pt scroll). A jump moves them while the + /// offset stays. So up to a quarter of the offset's change, or 50 pt, is a + /// scroll. + private func noCommonRow(_ a: Sample, _ b: Sample) { + let laid = a.content.keys.filter { b.content[$0] != nil && Self.isRow($0) } + let drift = laid.map { abs(b.content[$0]! - a.content[$0]!) }.min() + let detail = + "dt \(fmt((b.t - a.t) * 1000)) ms, latency \(fmt(a.latency * 1000))→\(fmt(b.latency * 1000)) ms, offset \(fmt(a.offset))→\(fmt(b.offset))" + if let drift, drift <= max(50, abs(b.offset - a.offset) / 4) { + log("SCROLLED no row in both frames, rows laid out in both moved \(fmt(drift)) (\(detail))") + return + } + let moved = drift.map { "rows laid out in both moved \(fmt($0))" } ?? "no row laid out in both" + flag(b, "no row in both frames, \(moved) (\(detail))", a) + } + /// The header and the footer: placed with the rows, but not rows. private static let accessories: Set = ["header", "footer"] private static func isRow(_ id: String) -> Bool { !accessories.contains(id) } From 1e41ca82b9d7068ca63ae9bd174f5541dd7d6323 Mon Sep 17 00:00:00 2001 From: tmm Date: Wed, 21 Oct 2026 12:53:42 -0400 Subject: [PATCH 101/104] 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. --- .../Sources/ZeroVirtual/RowsScrollView.swift | 25 +---- swift/Sources/ZeroVirtual/VirtualList.swift | 6 +- .../Sources/ZeroVirtual/VirtualListView.swift | 9 +- swift/Tests/AppleTests/VirtualListTests.swift | 106 +++++++++++------- .../AppleTests/VirtualListViewTests.swift | 6 +- 5 files changed, 78 insertions(+), 74 deletions(-) diff --git a/swift/Sources/ZeroVirtual/RowsScrollView.swift b/swift/Sources/ZeroVirtual/RowsScrollView.swift index 07ea56d021..9ec9f854d7 100644 --- a/swift/Sources/ZeroVirtual/RowsScrollView.swift +++ b/swift/Sources/ZeroVirtual/RowsScrollView.swift @@ -524,7 +524,7 @@ required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } - /// A tap or a key selects a row. + /// A tap selects a row. var selectable: Bool { get { rows.selectable } set { @@ -597,11 +597,9 @@ @objc private func tapped() { select(at: Double(tap.location(in: self).y)) } - /// A tap at `y`, in content coordinates: the list takes focus, and the - /// row there is selected. + /// A tap at `y`, in content coordinates: the row there is selected. func select(at y: Double) { guard let key = rows.key(at: y) else { return } - becomeFirstResponder() rows.userSelect(key) } @@ -610,25 +608,6 @@ _ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer ) -> Bool { true } - - override var canBecomeFirstResponder: Bool { rows.selectable } - - override var keyCommands: [UIKeyCommand]? { - guard rows.selectable else { return nil } - return [ - (UIKeyCommand.inputDownArrow, #selector(selectNext)), - (UIKeyCommand.inputUpArrow, #selector(selectPrevious)), - ("j", #selector(selectNext)), ("k", #selector(selectPrevious)), - ].map { input, action in - let command = UIKeyCommand(input: input, modifierFlags: [], action: action) - // Before the scroll view's own arrow keys. - command.wantsPriorityOverSystemBehavior = true - return command - } - } - - @objc func selectNext() { rows.moveSelection(by: 1) } - @objc func selectPrevious() { rows.moveSelection(by: -1) } } #else /// A scroll view that lays out the rows itself: their frames, the diff --git a/swift/Sources/ZeroVirtual/VirtualList.swift b/swift/Sources/ZeroVirtual/VirtualList.swift index 18b602776f..0acd87527a 100644 --- a/swift/Sources/ZeroVirtual/VirtualList.swift +++ b/swift/Sources/ZeroVirtual/VirtualList.swift @@ -26,7 +26,7 @@ /// } /// ``` /// - /// With a `selection`, a tap or a key selects a row (see + /// With a `selection`, a tap selects a row, and on macOS so do keys (see /// `init(_:selection:row:header:footer:)`). /// /// Underneath is the platform's scroll view (`UIScrollView`, @@ -67,8 +67,8 @@ } /// A list whose rows can be selected, one at a time: a tap selects a - /// row, and while the list has focus, the arrow keys and j and k - /// select the next or the previous one and scroll it into view. + /// row, and on macOS, while the list has focus, the arrow keys and j + /// and k select the next or the previous one and scroll it into view. /// /// A tap sets `selection` even to the row it is, so the app can open /// it again. The list highlights the selected row, as a `List` does; diff --git a/swift/Sources/ZeroVirtual/VirtualListView.swift b/swift/Sources/ZeroVirtual/VirtualListView.swift index 6b6d0f61d5..854fc856b4 100644 --- a/swift/Sources/ZeroVirtual/VirtualListView.swift +++ b/swift/Sources/ZeroVirtual/VirtualListView.swift @@ -44,8 +44,8 @@ /// it's in a window. /// /// A `headerView` and a `footerView` scroll with the rows, above and below - /// them. With an `onSelect`, a tap or a key selects a row; `row` then - /// takes whether its row is selected: + /// them. With an `onSelect`, a tap selects a row, and on macOS so do + /// keys; `row` then takes whether its row is selected: /// /// ```swift /// let list = VirtualListView(issues) { item, isSelected, reusing in @@ -193,8 +193,9 @@ } /// Called with a row the user selects: a tap selects a row (even the - /// selected one), and while the list has focus, the arrow keys and j - /// and k select the next or the previous one and scroll it into view. + /// selected one), and on macOS, while the list has focus, the arrow + /// keys and j and k select the next or the previous one and scroll it + /// into view. /// Rows can be selected while it's set. /// /// The list highlights the selected row with the platform's selection diff --git a/swift/Tests/AppleTests/VirtualListTests.swift b/swift/Tests/AppleTests/VirtualListTests.swift index f07321b08e..5611841027 100644 --- a/swift/Tests/AppleTests/VirtualListTests.swift +++ b/swift/Tests/AppleTests/VirtualListTests.swift @@ -208,8 +208,10 @@ } /// Selecting rows of 30 points, ids `n000`…, in a 400-point list: by a - /// tap, twice on the same row, then by the arrow keys and j and k (sent - /// through the window on macOS), past the screen's bottom. + /// tap, twice on the same row, then on macOS by the arrow keys and j and + /// k (sent through the window), past the screen's bottom. iOS has no + /// keys for it: there, a tap neither takes first responder nor offers + /// key commands. @MainActor struct RowsSelectionCheck { let list: VirtualListTests.List let view: RowsScrollView @@ -218,6 +220,16 @@ /// was last shown selected. let state: () -> (key: String?, sets: [String], shown: [String: Bool]) + /// The row selected last, and how many times the user selected one: + /// three taps on iOS; two taps and thirteen keys on macOS. + #if canImport(UIKit) + static let last = VirtualListTests.id(7) + static let selections = 3 + #else + static let last = VirtualListTests.id(14) + static let selections = 15 + #endif + func run(_ t: VirtualListTests) async throws { let id = VirtualListTests.id // Laid out: the snapshot can complete before the view's next pass. @@ -234,47 +246,47 @@ try await t.run(5) { state().sets.count == 2 } #expect(state().sets == [id(5), id(5)]) - for _ in 0..<10 { key(down: true, letter: false) } - try await t.run(5) { state().key == id(15) } - #expect(state().key == id(15)) - // Below the screen at first: scrolled into view, its bottom at the - // list's. - try await t.run(5) { list.lastVisibleItem()?.key == id(15) } - #expect(list.lastVisibleItem()?.key == id(15)) - key(down: false, letter: true) - key(down: false, letter: true) - key(down: true, letter: true) - try await t.run(5) { state().sets.count == 15 } - #expect(state().key == id(14)) - #expect(state().shown[id(15)] == false) - #expect(state().shown[id(5)] == false) - #expect(state().shown[id(14)] == true) - try await t.run(0.2) { false } - try expectHighlight(on: id(14)) - } - - func frame(_ key: String) throws -> VirtualRowFrame { - try #require(view.rows.host.rowFrames().first { $0.key == AnyHashable(key) }) + #if canImport(UIKit) + #expect(!view.isFirstResponder) + #expect(!view.canBecomeFirstResponder) + #expect(view.keyCommands?.isEmpty ?? true) + // Another row, by a tap. + try tap(id(7)) + try await t.run(5) { state().key == id(7) } + #expect(state().shown[id(5)] == false) + #expect(state().shown[id(7)] == true) + try await t.run(0.2) { false } + try expectHighlight(on: id(7)) + #else + try await keys(t) + #endif } - /// A tap's own handler, at the row's middle: UIKit has no way to send - /// a touch, nor AppKit a click to a window that isn't key, as a test's - /// isn't. - func tap(_ key: String) throws { - let f = try frame(key) - view.select(at: (f.minY + f.maxY) / 2) - } + #if canImport(AppKit) + /// The arrow keys and j and k, from the tapped row. + func keys(_ t: VirtualListTests) async throws { + let id = VirtualListTests.id + for _ in 0..<10 { key(down: true, letter: false) } + try await t.run(5) { state().key == id(15) } + #expect(state().key == id(15)) + // Below the screen at first: scrolled into view, its bottom at the + // list's. + try await t.run(5) { list.lastVisibleItem()?.key == id(15) } + #expect(list.lastVisibleItem()?.key == id(15)) + key(down: false, letter: true) + key(down: false, letter: true) + key(down: true, letter: true) + try await t.run(5) { state().sets.count == 15 } + #expect(state().key == id(14)) + #expect(state().shown[id(15)] == false) + #expect(state().shown[id(5)] == false) + #expect(state().shown[id(14)] == true) + try await t.run(0.2) { false } + try expectHighlight(on: id(14)) + } - /// The down or up arrow, or j or k. - func key(down: Bool, letter: Bool) { - #if canImport(UIKit) - let input = - letter - ? (down ? "j" : "k") : (down ? UIKeyCommand.inputDownArrow : UIKeyCommand.inputUpArrow) - let command = view.keyCommands?.first { $0.input == input } - #expect(command != nil, "no key command for \(input)") - if let action = command?.action { view.perform(action) } - #else + /// The down or up arrow, or j or k. + func key(down: Bool, letter: Bool) { let w = window as! NSWindow let (characters, code): (String, UInt16) = letter @@ -289,7 +301,19 @@ timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: w.windowNumber, context: nil, characters: characters, charactersIgnoringModifiers: characters, isARepeat: false, keyCode: code)!) - #endif + } + #endif + + func frame(_ key: String) throws -> VirtualRowFrame { + try #require(view.rows.host.rowFrames().first { $0.key == AnyHashable(key) }) + } + + /// A tap's own handler, at the row's middle: UIKit has no way to send + /// a touch, nor AppKit a click to a window that isn't key, as a test's + /// isn't. + func tap(_ key: String) throws { + let f = try frame(key) + view.select(at: (f.minY + f.maxY) / 2) } /// The highlight stands behind the row's frame. diff --git a/swift/Tests/AppleTests/VirtualListViewTests.swift b/swift/Tests/AppleTests/VirtualListViewTests.swift index 453e085d16..0088a916f3 100644 --- a/swift/Tests/AppleTests/VirtualListViewTests.swift +++ b/swift/Tests/AppleTests/VirtualListViewTests.swift @@ -163,12 +163,12 @@ $0.isAccessibilitySelected() #endif } - #expect(selected.map(\.id) == [VirtualListTests.id(14)]) + #expect(selected.map(\.id) == [RowsSelectionCheck.last]) // Set by the app: highlighted, and not reported back. view.selection = VirtualListTests.id(3) try await t.run(0.2) { false } - #expect(shown[VirtualListTests.id(3)] == true && shown[VirtualListTests.id(14)] == false) - #expect(sets.count == 15) + #expect(shown[VirtualListTests.id(3)] == true && shown[RowsSelectionCheck.last] == false) + #expect(sets.count == RowsSelectionCheck.selections) withExtendedLifetime((zero, window)) {} } From 3680ac4c47d5e3b8777dd0a04a959a29f18a81c3 Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 21 Oct 2026 18:02:26 +0000 Subject: [PATCH 102/104] 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 --- packages/zero-swift-cli/package.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/zero-swift-cli/package.json b/packages/zero-swift-cli/package.json index 7c1b983c3a..792ecf6d53 100644 --- a/packages/zero-swift-cli/package.json +++ b/packages/zero-swift-cli/package.json @@ -3,6 +3,11 @@ "version": "0.1.0", "description": "Generates the Swift row types and schema of a Zero app for the Zero Swift client.", "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/wevm/zero.git", + "directory": "packages/zero-swift-cli" + }, "bin": { "zero-swift": "./src/bin.ts" }, From dcd8d864a299e49d433bfb450dcdb613aedf1ae5 Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 21 Oct 2026 18:08:08 +0000 Subject: [PATCH 103/104] 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 --- .changeset/README.md | 17 ++ .changeset/config.json | 10 ++ .github/workflows/swift-release.yml | 132 ++++++++++---- package.json | 4 +- pnpm-lock.yaml | 270 ++++++++++++++++++++++++++++ swift/DESIGN.md | 10 +- 6 files changed, 400 insertions(+), 43 deletions(-) create mode 100644 .changeset/README.md create mode 100644 .changeset/config.json diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 0000000000..d262bd669c --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,17 @@ +# Changesets + +Changesets version the Swift client: the `zero-swift` npm package +(`packages/zero-swift-cli`) and the Swift package (`swift/`), which share one +version. Upstream Zero's packages are released separately and are ignored here. + +To release a change to either, add a changeset for `zero-swift`: + +```sh +pnpm changeset +``` + +On `main`, `.github/workflows/swift-release.yml` keeps a "Version Packages" pull +request open with the pending version and changelog. Merging it sets the +version in `packages/zero-swift-cli/package.json` and `swift/VERSION`, and the +workflow then publishes the npm package and tags the SwiftPM mirror after +approval in the `swift-release` environment. diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000000..9bfbad2ead --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@4.0.1/schema.json", + "baseBranch": "main", + "access": "public", + "changelog": "@changesets/cli/changelog", + "commit": false, + "format": "oxfmt", + "ignore": ["@rocicorp/zero-events", "replicache", "otel-proxy"], + "privatePackages": {"version": false, "tag": false} +} diff --git a/.github/workflows/swift-release.yml b/.github/workflows/swift-release.yml index 68af4831f0..7b822a926e 100644 --- a/.github/workflows/swift-release.yml +++ b/.github/workflows/swift-release.yml @@ -1,56 +1,127 @@ name: Swift Release -# Releases the version in swift/VERSION: -# - Publishes swift/ to the SwiftPM mirror (wevm/zero-swift) and tags the -# version there. SwiftPM needs Package.swift at a repository root and clones -# whole repositories, so consumers install from the mirror, not this monorepo. -# - Publishes the zero-swift CLI (packages/zero-swift-cli) to npm with trusted -# publishing. Its version must match swift/VERSION. +# Releases the Swift client, versioned with Changesets (see .changeset/README.md). +# The zero-swift npm package (packages/zero-swift-cli) and the Swift package +# (swift/) share one version, kept in step by `pnpm changeset:version`. # -# The swift-release environment requires approval. The mirror job is skipped -# until the ZERO_SWIFT_MIRROR_TOKEN secret (push access to the mirror) exists, -# and the npm jobs until the ZERO_SWIFT_NPM_PUBLISH variable is 'true'. +# On each push to main: +# - With pending changesets, opens or updates the "Version Packages" PR. +# - Otherwise, if the version in swift/VERSION isn't released yet, after +# approval in the swift-release environment: +# - publishes swift/ to the SwiftPM mirror (wevm/zero-swift) and tags the +# version there. SwiftPM needs Package.swift at a repository root and +# clones whole repositories, so consumers install from the mirror. +# - publishes the zero-swift CLI to npm with trusted publishing. +# Each step is skipped when its release already exists, so a failed run can be +# rerun. on: push: branches: [main] - paths: ['swift/VERSION'] workflow_dispatch: permissions: contents: read +concurrency: + group: swift-release + cancel-in-progress: false + jobs: + version: + name: Version PR + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + outputs: + has-changesets: ${{ steps.changesets.outputs.has-changesets }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + version: 11.11.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .nvmrc + package-manager-cache: false + - run: pnpm install --frozen-lockfile + - id: changesets + uses: changesets/action@ae32849d5ba541f9ae29e40e22a623bc13562f51 # v2.1.2 + with: + version-script: pnpm changeset:version + commit-message: 'chore(zero-swift): version packages' + pr-title: 'chore(zero-swift): version packages' + + plan: + name: Plan release + runs-on: ubuntu-latest + needs: version + if: needs.version.outputs.has-changesets == 'false' + outputs: + version: ${{ steps.plan.outputs.version }} + mirror: ${{ steps.plan.outputs.mirror }} + npm: ${{ steps.plan.outputs.npm }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + sparse-checkout: | + swift/VERSION + packages/zero-swift-cli/package.json + sparse-checkout-cone-mode: false + - id: plan + run: | + set -euo pipefail + version="$(tr -d '[:space:]' < swift/VERSION)" + package="$(jq -r .version packages/zero-swift-cli/package.json)" + if [ "$package" != "$version" ]; then + echo "packages/zero-swift-cli is $package but swift/VERSION is $version." >&2 + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + if git ls-remote --exit-code --tags https://github.com/wevm/zero-swift.git "refs/tags/$version" >/dev/null; then + echo "mirror=false" >> "$GITHUB_OUTPUT" + else + echo "mirror=true" >> "$GITHUB_OUTPUT" + fi + if npm view "zero-swift@$version" version >/dev/null 2>&1; then + echo "npm=false" >> "$GITHUB_OUTPUT" + else + echo "npm=true" >> "$GITHUB_OUTPUT" + fi + mirror: name: Publish mirror runs-on: ubuntu-latest + needs: plan + if: needs.plan.outputs.mirror == 'true' environment: swift-release env: MIRROR_TOKEN: ${{ secrets.ZERO_SWIFT_MIRROR_TOKEN }} MIRROR_REPO: wevm/zero-swift + VERSION: ${{ needs.plan.outputs.version }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 persist-credentials: false - name: Split and push - if: env.MIRROR_TOKEN != '' run: | - version="$(tr -d '[:space:]' < swift/VERSION)" + set -euo pipefail git subtree split --prefix=swift -b mirror remote="https://x-access-token:${MIRROR_TOKEN}@github.com/${MIRROR_REPO}.git" git push "$remote" mirror:main - if git ls-remote --exit-code --tags "$remote" "refs/tags/$version" >/dev/null; then - echo "Tag $version already exists on the mirror; not retagging." - else - git tag "$version" mirror - git push "$remote" "refs/tags/$version" - fi + git tag "$VERSION" mirror + git push "$remote" "refs/tags/$VERSION" npm-build: name: Build npm package runs-on: ubuntu-latest - if: vars.ZERO_SWIFT_NPM_PUBLISH == 'true' + needs: plan + if: needs.plan.outputs.npm == 'true' steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -63,14 +134,6 @@ jobs: node-version-file: .nvmrc package-manager-cache: false - run: pnpm install --frozen-lockfile - - name: Check the version matches swift/VERSION - run: | - version="$(tr -d '[:space:]' < swift/VERSION)" - package="$(node -p "require('./packages/zero-swift-cli/package.json').version")" - if [ "$package" != "$version" ]; then - echo "packages/zero-swift-cli is $package but swift/VERSION is $version." >&2 - exit 1 - fi - name: Build and pack run: | pnpm --filter zero-swift build @@ -84,11 +147,13 @@ jobs: npm-publish: name: Publish npm package runs-on: ubuntu-latest - needs: [mirror, npm-build] + needs: [plan, npm-build] environment: swift-release permissions: contents: read id-token: write # For npm trusted publishing. + env: + VERSION: ${{ needs.plan.outputs.version }} steps: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -99,13 +164,4 @@ jobs: name: zero-swift-npm path: dist/npm - name: Publish - run: | - set -euo pipefail - tarball="$(ls dist/npm/zero-swift-*.tgz)" - version="${tarball##*/zero-swift-}" - version="${version%.tgz}" - if npm view "zero-swift@$version" version >/dev/null 2>&1; then - echo "zero-swift@$version is already on npm; not republishing." - else - npm publish "$tarball" --provenance --access public - fi + run: npm publish "dist/npm/zero-swift-$VERSION.tgz" --provenance --access public diff --git a/package.json b/package.json index 4633336636..287913b867 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,11 @@ "api-snapshot:update": "vitest run -u --config tools/tsnapi/vitest.config.ts", "bench": "turbo run bench --concurrency=1", "bench:bmf": "turbo run bench:bmf --concurrency=1", - "bench:bmf:silent": "turbo run bench:bmf:silent --concurrency=1" + "bench:bmf:silent": "turbo run bench:bmf:silent --concurrency=1", + "changeset:version": "changeset version && node -p \"require('./packages/zero-swift-cli/package.json').version\" > swift/VERSION" }, "devDependencies": { + "@changesets/cli": "^3.0.3", "@e18e/eslint-plugin": "^0.4.1", "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5f2f9a3c28..bf7dc78665 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,6 +54,9 @@ importers: .: devDependencies: + '@changesets/cli': + specifier: ^3.0.3 + version: 3.0.3 '@e18e/eslint-plugin': specifier: ^0.4.1 version: 0.4.1(oxlint@1.80.0(oxlint-tsgolint@7.0.2001)) @@ -3019,6 +3022,75 @@ packages: '@blazediff/core@1.10.0': resolution: {integrity: sha512-AOQff0zgR7cGsZL+4E7hVkmujoPUpm0J9xzWGWZj5wCjd3gmxESXAPfKyuzs93VdpQNFhHlBhfOjrcZ+XTERtQ==} + '@changesets/apply-release-plan@8.1.1': + resolution: {integrity: sha512-nk2Hbq3M9NeHMdaIRBDvawKrQJzXB9sGyBUE5yWnIKtvdcNCPj75zBekJYr0gTT2ecJH3eFeTrc8eisPImcv0Q==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/assemble-release-plan@7.0.0': + resolution: {integrity: sha512-oEW8BxdA604kGGtDSCiHr5w9Tv4UWe9I2k61IBNZzCOE1kbYaJj4v+lFQNgcEZFkUc2pV/+hASErGDvpJOZCTg==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/changelog-git@1.0.0': + resolution: {integrity: sha512-3Dst2Ime2Op5nd4XmWJLPIgp11ZFqJqSkVug9izK6TDcIV4YlhPS4ECbEVR+eGI0bk0r1ItogD4j2Oli87bJrA==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/cli@3.0.3': + resolution: {integrity: sha512-L1i67bMfkK6iC/kDejuNO4pFgTRcWIObsD6Qof2woP1I4M8tj17DGlcpRaTzTv1DYSP5EMwWdEtR8X0Pj5Jg3Q==} + engines: {node: ^22.11 || ^24 || >=26, npm: '>=10.9.0', pnpm: '>=10.0.0', yarn: '>=4.5.2'} + hasBin: true + + '@changesets/config@4.0.1': + resolution: {integrity: sha512-PxpxSQLQSLDjceAgRq+4FWlU+HflDIw03mmPVipxoQx9xrXFUjmqTy3SE+n/2t+DqPdpPiLWVr5NRiLZvjyLwg==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/errors@1.0.0': + resolution: {integrity: sha512-ElN/mEzn6zmETgjwf5MclCMa9ef59sAR0lfO8VSYIsiRvbC2FbLB/92EoYw10Sl0kGixxHFiJZUSv7dA+YpR8g==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/format@0.1.2': + resolution: {integrity: sha512-Caez5XtNXCFS/G5bwyav3wuXL0tMxVd2ZGbaumWbzN08tyzO21asCw7JZhNtVsAZDCvDRUzZN+Iit9SyRITSYA==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/get-dependents-graph@3.0.0': + resolution: {integrity: sha512-ji/t5wFA1zREKXRUePE6Qi+Qu2UgxCeSSGQrphezwvQZrp49B7sJ+8+wvM0tA7zPeSxYKCojDy3WWgrl+s+awg==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/git@4.0.1': + resolution: {integrity: sha512-6vWpIAC4LkpmlqaIu37ViT5enyd9+uBwAiGqCGhASvkSHBgx4PrtTGXWTMFYpDmZbjgyonyVgMciPrW4MqtJsA==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/parse@1.0.0': + resolution: {integrity: sha512-P0iaMb9p9CRYZiTgAllEIF9AUMQHIy1G72tKlcIqJp61icZSsKQNiOPdxAMZG8m/DvZwt/oz5xEbTpike//dWg==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/pre@3.0.0': + resolution: {integrity: sha512-Zm/6YliV/a2oeWTqHJf6KxLrQwgcK1i/BRDl2m0EKZvbnxV5fG9QRhwJJGshjsZTUTS6dkfURQ2K6aAvgNw/3Q==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/read@1.0.1': + resolution: {integrity: sha512-nzvSC6RcTiWf/dsuwZFXpLzGGsRHYCL68U3uHV7srsulU93aVWY7u2GEJiDZ+4GIIElfaW5EqtKC0UHroY+LTQ==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/should-skip-package@1.0.0': + resolution: {integrity: sha512-pwqoJmbONn1XgXmZXPEExgAaT+HdZLjALFTDgIm+PnS5KeO2nLtzA2/Q+4aMFY14kFMuXKG30MObhvDzWgzDgg==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/types@7.0.0': + resolution: {integrity: sha512-c5GoiQyt3pxiXjrWSNoP8/GRf4kG+VnKzovx1OQM8dYYALlSwgedmkPmJ+ZqGxqwg9D3Bkj85Uo4KLd5BN3A0w==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/write@1.0.1': + resolution: {integrity: sha512-q/ThtP9gcnEP6xlv7LrY26C2mHqdGBti/MmOVngt/M/oIGYkssmQGxPK9WzBNt2juVcH/vml2WQ+ra8LXYOTaA==} + engines: {node: ^22.11 || ^24 || >=26} + + '@clack/core@1.5.1': + resolution: {integrity: sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.8.1': + resolution: {integrity: sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==} + engines: {node: '>= 20.12.0'} + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -4348,6 +4420,18 @@ packages: '@leichtgewicht/ip-codec@2.0.5': resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} + '@manypkg/find-root@3.1.0': + resolution: {integrity: sha512-BcSqCyKhBVZ5YkSzOiheMCV41kqAFptW6xGqYSTjkVTl9XQpr+pqHhwgGCOHQtjDCv7Is6EFyA14Sm5GVbVABA==} + engines: {node: '>=20.0.0'} + + '@manypkg/get-packages@3.1.0': + resolution: {integrity: sha512-0TbBVyvPrP7xGYBI/cP8UP+yl/z+HtbTttAD7FMAJgn/kXOTwh5/60TsqP9ZYY710forNfyV0N8P/IE/ujGZJg==} + engines: {node: '>=20.0.0'} + + '@manypkg/tools@2.1.2': + resolution: {integrity: sha512-6QEf6yqFbETdwGITKq57aYoPfX/3K8XFNwsAlx0C1M7o8cb79sv1M3w+tWuWvIcSbNqrLF7OD7YpZMVVz335hQ==} + engines: {node: '>=20.0.0'} + '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} @@ -4905,6 +4989,7 @@ packages: '@opentelemetry/propagator-jaeger@2.12.0': resolution: {integrity: sha512-Kz2Vz+NILW7S1WtN4rV1wr76fMtT/RRWvAMFBc91uTIi2dDMXxrfBKNe2KH7yaCfFp1X0xfAuabzuFczvoPB6A==} engines: {node: ^18.19.0 || >=20.6.0} + deprecated: Package no longer supported. See https://github.com/open-telemetry/opentelemetry-js/pull/7077 peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' @@ -4969,12 +5054,14 @@ packages: '@opentelemetry/sdk-trace-base@2.12.0': resolution: {integrity: sha512-JOERgZCxRM06FEJ5kEWxlSRChah6MmXDx3KwCruqEv/iEyLkTokCSemf2y1w8oIHtfrr8EVTpzebq/31vn2Cpg==} engines: {node: ^18.19.0 || >=20.6.0} + deprecated: Use @opentelemetry/sdk-trace package peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' '@opentelemetry/sdk-trace-node@2.12.0': resolution: {integrity: sha512-9DufH1IlmQMoKAfot5y/FhTP1RnmhLb5K6eKmJO/bvzPjYwPAazoiAUbTZFdPbqxOXN0FEbhZx/e6SB1Og4jmg==} engines: {node: ^18.19.0 || >=20.6.0} + deprecated: Use @opentelemetry/sdk-trace package peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' @@ -5490,6 +5577,10 @@ packages: resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} engines: {node: '>=12.22.0'} + '@pnpm/deps.graph-sequencer@1100.0.1': + resolution: {integrity: sha512-pOr5+q1fLYKwFN3LAJuGZEnfXDcQ73zqgDHMtGy+K+uIoUqyY+6MeDCWFwfu+4EFuq76I5EPFofoNAI+Bmmq4A==} + engines: {node: '>=22.13'} + '@pnpm/network.ca-file@1.0.2': resolution: {integrity: sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==} engines: {node: '>=12.22.0'} @@ -8536,12 +8627,21 @@ packages: fast-querystring@1.1.2: resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + fast-uri@3.1.8: resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} fast-uri@4.2.2: resolution: {integrity: sha512-EwVpUMzLVNOmcWW0zi5G3JJwkopB6XXjnVvNBGfqrMMsVew2hkQWl5e/pe/CfYUeS6jt4cIO2MnamEW7v/7wPw==} + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fast-xml-builder@1.2.0: resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} @@ -9077,6 +9177,10 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + human-id@4.2.2: + resolution: {integrity: sha512-Z+8unD6em/MJwyrkWQ2M3wygRGI1+MztbutiNwk1DCuEPXOZRWGjtcMPZYP1exY9iNZMhd7ZOSMQSo9WYtiQRw==} + hasBin: true + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -9126,6 +9230,9 @@ packages: resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} engines: {node: '>=8'} + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -9453,6 +9560,9 @@ packages: engines: {node: '>=6'} hasBin: true + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} @@ -10407,6 +10517,9 @@ packages: resolution: {integrity: sha512-cbH9IAIJHNj9uXi196JVsRlt7cHKak6u/e6AkL/bkRelZ7rlL3X1YKxsZwa36xipOEKAsdtmaG6aAJoM1fx2zA==} engines: {node: '>=14.16'} + package-manager-detector@1.9.0: + resolution: {integrity: sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==} + package-up@5.0.0: resolution: {integrity: sha512-MQEgDUvXCa3sGvqHg3pzHO8e9gqTCMPVrWUko3vPQGntwegmFo52mZb2abIVTjFnUcW0BcPz0D93jV5Cas1DWA==} engines: {node: '>=18'} @@ -12391,6 +12504,10 @@ packages: resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} engines: {node: '>=18'} + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} @@ -14432,6 +14549,122 @@ snapshots: '@blazediff/core@1.10.0': {} + '@changesets/apply-release-plan@8.1.1': + dependencies: + '@changesets/config': 4.0.1 + '@changesets/format': 0.1.2 + '@changesets/git': 4.0.1 + '@changesets/should-skip-package': 1.0.0 + '@changesets/types': 7.0.0 + import-meta-resolve: 4.2.0 + jsonc-parser: 3.3.1 + semver: 7.8.5 + + '@changesets/assemble-release-plan@7.0.0': + dependencies: + '@changesets/errors': 1.0.0 + '@changesets/get-dependents-graph': 3.0.0 + '@changesets/should-skip-package': 1.0.0 + '@changesets/types': 7.0.0 + semver: 7.8.5 + + '@changesets/changelog-git@1.0.0': + dependencies: + '@changesets/types': 7.0.0 + + '@changesets/cli@3.0.3': + dependencies: + '@changesets/apply-release-plan': 8.1.1 + '@changesets/assemble-release-plan': 7.0.0 + '@changesets/changelog-git': 1.0.0 + '@changesets/config': 4.0.1 + '@changesets/errors': 1.0.0 + '@changesets/get-dependents-graph': 3.0.0 + '@changesets/git': 4.0.1 + '@changesets/pre': 3.0.0 + '@changesets/read': 1.0.1 + '@changesets/should-skip-package': 1.0.0 + '@changesets/types': 7.0.0 + '@changesets/write': 1.0.1 + '@clack/prompts': 1.8.1 + '@manypkg/get-packages': 3.1.0 + '@pnpm/deps.graph-sequencer': 1100.0.1 + cac: 7.0.0 + import-meta-resolve: 4.2.0 + launch-editor: 2.14.2 + package-manager-detector: 1.9.0 + semver: 7.8.5 + tinyexec: 1.3.1 + + '@changesets/config@4.0.1': + dependencies: + '@changesets/get-dependents-graph': 3.0.0 + '@changesets/should-skip-package': 1.0.0 + '@changesets/types': 7.0.0 + '@manypkg/get-packages': 3.1.0 + picomatch: 4.0.7 + + '@changesets/errors@1.0.0': {} + + '@changesets/format@0.1.2': + dependencies: + package-manager-detector: 1.9.0 + tinyexec: 1.3.0 + + '@changesets/get-dependents-graph@3.0.0': + dependencies: + '@changesets/types': 7.0.0 + semver: 7.8.5 + + '@changesets/git@4.0.1': + dependencies: + '@changesets/errors': 1.0.0 + '@changesets/types': 7.0.0 + '@manypkg/get-packages': 3.1.0 + picomatch: 4.0.7 + tinyexec: 1.3.0 + + '@changesets/parse@1.0.0': + dependencies: + '@changesets/types': 7.0.0 + yaml: 2.9.1 + + '@changesets/pre@3.0.0': + dependencies: + '@changesets/errors': 1.0.0 + '@changesets/types': 7.0.0 + '@manypkg/get-packages': 3.1.0 + + '@changesets/read@1.0.1': + dependencies: + '@changesets/git': 4.0.1 + '@changesets/parse': 1.0.0 + '@changesets/types': 7.0.0 + + '@changesets/should-skip-package@1.0.0': + dependencies: + '@changesets/types': 7.0.0 + + '@changesets/types@7.0.0': {} + + '@changesets/write@1.0.1': + dependencies: + '@changesets/format': 0.1.2 + '@changesets/types': 7.0.0 + human-id: 4.2.2 + + '@clack/core@1.5.1': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clack/prompts@1.8.1': + dependencies: + '@clack/core': 1.5.1 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + '@colors/colors@1.5.0': optional: true @@ -16670,6 +16903,21 @@ snapshots: '@leichtgewicht/ip-codec@2.0.5': {} + '@manypkg/find-root@3.1.0': + dependencies: + '@manypkg/tools': 2.1.2 + + '@manypkg/get-packages@3.1.0': + dependencies: + '@manypkg/find-root': 3.1.0 + '@manypkg/tools': 2.1.2 + + '@manypkg/tools@2.1.2': + dependencies: + jju: 1.4.0 + tinyglobby: 0.2.17 + yaml: 2.9.1 + '@mdx-js/mdx@3.1.1': dependencies: '@types/estree': 1.0.9 @@ -17901,6 +18149,8 @@ snapshots: '@pnpm/config.env-replace@1.1.0': {} + '@pnpm/deps.graph-sequencer@1100.0.1': {} + '@pnpm/network.ca-file@1.0.2': dependencies: graceful-fs: 4.2.10 @@ -21343,10 +21593,20 @@ snapshots: dependencies: fast-decode-uri-component: 1.0.1 + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + fast-uri@3.1.8: {} fast-uri@4.2.2: {} + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + fast-xml-builder@1.2.0: dependencies: path-expression-matcher: 1.5.0 @@ -22006,6 +22266,8 @@ snapshots: transitivePeerDependencies: - supports-color + human-id@4.2.2: {} + human-signals@2.1.0: {} hyperdyperid@1.2.0: {} @@ -22043,6 +22305,8 @@ snapshots: import-lazy@4.0.0: {} + import-meta-resolve@4.2.0: {} + imurmurhash@0.1.4: {} indent-string@4.0.0: {} @@ -22306,6 +22570,8 @@ snapshots: json5@2.2.3: {} + jsonc-parser@3.3.1: {} + jsonfile@6.2.1: dependencies: universalify: 2.0.1 @@ -23711,6 +23977,8 @@ snapshots: registry-url: 6.0.1 semver: 7.8.5 + package-manager-detector@1.9.0: {} + package-up@5.0.0: dependencies: find-up-simple: 1.0.1 @@ -26005,6 +26273,8 @@ snapshots: tinyexec@1.3.0: {} + tinyexec@1.3.1: {} + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) diff --git a/swift/DESIGN.md b/swift/DESIGN.md index b324b83cd1..0d9e9813ba 100644 --- a/swift/DESIGN.md +++ b/swift/DESIGN.md @@ -98,10 +98,12 @@ apps/zbugs-swift/ dogfood app (iOS + macOS) - Independent semver starting at `0.1.0`. `1.0` once zbugs dogfooding shows the API is stable. -- A PR bumps `swift/VERSION` and the CLI's `package.json` version together. On - merge, `swift-release.yml` pushes the subtree split to the mirror, tags it, - and publishes the CLI to npm, after approval in the `swift-release` - environment. +- Changesets version both (see `.changeset/README.md`): a change to either + adds a changeset for `zero-swift`. `swift-release.yml` keeps a version PR + open, and `pnpm changeset:version` writes the CLI's version to + `swift/VERSION`. Once that PR merges, the workflow pushes the subtree split + to the mirror, tags it, and publishes the CLI to npm, after approval in the + `swift-release` environment. - The README carries a compatibility table: Swift version → sync protocol version → minimum `zero-cache` version → the Zero JS release whose vectors it passes. From 728925019a318f301d749c1a5d7f19305668922c Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 21 Oct 2026 20:17:31 +0000 Subject: [PATCH 104/104] 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 --- .changeset/README.md | 6 +- .github/workflows/swift-release.yml | 4 +- .prettierignore | 2 +- package.json | 4 +- packages/zero-swift-cli/package.json | 3 +- pnpm-lock.yaml | 267 --------------------------- swift/DESIGN.md | 2 +- 7 files changed, 12 insertions(+), 276 deletions(-) diff --git a/.changeset/README.md b/.changeset/README.md index d262bd669c..95a50ed7ad 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -7,9 +7,13 @@ version. Upstream Zero's packages are released separately and are ignored here. To release a change to either, add a changeset for `zero-swift`: ```sh -pnpm changeset +pnpm dlx @changesets/cli@3.0.3 ``` +Changesets isn't a workspace dependency, so the root `package.json` and +`pnpm-lock.yaml` stay as upstream has them and syncing upstream doesn't +conflict there. + On `main`, `.github/workflows/swift-release.yml` keeps a "Version Packages" pull request open with the pending version and changelog. Merging it sets the version in `packages/zero-swift-cli/package.json` and `swift/VERSION`, and the diff --git a/.github/workflows/swift-release.yml b/.github/workflows/swift-release.yml index 7b822a926e..c23e0d01a4 100644 --- a/.github/workflows/swift-release.yml +++ b/.github/workflows/swift-release.yml @@ -2,7 +2,7 @@ name: Swift Release # Releases the Swift client, versioned with Changesets (see .changeset/README.md). # The zero-swift npm package (packages/zero-swift-cli) and the Swift package -# (swift/) share one version, kept in step by `pnpm changeset:version`. +# (swift/) share one version, kept in step by `pnpm --filter zero-swift version-packages`. # # On each push to main: # - With pending changesets, opens or updates the "Version Packages" PR. @@ -51,7 +51,7 @@ jobs: - id: changesets uses: changesets/action@ae32849d5ba541f9ae29e40e22a623bc13562f51 # v2.1.2 with: - version-script: pnpm changeset:version + version-script: pnpm --filter zero-swift version-packages commit-message: 'chore(zero-swift): version packages' pr-title: 'chore(zero-swift): version packages' diff --git a/.prettierignore b/.prettierignore index 7d991ce1ee..352e066788 100644 --- a/.prettierignore +++ b/.prettierignore @@ -15,4 +15,4 @@ vendor !/renderer/src/constants.ts **/bot-recordings.ts .sst -prod/sst/sst-env.d.tspackages/zero-conformance/vectors +prod/sst/sst-env.d.ts \ No newline at end of file diff --git a/package.json b/package.json index 287913b867..4633336636 100644 --- a/package.json +++ b/package.json @@ -23,11 +23,9 @@ "api-snapshot:update": "vitest run -u --config tools/tsnapi/vitest.config.ts", "bench": "turbo run bench --concurrency=1", "bench:bmf": "turbo run bench:bmf --concurrency=1", - "bench:bmf:silent": "turbo run bench:bmf:silent --concurrency=1", - "changeset:version": "changeset version && node -p \"require('./packages/zero-swift-cli/package.json').version\" > swift/VERSION" + "bench:bmf:silent": "turbo run bench:bmf:silent --concurrency=1" }, "devDependencies": { - "@changesets/cli": "^3.0.3", "@e18e/eslint-plugin": "^0.4.1", "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", diff --git a/packages/zero-swift-cli/package.json b/packages/zero-swift-cli/package.json index 792ecf6d53..1ca64015a3 100644 --- a/packages/zero-swift-cli/package.json +++ b/packages/zero-swift-cli/package.json @@ -27,7 +27,8 @@ "check-types": "tsc", "lint": "oxlint --quiet --config ../../oxlint.config.ts src/", "format": "oxfmt .", - "check-format": "oxfmt --check ." + "check-format": "oxfmt --check .", + "version-packages": "cd ../.. && pnpm dlx @changesets/cli@3.0.3 version && jq -r .version packages/zero-swift-cli/package.json > swift/VERSION" }, "dependencies": { "typescript": "6.0.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf7dc78665..1ee7dd6b04 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,9 +54,6 @@ importers: .: devDependencies: - '@changesets/cli': - specifier: ^3.0.3 - version: 3.0.3 '@e18e/eslint-plugin': specifier: ^0.4.1 version: 0.4.1(oxlint@1.80.0(oxlint-tsgolint@7.0.2001)) @@ -3022,75 +3019,6 @@ packages: '@blazediff/core@1.10.0': resolution: {integrity: sha512-AOQff0zgR7cGsZL+4E7hVkmujoPUpm0J9xzWGWZj5wCjd3gmxESXAPfKyuzs93VdpQNFhHlBhfOjrcZ+XTERtQ==} - '@changesets/apply-release-plan@8.1.1': - resolution: {integrity: sha512-nk2Hbq3M9NeHMdaIRBDvawKrQJzXB9sGyBUE5yWnIKtvdcNCPj75zBekJYr0gTT2ecJH3eFeTrc8eisPImcv0Q==} - engines: {node: ^22.11 || ^24 || >=26} - - '@changesets/assemble-release-plan@7.0.0': - resolution: {integrity: sha512-oEW8BxdA604kGGtDSCiHr5w9Tv4UWe9I2k61IBNZzCOE1kbYaJj4v+lFQNgcEZFkUc2pV/+hASErGDvpJOZCTg==} - engines: {node: ^22.11 || ^24 || >=26} - - '@changesets/changelog-git@1.0.0': - resolution: {integrity: sha512-3Dst2Ime2Op5nd4XmWJLPIgp11ZFqJqSkVug9izK6TDcIV4YlhPS4ECbEVR+eGI0bk0r1ItogD4j2Oli87bJrA==} - engines: {node: ^22.11 || ^24 || >=26} - - '@changesets/cli@3.0.3': - resolution: {integrity: sha512-L1i67bMfkK6iC/kDejuNO4pFgTRcWIObsD6Qof2woP1I4M8tj17DGlcpRaTzTv1DYSP5EMwWdEtR8X0Pj5Jg3Q==} - engines: {node: ^22.11 || ^24 || >=26, npm: '>=10.9.0', pnpm: '>=10.0.0', yarn: '>=4.5.2'} - hasBin: true - - '@changesets/config@4.0.1': - resolution: {integrity: sha512-PxpxSQLQSLDjceAgRq+4FWlU+HflDIw03mmPVipxoQx9xrXFUjmqTy3SE+n/2t+DqPdpPiLWVr5NRiLZvjyLwg==} - engines: {node: ^22.11 || ^24 || >=26} - - '@changesets/errors@1.0.0': - resolution: {integrity: sha512-ElN/mEzn6zmETgjwf5MclCMa9ef59sAR0lfO8VSYIsiRvbC2FbLB/92EoYw10Sl0kGixxHFiJZUSv7dA+YpR8g==} - engines: {node: ^22.11 || ^24 || >=26} - - '@changesets/format@0.1.2': - resolution: {integrity: sha512-Caez5XtNXCFS/G5bwyav3wuXL0tMxVd2ZGbaumWbzN08tyzO21asCw7JZhNtVsAZDCvDRUzZN+Iit9SyRITSYA==} - engines: {node: ^22.11 || ^24 || >=26} - - '@changesets/get-dependents-graph@3.0.0': - resolution: {integrity: sha512-ji/t5wFA1zREKXRUePE6Qi+Qu2UgxCeSSGQrphezwvQZrp49B7sJ+8+wvM0tA7zPeSxYKCojDy3WWgrl+s+awg==} - engines: {node: ^22.11 || ^24 || >=26} - - '@changesets/git@4.0.1': - resolution: {integrity: sha512-6vWpIAC4LkpmlqaIu37ViT5enyd9+uBwAiGqCGhASvkSHBgx4PrtTGXWTMFYpDmZbjgyonyVgMciPrW4MqtJsA==} - engines: {node: ^22.11 || ^24 || >=26} - - '@changesets/parse@1.0.0': - resolution: {integrity: sha512-P0iaMb9p9CRYZiTgAllEIF9AUMQHIy1G72tKlcIqJp61icZSsKQNiOPdxAMZG8m/DvZwt/oz5xEbTpike//dWg==} - engines: {node: ^22.11 || ^24 || >=26} - - '@changesets/pre@3.0.0': - resolution: {integrity: sha512-Zm/6YliV/a2oeWTqHJf6KxLrQwgcK1i/BRDl2m0EKZvbnxV5fG9QRhwJJGshjsZTUTS6dkfURQ2K6aAvgNw/3Q==} - engines: {node: ^22.11 || ^24 || >=26} - - '@changesets/read@1.0.1': - resolution: {integrity: sha512-nzvSC6RcTiWf/dsuwZFXpLzGGsRHYCL68U3uHV7srsulU93aVWY7u2GEJiDZ+4GIIElfaW5EqtKC0UHroY+LTQ==} - engines: {node: ^22.11 || ^24 || >=26} - - '@changesets/should-skip-package@1.0.0': - resolution: {integrity: sha512-pwqoJmbONn1XgXmZXPEExgAaT+HdZLjALFTDgIm+PnS5KeO2nLtzA2/Q+4aMFY14kFMuXKG30MObhvDzWgzDgg==} - engines: {node: ^22.11 || ^24 || >=26} - - '@changesets/types@7.0.0': - resolution: {integrity: sha512-c5GoiQyt3pxiXjrWSNoP8/GRf4kG+VnKzovx1OQM8dYYALlSwgedmkPmJ+ZqGxqwg9D3Bkj85Uo4KLd5BN3A0w==} - engines: {node: ^22.11 || ^24 || >=26} - - '@changesets/write@1.0.1': - resolution: {integrity: sha512-q/ThtP9gcnEP6xlv7LrY26C2mHqdGBti/MmOVngt/M/oIGYkssmQGxPK9WzBNt2juVcH/vml2WQ+ra8LXYOTaA==} - engines: {node: ^22.11 || ^24 || >=26} - - '@clack/core@1.5.1': - resolution: {integrity: sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==} - engines: {node: '>= 20.12.0'} - - '@clack/prompts@1.8.1': - resolution: {integrity: sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==} - engines: {node: '>= 20.12.0'} - '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -4420,18 +4348,6 @@ packages: '@leichtgewicht/ip-codec@2.0.5': resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} - '@manypkg/find-root@3.1.0': - resolution: {integrity: sha512-BcSqCyKhBVZ5YkSzOiheMCV41kqAFptW6xGqYSTjkVTl9XQpr+pqHhwgGCOHQtjDCv7Is6EFyA14Sm5GVbVABA==} - engines: {node: '>=20.0.0'} - - '@manypkg/get-packages@3.1.0': - resolution: {integrity: sha512-0TbBVyvPrP7xGYBI/cP8UP+yl/z+HtbTttAD7FMAJgn/kXOTwh5/60TsqP9ZYY710forNfyV0N8P/IE/ujGZJg==} - engines: {node: '>=20.0.0'} - - '@manypkg/tools@2.1.2': - resolution: {integrity: sha512-6QEf6yqFbETdwGITKq57aYoPfX/3K8XFNwsAlx0C1M7o8cb79sv1M3w+tWuWvIcSbNqrLF7OD7YpZMVVz335hQ==} - engines: {node: '>=20.0.0'} - '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} @@ -5577,10 +5493,6 @@ packages: resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} engines: {node: '>=12.22.0'} - '@pnpm/deps.graph-sequencer@1100.0.1': - resolution: {integrity: sha512-pOr5+q1fLYKwFN3LAJuGZEnfXDcQ73zqgDHMtGy+K+uIoUqyY+6MeDCWFwfu+4EFuq76I5EPFofoNAI+Bmmq4A==} - engines: {node: '>=22.13'} - '@pnpm/network.ca-file@1.0.2': resolution: {integrity: sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==} engines: {node: '>=12.22.0'} @@ -8627,21 +8539,12 @@ packages: fast-querystring@1.1.2: resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} - fast-string-truncated-width@3.0.3: - resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} - - fast-string-width@3.0.2: - resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.8: resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} fast-uri@4.2.2: resolution: {integrity: sha512-EwVpUMzLVNOmcWW0zi5G3JJwkopB6XXjnVvNBGfqrMMsVew2hkQWl5e/pe/CfYUeS6jt4cIO2MnamEW7v/7wPw==} - fast-wrap-ansi@0.2.2: - resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} - fast-xml-builder@1.2.0: resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} @@ -9177,10 +9080,6 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} - human-id@4.2.2: - resolution: {integrity: sha512-Z+8unD6em/MJwyrkWQ2M3wygRGI1+MztbutiNwk1DCuEPXOZRWGjtcMPZYP1exY9iNZMhd7ZOSMQSo9WYtiQRw==} - hasBin: true - human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -9230,9 +9129,6 @@ packages: resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} engines: {node: '>=8'} - import-meta-resolve@4.2.0: - resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} - imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -9560,9 +9456,6 @@ packages: engines: {node: '>=6'} hasBin: true - jsonc-parser@3.3.1: - resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} - jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} @@ -10517,9 +10410,6 @@ packages: resolution: {integrity: sha512-cbH9IAIJHNj9uXi196JVsRlt7cHKak6u/e6AkL/bkRelZ7rlL3X1YKxsZwa36xipOEKAsdtmaG6aAJoM1fx2zA==} engines: {node: '>=14.16'} - package-manager-detector@1.9.0: - resolution: {integrity: sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==} - package-up@5.0.0: resolution: {integrity: sha512-MQEgDUvXCa3sGvqHg3pzHO8e9gqTCMPVrWUko3vPQGntwegmFo52mZb2abIVTjFnUcW0BcPz0D93jV5Cas1DWA==} engines: {node: '>=18'} @@ -12504,10 +12394,6 @@ packages: resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} engines: {node: '>=18'} - tinyexec@1.3.1: - resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} - engines: {node: '>=18'} - tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} @@ -14549,122 +14435,6 @@ snapshots: '@blazediff/core@1.10.0': {} - '@changesets/apply-release-plan@8.1.1': - dependencies: - '@changesets/config': 4.0.1 - '@changesets/format': 0.1.2 - '@changesets/git': 4.0.1 - '@changesets/should-skip-package': 1.0.0 - '@changesets/types': 7.0.0 - import-meta-resolve: 4.2.0 - jsonc-parser: 3.3.1 - semver: 7.8.5 - - '@changesets/assemble-release-plan@7.0.0': - dependencies: - '@changesets/errors': 1.0.0 - '@changesets/get-dependents-graph': 3.0.0 - '@changesets/should-skip-package': 1.0.0 - '@changesets/types': 7.0.0 - semver: 7.8.5 - - '@changesets/changelog-git@1.0.0': - dependencies: - '@changesets/types': 7.0.0 - - '@changesets/cli@3.0.3': - dependencies: - '@changesets/apply-release-plan': 8.1.1 - '@changesets/assemble-release-plan': 7.0.0 - '@changesets/changelog-git': 1.0.0 - '@changesets/config': 4.0.1 - '@changesets/errors': 1.0.0 - '@changesets/get-dependents-graph': 3.0.0 - '@changesets/git': 4.0.1 - '@changesets/pre': 3.0.0 - '@changesets/read': 1.0.1 - '@changesets/should-skip-package': 1.0.0 - '@changesets/types': 7.0.0 - '@changesets/write': 1.0.1 - '@clack/prompts': 1.8.1 - '@manypkg/get-packages': 3.1.0 - '@pnpm/deps.graph-sequencer': 1100.0.1 - cac: 7.0.0 - import-meta-resolve: 4.2.0 - launch-editor: 2.14.2 - package-manager-detector: 1.9.0 - semver: 7.8.5 - tinyexec: 1.3.1 - - '@changesets/config@4.0.1': - dependencies: - '@changesets/get-dependents-graph': 3.0.0 - '@changesets/should-skip-package': 1.0.0 - '@changesets/types': 7.0.0 - '@manypkg/get-packages': 3.1.0 - picomatch: 4.0.7 - - '@changesets/errors@1.0.0': {} - - '@changesets/format@0.1.2': - dependencies: - package-manager-detector: 1.9.0 - tinyexec: 1.3.0 - - '@changesets/get-dependents-graph@3.0.0': - dependencies: - '@changesets/types': 7.0.0 - semver: 7.8.5 - - '@changesets/git@4.0.1': - dependencies: - '@changesets/errors': 1.0.0 - '@changesets/types': 7.0.0 - '@manypkg/get-packages': 3.1.0 - picomatch: 4.0.7 - tinyexec: 1.3.0 - - '@changesets/parse@1.0.0': - dependencies: - '@changesets/types': 7.0.0 - yaml: 2.9.1 - - '@changesets/pre@3.0.0': - dependencies: - '@changesets/errors': 1.0.0 - '@changesets/types': 7.0.0 - '@manypkg/get-packages': 3.1.0 - - '@changesets/read@1.0.1': - dependencies: - '@changesets/git': 4.0.1 - '@changesets/parse': 1.0.0 - '@changesets/types': 7.0.0 - - '@changesets/should-skip-package@1.0.0': - dependencies: - '@changesets/types': 7.0.0 - - '@changesets/types@7.0.0': {} - - '@changesets/write@1.0.1': - dependencies: - '@changesets/format': 0.1.2 - '@changesets/types': 7.0.0 - human-id: 4.2.2 - - '@clack/core@1.5.1': - dependencies: - fast-wrap-ansi: 0.2.2 - sisteransi: 1.0.5 - - '@clack/prompts@1.8.1': - dependencies: - '@clack/core': 1.5.1 - fast-string-width: 3.0.2 - fast-wrap-ansi: 0.2.2 - sisteransi: 1.0.5 - '@colors/colors@1.5.0': optional: true @@ -16903,21 +16673,6 @@ snapshots: '@leichtgewicht/ip-codec@2.0.5': {} - '@manypkg/find-root@3.1.0': - dependencies: - '@manypkg/tools': 2.1.2 - - '@manypkg/get-packages@3.1.0': - dependencies: - '@manypkg/find-root': 3.1.0 - '@manypkg/tools': 2.1.2 - - '@manypkg/tools@2.1.2': - dependencies: - jju: 1.4.0 - tinyglobby: 0.2.17 - yaml: 2.9.1 - '@mdx-js/mdx@3.1.1': dependencies: '@types/estree': 1.0.9 @@ -18149,8 +17904,6 @@ snapshots: '@pnpm/config.env-replace@1.1.0': {} - '@pnpm/deps.graph-sequencer@1100.0.1': {} - '@pnpm/network.ca-file@1.0.2': dependencies: graceful-fs: 4.2.10 @@ -21593,20 +21346,10 @@ snapshots: dependencies: fast-decode-uri-component: 1.0.1 - fast-string-truncated-width@3.0.3: {} - - fast-string-width@3.0.2: - dependencies: - fast-string-truncated-width: 3.0.3 - fast-uri@3.1.8: {} fast-uri@4.2.2: {} - fast-wrap-ansi@0.2.2: - dependencies: - fast-string-width: 3.0.2 - fast-xml-builder@1.2.0: dependencies: path-expression-matcher: 1.5.0 @@ -22266,8 +22009,6 @@ snapshots: transitivePeerDependencies: - supports-color - human-id@4.2.2: {} - human-signals@2.1.0: {} hyperdyperid@1.2.0: {} @@ -22305,8 +22046,6 @@ snapshots: import-lazy@4.0.0: {} - import-meta-resolve@4.2.0: {} - imurmurhash@0.1.4: {} indent-string@4.0.0: {} @@ -22570,8 +22309,6 @@ snapshots: json5@2.2.3: {} - jsonc-parser@3.3.1: {} - jsonfile@6.2.1: dependencies: universalify: 2.0.1 @@ -23977,8 +23714,6 @@ snapshots: registry-url: 6.0.1 semver: 7.8.5 - package-manager-detector@1.9.0: {} - package-up@5.0.0: dependencies: find-up-simple: 1.0.1 @@ -26273,8 +26008,6 @@ snapshots: tinyexec@1.3.0: {} - tinyexec@1.3.1: {} - tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) diff --git a/swift/DESIGN.md b/swift/DESIGN.md index 0d9e9813ba..d6261eaa11 100644 --- a/swift/DESIGN.md +++ b/swift/DESIGN.md @@ -100,7 +100,7 @@ apps/zbugs-swift/ dogfood app (iOS + macOS) API is stable. - Changesets version both (see `.changeset/README.md`): a change to either adds a changeset for `zero-swift`. `swift-release.yml` keeps a version PR - open, and `pnpm changeset:version` writes the CLI's version to + open, and `pnpm --filter zero-swift version-packages` writes the CLI's version to `swift/VERSION`. Once that PR merges, the workflow pushes the subtree split to the mirror, tags it, and publishes the CLI to npm, after approval in the `swift-release` environment.