Skip to content

Asset Storage

From v1.3.0, BPMN processes, form schemas, and document templates are persisted to PostgreSQL via the LDE backend. The frontend uses a write-through cache with async hydration: all reads are synchronous from localStorage (zero-latency UI), writes update localStorage immediately and then POST to the backend in the background, and on editor mount a GET hydration call replaces local non-readonly records with the authoritative server state.


Architecture

graph TD
    A[Editor component] -->|mount| B[hydrateFromServer]
    A -->|save| C[localStorage cache]
    B -->|background fetch| D[LDE Backend]
    D --> E[(PostgreSQL โ€” lde_assets)]

Write-through cache strategy

On save, each service:

  1. Writes the updated record to localStorage immediately โ€” the UI reflects the change synchronously with zero latency.
  2. Fires a background fetch to POST /v1/assets/{type} โ€” no await, no spinner. If the network call fails, a warning is logged to the browser console but the user experience is unaffected.

On delete, each service:

  1. Removes the record from localStorage immediately.
  2. Fires a background DELETE /v1/assets/{type}/:id.

One refusal is not swallowed. When the backend answers a BPMN save with 409 PROCESS_ID_TAKEN, BpmnService.saveProcessDetailed() returns the conflict, and the BPMN Modeler shows it: a save that renames a process to a process id the organisation already uses is reported as not saved, and an import with such an id asks whether to replace the existing process or give the import another process id.

Examples are seeded on the frontend from static files in public/examples/ and from defaultTemplates.ts, and carry status: 'example'. Seeding saves them through the same service call as a user edit, so every example with readonly: false is written to the backend too. Only records with readonly: true are never written: the BPMN example example_dvtp_toestemming and the document template example_dvtp_consent_receipt. Hydration keeps those two from localStorage and takes everything else from the server.


Hydration on mount

Each editor component runs a second useEffect alongside the example seed effect:

useEffect(() => {
  BpmnService.hydrateFromServer().then(setProcesses);
}, []);

hydrateFromServer() calls GET /v1/assets/bpmn, merges the server response with the local readonly examples, writes the merged list back to localStorage, and returns it. If the request fails for any reason the existing localStorage contents are returned unchanged and a warning is logged.

This means the editor always starts from local cache (instant render) and then silently updates with authoritative server data within one network round-trip.


Database schema

process_definitions

CREATE TABLE process_definitions (
  id                     UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
  lde_id                 VARCHAR(255) UNIQUE NOT NULL,
  bpmn_process_id        VARCHAR(255) NOT NULL,
  name                   VARCHAR(500) NOT NULL,
  description            TEXT,
  xml                    TEXT         NOT NULL,
  process_role           VARCHAR(20)  NOT NULL DEFAULT 'standalone'
                           CHECK (process_role IN ('shell', 'subprocess', 'standalone')),
  called_element         VARCHAR(255),
  linked_dmn_templates   TEXT[]       NOT NULL DEFAULT '{}',
  status                 VARCHAR(20)  NOT NULL DEFAULT 'wip'
                           CHECK (status IN ('example', 'wip', 'e2e')),
  readonly               BOOLEAN      NOT NULL DEFAULT FALSE,
  schema_version         INTEGER      NOT NULL DEFAULT 1,
  deployed_at            TIMESTAMPTZ,
  operaton_url           TEXT,
  operaton_deployment_id TEXT,
  deployed_forms         TEXT[]       NOT NULL DEFAULT '{}',
  deployed_documents     TEXT[]       NOT NULL DEFAULT '{}',
  language               VARCHAR(2),                         -- v1.6.0
  organization           VARCHAR(100),                        -- v1.6.0
  board_owner            VARCHAR(50),
  shell_id               VARCHAR(255),
  created_at             TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
  updated_at             TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_pd_bpmn_process_id ON process_definitions (bpmn_process_id);
CREATE INDEX idx_pd_called_element  ON process_definitions (called_element)
  WHERE called_element IS NOT NULL;
CREATE INDEX idx_pd_process_role    ON process_definitions (process_role);
CREATE INDEX idx_pd_language        ON process_definitions (language)
  WHERE language IS NOT NULL;
CREATE INDEX idx_pd_organization    ON process_definitions (organization)
  WHERE organization IS NOT NULL;

CREATE UNIQUE INDEX idx_pd_org_bpmn_process_id_unique
  ON process_definitions (COALESCE(organization, ''), bpmn_process_id)
  WHERE status <> 'e2e' AND bpmn_process_id <> 'unknown';

lde_id is the internal LDE identifier (e.g. process_1774384869117). bpmn_process_id is the <process id="..."> value from the BPMN XML โ€” used for subprocess lookup during bundle assembly. A row that deploy creates for a process that was never saved has lde_id <organization>:<process id> (the bare process id without an organisation).

A stored process is unique per organisation and process id: two organisations may share a process id, one organisation may not hold it twice. idx_pd_org_bpmn_process_id_unique enforces this, exempting rows with status e2e (the E2E fixture shells reuse a seeded example's key on purpose) and the placeholder id unknown. upsertBpmn() checks first and throws ProcessIdTakenError, which the route answers with 409 PROCESS_ID_TAKEN; a save that loses a race against the index is reported the same way.

language (ISO 639-1, nullable) and organization (open-ended key, nullable) were added in v1.6.0 to drive list-panel grouping and the deploy-time language consistency check; partial indexes skip the common NULL case. See Multilingualism. The deployed_* columns were added in v1.4.0 for the deployment metadata flowback used by listPublicBundles().

form_schemas

CREATE TABLE form_schemas (
  id             TEXT        PRIMARY KEY,
  name           TEXT        NOT NULL,
  description    TEXT,
  schema         JSONB       NOT NULL,
  status         TEXT        NOT NULL DEFAULT 'wip',  -- NOT NULL since v2026.09.5
  schema_version INTEGER     NOT NULL DEFAULT 1,
  language       VARCHAR(2),                  -- v1.6.0
  organization   VARCHAR(100),                 -- v1.6.0
  created_at     TIMESTAMPTZ NOT NULL,
  updated_at     TIMESTAMPTZ NOT NULL
);

CREATE INDEX idx_fs_language     ON form_schemas (language)
  WHERE language IS NOT NULL;
CREATE INDEX idx_fs_organization ON form_schemas (organization)
  WHERE organization IS NOT NULL;

document_templates

CREATE TABLE document_templates (
  id             TEXT        PRIMARY KEY,
  name           TEXT        NOT NULL,
  description    TEXT,
  process_key    TEXT,
  service_id     TEXT,
  schema_version INTEGER     NOT NULL DEFAULT 1,
  zones          JSONB       NOT NULL,
  bindings       JSONB       NOT NULL DEFAULT '[]',
  assets         JSONB       NOT NULL DEFAULT '[]',
  status         TEXT        NOT NULL DEFAULT 'wip',  -- NOT NULL since v2026.09.5
  language       VARCHAR(2),                  -- v1.6.0
  organization   VARCHAR(100),                 -- v1.6.0
  created_at     TIMESTAMPTZ NOT NULL,
  updated_at     TIMESTAMPTZ NOT NULL
);

CREATE INDEX idx_dt_language     ON document_templates (language)
  WHERE language IS NOT NULL;
CREATE INDEX idx_dt_organization ON document_templates (organization)
  WHERE organization IS NOT NULL;

Migrations

Schema migrations run automatically on backend startup via migrate() in src/db/migrate.ts, called from startServer() in src/index.ts. The migration uses inline SQL with CREATE TABLE IF NOT EXISTS guards, making it idempotent and safe to run on every deploy.

Duplicate stored processes are resolved before the unique index is created, so the index cannot fail on old data. Per organisation and process id, the most recently deployed row stays โ€” the most recently updated one when none was deployed; rows that point at the others through shell_id are repointed to it; the others are copied, whole as JSON together with the id of the row kept, to process_definitions_dedup_archive and then deleted. Each archived row is logged as a warning ([DB] Archived duplicate stored processes before enforcing uniqueness). Once the index exists no duplicate can form again, so on every later start the step finds nothing.

status became NOT NULL in v2026.09.5. form_schemas.status and document_templates.status defaulted to 'wip' but were nullable, so a row written outside the upserts could reach the API as status: null, which the OpenAPI description forbids. The migration backfills any NULL and then sets the columns NOT NULL; both steps are safe to repeat on every start. Measured on 19 September 2026, no form on acceptance (253) or production (42) is without a status.

Input is validated before it reaches Postgres. The four upserts, the deploy mark and the ROPA delete check their input first and answer 400 INVALID_INPUT with a detail naming every field that failed, where a missing field, an unknown status or a malformed id used to come back as a 500. The checks follow the database's own constraints โ€” presence where a column is NOT NULL with no fallback, type where a value is bound, and enum where a column has a CHECK โ€” so form and document status, which has no CHECK, accepts any string. A blank or whitespace-only value is refused on the fields that identify or name a record: a ROPA record's title and bpmnProcessId, and the id, name and bpmnProcessId of processes, forms and documents. Other text fields may still be empty, so a ROPA draft can be saved while it is written.

Timestamps are ISO strings in every mapper. mapBpmn, mapForm and mapDocument return createdAt and updatedAt as ISO strings, as mapRopaRecord already did. The wire format did not change โ€” res.json already serialised the Date the same way โ€” but the domain types now say string and match what the upserts receive.

// src/index.ts
const startServer = async () => {
  await migrate();  // runs before app.listen()
  // ...
};


Frontend service files

File Storage key Backend prefix
src/services/bpmnService.ts linkedDataExplorer_bpmnProcesses /v1/assets/bpmn
src/services/formService.ts linkedDataExplorer_formSchemas /v1/assets/forms
src/services/documentService.ts linkedDataExplorer_documentTemplates /v1/assets/documents

All three services follow the same interface: getAll(), save(record), delete(id), getById(id), hydrateFromServer().


BpmnProcess type

interface BpmnProcess {
  id: string;
  name: string;
  description?: string;
  xml: string;
  createdAt: string;
  updatedAt: string;
  linkedDmnTemplates: string[];
  readonly?: boolean;
  status?: 'example' | 'wip' | 'e2e';
  bpmnProcessId?: string;                               // <process id="..."> from XML
  processRole?: 'shell' | 'subprocess' | 'standalone'; // hierarchy role
  calledElement?: string;                               // parent shell's bpmnProcessId
  shellId?: string;                                     // local record id of the owning shell
  language?: 'en' | 'nl' | 'de';
  organization?: string;
}

Type safety โ€” DB row types and mappers

The assets.service.ts functions use the three-layer DB type pattern to safely bridge PostgreSQL row data and service-layer domain objects. pool.query<T>() is typed with a row type from src/db/types.ts, and results are transformed to camelCase domain objects via pure mapper functions in src/db/mappers.ts. This eliminates implicit any casts that TypeScript strict mode rejects on some platforms, and keeps all snake_case โ†’ camelCase and null โ†’ undefined conversion in one place.

See DB Type Layer for the full pattern description and guidance on adding new entities.