Skip to content

Core Concepts

ZyraForm is built on three core components that work together to eliminate boilerplate code and provide type-safe, schema-driven development. Understanding what each component extracts and why it’s helpful will help you leverage ZyraForm’s full power.

🗄️ ExtendedTable

Schema definition that extracts PowerSync table definitions, validation rules, metadata, and code generation into a single fluent API.

📝 PowerSyncForm

Form state management that extracts React Hook Form patterns, validation logic, error handling, and field watching into SwiftUI.

⚙️ GenericPowerSyncService

CRUD operations that extract repetitive service class code into a single reusable service for all tables.

🗄️ ExtendedTable: Schema Definition Layer

Section titled “🗄️ ExtendedTable: Schema Definition Layer”

ExtendedTable extracts multiple separate concerns into a single schema definition:

  1. PowerSync Table Definition - The basic table structure
  2. TableFieldConfig - Field metadata (encrypted, types, orderBy)
  3. Validation Rules - Zod-like validation logic
  4. Type Information - Swift type inference
  5. Foreign Key Relationships - Database relationships
  6. Code Generation - Swift models, Drizzle schemas, SQL migrations (for reference/understanding only)

😰 Before ExtendedTable

⚠️ ALL OF THIS MUST BE WRITTEN FOR EVERY SINGLE TABLE

// 1. PowerSync Table (separate file - REPEAT FOR EVERY TABLE)
let itemsTable = Table(
name: "myApp-items",
columns: [
.text("item_name"),
.text("owner_id"),
.integer("age"),
.text("is_happy")
]
)
// 2. TableFieldConfig (separate file - HARDCODED VALUES FOR EVERY TABLE)
static let items = TableFieldConfig(
allFields: ["id", "item_name", "owner_id", "age", "is_happy", "created_at", "updated_at"],
encryptedFields: ["item_name", "age", "is_happy"],
integerFields: ["age"],
booleanFields: ["is_happy"],
defaultOrderBy: "created_at DESC"
)
// ⚠️ Need to manually list EVERY field name
// ⚠️ Need to manually remember which fields are encrypted
// ⚠️ Need to manually remember which fields are integers/booleans
// ⚠️ REPEAT THIS ENTIRE CONFIG FOR EVERY TABLE IN YOUR APP
// 3. Validation Logic (scattered throughout codebase - REPEAT FOR EVERY TABLE)
func validateItemName(_ name: String) -> String? {
if name.count < 2 { return "Name too short" }
if name.count > 50 { return "Name too long" }
return nil
}
// ⚠️ Validation logic duplicated from schema
// ⚠️ Easy to forget validation rules
// ⚠️ Must be maintained separately
// 4. Swift Model (separate file - REPEAT FOR EVERY TABLE)
struct Item: Codable {
let id: String
let itemName: String
let ownerId: String
let age: Int?
let isHappy: Bool
// ... CodingKeys, etc.
}
// ⚠️ Manual snake_case to camelCase conversion
// ⚠️ Must manually keep in sync with schema
// 5. Drizzle Schema (separate TypeScript file - REPEAT FOR EVERY TABLE)
export const items = createTable("items", {
item_name: text(),
owner_id: text(),
age: integer(),
is_happy: boolean()
});
// ⚠️ Must manually maintain TypeScript schema
// ⚠️ Easy to get out of sync with Swift schema
// 6. SQL Migration (separate SQL file - REPEAT FOR EVERY TABLE)
CREATE TABLE "myApp-items" (
id TEXT PRIMARY KEY,
item_name TEXT NOT NULL,
...
);
// ⚠️ Manual SQL generation
// ⚠️ Must remember foreign keys, enums, triggers

Problems:

  • ❌ 6 separate files to maintain FOR EVERY TABLE
  • ❌ Easy to get out of sync
  • ❌ Validation logic scattered
  • ❌ No single source of truth
  • ❌ Manual type conversions
  • Must repeat ALL of this for Projects, Users, Tasks, etc.

🎉 With ExtendedTable

// ONE schema definition replaces all 6 files above
let ItemsSchema = ExtendedTable(
name: "\(AppConfig.dbPrefix)items",
columns: [
.text("item_name").encrypted().minLength(2).maxLength(50).notNull(),
.text("owner_id").uuid().notNull(),
.text("age").int().encrypted().positive().nullable(),
.text("is_happy").bool().encrypted().default(false)
]
)
// Automatically generates everything:
// ✅ PowerSync table: ItemsSchema.toPowerSyncTable()
// ✅ Field config: ItemsSchema.toTableFieldConfig()
// ✅ Swift model: ItemsSchema.generateSwiftModel() (for reference)
// ✅ Drizzle schema: ItemsSchema.generateDrizzleSchema() (for reference)
// ✅ SQL migration: ItemsSchema.generateCreateTableSQL() (for reference)

Benefits:

  • ✅ Single source of truth
  • ✅ Validation defined with schema
  • ✅ Automatic code generation (SQL, Drizzle, Swift models)
  • ✅ Type safety guaranteed
  • ✅ Impossible to get out of sync
  • ✅ Auto-included columns (id, created_at, updated_at)
  • ✅ Auto-update triggers for updated_at
  • ✅ Support for enums, relations, objects, arrays

1. Single Source of Truth

  • Define your schema once, everything else is derived
  • Can’t have mismatched field names or types
  • Changes propagate automatically

2. Less Code

  • One definition replaces 6+ files
  • No manual field lists to maintain
  • No separate validation logic
  • Auto-included columns: id, created_at, updated_at added automatically
  • No repetitive boilerplate: Standard fields handled for you

3. Type Safety

  • Compile-time checking ensures consistency
  • Swift types inferred from schema
  • Prevents runtime errors

4. Complete Code Generation

  • SQL Migrations: Complete PostgreSQL migrations with:
    • Enum type creation
    • Foreign key constraints
    • Auto-update triggers for updated_at
    • Nested objects/arrays as JSONB
  • Drizzle Schemas: Complete TypeScript schemas with:
    • Enum definitions
    • Foreign key relations
    • Auto-update rules ($onUpdate)
    • Nested objects/arrays as JSONB
  • Swift Models: Codable structs with CodingKeys
  • Proper Ordering: Automatic dependency resolution for schema generation

PowerSyncForm extracts React Hook Form patterns into SwiftUI:

  1. Form State Management - Values, errors, validation state
  2. Validation Logic - Automatic validation based on schema
  3. Error Handling - Per-field error messages
  4. Field Watching - Track field values for conditional logic
  5. Validation Modes - onChange, onBlur, onSubmit
  6. Form Submission - Type-safe submission with validation

😰 Before PowerSyncForm

// Manual ObservableObject boilerplate
class ItemForm: ObservableObject {
@Published var itemName: String = ""
@Published var ownerId: String = ""
@Published var age: Int?
@Published var isHappy: Bool = false
@Published var errors: [String: String] = [:]
@Published var isValid: Bool = false
@Published var isDirty: Bool = false
// Manual validation
func validate() {
errors.removeAll()
if itemName.isEmpty {
errors["itemName"] = "Name is required"
} else if itemName.count < 2 {
errors["itemName"] = "Name must be at least 2 characters"
} else if itemName.count > 50 {
errors["itemName"] = "Name must be at most 50 characters"
}
if let age = age, age <= 0 {
errors["age"] = "Age must be positive"
}
// ... more validation logic
isValid = errors.isEmpty
}
// Manual field watching
func watch(_ field: String) -> Any? {
switch field {
case "itemName": return itemName
case "ownerId": return ownerId
case "age": return age
case "isHappy": return isHappy
default: return nil
}
}
// Manual submission
func submit(handler: @escaping (ItemFormValues) -> Void) {
validate()
if isValid {
handler(ItemFormValues(...))
}
}
}

Problems:

  • ❌ 100+ lines of boilerplate per form
  • ❌ Manual validation logic (duplicates schema rules)
  • ❌ Manual error handling
  • ❌ Manual field watching
  • ❌ Easy to miss validation rules
  • ❌ No automatic sync with schema

🎉 With PowerSyncForm

// Form automatically uses schema for validation
@StateObject var form = PowerSyncForm<ItemFormValues>(schema: ItemsSchema)
// Automatic validation based on schema rules
// Automatic error messages
// Automatic field watching
// Automatic form state management
// In SwiftUI
TextField("Item Name", text: form.binding(for: "item_name") ?? .constant(""))
if form.hasError("item_name") {
Text(form.getError("item_name") ?? "")
}
// Submit with automatic validation
form.handleSubmit { values in
// Values are guaranteed to be valid
}

Benefits:

  • ✅ Validation automatically matches schema
  • ✅ Zero boilerplate code
  • ✅ Reactive error updates
  • ✅ Field watching built-in
  • ✅ Type-safe values

1. Schema-Driven Validation

  • Validation rules come from schema automatically
  • Can’t forget to validate a field
  • Error messages consistent with schema

2. React Hook Form Patterns

  • Familiar API for web developers
  • Same mental model (watch, errors, submit)
  • Easy to learn

3. Less Boilerplate

  • No manual ObservableObject setup
  • No manual validation logic
  • No manual error handling

4. Type Safety

  • Form values are type-safe
  • Compile-time checking
  • No runtime type errors

5. SwiftUI Integration

  • Native SwiftUI bindings
  • Reactive updates
  • Automatic UI refreshing

⚙️ GenericPowerSyncService: CRUD Operations Layer

Section titled “⚙️ GenericPowerSyncService: CRUD Operations Layer”

GenericPowerSyncService extracts repetitive service class code into a single reusable service:

  1. CRUD Operations - Create, Read, Update, Delete
  2. Encryption Handling - Automatic encrypt/decrypt
  3. Type Conversion - Integer, boolean, string handling
  4. Query Building - SQL query construction
  5. Error Handling - Consistent error patterns
  6. Data Reloading - Automatic refresh after mutations

😰 Before GenericPowerSyncService

⚠️ THIS IS THE FULL CRUD SERVICE FOR JUST ONE TABLE - MUST REPEAT FOR EVERY TABLE

// FULL CRUD Service Class - MUST WRITE THIS FOR EVERY SINGLE TABLE
@MainActor
class PowerSyncItemService: ObservableObject {
@Published var Items: [[String: Any]] = []
private let powerSync = db
private let userId: String
private let encryptionManager = SecureEncryptionManager.shared
init(userId: String) {
self.userId = userId
}
// READ - Load all items
public func loadAllItems() async throws {
let query = "SELECT * FROM \"myApp-items\" WHERE user_id = ? ORDER BY created_at DESC"
var allResults: [[String: Any]] = []
for try await results in try powerSync.watch(
sql: query,
parameters: [userId],
mapper: { cursor in
var dict: [String: Any] = [:]
dict["id"] = try cursor.getString(name: "id")
dict["owner_id"] = try cursor.getString(name: "owner_id")
dict["created_at"] = try cursor.getString(name: "created_at")
dict["updated_at"] = try cursor.getString(name: "updated_at")
// Decrypt encrypted fields
let encryptedItemName = try cursor.getString(name: "item_name")
dict["item_name"] = try? self.encryptionManager.decryptIfEnabled(
encryptedItemName,
for: self.userId
)
let encryptedAge = try cursor.getString(name: "age")
if let decryptedAge = try? self.encryptionManager.decryptIfEnabled(
encryptedAge,
for: self.userId
),
let ageInt = Int(decryptedAge) {
dict["age"] = ageInt
} else {
dict["age"] = try? cursor.getIntOptional(name: "age")
}
let encryptedHappyBool = try cursor.getString(name: "is_happy")
if let decryptedBool = try? self.encryptionManager.decryptIfEnabled(
encryptedHappyBool,
for: self.userId
) {
dict["is_happy"] = decryptedBool == "true" || decryptedBool == "1"
} else {
dict["is_happy"] = (try? cursor.getIntOptional(name: "is_happy")) == 1
}
return dict
}
) {
allResults = results
break
}
Items = allResults
}
// CREATE - Create new item
public func createNewItem(
itemName: String,
ownerId: String,
age: Int,
isHappy: Bool? = false
) async throws -> String {
let id = UUID().uuidString
let now = ISO8601DateFormatter().string(from: Date())
let encryptedItemName = try encryptionManager.encryptIfEnabled(itemName, for: userId)
let encryptedAge = try encryptionManager.encryptIfEnabled(String(age), for: userId)
let encryptedHappyBool = try encryptionManager.encryptIfEnabled(
isHappy == true ? "true" : "false",
for: userId
)
try await powerSync.execute(sql: """
INSERT INTO "myApp-items"
(id, item_name, owner_id, age, is_happy, created_at, updated_at, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
parameters: [
id,
encryptedItemName,
ownerId,
encryptedAge,
encryptedHappyBool,
now,
now,
userId
]
)
try await loadAllItems()
return id
}
// UPDATE - Update existing item
public func updateItem(
id: String,
itemName: String? = nil,
ownerId: String? = nil,
age: Int? = nil,
isHappy: Bool? = nil
) async throws {
let now = ISO8601DateFormatter().string(from: Date())
var updateFields: [String] = []
var parameters: [Any] = []
if let itemName = itemName {
let encryptedItemName = try encryptionManager.encryptIfEnabled(itemName, for: userId)
updateFields.append("item_name = ?")
parameters.append(encryptedItemName)
}
if let ownerId = ownerId {
updateFields.append("owner_id = ?")
parameters.append(ownerId)
}
if let age = age {
let encryptedAge = try encryptionManager.encryptIfEnabled(String(age), for: userId)
updateFields.append("age = ?")
parameters.append(encryptedAge)
}
if let isHappy = isHappy {
let encryptedHappyBool = try encryptionManager.encryptIfEnabled(
isHappy ? "true" : "false",
for: userId
)
updateFields.append("is_happy = ?")
parameters.append(encryptedHappyBool)
}
updateFields.append("updated_at = ?")
parameters.append(now)
parameters.append(id)
let query = "UPDATE \"myApp-items\" SET \(updateFields.joined(separator: ", ")) WHERE id = ?"
try await powerSync.execute(sql: query, parameters: parameters)
try await loadAllItems()
}
// DELETE - Delete item
public func deleteItem(id: String) async throws {
try await powerSync.execute(
sql: "DELETE FROM \"myApp-items\" WHERE LOWER(id) = LOWER(?)",
parameters: [id]
)
try await loadAllItems()
}
}
// ⚠️ MUST REPEAT THIS ENTIRE CLASS FOR EVERY TABLE:
// PowerSyncItemService (200+ lines)
// PowerSyncProjectService (200+ lines)
// PowerSyncUserService (200+ lines)
// PowerSyncTaskService (200+ lines)
// ... 10 tables = 2000+ lines of nearly identical code

Problems:

  • 200+ lines PER TABLE - Full CRUD for just one table
  • Non-typesafe SQL strings everywhere
  • Manual encryption for every field
  • Manual type conversion Int/Bool → String
  • Must repeat for EVERY table in your app

🎉 With GenericPowerSyncService

// ONE service works for ALL tables - no need to write custom service classes!
let itemsService = GenericPowerSyncService(
tableName: "\(AppConfig.dbPrefix)items",
userId: currentUserId
)
let projectsService = GenericPowerSyncService(
tableName: "\(AppConfig.dbPrefix)projects",
userId: currentUserId
)
// Same methods work for ANY table - automatic encryption, type conversion, SQL building
let config = ItemsSchema.toTableFieldConfig() // Generated from schema!
try await itemsService.loadRecords(
fields: config.allFields, // ✅ Auto-generated from schema
encryptedFields: config.encryptedFields, // ✅ Auto-detected from schema
integerFields: config.integerFields, // ✅ Auto-detected from schema
booleanFields: config.booleanFields // ✅ Auto-detected from schema
)
try await itemsService.createRecord(
fields: [
"item_name": "New Item",
"age": 25,
"is_happy": true
],
encryptedFields: config.encryptedFields // ✅ Automatic encryption!
)
try await itemsService.updateRecord(
id: itemId,
fields: ["age": 26],
encryptedFields: config.encryptedFields // ✅ Automatic encryption!
)
try await itemsService.deleteRecord(id: itemId)
// ✅ Use the SAME service for projects, users, tasks - ANY table!
try await projectsService.loadRecords(
fields: ProjectsSchema.toTableFieldConfig().allFields,
encryptedFields: ProjectsSchema.toTableFieldConfig().encryptedFields
)

Benefits:

  • One service for ALL tables - write once, use everywhere
  • Typesafe field lists - generated from schema, no typos
  • Automatic encryption - schema knows which fields are encrypted
  • Automatic type conversion - schema knows field types
  • Consistent patterns - same API for every table
  • Bug fixes benefit all tables - fix once, works everywhere
  • No manual SQL strings - SQL built automatically
  • No copy-paste code - DRY principle applied

1. Eliminates Repetitive Code

  • Before: Write 200+ lines of CRUD code for EVERY table
  • After: Use one generic service for ALL tables
  • Impact: ✨ 10 tables = 2000+ lines → 10 service instances

2. No More Manual SQL Strings

  • SQL built automatically from schema (typesafe, no typos)

3. Automatic Field Management

  • Schema automatically provides field lists, encryption flags, and types

4. Consistent Patterns

  • Same API for every table - easier to learn, maintain, and debug

These three components form a complete ecosystem:

// 1. Define schema ONCE
let UsersSchema = ExtendedTable(
name: "\(AppConfig.dbPrefix)users",
columns: [
.text("email").email().notNull(),
.text("name").minLength(2).maxLength(50).notNull()
]
)
// 2. Create form with automatic validation
@StateObject var form = PowerSyncForm<UserFormValues>(schema: UsersSchema)
// 3. Create service with automatic CRUD
@StateObject var service = GenericPowerSyncService(
tableName: UsersSchema.name,
userId: currentUserId
)
// 4. Everything works together automatically
form.handleSubmit { values in
let config = UsersSchema.toTableFieldConfig()
try await service.createRecord(
fields: values,
encryptedFields: config.encryptedFields
)
}

The Flow:

  1. ExtendedTable defines structure and validation
  2. PowerSyncForm uses schema for validation
  3. GenericPowerSyncService uses schema for CRUD operations
  4. All three share the same schema as single source of truth

Result:

  • ✅ One schema definition
  • ✅ Automatic validation
  • ✅ Automatic CRUD operations
  • ✅ Type safety throughout
  • ✅ Zero boilerplate code

ComponentExtractsBefore (PER TABLE)AfterBenefit
ExtendedTable 🗄️Schema + Validation + Config + Code Gen6+ files, 200+ lines
Must repeat for EVERY table
1 definition, ~8 lines~96% less code
✅ Write once, use everywhere
PowerSyncForm 📝Form State + Validation + Errors100+ lines boilerplate
Must repeat for EVERY form
1 line initialization~99% less code
✅ Schema-driven validation
GenericPowerSyncService ⚙️CRUD Operations200+ lines per table
Non-typesafe SQL strings
Manual encryption/type conversion
Must repeat for EVERY table
1 service for all tables~95% less code
✅ Typesafe, automatic encryption

Note: Percentages are estimates based on typical form development patterns. Actual code reduction varies by project complexity.

🎯 Total Impact:

  • Before: 500+ lines per table × 10 tables = 5,000+ lines 😰
  • After: ~50 lines per table × 10 tables = ~500 lines 🎉
  • Reduction: 90%+ less code 📉
  • Development Time: Significantly faster (estimated 70-90% for typical forms) ⚡

✨ Key Insight: Without ZyraForm, you must write hundreds of lines of repetitive, error-prone code for EVERY table. With ZyraForm, you define your schema once and everything else is automatic!

Note: These metrics are estimates based on typical form development patterns. Actual savings may vary depending on form complexity and existing codebase structure.