API
SearchEngine::Schema.compile(klass)→ returns a Typesense-compatible schema hash built from the DSL. Pure and deterministic (no network I/O).SearchEngine::Schema.diff(klass)→ resolves alias → physical, fetches the live schema, and returns a structured diff plus a compact human summary.SearchEngine::Schema.update!(klass)→ attempts an in-place schema patch (TypesensePATCH /collections/:name) when the diff only contains field additions/drops; returnstruewhen the schema is already in sync or successfully patched.SearchEngine::Schema.apply!(klass, force_rebuild: false)→ blue/green lifecycle (create new physical, reindex, swap alias, retention). By default it first triesupdate!and only falls back to blue/green when incompatible changes are detected. Returns{ logical, new_physical, previous_physical, alias_target, dropped_physicals, action: :update|:rebuild }.SearchEngine::Schema.rollback(klass)→ swap alias back to previous retained physical; returns{ logical, new_target, previous_target }.
In-place schema updates (Typesense v29+)
SearchEngine::Schema.update!(klass, client: …)inspects the live diff and issues aPATCH /collections/:namewhen the changes are limited to field additions or drops. Type changes, reference changes, or collection-level option differences automatically returnfalse, signalling that a full blue/green rebuild is required.klass.update_collection!(available on everySearchEngine::Basesubclass) is a convenience wrapper that logs console guidance and delegates toSchema.update!.Schema.apply!(force_rebuild: false)now attempts an in-place update first. Passforce_rebuild: truewhen you explicitly need to skip PATCH (for example, when you want a new physical even if only field additions are pending).
bin/rails search_engine:schema:apply[Collection] inherit the same behavior because they call Schema.apply! under the hood.
Type mapping (DSL → Typesense)
- :string →
string - :integer →
int64(chosen consistently for wider range) - :float / :decimal →
float - :boolean →
bool - :time / :datetime →
int64(epoch seconds) - :time_string / :datetime_string →
string(ISO8601 timestamps) - Arrays like
[:string]→string[] - :auto (regex-style field names such as
".*_facet") →auto; enables Typesense auto schema detection and wildcard ingestion. The DSL enforces that:autocan only be used when the attribute name looks like a regex (contains metacharacters such as*,., etc.).
Array empty filtering (hidden fields)
When declaring an array attribute, you can enable automatic empty filtering by addingempty_filtering: true:
- Schema includes a hidden boolean field
promotion_ids_empty. - The mapper auto-populates it per document as:
promotion_ids.nil? || promotion_ids.empty?. - Hidden fields are not exposed via public APIs or
inspect; they are internal.
empty_filteringis only valid for array types (e.g.,[:string]); setting it on scalars raises an error.
.where(promotion_ids: [])→promotion_ids_empty:=true.where.not(promotion_ids: [])→promotion_ids_empty:=false
- For joined filters like
.joins(:brand).where(brand: { promotion_ids: [] }), the rewrite applies only if the joined collection hasattribute :promotion_ids, [:string], empty_filtering: true(hidden$brand.promotion_ids_emptyexists). Otherwise an empty array remains invalid.
System field: doc_updated_at
- Always present on every collection. Cannot be disabled—the gem automatically injects this field during document creation/upsert.
- Stored in Typesense as
int64(epoch seconds). If declared in the model DSL, its type will be coerced toint64at compile time to ensure consistency. - On hydration and console output, it is converted to a
Timein the current timezone (usesTime.zonewhen available, falling back toTime). - When using instance
attributes,:doc_updated_atis returned as aTimeobject. Unknown fields remain available under:unknown_attributes. - Typesense limitation: This field is required by Typesense for internal tracking. The gem enforces its presence to maintain compatibility.
Collection options
If declared in the DSL in the future, the builder may include top-level options likedefault_sorting_field, token_separators, symbols_to_index. Today, these are omitted to avoid noisy diffs.
Nested fields (auto-enabled)
- When any attribute is declared with type
:objector[:object], the schema compiler will automatically setenable_nested_fields: trueat the collection level. - This is required by Typesense to accept
object/object[]field types; otherwise the server responds with400 RequestMalformed. - The option is included in
Schema.apply!create payloads and appears undercollection_optionsinSchema.diff. - If you don’t need nested objects, consider flattening fields or storing JSON as a
:string.
Declaring nested subfields
Declare subfields inline via thenested: option on the base attribute:
- Base
:object→ subfields are scalars (float,int64,string). - Base
[:object]→ subfields are arrays (float[],int64[],string[]).
enable_nested_fields in collections.create (typesense.org).
Diff shape
- Field comparison is name-keyed and order-insensitive.
- Only changed keys appear under
changed_fields. - When the live collection is missing,
added_fieldscontain all compiled fields andcollection_optionsincludeslive: :missing.
Pretty print
The human summary includes:- Header: logical and physical names
- + Added fields:
name:type - - Removed fields:
name:type - ~ Changed fields:
field.attr compiled→live - ~ Collection options: shown only when differing
Lifecycle (Blue/Green with retention)
- Physical name format:
“#YYYYMMDD_HHMMSS###”(3-digit zero-padded sequence). - Alias equals the logical name (e.g.,
products). Swap is performed via a single upsert call, which the server handles atomically. - Idempotent: if alias already points to the new physical, swap is a no-op.
- Reindexing is required. Provide a block to
apply!or implementklass.reindex_all_to(physical_name)to perform bulk import. On failure, no alias swap occurs and the new physical remains for inspection.
Creating a physical collection manually, importing into it, and calling
Client#upsert_alias directly will NOT trigger retention cleanup. Old physical collections remain until removed explicitly. Retention cleanup only runs as part of Schema.apply! (and the search_engine:schema:apply[…] task), after a successful alias swap.Retention
- Global default: keep none.
- Per-collection override:
keep_last is deleted. The alias target is never deleted.
Typical operational pattern:
- Run
schema:apply(orSchema.apply!) to create a new physical, import, swap alias, then drop old physicals per retention. - Avoid manual create/import/alias routines in production unless you also implement a cleanup step; otherwise, old physicals will accumulate.
Rollback
SearchEngine::Schema.rollback(klass) will swap the alias back to the most recent retained physical (behind the current). If no previous physical exists, it raises an error (e.g., when keep_last is 0). No collections are deleted during rollback.
See also: Client, Configuration, and Compiler.
Troubleshooting
- Reindex step missing: Provide a block to
apply!or implementklass.reindex_all_to(name). - Retention errors: Ensure
keep_lastis set appropriately; rollback requires a previous retained physical.