Skip to content

feat: generate Swift and Kotlin client models and a sync-aware schema IR - #58

Merged
dawsontoth merged 15 commits into
mainfrom
client-codegen-swift-kotlin
Oct 7, 2026
Merged

dawsontoth merged 15 commits into
mainfrom
client-codegen-swift-kotlin

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

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.yaml become one IR; the emitters and the compatibility diff read only that IR, never Harper.

flowchart LR
  T[Live tables] --> B[buildSchemaIR]
  G[GraphQL type registry] --> B
  Y[sync.yaml] --> B
  P[Previous IR] -. type names .-> B
  B --> IR[(Schema IR JSON)]
  IR --> SW[Swift package]
  IR --> KT[Kotlin module]
  IR --> D[diffSchemaIR]
  IR -. future .-> GW[Gateway and Studio]
Loading

Nested object types come from the GraphQL type registry because live Table.attributes keep only a type name (address: Address becomes { 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?

One property per stored attribute, optional unless it is the primary key, server-managed or declared non-null; computed attributes are read-only and optional, and a Blob is always optional because sync does not deliver blob content.
_version carries the record version; on tables that are not @sealed, _extra keeps undeclared attributes for round trips, and declared names are filtered out of it on decode and again on encode.
New (insert, primary key optional), Patch (each attribute unset, set or cleared) and encodeUpsertRow() (full replacement, refused for tables holding a Blob) follow core's defineTable projections.

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?

Any error in a profile — an unknown table or attribute, a mistyped key, an invalid value — drops the whole profile and logs an error, so a typo can never widen a scope to every row.
An imported IR is held to the same shapes: validation refuses a scope that normalization would not have produced.

Example: a misspelled scope attribute

A profile scopes Order with scope: { 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?

A table's hash covers its wire and storage contract, including every nested type and embedded table it carries, each listed once by name; a profile's schemaHash covers its tables and is the value a device sends in its hello frame.
Equal hashes mean an identical contract, not identical source: descriptions, attribute order and generated type names are excluded.
An IR file is refused when a hash does not match its contents or a derived field (readOnly, projections, column affinity) does not follow from them, so a hand-edited IR cannot slip a change past the CI diff or emit a model its hash does not describe.

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 PUT drops 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.price from Float to String

The 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) and data.Product.price is now stored as TEXT (breaks storage), and schema-codegen diff exits 1.

Regeneration in dev mode

What runs, in what order, when the schema changes?

One regeneration

  1. Coalesce — schema events and sync.yaml edits collapse into one trailing run, and runs never overlap.
  2. Collect once — one filtered table list feeds both the TypeScript output and the client outputs.
  3. Build — the IR is built from that list, the type registry as it stands for this run, sync.yaml and the previous IR.
  4. Warn — a diff against the previous IR logs every change that breaks reads, writes or storage.
  5. Render — every output is rendered and validated in memory; an invalid option writes nothing.
  6. Publish — every changed file is staged beside its target, then all are renamed into place with the IR last; a failed rename restores what it had replaced, and a run whose scope closed stops before this step.

Generated code samples

The spike's Order table:

type Order @table(audit: true) @export {
	id: ID @primaryKey
	customerId: ID @indexed
	status: String @indexed
	amount: Float
	points: Float @computed(from: "Math.round((amount || 0) * (status == 'open' ? 10 : 25))")
}

Generated Swift (abridged):

public struct Order: HarperRecord, Identifiable {
	public static let schema = HarperTableSchema(
		database: "data", name: "Order", primaryKey: "id", sealed: false,
		hash: "379d4b048bb7c07979b6c89302af72a913aabf70628a6c84cef1a0d970b636be",
		versionColumn: "_version", extraColumn: "_extra", replaceable: true,
		columns: [
			HarperColumn(name: "id", type: .id, affinity: .text, nullable: false, primaryKey: true),
			HarperColumn(name: "customerId", type: .id, affinity: .text, nullable: true, indexed: true),
			…
			HarperColumn(name: "points", type: .float, affinity: .real, nullable: true, readOnly: true, computedFrom: "Math.round((amount || 0) * (status == 'open' ? 10 : 25))"),
		]
	)

	public let id: String
	public var customerId: String?
	public var status: String?
	public var amount: Double?
	/// Computed on the client from `Math.round((amount || 0) * (status == 'open' ? 10 : 25))`.
	public let points: Double?
	public let _version: Double?
	public var _extra: [String: HarperValue]

	public init(row: HarperRow, version: Double?) throws {
		self.id = try row.harperRequired("id", of: "Order")
		self.customerId = try row.harperOptional("customerId", of: "Order")
		…
		self._version = version
		self._extra = row.harperExtra(excluding: Self.declaredAttributes)
	}

	public func encodeUpsertRow() throws -> HarperRow {
		var row = self._extra.harperExtra(excluding: Self.declaredAttributes)
		row["id"] = self.id.harperValue
		if let value = self.customerId { row["customerId"] = value.harperValue }
		…   // no points: computed attributes are never written
		return row
	}

	public struct New: HarperWritable { … }   // insert body: id optional
	public struct Patch: HarperWritable {     // patch body: unset, value, or cleared
		public enum Field: String, Sendable, Hashable {
			case customerId = "customerId"
			case status = "status"
			case amount = "amount"
		}
		…
	}
}

Generated Kotlin (abridged):

private val stringListConverter = HarperConverters.list(HarperConverters.string)   // built once per file, not per decode

public data class Order(
	public val id: String,
	public val customerId: String? = null,
	public val status: String? = null,
	public val amount: Double? = null,
	/** Computed on the client from `Math.round((amount || 0) * (status == 'open' ? 10 : 25))`. */
	public val points: Double? = null,
	override val _version: Double? = null,
	public val _extra: Map<String, HarperValue> = emptyMap(),
) : HarperRecord {
	override fun encodeUpsertRow(): HarperRow = buildMap {
		putAll(this@Order._extra.harperExtra(Order.declaredAttributes))
		put("id", HarperConverters.string.encode(this@Order.id))
		this@Order.customerId?.let { put("customerId", HarperConverters.string.encode(it)) }
		…
	}

	public data class New(…) : HarperWritable
	public data class Patch(…, public val clear: Set<Field> = emptySet()) : HarperWritable

	public companion object : HarperTable<Order> {
		override val schema: HarperTableSchema = HarperTableSchema(…)
		override fun decode(row: HarperRow, version: Double?): Order = Order(
			id = row.harperRequired("id", "Order", HarperConverters.string),
			customerId = row.harperOptional("customerId", "Order", HarperConverters.string),
			…
		)
	}
}

Using the models:

import HarperModels

let row = json.mapValues { HarperValue(any: $0) }   // JSONSerialization, SQLite, or JSONDecoder → HarperRow
var order = try Order(row: row, version: version)   // HarperDecodingError names type + attribute on a mismatch
order.status = "shipped"
let putBody = try order.encodeUpsertRow()           // id + writable attributes + _extra; never `points`
let postBody = Order.New(customerId: "alice", status: "open", amount: 12.5).encodeRow()
let patchBody = Order.Patch(status: "closed", clear: [.amount]).encodeRow()   // ["status": "closed", "amount": null]

HarperSyncProfile.myOrders.schemaHash               // what a device sends in its hello frame
try attachment.encodeUpsertRow()                    // ✗ HarperEncodingError.replacementUnsupported(type: "coverage_Attachment", attribute: "file")
val order = Order.decode(row, version)
val putBody = order.copy(status = "shipped").encodeUpsertRow()
val patchBody = Order.Patch(status = "closed", clear = setOf(Order.Patch.Field.amount)).encodeRow()
val profile = HarperProfiles.myOrders

An excerpt of the IR (ios/harper-schema.json in the spike):

{
  irVersion: 1,
  schemaHash: '09d59bb0…',                 // every table's wire/storage contract
  tables: [{
    database: 'data', name: 'Order', typeName: 'Order', primaryKey: 'id', sealed: false,
    hash: '379d4b04…', profiles: ['my-orders'],
    attributes: [
      { name: 'points', type: { kind: 'scalar', scalar: 'Float' }, nullable: true, readOnly: true,
        computed: { from: "Math.round((amount || 0) * (status == 'open' ? 10 : 25))", version: … }, … },
      …
    ],
    projections: { record: […], insert: […], upsert: […], patch: […], clearable: ['customerId', 'status', 'amount'], unwritable: [], query: […] },
    storage: { versionColumn: '_version', extraColumn: '_extra', columns: [{ name: 'id', affinity: 'TEXT' }, …] },
  }],
  profiles: [{
    name: 'my-orders', direction: 'pull', retention: null, retentionMs: null,
    tables: [{ database: 'data', table: 'Order',
      scope: { type: 'match', conditions: [{ attribute: 'customerId', operator: 'equals', source: 'user', field: 'username' }] } }],
    hash: 'cac036c2…',                      // the profile definition (its version)
    schemaHash: '947763dc…',                // the tables it covers: the hello-frame value
  }],
  diagnostics: [],
}

Configuration, in the application's config.yaml and sync.yaml:

'@harperfast/schema-codegen':
  package: '@harperfast/schema-codegen'
  schemaIR: 'client/harper-schema.json'
  syncProfiles: 'sync.yaml'
  swift: 'ios/HarperModels'
  kotlin: 'android/harper-models'
  kotlinPackage: 'com.example.harper'
profiles:
  storefront:
    retention: 14d
    tables: [Product]
  my-orders:
    retention: 30d
    scope:
      customerId: $user.username # $token.<claim> also works; a list means "any of"
    tables: [Order]

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-models

Changes

For the human reviewer

  1. Scope. Covers HarperFast/harper-client-sdk-spike#8 as asked, plus a read/write/storage diff and 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.
  2. One compatibility rule to confirm: adding a computed attribute or relationship to a table that is not @sealed counts as write-breaking, because older models echo the new key from _extra and core's validate() rejects it.
  3. Hard to change once shipped: the type mapping (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 no exports map, so existing deep imports keep working.
  4. Blob is read-only and optional on read; a table with a writable Blob refuses full replacement (patches work) until blob sync exists, and a table that gains one breaks writes in the diff.
  5. Behavior change: existing schemaTypes/globalTypes/jsdoc paths now resolve against the app directory rather than Harper's working directory.
  6. Conventions: new modules are JavaScript with JSDoc, since Node won't strip types under node_modules; the one new dependency, yaml, loads only when syncProfiles is set.
  7. Smaller calls, cheap to revisit: sealing breaks writes but not storage; generated type names stay stable through the previous IR file; the runtime ships in each package until the SDKs exist; decoding is strict; dev mode publishes a breaking change with a warning, leaving CI's diff as the gate.

Verification

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

dawsontoth and others added 8 commits October 5, 2026 15:26
…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>
gemini-code-assist[bot]

This comment was marked as resolved.

dawsontoth and others added 6 commits October 5, 2026 17:47
- 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
dawsontoth marked this pull request as ready for review October 6, 2026 14:40
@dawsontoth
dawsontoth requested a review from a team as a code owner October 6, 2026 14:40

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, this will be nice!

🤖 Reviewed with Codex

Comment thread utils/client/diffSchemaIR.js
Comment thread utils/client/emitKotlin.js
…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>
@dawsontoth
dawsontoth merged commit 5e017e4 into main Oct 7, 2026
11 checks passed
@dawsontoth
dawsontoth deleted the client-codegen-swift-kotlin branch October 7, 2026 15:24
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown

🎉 This PR is included in version 2.1.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants