Define a Dart model once. Generate Zod schemas, TypeScript interfaces, Drift tables, Drizzle schemas, and JSON serialization from a single annotated class — with zero runtime footprint.
- How It Works
- Quick Start
- Annotation Reference
- 1. Schema-Level
- 2. Field Metadata
- 3. Primary Key & Database
- 4. Relations
- 5. Validation
- 6. Serialization
- 7. Type Overrides
- 8. Platform-Specific Exclusions
- 9. Security
- 10. UI Metadata
- 11. Lifecycle
- 12. Sync & Offline
- 13. API
- 14. Audit & Tracking
- 15. Migration
- 16. Feature & Release Control
- 17. Slug
- 18. Generator Control
- Writing a Custom Generator
- Build Pipeline
- Package Map
Schemix is a build_runner plugin. It runs in three phases:
- Scan — reads every
lib/**.dartfile and builds a type graph (schemix_registry.json). - Generate — reads the registry + one source file per invocation, analyzes annotations, and calls each active generator.
- Index — emits a barrel
gen/schemix.g.tsthat re-exports every generated Zod schema.
There is no runtime dependency. Add schemix to your regular dependencies (for the annotations) and schemix_builder to dev_dependencies (for the build tooling).
pubspec.yaml
dependencies:
schemix: any
dev_dependencies:
schemix_builder: any
build_runner: ^2.4.0Define a model
import 'package:schemix/schemix.dart';
@Schemix(
tableName: 'users',
schemaVersion: 1,
enableTimestamps: true,
)
class User {
@PrimaryKey(autoGenerate: true)
final String id;
@Email()
@Length(max: 255)
final String email;
@Hashed()
final String passwordHash;
@Indexed()
final String tenantId;
const User({
required this.id,
required this.email,
required this.passwordHash,
required this.tenantId,
});
}Run the build
dart run build_runner buildOutputs
lib/user.schemix.dart ← Dart JSON serialization
lib/user.table.dart ← Drift table class
gen/user.g.ts ← Zod schema + TypeScript interface
gen/user.drizzle.ts ← Drizzle ORM table schema
gen/user.go ← Go struct with Gorm tags
gen/schemix.g.ts ← Barrel re-export
The root annotation. Every class that should produce generated output must carry this. Controls the table name, schema version, timestamp injection, soft-delete, and which generators are active.
@Schemix(
tableName: 'business_entities',
schemaVersion: 2,
namespace: 'billing',
enableTimestamps: true,
enableSoftDelete: true,
)
class Business { ... }| Parameter | Type | Default | Description |
|---|---|---|---|
tableName |
String? |
snake_case of class name | SQL / Drift / Drizzle table name |
collectionName |
String? |
— | Firestore collection name |
schemaVersion |
int |
1 |
Monotonically increasing version for migration tracking |
namespace |
String? |
— | Logical domain grouping, e.g. 'auth', 'billing' |
enableTimestamps |
bool |
true |
Auto-injects createdAt / updatedAt if not declared |
enableSoftDelete |
bool |
true |
Auto-injects deletedAt nullable column |
abstractSchema |
bool |
false |
No table generated; class is only extended by others |
cacheable |
bool |
true |
Model may be cached at runtime |
syncable |
bool |
true |
Model participates in the sync engine |
embeddable |
bool |
false |
Fields are inlined into a parent table; no own table |
generateZod |
bool |
true |
Emit gen/{path}.g.ts |
generateDrift |
bool |
true |
Emit lib/{path}.table.dart |
generateDrizzle |
bool |
true |
Emit gen/{path}.drizzle.ts |
generateGorm |
bool |
true |
Emit gen/{path}.go |
Groups a schema into a named domain. Informational — used by tooling for documentation and organization reports.
@SchemaGroup('billing')
@Schemix()
class Invoice { ... }Attaches a human-readable description to a schema. The text propagates into generated code comments and API documentation.
@SchemaDescription('Represents a registered business entity with billing info.')
@Schemix()
class Business { ... }Marks an entire schema class as deprecated. Generators emit a deprecation comment in all outputs. Provide replacement so consumers know where to migrate.
@DeprecatedSchema(
reason: 'Replaced by BusinessV2 with normalized address fields.',
replacement: 'BusinessV2',
removalVersion: '4.0.0',
)
class Business { ... }Rich per-field metadata controlling visibility, searchability, and display hints. @AppField is a typedef alias — use whichever reads better in context.
@SchemixField(
searchable: true,
sortable: true,
filterable: true,
displayName: 'Business Name',
description: 'Legal registered name of the business.',
example: 'Acme Corp',
)
final String businessName;| Parameter | Default | Effect |
|---|---|---|
immutable |
false |
Field cannot change after creation |
readonly |
false |
Exposed for reads; clients cannot set it |
hidden |
false |
Hidden from UI but present in all outputs |
internal |
false |
Excluded from all external (public) outputs |
searchable |
false |
Included in full-text search indexes |
sortable |
false |
May be used as a sort key in queries |
filterable |
false |
May be used as a query filter |
computed |
false |
Derived value; never written to the database |
transient |
false |
In-memory only; never persisted |
virtual |
false |
Exists in the type system only; no DB column |
generated |
false |
Value is produced by the database engine |
unique |
false |
Value must be unique across all rows |
sensitive |
false |
Contains PII or confidential data |
Marks the primary key field. For String PKs, autoGenerate: true emits a UUID v4 client-side default. For int PKs, it maps to SERIAL / AUTOINCREMENT.
@PrimaryKey(autoGenerate: true)
final String id;
// Composite PK — declare compositeOrder on each participating field
@PrimaryKey(compositeOrder: 1)
final String tenantId;
@PrimaryKey(compositeOrder: 2)
final String userId;Creates a database index on this field. Supports unique, descending, full-text, and spatial variants.
@Indexed(unique: true)
final String email;
@Indexed(fullText: true)
final String description;Class-level annotation. Creates a multi-column index. Apply multiple times for multiple composite indexes.
@CompositeIndex(fields: ['email', 'tenantId'], unique: true)
@CompositeIndex(fields: ['createdAt', 'status'])
@Schemix()
class User { ... }Shorthand for @Indexed(unique: true). Use when you only need the uniqueness constraint with no other index options.
@Unique()
final String slug;Marks an int PK as auto-increment (SERIAL in PostgreSQL, AUTOINCREMENT in SQLite). Use alongside @PrimaryKey.
@PrimaryKey(autoGenerate: false)
@AutoIncrement()
final int id;Indicates the column value is entirely produced by the database engine (sequences, expressions, DEFAULT gen_random_uuid()).
@DatabaseGenerated(strategy: 'uuid')
final String id;Overrides the raw SQL column type emitted for this field. Use when the default mapping is not precise enough.
@SqlType('JSONB')
final Map<String, dynamic> metadata;
@SqlType('TIMESTAMPTZ')
final DateTime scheduledAt;Overrides the Drizzle ORM column builder function for this field.
@DrizzleType('jsonb')
final Map<String, dynamic> settings;Overrides the Drift column builder type for this field.
@DriftType('text')
final MyCustomEnum status;Sets a database-level default value. Accepts Dart primitives and enum constants.
@DatabaseDefault(UserStatus.active)
final UserStatus status;
@DatabaseDefault(0)
final int loginCount;Adds a SQL CHECK constraint to the column. The expression is written in SQL and must evaluate to true for every row.
@CheckConstraint('price >= 0')
final double price;
@CheckConstraint("status IN ('active', 'inactive', 'pending')")
final String status;Marks a field as an alternate lookup key (not the primary key). Informational for generators that produce repository or query helpers.
@SecondaryKey()
final String externalReferenceId;Marks the partition key for distributed or sharded databases (e.g. DynamoDB, Cassandra).
@PartitionKey()
final String tenantId;Marks a sort key for distributed or time-series databases. Used alongside @PartitionKey.
@SortKey(descending: true)
final DateTime createdAt;Marks a field for inclusion in a full-text search index. Equivalent to @Indexed(fullText: true) but reads more clearly on string fields.
@FullTextSearch()
final String bio;Marks a field whose value may be cached independently at the application layer.
@CachedField()
final String avatarUrl;Many-to-one. The annotated field stores the foreign-key ID. Generators emit an FK column.
@BelongsTo(User)
final String userId;
// With explicit FK name
@BelongsTo(Organization, foreignKey: 'org_id')
final String organizationId;One-to-one. No column is emitted on this side; the FK lives on the target model. The field type is typically the target class or its ID type.
@HasOne(Profile)
final Profile? profile;One-to-many. No column is emitted. Generators produce a virtual relation reference that is resolved via a FK on the target model.
@HasMany(Invoice)
final List<Invoice> invoices;Many-to-many via a junction table. No column is emitted. Provide junctionTable explicitly to control the junction table name.
@ManyToMany(Tag, junctionTable: 'product_tags')
final List<Tag> tags;Inlines the target class's fields into this model's table. No join is needed. The target class must be annotated with @Schemix(embeddable: true).
@Embedded()
final Address? address;Marks a field as the raw FK column that backs a named relation field on the same class. Connects the FK integer/string to its relation counterpart.
@BelongsTo(User)
final User? user;
@RelationField(fieldName: 'user')
final String userId;Marks a relation so that deleting the owner row also deletes all related rows (ON DELETE CASCADE).
@CascadeDelete()
@HasMany(OrderItem)
final List<OrderItem> items;Marks a relation as lazy-loaded. Generators that produce query helpers will not eagerly fetch this relation with the parent.
@LazyRelation()
@HasMany(AuditLog)
final List<AuditLog> auditLogs;All validation annotations emit corresponding Zod schema constraints in the generated TypeScript output.
Field must be present and non-empty. Emits .min(1) for strings, .nonEmpty() for arrays.
@Required()
final String businessName;Numeric range constraints (inclusive). Emit .gte(value) and .lte(value) in Zod.
@Min(0)
@Max(1000000)
final double price;String length constraint. Emit .min(n).max(n) in Zod.
@Length(min: 3, max: 150)
final String username;Validates the field value against a regular expression. Emits .regex(/pattern/) in Zod.
@Regex(r'^[A-Z]{2}\d{6}$')
final String passportNumber;Format validators. Each emits the corresponding Zod format method (.email(), .url(), .ip(), .uuid()).
@Email()
final String email;
@Url()
final String? websiteUrl;
@Uuid()
final String externalId;Specifies a fallback enum value when deserialization receives an unknown variant. Emits .catch(value) in Zod, preventing parse failures on unknown server values.
@EnumFallback(BusinessType.other)
final BusinessType type;Restricts or rejects a fixed set of values. Emit .refine(...) predicates in Zod.
@AllowedValues(['retail', 'wholesale', 'online'])
final String channel;
@DisallowValues(['admin', 'root', 'superuser'])
final String username;Sets a custom JSON key name for this field. Takes priority over @JsonKey(name:) and the default snake_case fallback.
@JsonField('business_name')
final String businessName;Excludes this field from all serialization and code generation outputs entirely. Cannot be combined with @PrimaryKey, validation, or relation annotations.
@IgnoreField()
final String _internalCache;@ReadOnlyField — serialized in responses, never accepted from clients.
@WriteOnlyField — accepted on writes, never included in responses.
@WriteOnlyField()
final String passwordHash;
@ReadOnlyField()
final String generatedSlug;Flattens a nested object's fields into the parent JSON object, removing the nesting level.
@Flatten()
final Address address;
// Serializes as { street: '...', city: '...' } instead of { address: { street: '...' } }Specifies a custom date format string for DateTime serialization.
@DateFormat('yyyy-MM-dd')
final DateTime birthDate;Specifies decimal precision and scale for numeric fields. Maps to DECIMAL(precision, scale) in SQL.
@Precision(precision: 18, scale: 2)
final double amount;Instructs serialization to write the value as a different type.
@SerializeAs('string')
final int legacyId;Enables specialized serialization logic for DateTime fields to support Firestore Timestamp conversion seamlessly.
@FirestoreDateTime()
final DateTime createdAt;Overrides the TypeScript type and optionally the Zod schema for this field. Use for branded types, union types, or any type the default resolver cannot infer.
@TsType('string | number')
final dynamic id;
@TsType("UserId", zodSchema: "z.string().brand('UserId')")
final String userId;Overrides the full Zod schema expression for this field. Use when @TsType(zodSchema:) is too verbose.
@ZodType("z.string().brand('TenantId')")
final String tenantId;Unified type override spanning all generation targets. Use when a field needs custom handling in Dart, TypeScript, SQL, and Drizzle simultaneously.
@CustomConverter(
dartConverter: 'MetadataConverter',
tsConverter: "z.record(z.string(), z.unknown())",
sqlType: 'JSONB',
drizzleType: 'jsonb',
)
final Map<String, dynamic>? metadata;Overrides the Go type for this field in Gorm structs.
@GormType('json.RawMessage')
final Map<String, dynamic> metadata;Use these to exclude a field from a single generator target while keeping it in all others.
Excludes this field from the Drift table generator only.
@DriftIgnore()
final String computedDisplayName;Excludes this field from the Drizzle schema generator only.
@DrizzleIgnore()
final String localOnlyFlag;Excludes this field from the Zod schema and TypeScript interface only.
@ZodIgnore()
final String internalServerId;Excludes this field from the Gorm struct generator only.
@GormIgnore()
final String internalServerId;Marks a field as encrypted at rest. Excluded from API response DTOs unless @ApiField(expose: true) is set.
@Encrypted()
final String taxIdentificationNumber;Marks a field as hashed (passwords, API keys). Excluded from API response DTOs. Never included in read outputs by default.
@Hashed()
final String passwordHash;Marks a field as sensitive PII. Excluded from API response DTOs. Generators emit appropriate warnings in comments.
@Sensitive()
final String socialSecurityNumber;Redacts this field's value in application and server logs.
@MaskInLogs()
final String creditCardNumber;Restricts read access to this field to callers who hold the given permission string.
@PermissionRequired('admin')
final double internalCostPrice;Restricts read or write access to callers who hold one of the listed OAuth / RBAC scope strings.
@ReadScope(['admin', 'owner'])
@WriteScope(['owner'])
final String privateNotes;Display metadata for generated form and table UIs. Consumed by generators that produce UI scaffolding.
@UiField(
label: 'Business Name',
icon: 'business',
section: 'general',
order: 1,
helpText: 'Enter the legal registered name.',
tooltip: 'This must match your business registration certificate.',
)
final String businessName;Controls the input widget type and behaviour for generated forms.
@FormField(widgetType: 'textarea', multiline: true, autofocus: false)
final String description;Column display hints for generated data-table UIs.
@TableColumn(width: 200, align: 'left', sortable: true)
final String businessName;Mark string fields as holding a colour hex value, an image URL, or a file URL. Form generators render the appropriate picker widget.
@ColorField()
final String brandColor;
@ImageField()
final String? logoUrl;
@FileField()
final String? attachmentUrl;Mark fields that should appear in query filter UIs or search bars.
@FilterField()
@SearchField()
final String status;Marks a DateTime field as the record creation timestamp. Auto-excluded from create/update API DTOs.
@CreatedAt()
final DateTime createdAt;Marks a DateTime field as the last-updated timestamp. Auto-excluded from create/update DTOs.
@UpdatedAt()
final DateTime updatedAt;Marks a nullable DateTime field as the soft-delete timestamp. Non-null means the record is deleted. Used with @Schemix(enableSoftDelete: true).
@DeletedAt()
final DateTime? deletedAt;Marks an int field as an optimistic-concurrency version counter. Incremented on every update. Auto-excluded from create/update DTOs.
@VersionField()
final int version;Marks a field as an audit-trail marker. Generators that produce change-history tables include this field in the audit schema.
@AuditField()
final String lastModifiedBy;Class-level. Specifies how write conflicts should be resolved during sync. Strategies: 'latestWins', 'firstWins', 'merge'.
@ConflictResolver(strategy: 'latestWins')
@Schemix(syncable: true)
class Document { ... }Field exists only in the local database — never synced to the server. Excluded from Zod / TypeScript outputs.
@OfflineOnly()
final bool isDirty;Field exists only in the remote/cloud database — not stored locally. Excluded from Drift / Drizzle table generation.
@CloudOnly()
final String cloudProcessingJobId;Sets the sync priority for this field during conflict resolution. Higher values are synced first.
@SyncPriority(priority: 10)
final String criticalFlag;Every mutation to this field is recorded as an operation log entry, enabling CRDT-style merge strategies.
@OperationTracked()
final int counter;Controls how this field appears in generated API DTO interfaces. Set expose: false to remove it from all DTOs entirely.
@ApiField(expose: true, readonly: true, deprecated: false)
final String publicId;
@ApiField(expose: false)
final String internalAuditKey;Tracks the API version lifecycle of a field. Generators emit appropriate deprecation warnings and changelog comments.
@ApiVersion(introducedIn: '1.2.0', deprecatedIn: '2.0.0', removedIn: '3.0.0')
final String legacyCode;Field is included in DTO interfaces only — not persisted to the database. Useful for computed or joined values returned by APIs.
@DtoOnly()
final String displayLabel;Field is for internal server use only. Excluded from all public API DTOs regardless of other settings.
@InternalApi()
final String serviceRoutingKey;Records every mutation to this field in the audit log table.
@TrackChanges()
final String status;Emits an analytics event whenever this field changes. eventName defaults to a generated name based on class and field.
@TrackAnalytics(eventName: 'subscription_plan_changed')
final String subscriptionPlan;Logs every write to this field using the application's structured logging system.
@LogChanges()
final String adminOverrideReason;Records that this field was previously named oldName. Generators use this to produce ALTER TABLE RENAME COLUMN migration scripts.
@RenamedFrom('company_name')
final String businessName;Marks a field as scheduled for removal in the given semver version. Generators emit a deprecation warning in all outputs.
@RemovedIn('3.0.0')
final String legacyExternalId;Attaches a free-form migration note to a field. Appears in generated migration scripts as a comment.
@MigrationNote('Backfill from the legacy profile table using the data pipeline in scripts/migrate_profile.ts')
final String normalizedAddress;Marks a field as a legacy carry-over that exists only for backwards compatibility. Excluded from new code generation paths.
@LegacyField()
final String oldFormatPhone;Gates this field behind a named feature flag. Generators emit conditional logic or comments referencing the flag identifier.
@FeatureFlag('new_billing_flow')
final String stripePaymentMethodId;Marks a field as experimental. Generated code includes a warning comment so consumers know the API may change.
@Experimental()
final Map<String, dynamic> aiGeneratedTags;Marks a field as available to enterprise-tier users only. Generators can emit tier-gating comments or exclude the field from public SDK output.
@EnterpriseOnly()
final String ssoConfigurationId;Marks a field as a URL-safe slug derived from another field's value. Combine with @Indexed(unique: true) to enforce uniqueness.
@SlugField(sourceField: 'businessName', separator: '-')
@Indexed(unique: true)
final String slug;
// 'Acme Corp' → 'acme-corp'Opts this class out of automatic code generation entirely. All output files must be written by hand. Use when the generated output is structurally incompatible with your requirements.
@ManualImplementation()
@Schemix()
class ComplexCustomModel { ... }Any package can publish a Schemix generator. Add schemix as a dependency, implement the SchemixGenerator interface, and declare a build.yaml builder.
import 'package:schemix/schemix.dart';
class MyCustomGenerator implements SchemixGenerator {
@override
String get id => 'my_custom_generator';
@override
List<String> get outputExtensions => ['.my.output'];
@override
bool shouldRun(ClassInfo classInfo) {
// Return false cheaply if this class is not relevant.
return classInfo.hasSchemix && !classInfo.abstractSchema;
}
@override
GeneratorOutput generate(ClassInfo classInfo, GeneratorContext context) {
final buf = StringBuffer();
// Use context.typeGraph to resolve cross-file types.
for (final field in classInfo.allFields) {
if (field.isIgnored) continue;
// Store generator-specific metadata in extensions rather than
// adding fields to FieldInfo.
final meta = field.extensions[id];
buf.writeln('// field: ${field.name} type: ${field.dartType}');
}
return GeneratorOutput({'.my.output': buf.toString()});
}
}// In your builder factory:
Builder myCustomBuilder(BuilderOptions options) {
GeneratorRegistry.register(MyCustomGenerator());
return MyCustomFileBuilder(options);
}builders:
my_custom_generator:
import: "package:my_custom_package/builder.dart"
builder_factories: ["myCustomBuilder"]
build_extensions:
"^lib/{{}}.dart":
- "gen/{{}}.my.output"
auto_apply: dependents
build_to: source
required_inputs:
- "lib/schemix_registry.json"required_inputs: [lib/schemix_registry.json] is mandatory — it ensures the scan phase completes before your generator runs and that build_runner correctly invalidates your outputs when the type graph changes.
TypeGraph — read-only view of the cross-file type graph. Available via context.typeGraph.
context.typeGraph.isEnum('MyEnum'); // true/false
context.typeGraph.isModel('User'); // true/false
context.typeGraph.resolve('User'); // TypeInfo?
context.typeGraph.cyclicTypes; // Set<String> — types in reference cycles
context.typeGraph.relativeImportFor(
typeName: 'User',
fromSourceAssetPath: context.sourceAssetPath,
); // relative import path for cross-file references, or null if same fileClassInfo — fully analyzed schema class.
classInfo.name // 'User'
classInfo.tableName // 'users' (explicit) or null (use snake_case of name)
classInfo.allFields // inherited + own fields
classInfo.ownFields // fields declared on this class only
classInfo.generators.zod // true/false
classInfo.generators.drift // true/false
classInfo.compositeIndexes // List<CompositeIndexInfo>
classInfo.enableTimestamps // bool
classInfo.abstractSchema // boolFieldInfo — analyzed field with all annotation data.
field.name // 'emailAddress'
field.dartType // 'String'
field.isNullable // true/false
field.isList // true/false
field.listItemType // 'Tag' (for List<Tag>)
field.isEnum // true/false
field.isIgnored // true — skip this field entirely
field.effectiveJsonName // 'email_address' (respects @JsonField / snake_case)
field.db.isPrimaryKey // true/false
field.db.isIndexed // true/false
field.db.sqlType // 'JSONB' (from @SqlType) or null
field.relation.kind // RelationKind.belongsTo / hasMany / etc.
field.relation.targetTypeName // 'User'
field.validation.isEmail // true/false
field.validation.minLength // 3 (from @Length(min: 3))
field.security.encrypted // true/false
field.security.sensitive // true/false
field.sync.offlineOnly // true/false
field.sync.cloudOnly // true/false
field.platform.zodIgnore // true/false
field.platform.driftIgnore // true/false
field.converter.zodTypeOverride // 'z.string().brand(...)' or null
field.isCreatedAt // true/false
field.isUpdatedAt // true/false
field.extensions[myGeneratorId] // generator-specific metadataPhase 1 — SchemixScanBuilder
Input: all lib/**.dart
Output: lib/schemix_registry.json (build_to: cache)
Purpose: builds the cross-file type graph once per package
Phase 2 — SchemixFileBuilder (+ any custom generators)
Input: one lib/{name}.dart + lib/schemix_registry.json
Output: lib/{name}.schemix.dart
lib/{name}.table.dart
gen/{name}.g.ts
gen/{name}.drizzle.ts
Purpose: full per-file code generation using the type graph
Phase 3 — SchemixIndexBuilder
Input: all gen/**.g.ts
Output: gen/schemix.g.ts (barrel re-export)
Purpose: single import point for all Zod schemas
Custom generators run in Phase 2. They must list lib/schemix_registry.json in required_inputs so build_runner tracks the dependency correctly and invalidates outputs when the type graph changes.
| Package | Role | Add as |
|---|---|---|
schemix |
Annotations, ClassInfo, FieldInfo, SchemixGenerator interface |
dependency |
schemix_builder |
Build infrastructure, scan/file/index builders | dev_dependency |
schemix_zod_generator |
Zod + TypeScript output | dev_dependency |
schemix_drift_generator |
Drift table classes | dev_dependency |
schemix_drizzle_generator |
Drizzle ORM schemas | dev_dependency |
schemix_serializable_generator |
Dart JSON serialization | dev_dependency |
schemix_firebase |
Firebase type descriptors (Timestamp, GeoPoint, etc.) | dependency (optional) |
schemix_generator_sdk |
Test utilities for generator authors | dev_dependency (optional) |
No generator package depends on another generator package. No generator package depends on schemix_builder. These two rules must never be broken.# Schemix
Define a Dart model once. Generate Zod schemas, TypeScript interfaces, Drift tables, Drizzle schemas, and JSON serialization from a single annotated class — with zero runtime footprint.
- How It Works
- Quick Start
- Annotation Reference
- 1. Schema-Level
- 2. Field Metadata
- 3. Primary Key & Database
- 4. Relations
- 5. Validation
- 6. Serialization
- 7. Type Overrides
- 8. Platform-Specific Exclusions
- 9. Security
- 10. UI Metadata
- 11. Lifecycle
- 12. Sync & Offline
- 13. API
- 14. Audit & Tracking
- 15. Migration
- 16. Feature & Release Control
- 17. Slug
- 18. Generator Control
- Writing a Custom Generator
- Build Pipeline
- Package Map
Schemix is a build_runner plugin. It runs in three phases:
- Scan — reads every
lib/**.dartfile and builds a type graph (schemix_registry.json). - Generate — reads the registry + one source file per invocation, analyzes annotations, and calls each active generator.
- Index — emits a barrel
gen/schemix.g.tsthat re-exports every generated Zod schema.
There is no runtime dependency. Add schemix to your regular dependencies (for the annotations) and schemix_builder to dev_dependencies (for the build tooling).
pubspec.yaml
dependencies:
schemix: any
dev_dependencies:
schemix_builder: any
build_runner: ^2.4.0Define a model
import 'package:schemix/schemix.dart';
@Schemix(
tableName: 'users',
schemaVersion: 1,
enableTimestamps: true,
)
class User {
@PrimaryKey(autoGenerate: true)
final String id;
@Email()
@Length(max: 255)
final String email;
@Hashed()
final String passwordHash;
@Indexed()
final String tenantId;
const User({
required this.id,
required this.email,
required this.passwordHash,
required this.tenantId,
});
}Run the build
dart run build_runner buildOutputs
lib/user.schemix.dart ← Dart JSON serialization
lib/user.table.dart ← Drift table class
gen/user.g.ts ← Zod schema + TypeScript interface
gen/user.drizzle.ts ← Drizzle ORM table schema
gen/schemix.g.ts ← Barrel re-export
The root annotation. Every class that should produce generated output must carry this. Controls the table name, schema version, timestamp injection, soft-delete, and which generators are active.
@Schemix(
tableName: 'business_entities',
schemaVersion: 2,
namespace: 'billing',
enableTimestamps: true,
enableSoftDelete: true,
)
class Business { ... }| Parameter | Type | Default | Description |
|---|---|---|---|
tableName |
String? |
snake_case of class name | SQL / Drift / Drizzle table name |
collectionName |
String? |
— | Firestore collection name |
schemaVersion |
int |
1 |
Monotonically increasing version for migration tracking |
namespace |
String? |
— | Logical domain grouping, e.g. 'auth', 'billing' |
enableTimestamps |
bool |
true |
Auto-injects createdAt / updatedAt if not declared |
enableSoftDelete |
bool |
true |
Auto-injects deletedAt nullable column |
abstractSchema |
bool |
false |
No table generated; class is only extended by others |
cacheable |
bool |
true |
Model may be cached at runtime |
syncable |
bool |
true |
Model participates in the sync engine |
embeddable |
bool |
false |
Fields are inlined into a parent table; no own table |
generateZod |
bool |
true |
Emit gen/{path}.g.ts |
generateDrift |
bool |
true |
Emit lib/{path}.table.dart |
generateDrizzle |
bool |
true |
Emit gen/{path}.drizzle.ts |
Groups a schema into a named domain. Informational — used by tooling for documentation and organization reports.
@SchemaGroup('billing')
@Schemix()
class Invoice { ... }Attaches a human-readable description to a schema. The text propagates into generated code comments and API documentation.
@SchemaDescription('Represents a registered business entity with billing info.')
@Schemix()
class Business { ... }Marks an entire schema class as deprecated. Generators emit a deprecation comment in all outputs. Provide replacement so consumers know where to migrate.
@DeprecatedSchema(
reason: 'Replaced by BusinessV2 with normalized address fields.',
replacement: 'BusinessV2',
removalVersion: '4.0.0',
)
class Business { ... }Rich per-field metadata controlling visibility, searchability, and display hints. @AppField is a typedef alias — use whichever reads better in context.
@SchemixField(
searchable: true,
sortable: true,
filterable: true,
displayName: 'Business Name',
description: 'Legal registered name of the business.',
example: 'Acme Corp',
)
final String businessName;| Parameter | Default | Effect |
|---|---|---|
immutable |
false |
Field cannot change after creation |
readonly |
false |
Exposed for reads; clients cannot set it |
hidden |
false |
Hidden from UI but present in all outputs |
internal |
false |
Excluded from all external (public) outputs |
searchable |
false |
Included in full-text search indexes |
sortable |
false |
May be used as a sort key in queries |
filterable |
false |
May be used as a query filter |
computed |
false |
Derived value; never written to the database |
transient |
false |
In-memory only; never persisted |
virtual |
false |
Exists in the type system only; no DB column |
generated |
false |
Value is produced by the database engine |
unique |
false |
Value must be unique across all rows |
sensitive |
false |
Contains PII or confidential data |
Marks the primary key field. For String PKs, autoGenerate: true emits a UUID v4 client-side default. For int PKs, it maps to SERIAL / AUTOINCREMENT.
@PrimaryKey(autoGenerate: true)
final String id;
// Composite PK — declare compositeOrder on each participating field
@PrimaryKey(compositeOrder: 1)
final String tenantId;
@PrimaryKey(compositeOrder: 2)
final String userId;Creates a database index on this field. Supports unique, descending, full-text, and spatial variants.
@Indexed(unique: true)
final String email;
@Indexed(fullText: true)
final String description;Class-level annotation. Creates a multi-column index. Apply multiple times for multiple composite indexes.
@CompositeIndex(fields: ['email', 'tenantId'], unique: true)
@CompositeIndex(fields: ['createdAt', 'status'])
@Schemix()
class User { ... }Shorthand for @Indexed(unique: true). Use when you only need the uniqueness constraint with no other index options.
@Unique()
final String slug;Marks an int PK as auto-increment (SERIAL in PostgreSQL, AUTOINCREMENT in SQLite). Use alongside @PrimaryKey.
@PrimaryKey(autoGenerate: false)
@AutoIncrement()
final int id;Indicates the column value is entirely produced by the database engine (sequences, expressions, DEFAULT gen_random_uuid()).
@DatabaseGenerated(strategy: 'uuid')
final String id;Overrides the raw SQL column type emitted for this field. Use when the default mapping is not precise enough.
@SqlType('JSONB')
final Map<String, dynamic> metadata;
@SqlType('TIMESTAMPTZ')
final DateTime scheduledAt;Overrides the Drizzle ORM column builder function for this field.
@DrizzleType('jsonb')
final Map<String, dynamic> settings;Overrides the Drift column builder type for this field.
@DriftType('text')
final MyCustomEnum status;Sets a database-level default value. Accepts Dart primitives and enum constants.
@DatabaseDefault(UserStatus.active)
final UserStatus status;
@DatabaseDefault(0)
final int loginCount;Adds a SQL CHECK constraint to the column. The expression is written in SQL and must evaluate to true for every row.
@CheckConstraint('price >= 0')
final double price;
@CheckConstraint("status IN ('active', 'inactive', 'pending')")
final String status;Marks a field as an alternate lookup key (not the primary key). Informational for generators that produce repository or query helpers.
@SecondaryKey()
final String externalReferenceId;Marks the partition key for distributed or sharded databases (e.g. DynamoDB, Cassandra).
@PartitionKey()
final String tenantId;Marks a sort key for distributed or time-series databases. Used alongside @PartitionKey.
@SortKey(descending: true)
final DateTime createdAt;Marks a field for inclusion in a full-text search index. Equivalent to @Indexed(fullText: true) but reads more clearly on string fields.
@FullTextSearch()
final String bio;Marks a field whose value may be cached independently at the application layer.
@CachedField()
final String avatarUrl;Many-to-one. The annotated field stores the foreign-key ID. Generators emit an FK column.
@BelongsTo(User)
final String userId;
// With explicit FK name
@BelongsTo(Organization, foreignKey: 'org_id')
final String organizationId;One-to-one. No column is emitted on this side; the FK lives on the target model. The field type is typically the target class or its ID type.
@HasOne(Profile)
final Profile? profile;One-to-many. No column is emitted. Generators produce a virtual relation reference that is resolved via a FK on the target model.
@HasMany(Invoice)
final List<Invoice> invoices;Many-to-many via a junction table. No column is emitted. Provide junctionTable explicitly to control the junction table name.
@ManyToMany(Tag, junctionTable: 'product_tags')
final List<Tag> tags;Inlines the target class's fields into this model's table. No join is needed. The target class must be annotated with @Schemix(embeddable: true).
@Embedded()
final Address? address;Marks a field as the raw FK column that backs a named relation field on the same class. Connects the FK integer/string to its relation counterpart.
@BelongsTo(User)
final User? user;
@RelationField(fieldName: 'user')
final String userId;Marks a relation so that deleting the owner row also deletes all related rows (ON DELETE CASCADE).
@CascadeDelete()
@HasMany(OrderItem)
final List<OrderItem> items;Marks a relation as lazy-loaded. Generators that produce query helpers will not eagerly fetch this relation with the parent.
@LazyRelation()
@HasMany(AuditLog)
final List<AuditLog> auditLogs;All validation annotations emit corresponding Zod schema constraints in the generated TypeScript output.
Field must be present and non-empty. Emits .min(1) for strings, .nonEmpty() for arrays.
@Required()
final String businessName;Numeric range constraints (inclusive). Emit .gte(value) and .lte(value) in Zod.
@Min(0)
@Max(1000000)
final double price;String length constraint. Emit .min(n).max(n) in Zod.
@Length(min: 3, max: 150)
final String username;Validates the field value against a regular expression. Emits .regex(/pattern/) in Zod.
@Regex(r'^[A-Z]{2}\d{6}$')
final String passportNumber;Format validators. Each emits the corresponding Zod format method (.email(), .url(), .ip(), .uuid()).
@Email()
final String email;
@Url()
final String? websiteUrl;
@Uuid()
final String externalId;Specifies a fallback enum value when deserialization receives an unknown variant. Emits .catch(value) in Zod, preventing parse failures on unknown server values.
@EnumFallback(BusinessType.other)
final BusinessType type;Restricts or rejects a fixed set of values. Emit .refine(...) predicates in Zod.
@AllowedValues(['retail', 'wholesale', 'online'])
final String channel;
@DisallowValues(['admin', 'root', 'superuser'])
final String username;Sets a custom JSON key name for this field. Takes priority over @JsonKey(name:) and the default snake_case fallback.
@JsonField('business_name')
final String businessName;Excludes this field from all serialization and code generation outputs entirely. Cannot be combined with @PrimaryKey, validation, or relation annotations.
@IgnoreField()
final String _internalCache;@ReadOnlyField — serialized in responses, never accepted from clients.
@WriteOnlyField — accepted on writes, never included in responses.
@WriteOnlyField()
final String passwordHash;
@ReadOnlyField()
final String generatedSlug;Flattens a nested object's fields into the parent JSON object, removing the nesting level.
@Flatten()
final Address address;
// Serializes as { street: '...', city: '...' } instead of { address: { street: '...' } }Specifies a custom date format string for DateTime serialization.
@DateFormat('yyyy-MM-dd')
final DateTime birthDate;Specifies decimal precision and scale for numeric fields. Maps to DECIMAL(precision, scale) in SQL.
@Precision(precision: 18, scale: 2)
final double amount;Instructs serialization to write the value as a different type.
@SerializeAs('string')
final int legacyId;Overrides the TypeScript type and optionally the Zod schema for this field. Use for branded types, union types, or any type the default resolver cannot infer.
@TsType('string | number')
final dynamic id;
@TsType("UserId", zodSchema: "z.string().brand('UserId')")
final String userId;Overrides the full Zod schema expression for this field. Use when @TsType(zodSchema:) is too verbose.
@ZodType("z.string().brand('TenantId')")
final String tenantId;Unified type override spanning all generation targets. Use when a field needs custom handling in Dart, TypeScript, SQL, and Drizzle simultaneously.
@CustomConverter(
dartConverter: 'MetadataConverter',
tsConverter: "z.record(z.string(), z.unknown())",
sqlType: 'JSONB',
drizzleType: 'jsonb',
)
final Map<String, dynamic>? metadata;Use these to exclude a field from a single generator target while keeping it in all others.
Excludes this field from the Drift table generator only.
@DriftIgnore()
final String computedDisplayName;Excludes this field from the Drizzle schema generator only.
@DrizzleIgnore()
final String localOnlyFlag;Excludes this field from the Zod schema and TypeScript interface only.
@ZodIgnore()
final String internalServerId;Marks a field as encrypted at rest. Excluded from API response DTOs unless @ApiField(expose: true) is set.
@Encrypted()
final String taxIdentificationNumber;Marks a field as hashed (passwords, API keys). Excluded from API response DTOs. Never included in read outputs by default.
@Hashed()
final String passwordHash;Marks a field as sensitive PII. Excluded from API response DTOs. Generators emit appropriate warnings in comments.
@Sensitive()
final String socialSecurityNumber;Redacts this field's value in application and server logs.
@MaskInLogs()
final String creditCardNumber;Restricts read access to this field to callers who hold the given permission string.
@PermissionRequired('admin')
final double internalCostPrice;Restricts read or write access to callers who hold one of the listed OAuth / RBAC scope strings.
@ReadScope(['admin', 'owner'])
@WriteScope(['owner'])
final String privateNotes;Display metadata for generated form and table UIs. Consumed by generators that produce UI scaffolding.
@UiField(
label: 'Business Name',
icon: 'business',
section: 'general',
order: 1,
helpText: 'Enter the legal registered name.',
tooltip: 'This must match your business registration certificate.',
)
final String businessName;Controls the input widget type and behaviour for generated forms.
@FormField(widgetType: 'textarea', multiline: true, autofocus: false)
final String description;Column display hints for generated data-table UIs.
@TableColumn(width: 200, align: 'left', sortable: true)
final String businessName;Mark string fields as holding a colour hex value, an image URL, or a file URL. Form generators render the appropriate picker widget.
@ColorField()
final String brandColor;
@ImageField()
final String? logoUrl;
@FileField()
final String? attachmentUrl;Mark fields that should appear in query filter UIs or search bars.
@FilterField()
@SearchField()
final String status;Marks a DateTime field as the record creation timestamp. Auto-excluded from create/update API DTOs.
@CreatedAt()
final DateTime createdAt;Marks a DateTime field as the last-updated timestamp. Auto-excluded from create/update DTOs.
@UpdatedAt()
final DateTime updatedAt;Marks a nullable DateTime field as the soft-delete timestamp. Non-null means the record is deleted. Used with @Schemix(enableSoftDelete: true).
@DeletedAt()
final DateTime? deletedAt;Marks an int field as an optimistic-concurrency version counter. Incremented on every update. Auto-excluded from create/update DTOs.
@VersionField()
final int version;Marks a field as an audit-trail marker. Generators that produce change-history tables include this field in the audit schema.
@AuditField()
final String lastModifiedBy;Class-level. Specifies how write conflicts should be resolved during sync. Strategies: 'latestWins', 'firstWins', 'merge'.
@ConflictResolver(strategy: 'latestWins')
@Schemix(syncable: true)
class Document { ... }Field exists only in the local database — never synced to the server. Excluded from Zod / TypeScript outputs.
@OfflineOnly()
final bool isDirty;Field exists only in the remote/cloud database — not stored locally. Excluded from Drift / Drizzle table generation.
@CloudOnly()
final String cloudProcessingJobId;Sets the sync priority for this field during conflict resolution. Higher values are synced first.
@SyncPriority(priority: 10)
final String criticalFlag;Every mutation to this field is recorded as an operation log entry, enabling CRDT-style merge strategies.
@OperationTracked()
final int counter;Controls how this field appears in generated API DTO interfaces. Set expose: false to remove it from all DTOs entirely.
@ApiField(expose: true, readonly: true, deprecated: false)
final String publicId;
@ApiField(expose: false)
final String internalAuditKey;Tracks the API version lifecycle of a field. Generators emit appropriate deprecation warnings and changelog comments.
@ApiVersion(introducedIn: '1.2.0', deprecatedIn: '2.0.0', removedIn: '3.0.0')
final String legacyCode;Field is included in DTO interfaces only — not persisted to the database. Useful for computed or joined values returned by APIs.
@DtoOnly()
final String displayLabel;Field is for internal server use only. Excluded from all public API DTOs regardless of other settings.
@InternalApi()
final String serviceRoutingKey;Records every mutation to this field in the audit log table.
@TrackChanges()
final String status;Emits an analytics event whenever this field changes. eventName defaults to a generated name based on class and field.
@TrackAnalytics(eventName: 'subscription_plan_changed')
final String subscriptionPlan;Logs every write to this field using the application's structured logging system.
@LogChanges()
final String adminOverrideReason;Records that this field was previously named oldName. Generators use this to produce ALTER TABLE RENAME COLUMN migration scripts.
@RenamedFrom('company_name')
final String businessName;Marks a field as scheduled for removal in the given semver version. Generators emit a deprecation warning in all outputs.
@RemovedIn('3.0.0')
final String legacyExternalId;Attaches a free-form migration note to a field. Appears in generated migration scripts as a comment.
@MigrationNote('Backfill from the legacy profile table using the data pipeline in scripts/migrate_profile.ts')
final String normalizedAddress;Marks a field as a legacy carry-over that exists only for backwards compatibility. Excluded from new code generation paths.
@LegacyField()
final String oldFormatPhone;Gates this field behind a named feature flag. Generators emit conditional logic or comments referencing the flag identifier.
@FeatureFlag('new_billing_flow')
final String stripePaymentMethodId;Marks a field as experimental. Generated code includes a warning comment so consumers know the API may change.
@Experimental()
final Map<String, dynamic> aiGeneratedTags;Marks a field as available to enterprise-tier users only. Generators can emit tier-gating comments or exclude the field from public SDK output.
@EnterpriseOnly()
final String ssoConfigurationId;Marks a field as a URL-safe slug derived from another field's value. Combine with @Indexed(unique: true) to enforce uniqueness.
@SlugField(sourceField: 'businessName', separator: '-')
@Indexed(unique: true)
final String slug;
// 'Acme Corp' → 'acme-corp'Opts this class out of automatic code generation entirely. All output files must be written by hand. Use when the generated output is structurally incompatible with your requirements.
@ManualImplementation()
@Schemix()
class ComplexCustomModel { ... }Any package can publish a Schemix generator. Add schemix as a dependency, implement the SchemixGenerator interface, and declare a build.yaml builder.
import 'package:schemix/schemix.dart';
class MyCustomGenerator implements SchemixGenerator {
@override
String get id => 'my_custom_generator';
@override
List<String> get outputExtensions => ['.my.output'];
@override
bool shouldRun(ClassInfo classInfo) {
// Return false cheaply if this class is not relevant.
return classInfo.hasSchemix && !classInfo.abstractSchema;
}
@override
GeneratorOutput generate(ClassInfo classInfo, GeneratorContext context) {
final buf = StringBuffer();
// Use context.typeGraph to resolve cross-file types.
for (final field in classInfo.allFields) {
if (field.isIgnored) continue;
// Store generator-specific metadata in extensions rather than
// adding fields to FieldInfo.
final meta = field.extensions[id];
buf.writeln('// field: ${field.name} type: ${field.dartType}');
}
return GeneratorOutput({'.my.output': buf.toString()});
}
}// In your builder factory:
Builder myCustomBuilder(BuilderOptions options) {
GeneratorRegistry.register(MyCustomGenerator());
return MyCustomFileBuilder(options);
}builders:
my_custom_generator:
import: "package:my_custom_package/builder.dart"
builder_factories: ["myCustomBuilder"]
build_extensions:
"^lib/{{}}.dart":
- "gen/{{}}.my.output"
auto_apply: dependents
build_to: source
required_inputs:
- "lib/schemix_registry.json"required_inputs: [lib/schemix_registry.json] is mandatory — it ensures the scan phase completes before your generator runs and that build_runner correctly invalidates your outputs when the type graph changes.
TypeGraph — read-only view of the cross-file type graph. Available via context.typeGraph.
context.typeGraph.isEnum('MyEnum'); // true/false
context.typeGraph.isModel('User'); // true/false
context.typeGraph.resolve('User'); // TypeInfo?
context.typeGraph.cyclicTypes; // Set<String> — types in reference cycles
context.typeGraph.relativeImportFor(
typeName: 'User',
fromSourceAssetPath: context.sourceAssetPath,
); // relative import path for cross-file references, or null if same fileClassInfo — fully analyzed schema class.
classInfo.name // 'User'
classInfo.tableName // 'users' (explicit) or null (use snake_case of name)
classInfo.allFields // inherited + own fields
classInfo.ownFields // fields declared on this class only
classInfo.generators.zod // true/false
classInfo.generators.drift // true/false
classInfo.compositeIndexes // List<CompositeIndexInfo>
classInfo.enableTimestamps // bool
classInfo.abstractSchema // boolFieldInfo — analyzed field with all annotation data.
field.name // 'emailAddress'
field.dartType // 'String'
field.isNullable // true/false
field.isList // true/false
field.listItemType // 'Tag' (for List<Tag>)
field.isEnum // true/false
field.isIgnored // true — skip this field entirely
field.effectiveJsonName // 'email_address' (respects @JsonField / snake_case)
field.db.isPrimaryKey // true/false
field.db.isIndexed // true/false
field.db.sqlType // 'JSONB' (from @SqlType) or null
field.relation.kind // RelationKind.belongsTo / hasMany / etc.
field.relation.targetTypeName // 'User'
field.validation.isEmail // true/false
field.validation.minLength // 3 (from @Length(min: 3))
field.security.encrypted // true/false
field.security.sensitive // true/false
field.sync.offlineOnly // true/false
field.sync.cloudOnly // true/false
field.platform.zodIgnore // true/false
field.platform.driftIgnore // true/false
field.converter.zodTypeOverride // 'z.string().brand(...)' or null
field.isCreatedAt // true/false
field.isUpdatedAt // true/false
field.extensions[myGeneratorId] // generator-specific metadataPhase 1 — SchemixScanBuilder
Input: all lib/**.dart
Output: lib/schemix_registry.json (build_to: cache)
Purpose: builds the cross-file type graph once per package
Phase 2 — SchemixFileBuilder (+ any custom generators)
Input: one lib/{name}.dart + lib/schemix_registry.json
Output: lib/{name}.schemix.dart
lib/{name}.table.dart
gen/{name}.g.ts
gen/{name}.drizzle.ts
Purpose: full per-file code generation using the type graph
Phase 3 — SchemixIndexBuilder
Input: all gen/**.g.ts
Output: gen/schemix.g.ts (barrel re-export)
Purpose: single import point for all Zod schemas
Custom generators run in Phase 2. They must list lib/schemix_registry.json in required_inputs so build_runner tracks the dependency correctly and invalidates outputs when the type graph changes.
| Package | Role | Add as |
|---|---|---|
schemix |
Annotations, ClassInfo, FieldInfo, SchemixGenerator interface |
dependency |
schemix_builder |
Build infrastructure, scan/file/index builders | dev_dependency |
schemix_zod_generator |
Zod + TypeScript output | dev_dependency |
schemix_drift_generator |
Drift table classes | dev_dependency |
schemix_drizzle_generator |
Drizzle ORM schemas | dev_dependency |
schemix_serializable_generator |
Dart JSON serialization | dev_dependency |
schemix_firebase |
Firebase type descriptors (Timestamp, GeoPoint, etc.) | dependency (optional) |
schemix_generator_sdk |
Test utilities for generator authors | dev_dependency (optional) |
No generator package depends on another generator package. No generator package depends on schemix_builder. These two rules must never be broken.