Data persistence and identifiers
Map forms to relational tables, store generic submissions and use portable native UUID v7 identifiers.
Generate a Database Table from a Form
Purpose
**Manage Data Models → Create table from form** converts an already saved form into three coordinated assets:
1. an editable Data Model stored in system metadata;
2. a new physical database table, created through the provider-neutral ORM migration planner;
3. a Form and Data Mapping that writes the form to that Data Model.
The workflow is deliberately conservative. It creates a **new** table only. It never alters or drops an existing table, column, or mapping.
It currently supports unmapped **System** forms. Functional forms have a URL/server-action submission contract, and Survey response tables are owned by Survey Deployment; both are rejected rather than being silently reconfigured.
Workflow
1. Give every control that must be stored a unique **Other → Property Name**.
2. Open **Manage Data Models** and select **Create table from form**.
3. Select the form and, if necessary, override the generated Data Model name, table name, schema, or data source.
4. Select **Generate preview**. Check the generated fields and SQL migration plan.
5. Select **Create table and mapping**. The system validates the model, creates the table, registers the model, and saves the form mapping.
The generated table has an application-assigned UUID v7 primary key. It is stored as PostgreSQL `uuid`, SQL Server `uniqueidentifier`, or MySQL `binary(16)`. FormPlatform assigns it before insert, so no provider-specific UUID default or identity-return behavior is required. See Native UUID v7 identifiers.
Type rules
| Form control | Generated Data Model type | Notes |
|---|---|---|
| Text input, textarea | `String` | File inputs store a platform media ID as `String`. |
| Number/range input | `Decimal` | Use a manual model edit later if an integer type is required. |
| Date input/calendar | `Date` | |
| Date-time input | `DateTime` | |
| Checkbox | `Boolean` when its configured value is Boolean; otherwise `String` | A Boolean checkbox is `true`/`false`; an unchecked custom-value checkbox is `null`. |
| Radio | inferred from option values | All Boolean options produce `Boolean`; all integer options produce `Int64`; other values produce `String`. |
| Dropdown | inferred from option values | A multiple dropdown is stored as `Json`. |
| AsyncSelect / Tree | `String` for one selected ID; `Json` for multiple IDs | Configure a manual reference afterwards when a database foreign-key relationship is desired. |
| GeoLocation | `Json` by default | The browser location snapshot, including latitude, longitude, accuracy and capture time, is stored in one JSON column. With `Value format = coordinates`, it becomes a `String` in `latitude,longitude` form. |
Required controls produce non-nullable columns. Other controls produce nullable columns. A control without a Property Name is not persisted and is listed as a preview warning.
After generation
Use **Form and Data Mapping** for advanced work:
- rename a logical property while retaining a different physical column name;
- create `Reference` attributes and relation-aware selectors;
- choose a different persistence mode;
- add triggers, ownership rules, or field-level read/write mapping.
For an existing database object, use **Create from database table** instead. For an existing Data Model, use the general ORM migration-plan endpoint and review every non-safe operation manually.
Incremental synchronization of a generated form
After the first table has been created, a System form can gain new controls. Open **Manage Data Models → Synchronize form fields**, select the mapped form, and generate a preview.
This workflow is deliberately additive:
- a new persistable control with a new Property Name adds a matching nullable Data Model attribute, a nullable database column, and an explicit form mapping;
- a control that matches an existing writable Data Model attribute but has no mapping adds only the mapping;
- an existing mapped control is reported as unchanged;
- a changed type, a changed Radio option value type, a renamed/missing attribute, a reference-path mapping, a key change, or any SQL `ALTER`/delete operation is reported for manual review and is never executed by this workflow.
New columns always begin nullable, including when the control is required. This keeps existing records valid. Required behaviour remains enforced by the form validation contract for subsequent submissions.
Each successful run writes `mapping.options.formTableSynchronization` with the source form ID/name, a SHA-256 hash of its current schema, a timestamp, and a format version. It is an audit marker for administrators and future tooling; it is not used to silently apply a migration.
Safety and recovery
The create endpoint rejects an existing table, existing Data Model name, or a form that already has a Data Model mapping. It validates all of those conditions during preview and again before execution.
If table creation fails, the just-created metadata model is removed. If table creation succeeds but saving the form mapping later fails, the table and Data Model are retained and no table is dropped automatically. Open **Form and Data Mapping** to finish the mapping after resolving the reported error.
The same retention rule applies to incremental synchronization: after a successful `ADD COLUMN`, FormPlatform never drops that column if publishing the Data Model or mapping later fails. Correct the reported metadata issue and run synchronization again; it will detect the column and safely finish the remaining mapping work.
---
Generic submission records
Purpose
Forms without a Data Model mapping still need durable, maintainable data. FormPlatform now treats every such submission as a generic document record, rather than as a write-only event.
The feature is intended for contact forms, small workflows, application forms, imported third-party forms, and early prototypes where a relational model is not yet justified.
Storage model
Relational form storage extends `form_submissions` with these fields:
| Field | Meaning |
|---|---|
| `id` | Stable record ID used in URLs. |
| `form_id` | Owning form. |
| `data_json` | The submitted JSON document. |
| `submitted_at` | First submission time. |
| `submitted_by` | System user ID at creation, or `NULL` for anonymous submissions. |
| `updated_at` | Most recent write time. |
| `updated_by` | System user ID of the most recent edit, or `NULL`. |
| `version` | Optimistic-concurrency value. |
| `schema_revision` | Immutable semantic form-schema revision used when the record was created. |
`Memory` and `File` providers implement the same contract. In the File provider, revision snapshots live under the configured form store's `_revisions` directory. In relational storage they live in `form_schema_revisions`.
Access and routes
Anonymous visitors may create a record when normal form access permits it, but are never given a record-reading route. Signed-in system users require **Edit** permission on the form to review, update, or delete generic records.
| Purpose | Route |
|---|---|
| Open a submitted record | `/forms/{formId}/{recordId}` |
| List generic records | `/admin/forms/{formId}/submissions` |
| List API | `GET /api/forms/{formId}/submissions` |
| Read API | `GET /api/forms/{formId}/generic-submissions/{recordId}` |
| Update API | `PUT /api/forms/{formId}/generic-submissions/{recordId}` |
| Delete API | `DELETE /api/forms/{formId}/generic-submissions/{recordId}` |
| Current schema snapshot | `GET /api/forms/{formId}/schema-revisions/current` |
| One schema snapshot | `GET /api/forms/{formId}/schema-revisions/{revision}` |
The Viewer automatically changes a newly-created signed-in generic record to `/forms/{formId}/{recordId}`. Anonymous submitters remain on their configured completion page and cannot infer or reuse this management route.
Updates carry `keys: { id, version }`. If a different administrator saved the record first, the server rejects the stale write instead of silently overwriting it.
Semantic schema history
Each form gets a first semantic snapshot when it is first saved or first needs a revision. A new revision is created only when a change affects the meaning, collection, or interpretation of data, for example:
- an input is added, removed, renamed, or its property name changes;
- validation, requiredness, visibility logic, read-only logic, values, or functional component settings change;
- data-related nested component structure changes.
Pure presentation is deliberately excluded: `runtimeCss`, generated CSS candidates, `exportedAt`, root `presentation`, and visual CSS class/style properties do not create a new revision. A colour, spacing, gradient, or page background must not make an old answer harder to interpret.
At present, the snapshot is an immutable JSON copy in the form store. It does not attempt to replay old Action Code, external JavaScript files, or server triggers; those are executable deployment behavior and need their own release/version discipline.
Pre-existing `form_submissions` rows remain at revision `0`: their historic schema is unknown and is not falsely labelled as the current schema. New records receive the current positive revision.
Form-level completion and appearance
In **Designer → Form settings**, configure **Success redirect URL**. It must be a site-relative URL such as `/thank-you`; a Button's existing redirect remains an explicit per-button override. **Replace browser history** controls whether the completion page replaces the current form URL.
In **Designer → Form appearance**, configure:
- a page-level Tailwind/CSS class;
- inline page CSS (for example a page background);
- full-width layout;
- full-viewport presentation.
Full viewport hides the authenticated application shell for the Viewer route so a form can occupy the browser page. This is presentation-only and does not create a semantic revision.
Generic submission triggers
In **Form and Data Mapping**, select **Generic JSON submissions** and configure its dedicated trigger chain. This is deliberately separate from **Main Entity Triggers**: entity triggers operate on ORM `DynamicEntity` instances; generic triggers operate on a mutable JSON document.
Supported lifecycle events are `Validate`, `BeforeInsert`, `AfterInsert`, `BeforeUpdate`, `AfterUpdate`, `BeforeDelete`, and `AfterDelete`. `Validate` always runs before an insert or update. Use `Before*` when an action must change data that will be written. `After*` actions receive a copy for notifications and follow-up work; their changes are not written a second time.
The built-in actions are:
| Action | Example parameter | Purpose |
|---|---|---|
| `RequireFields` | `{ "fields": ["email"], "message": "Email is required." }` | Rejects the mutation with a field error. Dotted JSON paths are supported. |
| `SetFields` | `{ "updatedBy": "@userId", "updatedAt": "@Datetime" }` | Adds or changes document properties. `@userId`, `@Datetime`, and `@id` are supported. |
| `Notify` | `{ "message": "Saved.", "type": "success" }` | Returns a toast to the browser. |
| `NoOp` | `{}` | Useful for testing a lifecycle chain. |
External modules can add actions by implementing `IGenericSubmissionActionsProvider` and registering it from their module's `ConfigureServices`. Names are global and must be unique. The generic trigger contract intentionally does not expose SQL, a fake entity model, or transactions; promote a workflow to a Data Model when it needs those guarantees.
---
Native UUID v7 identifier standard
FormPlatform-owned persistent identifiers use RFC 9562 UUID version 7 values. The application creates the identifier before `INSERT`; databases store it in their native compact type.
| Layer/provider | Representation |
|---|---|
| .NET and ORM | `Guid`, `DataValueKind.Guid` |
| JSON, routes and form values | canonical lower-case UUID string (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) |
| PostgreSQL | `uuid` |
| SQL Server | `uniqueidentifier` |
| MySQL | `binary(16)`, with MySqlConnector `GuidFormat=Binary16` |
Do not define `DEFAULT gen_random_uuid()`, `NEWID()`, `UUID()`, or a text `varchar(36)` primary key for new FormPlatform-owned entities. Use `Uuid7.NewGuid()`/`Uuid7.NewString()`, or let `DynamicRepository.InsertAsync` populate an empty writable Guid key. Mark that key with `ValueKind = Guid`, `IsKey = true`, `IsGenerated = false`.
var model = new EntityModel(
"ExampleItem",
"example_item",
null,
[
new("id", "id", DataValueKind.Guid, false, true, false),
new("ownerId", "owner_id", DataValueKind.Guid, false),
new("name", "name", DataValueKind.String, false, MaxLength: 200)
]);
var item = new DynamicEntity(model, new Dictionary<string, object?>
{
["id"] = Uuid7.NewGuid(),
["ownerId"] = ownerId,
["name"] = "Example"
});
Provider DDL:
-- PostgreSQL
CREATE TABLE example_item (
id uuid PRIMARY KEY,
owner_id uuid NOT NULL,
name varchar(200) NOT NULL
);
-- SQL Server
CREATE TABLE example_item (
id uniqueidentifier NOT NULL PRIMARY KEY,
owner_id uniqueidentifier NOT NULL,
name nvarchar(200) NOT NULL
);
-- MySQL
CREATE TABLE example_item (
id binary(16) NOT NULL PRIMARY KEY,
owner_id binary(16) NOT NULL,
name varchar(200) NOT NULL
);
What remains a string
Do not force non-FormPlatform identities into UUID columns. Provider subjects (Google/Facebook/DNN IDs), slugs, order numbers, license IDs, security tokens, hashes, polymorphic subject IDs that can contain reserved words, and JSON data remain strings. A foreign key to a FormPlatform-owned entity is a Guid even when its public DTO is a UUID string.
Ordering and indexes
UUID v7 embeds creation time, so newly created keys have much better index locality than random UUID v4 values. It does not replace an explicit `created_at` business field. PostgreSQL and MySQL `binary(16)` preserve the RFC byte order. SQL Server still uses its native `uniqueidentifier` comparison rules; UUID v7 nevertheless keeps one portable identifier contract and avoids text storage and comparison.
Import and seed source keys
An identifier in an external file is not automatically a FormPlatform entity ID. Country codes such as `AD`, legacy natural keys and third-party row keys must remain import source keys. Before inserting a related batch, allocate one UUID v7 for every source key and use that same map to translate both primary IDs and parent/foreign references. Store business codes such as `country_code` in their own columns. Never pass an arbitrary source key to `Uuid7.Parse`.
Development reset
This conversion deliberately has no compatibility or data migration layer. Recreate each development database after updating the host and modules. Reusing a database whose migration history describes `varchar(36)` columns will either cause checksum errors or type mismatches. Do not edit a migration after release; this destructive reset is acceptable only because the current databases contain disposable development data.
Module checklist
1. Declare owned IDs and references as `DataValueKind.Guid`.
2. Use native provider DDL shown above and omit database UUID defaults.
3. Use `Uuid7` when code explicitly creates a record; repository-generated empty Guid keys are also UUID v7.
4. Keep API values as canonical UUID strings and validate them at the boundary.
5. Configure MySQL through FormPlatform so `GuidFormat=Binary16` is applied.
6. Add provider integration tests for inserts, reads, filters, joins, updates and deletes.
7. A module that calls `Uuid7` or depends on ORM-generated UUID v7 keys must declare SDK `minimum` `1.3.0` in `module.json`.