Skip to main content

Schema Export / Import

Fyso's metadata export/import system serializes the complete schema definition of a tenant — entities, fields, and business rules — into a portable JSON structure.

Use cases

  • Backup and restore — export a tenant's schema periodically and restore it on demand
  • Cloning — copy a schema from one tenant to another
  • Presets — industry templates that bootstrap a new tenant with a ready-made schema
  • Round-trip safety — export → import → re-export produces equivalent output
  • App Distribution — builder ships a schema to subscriber tenants as a managed update

Export

MCP tool

fyso_meta({ action: "export" })

REST endpoint

GET /api/metadata/export
Authorization: Bearer <admin-token-or-api-key>

Response envelope

The export endpoint wraps its response in the standard API envelope. The MCP tool unwraps it automatically.

{
"success": true,
"data": {
"version": "1.0",
"exportedAt": "2026-03-29T12:00:00.000Z",
"entities": [...],
"businessRules": [...]
}
}

When working via REST, the schema payload lives inside data.

When working via REST, the schema payload lives inside data. When working via MCP, the grouped fyso_meta export action returns a short text summary plus the temp file path where the full JSON was written.

What gets exported

  • Only published entities (drafts are excluded)
  • All field definitions with their configs
  • All business rules with ruleDsl, triggerType, priority, and versioning data
  • Relation fields include config.targetEntity (entity name, not ID)

Content negotiation

For payloads larger than 10 KB, the server may return the response compressed as application/gzip — but only when the client sends:

Accept-Encoding: gzip

If the client omits that header, or explicitly uses Accept-Encoding: identity, the response stays application/json in the standard envelope.

When gzip is returned, the response includes two diagnostic headers:

HeaderDescription
X-Original-SizeUncompressed size in bytes
X-Compressed-SizeCompressed size in bytes

Import

MCP tool

fyso_meta({ action: "import", data: "<json-string>" })

The grouped MCP action delegates to the legacy metadata import tool. The data field must be a JSON string.

REST endpoint

POST /api/metadata/import
Authorization: Bearer <admin-token-or-api-key>
Content-Type: application/json (or application/gzip)

Accepts the same JSON structure returned by export. Both application/json and application/gzip content types are supported on import.

Upsert behavior

Import is an upsert, not a replace. Existing objects are updated; objects in the target that are not in the import payload are left untouched.

ObjectMatch keyBehavior
EntitynameExisting → update metadata. New → create + partition.
Field(entityId, fieldKey)System field (isSystem=true) → update. Custom field (isSystem=false) → skip. New → create as isSystem=true.
Business Rule(entityId, name)Existing → update DSL, triggers, priority, status. New → create.

Import never deletes. Entities, fields, or rules present in the target but absent from the import source are preserved.

Entity ID mapping

Import maintains an internal entityIdMap (export ID → target ID) so that business rule entityId references resolve correctly. If a rule references an entity not in the import payload, the rule is skipped with a warning.

Versioning data

Import carries over status, version, publishedVersion, publishedAt, and publishedBy from the source when present.

Partitions

When a schemaName is available, new entities automatically get a dedicated record partition.

DDL is not run on import

Import creates and updates definition rows (entity_definitions, field_definitions, business_rules) but does not execute ALTER TABLE. The publish step (POST /api/metadata/entities/:name/publish) is what runs DDL.

For presets and App Distribution, the source is already published so DDL columns exist from createTenantTables. If new fields are added via import to an already-published entity, a publish call is still required.

Response

{
"success": true,
"data": {
"entities": { "created": 2, "updated": 0 },
"fields": { "created": 10, "updated": 0, "skipped": 0 },
"rules": { "created": 1, "updated": 0 }
}
}

Business rules in the export payload

Business rule objects use these field names:

FieldTypeDescription
namestringRule identifier
triggerTypestringWhen the rule fires — see enum below
ruleDslobjectThe DSL definition
prioritynumberExecution order
statusstringactive or inactive

triggerType enum

ValueDescription
field_changeFires when a specific field changes
before_saveFires before the record is saved
after_saveFires after the record is saved
scheduledFires on a cron schedule
on_queryFires when a query is executed against the entity

System fields vs. custom fields

Fields created via import are marked isSystem: true. Fields created by tenant users through the custom fields endpoint are isSystem: false.

Import never overwrites custom fields — it skips them to preserve user customizations. This is the core contract for App Distribution: the builder's schema fields are system-protected, and the tenant user's additions are preserved across updates.


Entity versioning

Each entity tracks its own version history.

PropertyTypeDescription
versionintCurrent draft version number
publishedVersionint, nullableLast published version
statusstringdraft or published

Publishing creates a snapshot in entity_versions with the full entity and field state, a diff from the previous version, and the publishedAt, publishedBy, and notes fields.

Versioning endpoints

GET /api/metadata/entities/:name/versions
GET /api/metadata/entities/:name/versions/:version
GET /api/metadata/entities/:name/versions/:version/diff
GET /api/metadata/entities/:name/diff

The last endpoint compares the current draft against the last published version.


Server-side usage (internal)

metadataService.exportMetadata(dbClient) and metadataService.importMetadata(data, dbClient, schemaName) can be called directly from other services (for example, tenantService.createTenant for schema cloning). When running cross-tenant operations, set search_path before calling:

// Export from source tenant
const sourceMetadata = await conn.transaction(async (tx) => {
await tx.execute(sql.raw(`SET search_path TO "${sourceSchemaName}", public`));
return metadataService.exportMetadata(tx);
});

// Import into target tenant
await conn.transaction(async (tx) => {
await tx.execute(sql.raw(`SET search_path TO "${targetSchemaName}", public`));
await metadataService.importMetadata(sourceMetadata, tx, targetSchemaName);
});

Presets

Presets use the same import mechanism. POST /api/presets/:name/install loads a preset JSON and calls metadataService.importMetadata. The same upsert behavior applies.


Known limitations

LimitationNotes
No delete propagationRemoving an entity or field from source and re-importing does not delete it from the target
Relation resolution by nameIf the target entity for a relation field does not exist at import time, the field is created but the relation won't resolve until the target is imported
No diff on importImport returns counts (created/updated/skipped) but not a detailed diff
No auto-publishImport writes definitions only; publish must be called separately per entity if DDL changes are needed
No selective exportAlways exports all published entities with no filtering
Skipped fields lack detailReturns skipped: N count but not which fields were skipped