Repository navigation
feat: generate Swift and Kotlin client models and a sync-aware schema IR - #58
Merged
Merged
Conversation
…ync profiles Builds a deterministic, versioned schema IR from live tables, the GraphQL type registry and sync.yaml; emits a Swift package and a Kotlin module from it; hashes each table's wire/storage contract and classifies schema drift. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…native builds Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…I; run native builds in CI Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A table that embeds another table's shape hashed only the reference, so a profile's schemaHash did not change when the embedded table did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… client outputs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- diff: a computed attribute or relationship added to an unsealed table breaks writes (older models echo it from _extra; validate rejects it), as does turning a stored attribute into a relationship; relations are diffed one by one with canonical comparison; table keys no longer collide on dots; nested types are compared once per table - hashing: contracts list each reachable object type and embedded table once by name (linear for shared and recursive types); Blob keeps its schema nullability so Blob -> Blob! changes the hash and breaks inserts, while the record projection keeps it optional on read - validation: imported IRs must carry hashes that match their contents, hex hashes, a single-line generator, well-formed profile scopes, relations and duplicate-free projections - emitters: profile constants are allocated by raw name; Kotlin list converters are file-level values instead of per-decode allocations - lifecycle: the scheduler owns its drain before running and survives sync throws, re-entry and a throwing reporter; a closed scope's in-flight run stops before publishing; the type registry is read per run - publication: every changed file is staged before any is renamed in, so a failed write publishes nothing; renames follow symlinks and fall back to an in-place write when Windows holds the target open - profiles fail closed inside normalizeProfile; retention must round to a positive safe integer - package: drop the exports map so existing deep imports keep working Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…te the same sources On Windows checkouts the Kotlin runtime's package line ended in CRLF, so the package was never rewritten. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- publication restores files already replaced when a later rename fails; the stale-file scan skips build and dot folders and unreadable files - the scheduler keeps serving queued requests when the error reporter throws, and surfaces that failure once it has drained - writes follow a dangling symlink to its target instead of replacing it - embedded tables are hashed with their sealing, attributes and relations, so sealing an embedded table reaches the embedding table - validation rejects derived fields (readOnly, projections, column affinity) that disagree with the contract, and reserved type names - members avoid runtime and generated type names, which member bodies reference unqualified; ModelsKt and the other Kotlin file facades are reserved type names - Kotlin decodes a Long or BigInt from a double only within the Long range: 2^63 used to saturate and pass the round-trip check - the diff itemizes server-managed and nullable nested changes, and names a table whose contract changed only through what it embeds Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- members reserve only the runtime types and their own type: Kotlin reaches other generated types' converters through file-level values, so adding a table never renames an existing property - a failed in-place fallback is restored with the rest of the generation - dates decode only within JavaScript's Date range in both runtimes, so NaN no longer reads as the epoch and huge values raise a decoding error - a contract changed only through an embedded table inherits that table's read and write breaks - the stale-file scan skips build folders only at the package root and follows symlinked sources; a symlink cycle fails with ELOOP Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- a table attribute that holds a Blob at any depth, and a nested attribute or array element that is a Blob or an array of them, are optional on read: sync does not deliver blob content - a computed Blob no longer blocks full replacement - a table that embeds a broken table inherits its read/write breaks even when it has changes of its own - embedding a table whose database is excluded warns (TABLE_NOT_GENERATED) instead of silently generating a nested copy Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dawsontoth
marked this pull request as ready for review
October 6, 2026 14:40
kriszyp
approved these changes
Oct 6, 2026
kriszyp
left a comment
Member
There was a problem hiding this comment.
Cool, this will be nice!
🤖 Reviewed with Codex
…tlin byte lists by content - diffSchemaIR records `replacementChanged`. A table whose upsert projection becomes null, because a writable Blob was added at any depth, breaks writes: an older client's PUT would erase Blob content that sync does not deliver. Becoming replaceable again is additive. - Generated Kotlin models compare byte arrays inside lists by content, through the runtime's HarperContent, as they already did for ByteArray fields. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
🎉 This PR is included in version 2.1.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Generates typed Swift and Kotlin client models, and a versioned schema IR — an intermediate representation: one language-neutral JSON description of each table's client contract (attributes, types, nullability, read/write projections, replica layout), the sync profiles and the contract hashes — from the live Harper schema — the codegen the client SDK needs (HarperFast/harper-client-sdk-spike#8). The spike's iOS demo now builds and syncs on the generated package instead of hand-written models (companion: HarperFast/harper-client-sdk-spike#19). Existing TypeScript/JSDoc output is unchanged, except that its paths now resolve against the application directory.
The IR builder reads live tables, the GraphQL type registry and
sync.yaml; the Swift and Kotlin emitters read only the IR; contract hashes and a read/write/storage compatibility diff tell a team when a schema change would break clients already in the field.Product and architecture tour
One IR feeds every client artifact
What does the generator read, and what does it produce?
From schema to client packages
Live tables, the GraphQL type registry and
sync.yamlbecome one IR; the emitters and the compatibility diff read only that IR, never Harper.Nested object types come from the GraphQL type registry because live
Table.attributeskeep only a type name (address: Addressbecomes{ type: 'Address' }, verified on 5.3.0-beta.2). The IR is plain JSON (irVersion: 1), byte-for-byte deterministic for any input order, and published after the sources, so it is always the baseline the published sources were generated from.What an app gets for each table
Which reads and writes does a generated model allow?
Example: a patch that clears one attribute
An app marks an order closed and clears its amount with
Order.Patch(status: "closed", clear: [.amount]).encodeRow().Outcome: the body is
{ status: "closed", amount: null }; attributes left unset are absent, so the server leaves them unchanged.Sync profiles fail closed
What happens to a profile with a mistake in it?
Example: a misspelled scope attribute
A profile scopes
Orderwithscope: { custmerId: $token.sub }.Outcome: the component logs
profile "broken" scopes "Order" by "custmerId", which is not a stored attribute, and the profile is absent from the IR and the generated code.Hashes and schema drift
How does a team learn that a schema change breaks clients already in the field?
The diff classifies each change by what it breaks for clients generated from the older IR: read (their decoding can fail), write (the server rejects their writes, or a
PUTdrops data) and storage (their replica cannot be extended in place). Additive changes come with a delta an older client can apply without regenerating: new tables' storage, added columns, and computed expressions to recompute.Example: changing
Product.pricefromFloattoStringThe dev-mode component compares the new IR with the previous IR file on regeneration.
Outcome: it warns
data.Product.price type changed from Float to String (breaks read, write)anddata.Product.price is now stored as TEXT (breaks storage), andschema-codegen diffexits 1.Regeneration in dev mode
What runs, in what order, when the schema changes?
One regeneration
sync.yamledits collapse into one trailing run, and runs never overlap.sync.yamland the previous IR.Generated code samples
The spike's
Ordertable:Generated Swift (abridged):
Generated Kotlin (abridged):
Using the models:
An excerpt of the IR (
ios/harper-schema.jsonin the spike):Configuration, in the application's
config.yamlandsync.yaml:The same compatibility check runs in CI without Harper:
npx @harperfast/schema-codegen diff before.json after.json --fail-on read,write # exit 1 on a breaking change npx @harperfast/schema-codegen generate-client --ir client/harper-schema.json --swift ios/HarperModels --kotlin android/harper-modelsChanges
utils/client/buildSchemaIR.js, describeTable applies the read-nullability rules ("undefined means nullable"; primary key and server-managed required; computed and derived optional) and lays out the replica columns, with metadata column names that step aside for attributes named_version/_extra; toType resolves nested types through the registry, embedded tables as records (warning when an embedded table's database is excluded) and anything unresolvable toAnywith a diagnostic; relationships are described but never stored; projectionsOf ports thedefineTableprojections with the Blob rules; requiredOnInsert is the shared insert-requiredness rule; and allocateTypeNames assigns identifier-safe type names in one namespace.utils/client/contractHash.jshash each table contract, whose nested shapes are listed once by name so shared and recursive types hash in linear time;utils/client/canonicalHash.jssupplies canonicalJSON (sorted keys at every depth) and hashCanonical.utils/client/syncProfiles.jsholds parseDuration (retention rounds to a positive safe integer), loadSyncProfilesFile (YAML or JSON), normalizeProfile (returns nothing once any part fails) and normalizeCondition (literals,$token.<claim>,$user.<field>,$$escapes, membership lists).utils/client/validateSchemaIR.jschecks shapes, unique identifier-safe type names, references, projections, metadata columns, a single-line generator, profile scopes that derived fields follow from the contract, and, last, recomputes every hash.utils/client/irTypes.jsdeclares the IR shapes;utils/client/irConstants.jsthe IR version and scalar set;utils/client/generator.jsthe generator stamp; andutils/client/naming.jsthe reserved type names, toCodeIdentifier, the collision-suffixing allocator, member allocation that claims verbatim names first against the runtime types and the owning type, which member bodies name (profile constants also avoid every generated type), and keyword escaping.utils/client/diffSchemaIR.js, diffSchemaIR validates both IRs and summarizes each axis; attribute removals and additions carry the sealed, overflow-echo and relationship rules; relationships are diffed one by one; a table that stops supporting full replacement breaks writes; compareTypes compares each nested type pair once per table; a table that embeds a broken table inherits its read and write breaks (found by embeddedTableKeys), and one whose contract changed only through what it embeds is named as such; and diffProfiles lists profile changes.utils/client/emitSwift.js, tableModel emits each record struct (decode, encode, upsert refusal,New,PatchwithField/clear); objectModel emits nested types; schemaSource emitsHarperSchemaand the profile constants, allocated by raw profile name; and packageManifest is thePackage.swiftscaffold, written once.utils/client/emitKotlin.js, tableModel emits each data class with its companionHarperTable; converterOf hoists every non-scalar converter into a file-level value named against every name that could shadow it, so list converters are built once and member bodies never name another generated type; contentEquality gives classes holding byte arrays, in lists too, content equality; and schemaSource emitsHarperSchemaandHarperProfiles.utils/client/emitSupport.jsholds the generated-file header, runtime templates read with LF endings, comment sanitizing, Swift and Kotlin string escaping, and recursiveValueEdges, which finds the by-value cycles Swift must box.utils/client/runtime/HarperRuntime.swiftdefines HarperValue, HarperBigInt, the row decoding helpers, dates within JavaScript's range, HarperTableSchema, HarperRecord and HarperSyncProfile;utils/client/runtime/HarperRuntime.ktdefines the Kotlin HarperValue, HarperConverters (decoding aLongfrom a double only within its exact range and a date only within JavaScript's range), HarperContent (byte-array content equality), HarperTable and HarperSyncProfile.utils/client/clientOutputs.js, renderClientOutputs renders everything in memory with the IR last, publishClientOutputs stages every changed file before renaming any and restores replaced files if a rename fails, and findStaleGeneratedFiles reports sources a renamed module left behind, skipping the package's build folders and unreadable files;utils/writeIfChanged.jsadds stageFile (symlinks followed, dangling ones included, cycles refused) and commitStaged (in-place fallback when Windows holds the target open), which writeIfChanged now uses;utils/generateTablesDTS.jswrites through writeIfChanged.utils/client/regenerateClient.jsreads the previous IR, ignoring an invalid one, stops when its scope closed, logs diagnostics and breaking changes, publishes, and warns about the manifest and stale files;utils/regenerateAll.jsloads the client modules only when an output is configured; createRegenerationScheduler inutils/createRegenerationScheduler.jscoalesces and serializes runs, and keeps serving queued requests when the error reporter throws.extensionModule.jsreads the client options, passes the registry and a close signal per run, starts after the initial delay, watches sync.yaml when a client output is configured, and aborts and detaches on close.utils/client/cli.jsimplements generate-client and diff with --fail-on and --json;bin/schema-codegen.jsis the bin entry;index.jsis the public API;package.jsonadds bin, main and files and the yaml dependency, locked inpackage-lock.jsonhere.README.mdgains the Client models section;docs/design/client-codegen.mdis the design note and decision record;.github/workflows/verify-pr.yamladds a macOS job that compiles and runs the generated Swift and Kotlin and skips the native tests in the Node matrix, as does the release workflow.For the human reviewer
diffand CLI that nothing consumes until the gateway (HarperFast/harper-client-sdk-spike#10); those can be dropped without touching the emitters. Emitting only the IR and leaving models to the SDKs was rejected because the demo needs models now.@sealedcounts as write-breaking, because older models echo the new key from_extraand core'svalidate()rejects it.Long→Int64/Long,BigInt→ decimal string/BigInteger,Date→ epoch ms,Any→HarperValue), the sync.yaml profile format (which fails closed), and the public index.js API, which has no consumer until the gateway and Studio arrive. There is noexportsmap, so existing deep imports keep working.schemaTypes/globalTypes/jsdocpaths now resolve against the app directory rather than Harper's working directory.node_modules; the one new dependency,yaml, loads only whensyncProfilesis set.diffas the gate.Verification
npm test(vitest): 30 files, 325 tests; withKOTLINC/JAVA_HOMEset andSCHEMA_CODEGEN_NATIVE_TESTS=require, all pass locally, and the PR's CI is green on Ubuntu and Windows (Node 20/22/24) and on the macOS native job.npm run lint:checkandnpm run format:checkpass.Longat 2^63, aNaNdate, raw-name round trips, records through a reopened SQLite replica built from the descriptors); it compiles the generated Kotlin with-Werrorand runs KotlinHarness.kt for the same contract on the JVM, byte arrays inside lists compared by content included. The adversarial fixtures include attributes named like a runtime type and like a generated type, and a table namedModelsKt, which the previous emitter could not compile.harper dev(5.3.0-beta.2) started from a different working directory wrote the IR, Swift package, Kotlin module and TS types into the application directory for the spike schema plus a coverage database (nested types, GraphQL anddefineTablerelationships, sealed and Blob tables);swift buildandkotlincbuilt both; editingsync.yamlregenerated without a restart; theProduct.pricechange produced the warning above, andschema-codegen diffexited 1. The README'snpxcommands ran verbatim from a packed tarball.xcodebuildsucceeds, and in the iOS simulator against the spike gateway the demo checked out 500 products and alice's orders through the generated models, with a live REST update arriving with on-device computedpointsand an undeclared attribute preserved in_extra.Related PRs: #57 independent (dependency updates, already on main; only package.json and package-lock.json are shared)
Complexity: complicated
Review-Coverage: authored=claude; ran=gemini,codex,cursor-composer,cursor-muse; adjudicated=domain; blocked=cursor-grok(failed); declined=cursor-kimi; rounds=4; full=2 @ 8dde785
Review-Attention: deep ~160m (critical: schema-codegen.js, buildSchemaIR.js +2; sensitive: verify-pr.yaml, package-lock.json; decisions: do-less-alternative, file-level-converters-for-name-stability, rollback-by-rewrite, blob-read-optional-scope, date-range-js-bound, rethrow-reporter-failure, js-over-ts, base-directory-for-existing-outputs, public-root-api-now) @ 8dde785