Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ jobs:
run: npm run format:check
- name: Run unit tests
run: npm test
env:
SCHEMA_CODEGEN_NATIVE_TESTS: skip
- name: Semantic Release
if: ${{ github.event_name == 'push' }}
uses: cycjimmy/semantic-release-action@b12c8f6015dc215fe37bc154d4ad456dd3833c90 # v6.0.0
Expand Down
22 changes: 22 additions & 0 deletions .github/workflows/verify-pr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,25 @@ jobs:
run: npm ci
- name: Run unit tests
run: npm test
env:
SCHEMA_CODEGEN_NATIVE_TESTS: skip

native:
name: Generated Swift and Kotlin
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: '.nvmrc'
cache: npm
- name: Install the Kotlin compiler
run: command -v kotlinc || brew install kotlin
- name: Install dependencies
run: npm ci
- name: Compile and run the generated packages
run: npx vitest run utils/client/native.test.js
env:
SCHEMA_CODEGEN_NATIVE_TESTS: require
139 changes: 138 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @HarperFast/Schema-Codegen

Schema Codegen will generate TypeScript types for your GraphQL schemas, making it easier to work with your data in TypeScript and JavaScript applications.
Schema Codegen will generate TypeScript types for your GraphQL schemas, making it easier to work with your data in TypeScript and JavaScript applications. It can also generate Swift and Kotlin client models, plus a schema IR that describes your tables and sync profiles for client SDKs — see [Client models](#client-models-swift-and-kotlin).

## Installation

Expand Down Expand Up @@ -40,6 +40,9 @@ When you `harper dev`, it will generate types based on the schema that's actuall
| `includeDatabases` | (all) | List of database names to generate types for. When set, only matching databases are emitted. Supports `*` as a wildcard, e.g. `metrics-*`. |
| `excludeDatabases` | (none) | List of database names to skip. Useful on a shared instance to keep other applications' databases out of your generated types. Supports `*` as a wildcard. |

Output paths are relative to your application directory. Client model options are described
[below](#client-models-swift-and-kotlin).

`includeDatabases`/`excludeDatabases` scope generation to your application's own
databases. On a shared local instance, codegen otherwise sees every database on
the instance — including ones belonging to other projects — so scoping keeps
Expand Down Expand Up @@ -111,6 +114,140 @@ declare module 'harper' {
}
```

## Client models (Swift and Kotlin)

Schema Codegen can also generate the typed models a mobile client works with: a Swift package and a
Kotlin module, built from a schema IR (a versioned JSON description of your tables, nested types and
sync profiles). Turn it on with any of these options:

| Option | Default | Description |
| --------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `schemaIR` | — | Path to write the schema IR (`.json`). Set it whenever you generate `swift` or `kotlin`: it is the baseline that keeps generated type names stable and lets a regeneration warn about breaking changes. |
| `swift` | — | Directory of the Swift package to generate. |
| `swiftModule` | `HarperModels` | The Swift module (and target) name. |
| `kotlin` | — | Directory of the Kotlin module to generate. |
| `kotlinPackage` | `harper.models` | The Kotlin package. |
| `syncProfiles` | — | Path to a `sync.yaml` describing sync profiles; edits regenerate without a restart. |

```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'
```

Each table becomes a Swift `struct` and a Kotlin `data class` with one typed property per attribute,
`_version` (the record's version), and — unless the table is `@sealed` — `_extra`, which keeps
attributes the schema does not declare so they survive a round trip. Every model also has a nested
`New` (insert body) and `Patch` (patch body) type, and a schema descriptor for the device's SQLite
replica. For the `Order` table of a schema like this:

```graphql
type Order @table @export {
id: ID @primaryKey
customerId: ID @indexed
status: String @indexed
amount: Float
points: Float @computed(from: "Math.round((amount || 0) * 10)")
}
```

```swift
import HarperModels

var order = try Order(row: row, version: version) // row: HarperRow, e.g. [String: HarperValue]
order.status = "closed"
let body = try order.encodeUpsertRow() // PUT body: key, writable attributes, _extra
let patch = Order.Patch(status: "closed", clear: [.amount]).encodeRow()
```

```kotlin
val order = Order.decode(row, version)
val patch = Order.Patch(status = "closed", clear = setOf(Order.Patch.Field.amount)).encodeRow()
```

How Harper types map, and why, is recorded in [docs/design/client-codegen.md](docs/design/client-codegen.md):

| Harper | Swift | Kotlin |
| -------------- | ------------------------- | ------------------------- |
| `ID`, `String` | `String` | `String` |
| `Int` | `Int` | `Int` |
| `Long` | `Int64` | `Long` |
| `Float` | `Double` | `Double` |
| `BigInt` | `HarperBigInt` | `java.math.BigInteger` |
| `Boolean` | `Bool` | `Boolean` |
| `Date` | `Date` | `java.time.Instant` |
| `Bytes` | `Data` | `ByteArray` |
| `Blob` | `HarperValue?`, read-only | `HarperValue?`, read-only |
| `Any` | `HarperValue` | `HarperValue` |
| `[T]` | `[T]` | `List<T>` |
| nested type | generated `struct` | generated `data class` |

Attributes are optional unless they are the primary key, server-managed (`@createdTime`,
`@updatedTime`), or declared non-null (`String!`). Computed attributes are read-only and optional;
relationships are not stored on the model. A table with a `Blob` attribute cannot be replaced in full
(`encodeUpsertRow` throws), because a client has no way to send a blob back yet; use a patch. A
`Blob` is always optional on the model, since sync does not deliver blob content; declaring one
required (`Blob!`) makes client inserts into its table fail, and generation warns about it.

The generated Swift package needs Swift 5.9 (iOS 13, macOS 10.15); the Kotlin module targets the JVM
and needs Android API 26 (or core library desugaring) for `java.time`. `Package.swift` and
`build.gradle.kts` are created once and then left to you; the sources are regenerated.

### Sync profiles

A `sync.yaml` declares which tables replicate to devices, scoped to the rows each device may see:

```yaml
profiles:
storefront:
retention: 14d # how long a device may stay offline and still catch up
tables: [Product]
my-orders:
direction: pull # pull (default), push or bidirectional
retention: 30d
scope:
customerId: $token.sub # a token claim; $user.<field> reads the user record
tables: [Order]
regional:
tables:
Store:
database: retail
scope: { region: [us-east, us-west] } # literal values; a list means "any of"
```

Profiles fail closed: a profile with any error — an unknown table or attribute, a mistyped key, an
invalid value — is left out entirely and reported, so a typo can never widen a scope to every row.
Scoping by an attribute without `@indexed` works but logs a warning.

### Schema hashes and compatibility

The IR records a SHA-256 hash of each table's wire and storage contract, of the whole schema, and of
the tables in each sync profile (the value a device sends when it connects). When a regeneration
would break clients built from the previous IR — for example, a changed attribute type, a removed
table, a new required attribute, or a new computed attribute or relationship on a table that is not
`@sealed` (older models keep it in `_extra` and send it back, which the server rejects) — the
component logs which changes break reads, writes or the device's storage. To enforce this in CI,
compare two IR files with the CLI; an IR file whose hashes do not match its contents is refused.

### CLI

The `schema-codegen` command generates client packages from an IR file, without a running Harper:

```bash
npx @harperfast/schema-codegen generate-client --ir client/harper-schema.json --swift ios/HarperModels --kotlin android/harper-models --kotlin-package com.example.harper
```

```bash
npx @harperfast/schema-codegen diff previous-schema.json client/harper-schema.json --fail-on read,write
```

`diff` exits with 1 when the change breaks any of the listed axes (`read`, `write`, `storage`; all
three by default), and `--json` prints the full report.

## Development

To use this in an application, first link it:
Expand Down
8 changes: 8 additions & 0 deletions bin/schema-codegen.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/usr/bin/env node
import { runCli } from '../utils/client/cli.js';

process.exitCode = runCli(process.argv.slice(2), {
stdout: (text) => process.stdout.write(text),
stderr: (text) => process.stderr.write(text),
cwd: process.cwd(),
});
Loading
Loading