zod_rails 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/ZOD_RAILS.md ADDED
@@ -0,0 +1,1178 @@
1
+ # ZodRails: Complete Architectural Plan
2
+
3
+ > **Status**: Planning Phase
4
+ > **Last Updated**: 2025-01-13
5
+ > **Development Approach**: Prose-Driven TDD with RSpec
6
+
7
+ ---
8
+
9
+ ## Table of Contents
10
+
11
+ 1. [Problem Statement](#1-problem-statement)
12
+ 2. [Summary of Decisions](#2-summary-of-decisions)
13
+ 3. [Gem File Structure](#3-gem-file-structure)
14
+ 4. [Type Mapping Tables](#4-type-mapping-tables)
15
+ 5. [Validation Mapping Tables](#5-validation-mapping-tables)
16
+ 6. [Prose-Driven RSpec Specifications](#6-prose-driven-rspec-specifications)
17
+ 7. [Example Generated Output](#7-example-generated-output)
18
+ 8. [Configuration DSL](#8-configuration-dsl)
19
+ 9. [Implementation Order](#9-implementation-order)
20
+ 10. [Open Questions](#10-open-questions)
21
+ 11. [Research Findings](#11-research-findings)
22
+
23
+ ---
24
+
25
+ ## 1. Problem Statement
26
+
27
+ ### The "Type Gap"
28
+
29
+ The core issue is that **ActiveRecord (Ruby)** and **Zod (TypeScript)** speak two different languages regarding data structure.
30
+
31
+ - **Implicit vs. Explicit**: Rails models are "magic." Attributes are discovered at runtime by querying the database schema. Zod requires explicit, static definitions.
32
+
33
+ - **Type Mapping**: A Rails `:datetime` needs to become a `z.coerce.date()`. A `:string` with a `validates :presence` needs to be `z.string().min(1)`, while an optional one needs `.nullable()`.
34
+
35
+ - **The Sync Problem**: Every time you run a migration in Rails, your Zod schemas are instantly out of date. Without a gem, you are manually typing the same structure in two places, which is the primary source of "undefined is not a function" errors in production.
36
+
37
+ ### Why This Matters
38
+
39
+ Building this gem solves a major pain point in the Rails ecosystem. Currently, developers have to choose between:
40
+
41
+ - **Manual duplication** (Error prone)
42
+ - **JSON Schema intermediate steps** (Overly complex)
43
+ - **TypeScript Interfaces** (No runtime protection)
44
+
45
+ By creating a gem that outputs **Zod Schemas** specifically, you provide the frontend with a "contract" that actually has teeth—it won't just tell you the data is a string; it will crash safely or handle the error if the backend sends a null.
46
+
47
+ ### Market Gap
48
+
49
+ **No gem exists that directly generates Zod schemas from Rails/ActiveRecord.** Research confirmed:
50
+
51
+ - `zod_rails` ❌ unclaimed
52
+ - `rails_zod` ❌ unclaimed
53
+ - `active_record_zod` ❌ unclaimed
54
+
55
+ The closest solutions are either archived, don't read from ActiveRecord, or require manual DSL definitions.
56
+
57
+ ---
58
+
59
+ ## 2. Summary of Decisions
60
+
61
+ | Decision | Choice | Rationale |
62
+ |----------|--------|-----------|
63
+ | **Gem Name** | `zod_rails` | Clear, follows Rails gem conventions |
64
+ | **Output Strategy** | File generation + optional file watcher | Deterministic, version-controllable |
65
+ | **File Watcher** | `listen` (optional dependency) | Already in most Rails apps, actively maintained |
66
+ | **Validation Mapping** | Tier 1 + 2 for v1 | Covers 90% of use cases |
67
+ | **Association Strategy** | Columns-only by default | `belongs_to` FKs only; `has_one`/`has_many` require opt-in |
68
+ | **Schema Types** | Generate both Response + Input schemas | `ModelSchema` (full) + `ModelInputSchema` (forms) |
69
+ | **Property Naming** | `snake_case` by default | Matches Rails API responses; `camelCase` opt-in |
70
+ | **Serializer Awareness** | v2 roadmap item | Keep v1 scope focused |
71
+ | **Enum Handling** | String keys (modern Rails convention) | Matches typical API responses |
72
+ | **Development Approach** | Prose-driven TDD with RSpec | Specs as executable documentation |
73
+
74
+ ---
75
+
76
+ ## 3. Gem File Structure
77
+
78
+ ```
79
+ zod_rails/
80
+ ├── .rspec # RSpec configuration
81
+ ├── .rubocop.yml # Code style (optional)
82
+ ├── Gemfile # Gem dependencies
83
+ ├── LICENSE.txt # MIT License
84
+ ├── README.md # Documentation
85
+ ├── ROADMAP.md # Completed + Future features
86
+ ├── ARCHITECTURE.md # This document
87
+ ├── Rakefile # Gem build tasks
88
+ ├── zod_rails.gemspec # Gem specification
89
+
90
+ ├── lib/
91
+ │ ├── zod_rails.rb # Main entry point
92
+ │ ├── zod_rails/
93
+ │ │ ├── version.rb # Version constant
94
+ │ │ ├── configuration.rb # Configuration DSL
95
+ │ │ ├── railtie.rb # Rails integration (rake tasks)
96
+ │ │ │
97
+ │ │ ├── introspection/ # Reading from ActiveRecord
98
+ │ │ │ ├── model_inspector.rb # Reads columns, validators, enums
99
+ │ │ │ ├── column_info.rb # Value object for column metadata
100
+ │ │ │ └── validation_info.rb # Value object for validation metadata
101
+ │ │ │
102
+ │ │ ├── mapping/ # Type translation logic
103
+ │ │ │ ├── type_mapper.rb # Rails type → Zod type
104
+ │ │ │ ├── validation_mapper.rb # Rails validation → Zod chain
105
+ │ │ │ └── enum_mapper.rb # Rails enum → z.enum()
106
+ │ │ │
107
+ │ │ ├── generation/ # Output generation
108
+ │ │ │ ├── schema_builder.rb # Builds Zod schema string per model
109
+ │ │ │ ├── file_writer.rb # Writes the .ts file
110
+ │ │ │ └── typescript_emitter.rb # Formats TypeScript output
111
+ │ │ │
112
+ │ │ └── associations/ # Association handling
113
+ │ │ ├── association_resolver.rb # Determines how to represent assocs
114
+ │ │ └── nesting_strategy.rb # ID-only vs nested objects
115
+ │ │
116
+ │ └── tasks/
117
+ │ └── zod_rails.rake # Rake tasks (generate, watch)
118
+
119
+ └── spec/
120
+ ├── spec_helper.rb # RSpec configuration
121
+ ├── support/
122
+ │ ├── rails_app/ # Dummy Rails app for integration tests
123
+ │ └── model_helpers.rb # Test model factories
124
+
125
+ ├── zod_rails/
126
+ │ ├── configuration_spec.rb
127
+ │ ├── introspection/
128
+ │ │ └── model_inspector_spec.rb
129
+ │ ├── mapping/
130
+ │ │ ├── type_mapper_spec.rb
131
+ │ │ ├── validation_mapper_spec.rb
132
+ │ │ └── enum_mapper_spec.rb
133
+ │ ├── generation/
134
+ │ │ ├── schema_builder_spec.rb
135
+ │ │ └── file_writer_spec.rb
136
+ │ └── associations/
137
+ │ └── association_resolver_spec.rb
138
+
139
+ └── integration/
140
+ ├── full_generation_spec.rb # End-to-end tests
141
+ └── rails_integration_spec.rb # Rake task tests
142
+ ```
143
+
144
+ ---
145
+
146
+ ## 4. Type Mapping Tables
147
+
148
+ > **Zod Version**: This gem targets **Zod 4** (stable as of 2025). Zod 4 introduces top-level string formats and number formats that we prefer over the deprecated method equivalents.
149
+
150
+ ### Column Types → Zod Types
151
+
152
+ | Rails Type | Zod Type | Notes |
153
+ |------------|----------|-------|
154
+ | `:string` | `z.string()` | |
155
+ | `:text` | `z.string()` | |
156
+ | `:integer` | `z.int()` | Zod 4 top-level, replaces `z.number().int()` |
157
+ | `:bigint` | `z.string()` | Avoids JS safe integer overflow; use `z.int()` only if values guaranteed safe |
158
+ | `:float` | `z.number()` | |
159
+ | `:decimal` | `z.string()` | Preserves precision; `z.number()` opt-in via config |
160
+ | `:boolean` | `z.boolean()` | |
161
+ | `:date` | `z.iso.date()` | ISO date string by default; `z.coerce.date()` opt-in |
162
+ | `:datetime` | `z.iso.datetime()` | ISO datetime string; `z.coerce.date()` opt-in for Date objects |
163
+ | `:time` | `z.string()` | ISO time string |
164
+ | `:json` / `:jsonb` | `z.json()` | Zod 4 native; accepts any JSON-encodable value |
165
+ | `:uuid` | `z.uuid()` | Zod 4 top-level |
166
+ | `:binary` | `z.string()` | Base64 encoded |
167
+ | `:array` (PostgreSQL) | `z.array(innerType)` | Depends on inner type |
168
+
169
+ ### Zod 4 Number Formats (for strict typing)
170
+
171
+ | Use Case | Zod Type | Range |
172
+ |----------|----------|-------|
173
+ | General integer | `z.int()` | `[Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER]` |
174
+ | 32-bit signed | `z.int32()` | `[-2147483648, 2147483647]` |
175
+ | 32-bit unsigned | `z.uint32()` | `[0, 4294967295]` |
176
+ | 32-bit float | `z.float32()` | IEEE 754 single precision |
177
+ | 64-bit float | `z.float64()` | IEEE 754 double precision |
178
+
179
+ These can be used via configuration for stricter database-aware typing.
180
+
181
+ ### Nullability vs Optionality (CRITICAL)
182
+
183
+ Zod distinguishes three concepts that Rails conflates:
184
+
185
+ | Zod Method | Meaning | Use Case |
186
+ |------------|---------|----------|
187
+ | `.nullable()` | Accepts `null` value | Column allows NULL in DB |
188
+ | `.optional()` | Key can be missing (`undefined`) | PATCH payloads, sparse responses |
189
+ | `.nullish()` | Accepts `null` OR `undefined` | Flexible API contracts |
190
+
191
+ **Mapping Rules:**
192
+
193
+ | Rails Condition | Response Schema | Input Schema |
194
+ |-----------------|-----------------|--------------|
195
+ | `null: false` in DB | Required (no modifier) | Required |
196
+ | `null: true` in DB | `.nullable()` | `.nullish()` |
197
+ | `validates :presence` | Removes `.nullable()` | `.min(1)` for strings |
198
+ | `allow_nil: true` on validator | Preserves `.nullable()` | `.nullable()` |
199
+ | `allow_blank: true` on validator | Allows empty string | No `.min(1)` |
200
+ | DB default exists | Required in response | `.optional()` in input |
201
+
202
+ ### Nullability Rules (Legacy - Simplified)
203
+
204
+ | Condition | Zod Output |
205
+ |-----------|------------|
206
+ | `null: false` in schema | Base type (required) |
207
+ | `null: true` or unspecified | `.nullable()` |
208
+ | `validates :presence` | `.min(1)` for strings, removes nullable |
209
+
210
+ ---
211
+
212
+ ## 5. Validation Mapping Tables
213
+
214
+ ### Tier 1: Essential Validations (v1)
215
+
216
+ | Rails Validation | Zod Equivalent |
217
+ |------------------|----------------|
218
+ | `presence: true` | `.min(1)` (string), removes `.nullable()` |
219
+ | `length: { minimum: N }` | `.min(N)` |
220
+ | `length: { maximum: N }` | `.max(N)` |
221
+ | `length: { is: N }` | `.length(N)` |
222
+ | `length: { in: X..Y }` | `.min(X).max(Y)` |
223
+ | `inclusion: { in: [...] }` | `z.enum([...])` (replaces base type) |
224
+ | `numericality: true` | `z.number()` |
225
+ | `numericality: { only_integer: true }` | `.int()` |
226
+ | `numericality: { greater_than: N }` | `.gt(N)` |
227
+ | `numericality: { greater_than_or_equal_to: N }` | `.gte(N)` |
228
+ | `numericality: { less_than: N }` | `.lt(N)` |
229
+ | `numericality: { less_than_or_equal_to: N }` | `.lte(N)` |
230
+ | `numericality: { other_than: N }` | `.refine(v => v !== N)` |
231
+ | `numericality: { odd: true }` | `.refine(v => v % 2 === 1)` |
232
+ | `numericality: { even: true }` | `.refine(v => v % 2 === 0)` |
233
+
234
+ ### Tier 2: Valuable Validations (v1)
235
+
236
+ | Rails Validation | Zod Equivalent |
237
+ |------------------|----------------|
238
+ | `format: { with: /regex/ }` | `.regex(/regex/)` |
239
+ | `exclusion: { in: [...] }` | `.refine(v => ![...].includes(v))` |
240
+ | `acceptance: true` | `z.literal(true)` or `z.boolean()` |
241
+ | `allow_nil: true` (on any validator) | Preserves `.nullable()` on type |
242
+ | `allow_blank: true` (on string) | Skips `.min(1)` from presence |
243
+
244
+ ### Presence on Booleans (CAUTION)
245
+
246
+ Rails `presence: true` on booleans effectively means "must be truthy" because `false.blank? == true`. This is almost always a bug in Rails code.
247
+
248
+ **Our behavior**: Emit warning comment + map to `z.literal(true)`. Recommend user fix to `inclusion: { in: [true, false] }` if they want non-nil boolean.
249
+
250
+ ### Validation Deduplication
251
+
252
+ When multiple validations apply to the same constraint, use the **stricter** value:
253
+
254
+ | Combined Validations | Deduped Output |
255
+ |---------------------|----------------|
256
+ | `presence: true` + `length: { minimum: 2 }` | `.min(2)` (not `.min(1).min(2)`) |
257
+ | `numericality: { greater_than: 0 }` + `numericality: { greater_than: 5 }` | `.gt(5)` |
258
+
259
+ The `ValidationMapper` must consolidate constraints before emitting Zod chains.
260
+
261
+ ### Regex Conversion Strategy
262
+
263
+ Ruby regexes don't always translate cleanly to JavaScript. Use a **whitelist approach**:
264
+
265
+ | Rails Pattern | Detection | JavaScript Equivalent |
266
+ |--------------|-----------|----------------------|
267
+ | `URI::MailTo::EMAIL_REGEXP` | Check class/constant | `/^[^\s@]+@[^\s@]+\.[^\s@]+$/` |
268
+ | `URI.regexp` | Check class/constant | Known URL regex |
269
+ | Simple patterns | Direct conversion | Escape as needed |
270
+ | Complex/unknown | Emit warning comment | Skip or use `.refine()` |
271
+
272
+ For unrecognized regexes, emit a TODO comment and optionally use `.refine()` with a runtime check.
273
+
274
+ ### Zod 4 Built-in Email Regexes
275
+
276
+ Zod 4 provides several email regex options we can leverage:
277
+
278
+ | Rails Pattern | Zod 4 Equivalent |
279
+ |--------------|------------------|
280
+ | `URI::MailTo::EMAIL_REGEXP` | `z.email()` (default Gmail-style) |
281
+ | Loose validation | `z.email({ pattern: z.regexes.unicodeEmail })` |
282
+ | Browser-compatible | `z.email({ pattern: z.regexes.html5Email })` |
283
+ | RFC 5322 strict | `z.email({ pattern: z.regexes.rfc5322Email })` |
284
+
285
+ ### Skipped Validations (Server-Side Only)
286
+
287
+ | Rails Validation | Reason |
288
+ |------------------|--------|
289
+ | `uniqueness` | Requires database query |
290
+ | `confirmation` | UI pattern - handled separately in InputSchema |
291
+ | Custom validators with `if:`/`unless:` | Conditional logic |
292
+ | `inclusion: { in: -> { ... } }` | Dynamic proc/lambda - cannot introspect statically |
293
+
294
+ **Dynamic Inclusions**: When `in:` is a proc/lambda, skip with comment. Allow config override to provide static list.
295
+
296
+ ### Tier 3: Advanced Validations (v2 Roadmap)
297
+
298
+ | Rails Validation | Potential Zod Equivalent |
299
+ |------------------|--------------------------|
300
+ | Conditional validations (`if:`) | `.refine()` with runtime check |
301
+ | Custom validators | Extension point for user mapping |
302
+ | Associated validations | Nested schema validation |
303
+
304
+ ---
305
+
306
+ ## 6. Prose-Driven RSpec Specifications
307
+
308
+ > **Philosophy**: Each `it` statement without a block is a pending spec—our implementation checklist. Write the behavior we want in plain English first, then implement.
309
+
310
+ ### `spec/zod_rails/configuration_spec.rb`
311
+
312
+ ```ruby
313
+ RSpec.describe ZodRails::Configuration do
314
+ describe "output path" do
315
+ it "defaults to 'app/javascript/schemas/zod_schemas.ts'"
316
+ it "can be configured via initializer"
317
+ it "expands relative paths from Rails.root"
318
+ it "creates the output directory if it does not exist"
319
+ end
320
+
321
+ describe "model selection" do
322
+ it "includes all ApplicationRecord descendants by default"
323
+ it "filters out abstract models (abstract_class = true)"
324
+ it "filters out models without tables (table_exists? = false)"
325
+ it "allows explicit model list via 'only' option"
326
+ it "allows exclusion list via 'except' option"
327
+ it "supports glob patterns for model selection"
328
+ it "eager loads Rails app before introspection"
329
+ end
330
+
331
+ describe "association handling" do
332
+ it "defaults to :ids_only mode"
333
+ it "can be set to :nested mode globally"
334
+ it "can be overridden per-model"
335
+ it "supports :omit mode to exclude associations entirely"
336
+ end
337
+
338
+ describe "enum format" do
339
+ it "defaults to string keys"
340
+ it "can be configured to use integer values"
341
+ it "documents the enum format choice in generated comments"
342
+ end
343
+
344
+ describe "custom type mappings" do
345
+ it "allows overriding default type mappings"
346
+ it "allows registering custom column types"
347
+ it "applies custom mappings before defaults"
348
+ end
349
+ end
350
+ ```
351
+
352
+ ### `spec/zod_rails/introspection/model_inspector_spec.rb`
353
+
354
+ ```ruby
355
+ RSpec.describe ZodRails::Introspection::ModelInspector do
356
+ describe "column extraction" do
357
+ it "extracts all column names from an ActiveRecord model"
358
+ it "extracts column types (string, integer, datetime, etc.)"
359
+ it "detects nullability from column definition"
360
+ it "identifies primary key columns"
361
+ it "identifies foreign key columns"
362
+ it "handles PostgreSQL-specific types (uuid, jsonb, array)"
363
+ end
364
+
365
+ describe "validation extraction" do
366
+ it "extracts presence validations"
367
+ it "extracts length validations with all options (min, max, is, in)"
368
+ it "extracts inclusion validations with array values"
369
+ it "extracts numericality validations with all constraints"
370
+ it "extracts format validations with regex patterns"
371
+ it "extracts allow_nil option from validators"
372
+ it "extracts allow_blank option from validators"
373
+ it "ignores validations with conditional :if/:unless options"
374
+ it "ignores uniqueness validations (server-side only)"
375
+ it "skips inclusion with proc/lambda :in (dynamic)"
376
+ it "handles multiple validations on same attribute"
377
+ end
378
+
379
+ describe "enum extraction" do
380
+ it "extracts Rails enum definitions"
381
+ it "captures enum values as strings"
382
+ it "handles enums with custom database values"
383
+ it "handles enums with prefix/suffix options"
384
+ end
385
+
386
+ describe "association extraction" do
387
+ it "extracts belongs_to associations"
388
+ it "extracts has_many associations"
389
+ it "extracts has_one associations"
390
+ it "identifies optional vs required belongs_to"
391
+ it "captures foreign key names"
392
+ it "handles polymorphic associations (marks as v2/unsupported)"
393
+ end
394
+ end
395
+ ```
396
+
397
+ ### `spec/zod_rails/mapping/type_mapper_spec.rb`
398
+
399
+ ```ruby
400
+ RSpec.describe ZodRails::Mapping::TypeMapper do
401
+ describe "primitive type mapping" do
402
+ it "maps :string to z.string()"
403
+ it "maps :text to z.string()"
404
+ it "maps :integer to z.number().int()"
405
+ it "maps :bigint to z.number().int()"
406
+ it "maps :float to z.number()"
407
+ it "maps :decimal to z.number() by default"
408
+ it "maps :decimal to z.string() when precision mode enabled"
409
+ it "maps :boolean to z.boolean()"
410
+ end
411
+
412
+ describe "date and time mapping" do
413
+ it "maps :date to z.coerce.date()"
414
+ it "maps :datetime to z.coerce.date()"
415
+ it "maps :time to z.string() with time format note"
416
+ end
417
+
418
+ describe "special type mapping" do
419
+ it "maps :uuid to z.string().uuid()"
420
+ it "maps :json to z.record(z.unknown())"
421
+ it "maps :jsonb to z.record(z.unknown())"
422
+ it "maps :binary to z.string() with base64 note"
423
+ end
424
+
425
+ describe "PostgreSQL array types" do
426
+ it "maps string[] to z.array(z.string())"
427
+ it "maps integer[] to z.array(z.number().int())"
428
+ it "preserves nullability on array elements"
429
+ end
430
+
431
+ describe "nullability" do
432
+ it "appends .nullable() when column allows null"
433
+ it "does not append .nullable() when null: false"
434
+ it "removes .nullable() when presence validation exists"
435
+ end
436
+
437
+ describe "unknown types" do
438
+ it "falls back to z.unknown() for unrecognized types"
439
+ it "emits a warning comment for unknown types"
440
+ it "allows custom type handlers to be registered"
441
+ end
442
+ end
443
+ ```
444
+
445
+ ### `spec/zod_rails/mapping/validation_mapper_spec.rb`
446
+
447
+ ```ruby
448
+ RSpec.describe ZodRails::Mapping::ValidationMapper do
449
+ describe "presence validation" do
450
+ it "adds .min(1) to string types"
451
+ it "removes .nullable() from the type"
452
+ it "has no effect on boolean types (false is valid)"
453
+ it "adds .min(1) to array types"
454
+ end
455
+
456
+ describe "length validation" do
457
+ it "maps minimum: N to .min(N)"
458
+ it "maps maximum: N to .max(N)"
459
+ it "maps is: N to .length(N)"
460
+ it "maps in: X..Y to .min(X).max(Y)"
461
+ it "handles length on string types"
462
+ it "handles length on array types"
463
+ end
464
+
465
+ describe "inclusion validation" do
466
+ it "converts to z.enum([...]) when all values are strings"
467
+ it "converts to z.union([z.literal()...]) for mixed types"
468
+ it "preserves the original type when inclusion has :in as Range"
469
+ it "uses .refine() for Range-based inclusion"
470
+ end
471
+
472
+ describe "numericality validation" do
473
+ it "confirms base type is z.number()"
474
+ it "adds .int() for only_integer: true"
475
+ it "adds .positive() for greater_than: 0"
476
+ it "adds .nonnegative() for greater_than_or_equal_to: 0"
477
+ it "adds .gt(N) for greater_than: N"
478
+ it "adds .gte(N) for greater_than_or_equal_to: N"
479
+ it "adds .lt(N) for less_than: N"
480
+ it "adds .lte(N) for less_than_or_equal_to: N"
481
+ it "adds .multipleOf(N) for other: N (if supported)"
482
+ end
483
+
484
+ describe "format validation" do
485
+ it "maps format with regex to .regex()"
486
+ it "escapes regex special characters for TypeScript"
487
+ it "converts Ruby regex to JavaScript regex syntax"
488
+ it "handles common Rails regex patterns (email, url)"
489
+ end
490
+
491
+ describe "validation chaining" do
492
+ it "chains multiple validations in correct order"
493
+ it "applies presence before length (removes nullable first)"
494
+ it "applies type-changing validations (inclusion) before chainable ones"
495
+ end
496
+
497
+ describe "conditional validations" do
498
+ it "skips validations with :if option"
499
+ it "skips validations with :unless option"
500
+ it "skips validations with :on option (create/update)"
501
+ it "documents skipped validations in comments"
502
+ end
503
+ end
504
+ ```
505
+
506
+ ### `spec/zod_rails/mapping/enum_mapper_spec.rb`
507
+
508
+ ```ruby
509
+ RSpec.describe ZodRails::Mapping::EnumMapper do
510
+ describe "basic enum mapping" do
511
+ it "converts enum values to z.enum([...])"
512
+ it "uses string keys by default"
513
+ it "orders values as defined in the model"
514
+ end
515
+
516
+ describe "enum with custom values" do
517
+ it "uses the defined keys, not database integers"
518
+ it "handles enums defined with hash syntax"
519
+ end
520
+
521
+ describe "enum with prefix/suffix" do
522
+ it "preserves the base enum values without prefix"
523
+ it "documents the prefix/suffix in comments"
524
+ end
525
+
526
+ describe "enum format configuration" do
527
+ it "can be configured to use integer values"
528
+ it "outputs z.union([z.literal(0), ...]) for integer mode"
529
+ end
530
+ end
531
+ ```
532
+
533
+ ### `spec/zod_rails/generation/schema_builder_spec.rb`
534
+
535
+ ```ruby
536
+ RSpec.describe ZodRails::Generation::SchemaBuilder do
537
+ describe "schema structure" do
538
+ it "generates z.object({...}) wrapper"
539
+ it "includes all mapped columns as properties"
540
+ it "uses snake_case property names by default"
541
+ it "can be configured to use camelCase property names"
542
+ end
543
+
544
+ describe "schema naming" do
545
+ it "names schema after model (UserSchema for User)"
546
+ it "flattens namespaced models (Admin::User → AdminUserSchema)"
547
+ it "generates both schema and inferred type export"
548
+ end
549
+
550
+ describe "dual schema generation" do
551
+ it "generates ModelSchema for response/DB shape"
552
+ it "generates ModelInputSchema omitting id, timestamps, readonly fields"
553
+ it "applies .optional() to fields with DB defaults in InputSchema"
554
+ it "handles confirmation fields in InputSchema when present"
555
+ end
556
+
557
+ describe "generated output format" do
558
+ it "includes import statement for zod"
559
+ it "exports each schema as named export"
560
+ it "exports TypeScript type using z.infer<>"
561
+ it "adds JSDoc comments with model name"
562
+ it "sorts schemas alphabetically"
563
+ end
564
+
565
+ describe "association fields" do
566
+ it "adds foreign key fields for belongs_to (column exists)"
567
+ it "does NOT add _id fields for has_one (column on other table)"
568
+ it "does NOT add _ids arrays for has_many by default (virtual)"
569
+ it "adds _ids arrays for has_many when config.include_association_ids = true"
570
+ it "marks optional belongs_to as nullable"
571
+ it "adds nested schema reference in :nested mode"
572
+ it "adds array of nested schemas for has_many in :nested mode"
573
+ end
574
+
575
+ describe "topological sorting" do
576
+ it "orders schemas to resolve dependencies (referenced before referencer)"
577
+ it "handles circular dependencies with getter syntax"
578
+ it "emits all leaf models (no outbound associations) first"
579
+ end
580
+
581
+ describe "special cases" do
582
+ it "handles models with no validations"
583
+ it "handles models with no associations"
584
+ it "handles STI models (uses base class columns)"
585
+ it "generates z.literal() for STI type discriminator column"
586
+ it "marks unsupported features with TODO comments"
587
+ end
588
+ end
589
+ ```
590
+
591
+ ### `spec/zod_rails/generation/file_writer_spec.rb`
592
+
593
+ ```ruby
594
+ RSpec.describe ZodRails::Generation::FileWriter do
595
+ describe "file output" do
596
+ it "writes to configured output path"
597
+ it "creates parent directories if needed"
598
+ it "overwrites existing file"
599
+ it "adds generation timestamp header"
600
+ it "adds 'do not edit' warning comment"
601
+ end
602
+
603
+ describe "file formatting" do
604
+ it "uses consistent indentation (2 spaces)"
605
+ it "adds trailing newline"
606
+ it "groups imports at top"
607
+ it "separates schemas with blank lines"
608
+ end
609
+
610
+ describe "error handling" do
611
+ it "raises descriptive error if directory not writable"
612
+ it "raises descriptive error if path is invalid"
613
+ end
614
+ end
615
+ ```
616
+
617
+ ### `spec/zod_rails/associations/association_resolver_spec.rb`
618
+
619
+ ```ruby
620
+ RSpec.describe ZodRails::Associations::AssociationResolver do
621
+ describe "belongs_to resolution" do
622
+ it "returns foreign key field name (column exists on this model)"
623
+ it "determines if association is optional"
624
+ it "resolves the associated model class"
625
+ it "detects polymorphic and emits both _type and _id columns"
626
+ end
627
+
628
+ describe "has_many resolution" do
629
+ it "does NOT generate _ids by default (virtual attribute)"
630
+ it "generates _ids when include_association_ids config enabled"
631
+ it "resolves has_many :through to final target model"
632
+ it "detects polymorphic :through and marks unsupported"
633
+ end
634
+
635
+ describe "has_one resolution" do
636
+ it "does NOT generate _id by default (column on other table)"
637
+ it "resolves the associated model class for nested mode"
638
+ end
639
+
640
+ describe "nesting strategy" do
641
+ it "returns ID type for :ids_only mode (belongs_to FK only)"
642
+ it "returns schema reference for :nested mode"
643
+ it "handles circular references with getter syntax (Zod 4 pattern)"
644
+ it "limits nesting depth to configurable level (default: 1)"
645
+ it "switches to IDs at depth limit with comment"
646
+ end
647
+
648
+ describe "polymorphic associations" do
649
+ it "emits actual _type and _id columns with correct types"
650
+ it "uses z.string() for _type column"
651
+ it "infers _id type from column definition (int vs uuid)"
652
+ it "adds TODO comment noting polymorphic limitation"
653
+ end
654
+ end
655
+ ```
656
+
657
+ ### `spec/integration/full_generation_spec.rb`
658
+
659
+ ```ruby
660
+ RSpec.describe "Full schema generation", type: :integration do
661
+ describe "generating from a complete Rails model" do
662
+ it "generates valid TypeScript that compiles without errors"
663
+ it "generates valid Zod that can be imported"
664
+ it "matches expected output for a User model with validations"
665
+ it "matches expected output for a Post model with associations"
666
+ end
667
+
668
+ describe "multi-model generation" do
669
+ it "generates all models in a single file"
670
+ it "orders schemas to resolve dependencies"
671
+ it "handles circular dependencies with z.lazy()"
672
+ end
673
+
674
+ describe "incremental generation" do
675
+ it "regenerates only changed models when configured"
676
+ it "preserves custom additions in separate file"
677
+ end
678
+ end
679
+ ```
680
+
681
+ ### `spec/integration/rails_integration_spec.rb`
682
+
683
+ ```ruby
684
+ RSpec.describe "Rails integration", type: :integration do
685
+ describe "rake zod_rails:generate" do
686
+ it "is available after gem is loaded"
687
+ it "generates schema file to configured path"
688
+ it "outputs success message with file path"
689
+ it "outputs model count in success message"
690
+ end
691
+
692
+ describe "rake zod_rails:watch" do
693
+ it "requires listen gem to be available"
694
+ it "outputs helpful error if listen not installed"
695
+ it "watches configured model paths"
696
+ it "regenerates on .rb file changes"
697
+ it "can be stopped with Ctrl+C"
698
+ end
699
+
700
+ describe "Rails initializer" do
701
+ it "loads configuration from config/initializers/zod_rails.rb"
702
+ it "provides helpful error for invalid configuration"
703
+ end
704
+ end
705
+ ```
706
+
707
+ ---
708
+
709
+ ## 7. Example Generated Output
710
+
711
+ ### Input: Rails Model
712
+
713
+ ```ruby
714
+ # app/models/user.rb
715
+ class User < ApplicationRecord
716
+ enum role: { member: 0, admin: 1, moderator: 2 }
717
+
718
+ validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
719
+ validates :name, presence: true, length: { minimum: 2, maximum: 100 }
720
+ validates :age, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
721
+
722
+ has_many :posts
723
+ belongs_to :organization, optional: true
724
+ end
725
+ ```
726
+
727
+ ### Output: Zod Schema
728
+
729
+ ```typescript
730
+ // app/javascript/schemas/zod_schemas.ts
731
+ // Generated by ZodRails - DO NOT EDIT
732
+ // Generated at: 2025-01-13T12:00:00Z
733
+ // Zod version: ^4.0.0
734
+
735
+ import { z } from "zod";
736
+
737
+ // ============================================
738
+ // User model schemas
739
+ // ============================================
740
+
741
+ /** User response schema (database shape) */
742
+ export const UserSchema = z.object({
743
+ id: z.int(),
744
+ email: z.email(),
745
+ name: z.string(),
746
+ age: z.int().gte(0).nullable(),
747
+ role: z.enum(["member", "admin", "moderator"] as const),
748
+ organization_id: z.int().nullable(),
749
+ created_at: z.iso.datetime(),
750
+ updated_at: z.iso.datetime(),
751
+ });
752
+
753
+ export type User = z.infer<typeof UserSchema>;
754
+
755
+ /** User input schema (form submission) */
756
+ export const UserInputSchema = z.object({
757
+ email: z.email().min(1),
758
+ name: z.string().min(2).max(100),
759
+ age: z.int().gte(0).nullish(),
760
+ role: z.enum(["member", "admin", "moderator"] as const).optional(),
761
+ organization_id: z.int().nullish(),
762
+ });
763
+
764
+ export type UserInput = z.infer<typeof UserInputSchema>;
765
+ ```
766
+
767
+ **Key differences between schemas:**
768
+ - `UserSchema` (Response): Matches DB shape, all fields present, uses `.nullable()` for NULL columns
769
+ - `UserInputSchema` (Input): Omits `id`/timestamps, uses `.nullish()` for optional fields, applies validation chains
770
+
771
+ ---
772
+
773
+ ## 8. Configuration DSL
774
+
775
+ ```ruby
776
+ # config/initializers/zod_rails.rb
777
+ ZodRails.configure do |config|
778
+ # Output path (relative to Rails.root)
779
+ config.output_path = "app/javascript/schemas/zod_schemas.ts"
780
+
781
+ # Model selection
782
+ config.only = %w[User Post Comment] # nil = all models
783
+ config.except = %w[ActiveStorage::Blob]
784
+
785
+ # Schema generation modes
786
+ config.generate_input_schemas = true # Generate ModelInputSchema alongside ModelSchema
787
+
788
+ # Association handling
789
+ config.associations = :columns_only # :columns_only | :include_ids | :nested | :omit
790
+ # :columns_only = Only belongs_to foreign keys (actual columns)
791
+ # :include_ids = Add virtual _ids arrays for has_many (opt-in)
792
+ # :nested = Reference other schemas directly
793
+ # :omit = Exclude all association fields
794
+
795
+ config.nesting_depth = 1 # Max depth for :nested mode before switching to IDs
796
+
797
+ # Property naming (IMPORTANT: must match your API serializer)
798
+ config.property_case = :snake # :snake | :camel
799
+ # WARNING: Rails APIs default to snake_case. Only use :camel if your
800
+ # serializer transforms keys (e.g., ActiveModelSerializers key_transform)
801
+
802
+ # Enum format (string keys = modern Rails convention)
803
+ config.enum_format = :string # :string | :integer
804
+
805
+ # Timestamp handling
806
+ config.include_timestamps = true # true | false
807
+
808
+ # Primary key handling
809
+ config.include_primary_key = true # true | false
810
+
811
+ # Date/time format
812
+ config.datetime_format = :iso_string # :iso_string | :date_object
813
+ # :iso_string = z.iso.datetime() - keeps as string (recommended)
814
+ # :date_object = z.coerce.date() - parses to Date object
815
+
816
+ # BigInt handling
817
+ config.bigint_format = :string # :string | :number
818
+ # :string = Safe for all values (recommended)
819
+ # :number = z.int() - DANGER: overflows beyond Number.MAX_SAFE_INTEGER
820
+
821
+ # Custom type mappings (advanced)
822
+ config.type_mappings[:money] = "z.string()" # Or with chain: "z.string().regex(/^\\d+\\.\\d{2}$/)"
823
+
824
+ # Paths to watch (for file watcher)
825
+ config.watch_paths = ["app/models"]
826
+ end
827
+ ```
828
+
829
+ ---
830
+
831
+ ## 9. Implementation Order
832
+
833
+ After prose specs are written and approved:
834
+
835
+ ### Phase 1: Foundation
836
+ 1. `version.rb` - Version constant
837
+ 2. `configuration.rb` - Configuration DSL (all options)
838
+ 3. Gem structure and gemspec
839
+
840
+ ### Phase 2: Introspection
841
+ 4. `ModelInspector` - Read columns, validators, enums from AR models
842
+ 5. `ColumnInfo` - Value object for column metadata
843
+ 6. `ValidationInfo` - Value object for validation metadata (including allow_nil/allow_blank)
844
+
845
+ ### Phase 3: Associations (BEFORE Generation)
846
+ 7. `AssociationResolver` - Determines how to represent associations
847
+ 8. `NestingStrategy` - Columns-only vs nested, depth limits
848
+
849
+ ### Phase 4: Mapping
850
+ 9. `TypeMapper` - Rails type → Zod type
851
+ 10. `ValidationMapper` - Rails validation → Zod chain (with deduplication)
852
+ 11. `EnumMapper` - Rails enum → z.enum() with `as const`
853
+
854
+ ### Phase 5: Generation
855
+ 12. `SchemaBuilder` - Builds both Response and Input schemas per model
856
+ 13. `DependencyResolver` - Topological sort for schema ordering
857
+ 14. `TypescriptEmitter` - Formats TypeScript output
858
+ 15. `FileWriter` - Writes the .ts file
859
+
860
+ ### Phase 6: Rails Integration
861
+ 16. `Railtie` - Rails integration with eager loading
862
+ 17. Rake tasks (`generate`, `watch`)
863
+
864
+ ### Phase 7: Polish
865
+ 18. Error messages and edge cases
866
+ 19. Documentation (README)
867
+ 20. File watcher (optional `listen` integration)
868
+
869
+ ---
870
+
871
+ ## 10. Open Questions
872
+
873
+ These questions should be answered before implementation begins:
874
+
875
+ ### Property Naming Default ✅ RESOLVED
876
+ - **Decision**: `snake_case` by default
877
+ - **Rationale**: Rails APIs send snake_case unless serializer transforms keys. Mismatched keys cause immediate validation failures.
878
+ - **Config**: `property_case: :camel` for apps with key transformation
879
+
880
+ ### Timestamps ✅ RESOLVED
881
+ - **Decision**: Include by default, opt-out via config
882
+ - **Rationale**: Response schemas need them; Input schemas auto-exclude them
883
+
884
+ ### ID Fields ✅ RESOLVED
885
+ - **Decision**: Include by default, opt-out via config
886
+ - **Rationale**: Response schemas need them; Input schemas auto-exclude them
887
+
888
+ ### Dual Schema Generation ✅ RESOLVED
889
+ - **Decision**: Generate both `ModelSchema` (response) and `ModelInputSchema` (forms) by default
890
+ - **Rationale**: Solves Input vs Output conflation; `generate_input_schemas: false` to disable
891
+
892
+ ### Association IDs ✅ RESOLVED
893
+ - **Decision**: `has_one`/`has_many` do NOT generate `_id`/`_ids` fields by default
894
+ - **Rationale**: These are virtual attributes, not columns. Only `belongs_to` FK columns exist on the model.
895
+ - **Config**: `associations: :include_ids` to opt-in to virtual `_ids` arrays
896
+
897
+ ---
898
+
899
+ ## 11. Research Findings
900
+
901
+ ### Zod 4 Considerations
902
+
903
+ > **Target Version**: Zod 4.x (stable since May 2025, current v4.3.5)
904
+
905
+ #### Key Zod 4 Features We Leverage
906
+
907
+ | Feature | Usage in ZodRails |
908
+ |---------|-------------------|
909
+ | `z.int()`, `z.int32()` | Replace `z.number().int()` for integer columns |
910
+ | `z.email()`, `z.uuid()`, `z.url()` | Top-level formats instead of deprecated `.email()` methods |
911
+ | `z.iso.date()`, `z.iso.datetime()` | Default for date/time columns (keeps as ISO string) |
912
+ | Object getter syntax | Replace `z.lazy()` for circular/recursive associations |
913
+ | `.meta()` | Embed Rails metadata (column info, primary key, etc.) |
914
+ | `z.json()` | Native Zod 4 type for json/jsonb columns |
915
+ | Refinements inside schemas | `.refine()` chains with `.min()` etc. without ZodEffects wrapper |
916
+
917
+ #### Deprecations to Avoid
918
+
919
+ | Deprecated | Use Instead |
920
+ |------------|-------------|
921
+ | `z.string().email()` | `z.email()` |
922
+ | `z.string().uuid()` | `z.uuid()` |
923
+ | `z.object().merge()` | `.extend()` or spread syntax |
924
+ | `z.nativeEnum()` | `z.enum()` |
925
+ | `z.lazy()` for objects | Getter syntax: `get field() { return Schema }` |
926
+
927
+ #### Recursive Schema Pattern (Zod 4)
928
+
929
+ ```typescript
930
+ // Old (Zod 3) - avoid
931
+ const Category = z.lazy(() => z.object({
932
+ subcategories: z.array(Category)
933
+ }));
934
+
935
+ // New (Zod 4) - preferred
936
+ const Category = z.object({
937
+ name: z.string(),
938
+ get subcategories() {
939
+ return z.array(Category)
940
+ }
941
+ });
942
+ ```
943
+
944
+ ### Competitive Landscape
945
+
946
+ | Existing Solution | Approach | Limitation |
947
+ |-------------------|----------|------------|
948
+ | `schema2type` | Parse `db/schema.rb` | Archived (2023), no validations |
949
+ | `camille` | Custom DSL | Not AR-aware, manual type definition |
950
+ | `easy_talk` | Ruby DSL → JSON Schema | Not AR-aware |
951
+ | `active_json_schema` | AR → JSON Schema | No Zod output, minimal adoption |
952
+ | `json-schema-to-zod` | npm converter | Two-step process, fidelity loss |
953
+
954
+ **Conclusion**: No direct Rails → Zod solution exists. Clear market opportunity.
955
+
956
+ ### File Watcher Research
957
+
958
+ | Option | Stars | Status | Best For |
959
+ |--------|-------|--------|----------|
960
+ | `listen` | 1.9k | Active (Jan 2026) | Our use case |
961
+ | `guard` | 6.7k | Active | Complex multi-task workflows |
962
+ | `filewatcher` | 463 | Active | Zero-dependency environments |
963
+
964
+ **Decision**: Use `listen` as optional dependency. Most Rails apps already have it.
965
+
966
+ ---
967
+
968
+ ## 12. Known Challenges & Mitigations
969
+
970
+ | Challenge | Mitigation |
971
+ |-----------|------------|
972
+ | **Input vs Output conflation** | Generate both `ModelSchema` (response) and `ModelInputSchema` (forms) |
973
+ | **Optional vs Nullable confusion** | Use `.nullable()` for DB NULL, `.optional()` for missing keys, `.nullish()` for both |
974
+ | **Association IDs don't exist** | `has_one`/`has_many` don't create columns; only emit `belongs_to` FKs by default |
975
+ | **snake_case vs camelCase** | Default to `snake_case` matching Rails; camelCase opt-in with warning |
976
+ | **BigInt overflow** | Default to `z.string()` for `:bigint`; `z.int()` opt-in with warning |
977
+ | **Decimal precision loss** | Default to `z.string()`; `z.number()` opt-in |
978
+ | **json/jsonb type** | Use `z.json()` (Zod 4 native) not `z.record()` |
979
+ | **Ruby → JS regex** | Whitelist common patterns; emit TODO for complex regexes |
980
+ | **Validation deduplication** | `ValidationMapper` consolidates to strictest constraint |
981
+ | **Circular associations** | Getter syntax (Zod 4); topological sort for ordering |
982
+ | **Conditional validations** | Skip `:if/:unless/:on`; document in comments |
983
+ | **Dynamic inclusions** | Skip proc/lambda `:in`; allow config override |
984
+ | **STI discriminators** | Generate `z.literal("ChildClass")` for type column |
985
+ | **Polymorphic associations** | Emit actual `_type`/`_id` columns with correct types |
986
+ | **Abstract models** | Filter with `table_exists? && !abstract_class?` |
987
+ | **Namespaced models** | Flatten `Admin::User` → `AdminUserSchema` |
988
+ | **Presence on booleans** | Emit warning; map to `z.literal(true)` |
989
+ | **Enum type inference** | Use `as const` assertion for literal types |
990
+ | **Forward references** | Topological sort + getter syntax for cycles |
991
+
992
+ ---
993
+
994
+ ### Implementation Strategy
995
+
996
+ ```ruby
997
+ # lib/tasks/zod_rails.rake
998
+ namespace :zod_rails do
999
+ desc "Watch for model changes and regenerate TypeScript"
1000
+ task :watch => :environment do
1001
+ begin
1002
+ require 'listen'
1003
+ rescue LoadError
1004
+ abort <<~ERROR
1005
+ The 'listen' gem is required for file watching.
1006
+ Add to your Gemfile:
1007
+ gem 'listen', group: :development
1008
+ Then run: bundle install
1009
+ ERROR
1010
+ end
1011
+
1012
+ paths = ZodRails.configuration.watch_paths
1013
+
1014
+ puts "Watching #{paths.join(', ')} for changes..."
1015
+ puts "Press Ctrl+C to stop"
1016
+
1017
+ listener = Listen.to(*paths, only: /\.rb$/) do |modified, added, removed|
1018
+ puts "\n[#{Time.now.strftime('%H:%M:%S')}] Change detected"
1019
+ Rake::Task['zod_rails:generate'].reenable
1020
+ Rake::Task['zod_rails:generate'].invoke
1021
+ end
1022
+
1023
+ listener.start
1024
+ sleep
1025
+ rescue Interrupt
1026
+ puts "\nStopping watcher..."
1027
+ end
1028
+ end
1029
+ ```
1030
+
1031
+ ---
1032
+
1033
+ ## Next Steps
1034
+
1035
+ 1. **Answer open questions** (property naming, timestamps, IDs)
1036
+ 2. **Create ROADMAP.md** with completed/future sections
1037
+ 3. **Scaffold gem structure** with Bundler
1038
+ 4. **Write all prose specs** as pending RSpec examples
1039
+ 5. **Review specs** for completeness
1040
+ 6. **Begin implementation** by making specs pass one by one
1041
+
1042
+ ---
1043
+
1044
+ *This document serves as the single source of truth for the ZodRails gem architecture. Update it as decisions are made and implementation progresses.*
1045
+ # ZodRails Roadmap
1046
+
1047
+ > **Last Updated**: 2025-01-13
1048
+
1049
+ ---
1050
+
1051
+ ## Completed
1052
+
1053
+ *Nothing yet - project in planning phase*
1054
+
1055
+ ---
1056
+
1057
+ ## v1.0 Scope (In Progress)
1058
+
1059
+ ### Core Features
1060
+ - [ ] Column type → Zod type mapping (all common types)
1061
+ - [ ] Nullability inference from column definition
1062
+ - [ ] Validation mapping (Tier 1 + Tier 2)
1063
+ - [ ] Rails enum support (string keys)
1064
+ - [ ] Rake task: `rails zod_rails:generate`
1065
+ - [ ] Configuration file (initializer DSL)
1066
+ - [ ] Customizable output path
1067
+ - [ ] Per-model include/exclude options
1068
+
1069
+ ### Association Handling
1070
+ - [ ] Foreign key fields for `belongs_to`
1071
+ - [ ] ID arrays for `has_many`
1072
+ - [ ] Configurable: IDs-only vs nested schemas
1073
+ - [ ] Optional association support
1074
+
1075
+ ### Developer Experience
1076
+ - [ ] File watcher: `rails zod_rails:watch` (requires `listen` gem)
1077
+ - [ ] Clear error messages for configuration issues
1078
+ - [ ] Generation timestamp in output
1079
+ - [ ] "Do not edit" warning in generated files
1080
+
1081
+ ### Documentation
1082
+ - [ ] README with quick start guide
1083
+ - [ ] Configuration reference
1084
+ - [ ] Type mapping reference
1085
+ - [ ] Validation mapping reference
1086
+
1087
+ ### Quality
1088
+ - [ ] Comprehensive RSpec test suite (prose-driven TDD)
1089
+ - [ ] CI/CD pipeline (GitHub Actions)
1090
+ - [ ] RuboCop configuration
1091
+
1092
+ ---
1093
+
1094
+ ## v1.1 (Post-Release Polish)
1095
+
1096
+ - [ ] Custom type mapping extension point
1097
+ - [ ] Per-model configuration overrides
1098
+ - [ ] Multiple output file support (split by namespace)
1099
+ - [ ] Better regex conversion (Ruby → JavaScript)
1100
+ - [ ] Common email/URL format detection
1101
+
1102
+ ---
1103
+
1104
+ ## v2.0 (Future)
1105
+
1106
+ ### Serializer Integration
1107
+ - [ ] Alba adapter - generate from Alba serializers
1108
+ - [ ] Blueprinter adapter - generate from Blueprinter views
1109
+ - [ ] ActiveModelSerializers adapter
1110
+ - [ ] Custom serializer extension point
1111
+
1112
+ ### Advanced Features
1113
+ - [ ] Polymorphic association support
1114
+ - [ ] STI (Single Table Inheritance) handling
1115
+ - [ ] Conditional validation mapping (Tier 3)
1116
+ - [ ] Custom validator extension API
1117
+ - [ ] Schema versioning support
1118
+
1119
+ ### Performance
1120
+ - [ ] Incremental generation (only changed models)
1121
+ - [ ] Parallel model introspection
1122
+ - [ ] Caching layer for large codebases
1123
+
1124
+ ### Ecosystem
1125
+ - [ ] VS Code extension for jump-to-definition
1126
+ - [ ] Integration with TypeScript Language Server
1127
+ - [ ] Rails generator: `rails g zod_rails:install`
1128
+
1129
+ ---
1130
+
1131
+ ## v3.0 (Vision)
1132
+
1133
+ ### Bidirectional Sync
1134
+ - [ ] Detect drift between Rails and generated schemas
1135
+ - [ ] Generate Rails migrations from Zod schema changes
1136
+ - [ ] Schema diff tool
1137
+
1138
+ ### API Documentation
1139
+ - [ ] OpenAPI spec generation alongside Zod
1140
+ - [ ] Swagger UI integration
1141
+ - [ ] API versioning support
1142
+
1143
+ ### Runtime Features
1144
+ - [ ] Runtime API endpoint serving schema JSON
1145
+ - [ ] Schema hot-reloading in development
1146
+ - [ ] Client library generation (fetch/axios wrappers)
1147
+
1148
+ ---
1149
+
1150
+ ## Ideas Backlog
1151
+
1152
+ *Unscheduled ideas for future consideration*
1153
+
1154
+ - GraphQL type generation
1155
+ - Prisma schema generation
1156
+ - Database-level constraint extraction (beyond AR validations)
1157
+ - Multi-database support
1158
+ - Encrypted attribute handling
1159
+ - Action Text rich text field support
1160
+ - Active Storage attachment field support
1161
+ - I18n support for validation messages
1162
+ - JSON Schema output (alternative to Zod)
1163
+ - Yup schema output (alternative to Zod)
1164
+
1165
+ ---
1166
+
1167
+ ## Contributing
1168
+
1169
+ Have an idea? Open an issue or PR! We especially welcome:
1170
+
1171
+ 1. New type mappings for database-specific types
1172
+ 2. Validation mapping improvements
1173
+ 3. Serializer adapter implementations
1174
+ 4. Documentation improvements
1175
+
1176
+ ---
1177
+
1178
+ *This roadmap is a living document. Priorities may shift based on community feedback.*