Saltar a contenido

Operate Tasklist Mvp Detailed

Spec implementable para los dos webapps prioritarios del MVP. Operate (~10K LOC, ~3 sprints): lista filtrable, detalle con tree, incidents, batch operations. Tasklist (~8K LOC, ~3 sprints): mis tasks, claim, complete, forms via JSON Schema. Ambos consultan PostgreSQL directamente (real-time, sin export lag). Stack: React/Vue + Tailwind + REST API + bpmn-js viewer. Schemas SQL completos, queries optimizadas, índices, paginación cursor-based, real-time updates via SSE en Phase 2.

Por qué Operate y Tasklist primero

De los 6 componentes UI, Operate y Tasklist son los únicos que tocan al usuario diariamente:

Componente Usuario Frecuencia
Operate Ops, devs Diaria (incidents, debugging)
Tasklist Business users Continua (work queue)
Optimize Analysts Semanal/mensual
Identity Admins Rara
Modeler Process designers Ocasional
Connectors Devs Setup, después raro

Sin Operate, no puedes operar el sistema. Sin Tasklist, no hay human workflow. El resto puede esperar.

OPERATE — Process monitoring

MVP scope (3 sprints)

Sprint 1 — Lista + detalle básico

Features: - Lista de process instances con filtros - Detalle de una instance: state, start/end dates, variables, current elements - Búsqueda por business ID - Paginación cursor-based

Endpoints API:

GET  /v2/process-instances/search?state=ACTIVE&bpmnProcessId=loan-approval&limit=50&cursor=...
GET  /v2/process-instances/{key}
GET  /v2/process-instances/{key}/variables
GET  /v2/process-instances/{key}/element-instances
GET  /v2/process-instances/{key}/history

Sprint 2 — Incidents

Features: - Lista de incidents activos - Detalle de incident con stack trace - Resolve incident manualmente - Filtros por errorType, processDefinitionId

Endpoints:

GET  /v2/incidents/search
GET  /v2/incidents/{key}
POST /v2/incidents/{key}/resolve

Sprint 3 — Batch operations + BPMN viewer

Features: - Cancel múltiples instances (selection) - Resolve múltiples incidents - BPMN viewer con overlay del current state (vía bpmn-js) - Auto-refresh polling

Endpoints:

POST /v2/operations/batch/cancel-instances
POST /v2/operations/batch/resolve-incidents
GET  /v2/process-definitions/{key}/xml

Schema PostgreSQL

-- Tabla principal (ya existe en el engine state)
CREATE TABLE process_instances (
    process_instance_key BIGINT PRIMARY KEY,
    bpmn_process_id TEXT NOT NULL,
    process_definition_key BIGINT NOT NULL,
    process_definition_version INT NOT NULL,
    parent_process_instance_key BIGINT,
    root_process_instance_key BIGINT NOT NULL,
    business_id TEXT,
    tenant_id TEXT NOT NULL,
    state TEXT NOT NULL,  -- ACTIVE | COMPLETED | CANCELED | INCIDENT
    start_date TIMESTAMPTZ NOT NULL,
    end_date TIMESTAMPTZ,
    has_incident BOOLEAN NOT NULL DEFAULT FALSE,
    tags TEXT[]
);

-- Índices críticos para Operate
CREATE INDEX idx_pi_tenant_state ON process_instances(tenant_id, state, start_date DESC);
CREATE INDEX idx_pi_bpmn_id ON process_instances(bpmn_process_id, start_date DESC);
CREATE INDEX idx_pi_business_id ON process_instances(business_id) WHERE business_id IS NOT NULL;
CREATE INDEX idx_pi_incident ON process_instances(tenant_id, has_incident) WHERE has_incident = TRUE;
CREATE INDEX idx_pi_parent ON process_instances(parent_process_instance_key) WHERE parent_process_instance_key IS NOT NULL;

-- Element instances (para tree view)
CREATE TABLE element_instances (
    element_instance_key BIGINT PRIMARY KEY,
    process_instance_key BIGINT NOT NULL REFERENCES process_instances,
    parent_scope_key BIGINT,
    element_id TEXT NOT NULL,
    element_type TEXT NOT NULL,
    state TEXT NOT NULL,
    activated_at TIMESTAMPTZ NOT NULL,
    completed_at TIMESTAMPTZ,
    tenant_id TEXT NOT NULL
);

CREATE INDEX idx_ei_pi ON element_instances(process_instance_key);
CREATE INDEX idx_ei_parent ON element_instances(parent_scope_key) WHERE parent_scope_key IS NOT NULL;

-- Variables (per scope)
CREATE TABLE variables (
    variable_key BIGINT PRIMARY KEY,
    scope_key BIGINT NOT NULL REFERENCES element_instances,
    process_instance_key BIGINT NOT NULL,
    name TEXT NOT NULL,
    value JSONB NOT NULL,
    updated_at TIMESTAMPTZ NOT NULL,
    UNIQUE (scope_key, name)
);

CREATE INDEX idx_var_pi ON variables(process_instance_key);

-- Incidents
CREATE TABLE incidents (
    incident_key BIGINT PRIMARY KEY,
    process_instance_key BIGINT NOT NULL REFERENCES process_instances,
    element_instance_key BIGINT REFERENCES element_instances,
    job_key BIGINT,
    tenant_id TEXT NOT NULL,
    error_type TEXT NOT NULL,  -- JOB_NO_RETRIES | UNHANDLED_ERROR_EVENT | EXTRACT_VALUE_ERROR
    error_message TEXT NOT NULL,
    state TEXT NOT NULL,        -- ACTIVE | RESOLVED
    created_at TIMESTAMPTZ NOT NULL,
    resolved_at TIMESTAMPTZ,
    resolved_by TEXT
);

CREATE INDEX idx_incident_tenant_state ON incidents(tenant_id, state, created_at DESC);
CREATE INDEX idx_incident_pi ON incidents(process_instance_key);

-- Event log (history)
CREATE TABLE event_log (
    event_id BIGSERIAL PRIMARY KEY,
    process_instance_key BIGINT,
    element_instance_key BIGINT,
    tenant_id TEXT NOT NULL,
    event_type TEXT NOT NULL,
    intent TEXT NOT NULL,
    payload JSONB NOT NULL,
    timestamp TIMESTAMPTZ NOT NULL
);

CREATE INDEX idx_event_pi ON event_log(process_instance_key, timestamp);
CREATE INDEX idx_event_tenant_time ON event_log(tenant_id, timestamp DESC);

Queries clave optimizadas

Lista de instances filtrable

-- Cursor-based pagination (preferred over OFFSET para large datasets)
SELECT 
    pi.process_instance_key,
    pi.bpmn_process_id,
    pi.process_definition_version,
    pi.business_id,
    pi.state,
    pi.start_date,
    pi.end_date,
    pi.has_incident,
    pi.tags
FROM process_instances pi
WHERE pi.tenant_id = $1
  AND pi.state = ANY($2)            -- e.g., ['ACTIVE', 'INCIDENT']
  AND ($3::text IS NULL OR pi.bpmn_process_id = $3)
  AND ($4::text IS NULL OR pi.business_id = $4)
  AND ($5::tsrange IS NULL OR pi.start_date <@ $5)
  AND ($6::bigint IS NULL OR pi.process_instance_key < $6)  -- cursor
ORDER BY pi.process_instance_key DESC
LIMIT 50;

Por qué cursor-based vs OFFSET: OFFSET es O(N) — degrada en tablas grandes. Cursor (WHERE key < $cursor ORDER BY key DESC LIMIT N) es O(log N).

Detalle de instance con tree de elements

WITH RECURSIVE element_tree AS (
    -- Root level (parent_scope_key IS NULL)
    SELECT 
        ei.element_instance_key,
        ei.parent_scope_key,
        ei.element_id,
        ei.element_type,
        ei.state,
        ei.activated_at,
        ei.completed_at,
        0 AS depth,
        ei.element_id AS path
    FROM element_instances ei
    WHERE ei.process_instance_key = $1
      AND ei.parent_scope_key IS NULL

    UNION ALL

    -- Recurse children
    SELECT 
        ei.element_instance_key,
        ei.parent_scope_key,
        ei.element_id,
        ei.element_type,
        ei.state,
        ei.activated_at,
        ei.completed_at,
        et.depth + 1,
        et.path || ' / ' || ei.element_id
    FROM element_instances ei
    JOIN element_tree et ON ei.parent_scope_key = et.element_instance_key
)
SELECT * FROM element_tree ORDER BY depth, activated_at;

Variables de una instance (todos los scopes)

SELECT 
    v.scope_key,
    ei.element_id AS scope_element_id,
    v.name,
    v.value,
    v.updated_at
FROM variables v
JOIN element_instances ei ON ei.element_instance_key = v.scope_key
WHERE v.process_instance_key = $1
ORDER BY v.scope_key, v.name;

History timeline

SELECT 
    el.timestamp,
    el.event_type,
    el.intent,
    el.payload,
    ei.element_id
FROM event_log el
LEFT JOIN element_instances ei ON ei.element_instance_key = el.element_instance_key
WHERE el.process_instance_key = $1
ORDER BY el.timestamp DESC, el.event_id DESC
LIMIT 200;

Batch operations

Cancel múltiples instances

-- Implementación naive: hacer N inserts al command_log
INSERT INTO command_log (intent, process_instance_key, payload, created_at)
SELECT 
    'CANCEL',
    process_instance_key,
    jsonb_build_object('reason', $1, 'operator', $2),
    NOW()
FROM unnest($3::bigint[]) AS process_instance_key
WHERE EXISTS (
    SELECT 1 FROM process_instances 
    WHERE process_instance_key = process_instance_key 
      AND state IN ('ACTIVE', 'INCIDENT')
);

El engine procesa estos CANCEL commands secuencialmente. Para mejor performance con batches grandes, considerar:

-- Optimization: batch operations table que el engine procesa
INSERT INTO batch_operations (
    batch_operation_key, type, payload, status, created_at, created_by
) VALUES (
    nextval('batch_op_seq'),
    'CANCEL_PROCESS_INSTANCES',
    jsonb_build_object(
        'process_instance_keys', $1::bigint[],
        'reason', $2
    ),
    'PENDING',
    NOW(),
    $3
);

Engine tiene un background worker que ejecuta batch operations en chunks, reporta progress.

Frontend architecture

Stack recomendado: - React (familiar, ecosystem maduro) o Vue (más simple) - Tailwind CSS (utility-first, fast iteration) - TanStack Query (cache + invalidation automático) - bpmn-js para BPMN viewer

Componentes principales:

Operate/
├── ProcessInstanceList/
│   ├── Filters.tsx          (state, bpmn_id, dates, business_id)
│   ├── Table.tsx            (cursor pagination)
│   └── BulkActions.tsx      (cancel selected)
├── ProcessInstanceDetail/
│   ├── Header.tsx           (state, dates, links)
│   ├── BpmnViewer.tsx       (bpmn-js + overlay)
│   ├── ElementTree.tsx      (recursive tree component)
│   ├── VariablesTable.tsx
│   └── HistoryTimeline.tsx
├── Incidents/
│   ├── IncidentList.tsx
│   └── IncidentDetail.tsx
└── shared/
    ├── Sidebar.tsx
    └── api/
        └── queries.ts       (TanStack Query hooks)

BPMN viewer con overlay

import BpmnViewer from 'bpmn-js/lib/NavigatedViewer';

const viewer = new BpmnViewer({ container: '#canvas' });
await viewer.importXML(bpmnXml);

const overlays = viewer.get('overlays');
const canvas = viewer.get('canvas');

// Highlight elements based on state
elementInstances.forEach(ei => {
  // Color por state
  const className = {
    'ACTIVE': 'highlight-active',
    'COMPLETED': 'highlight-completed',
    'TERMINATED': 'highlight-terminated'
  }[ei.state];
  canvas.addMarker(ei.element_id, className);

  // Overlay con count si multi-instance
  if (ei.count > 1) {
    overlays.add(ei.element_id, {
      position: { top: -10, right: -10 },
      html: `<div class="instance-count">${ei.count}</div>`
    });
  }
});

CSS:

.highlight-active rect { fill: #FFF3CD !important; stroke: #FFC107 !important; }
.highlight-completed rect { fill: #D4EDDA !important; stroke: #28A745 !important; }
.highlight-terminated rect { fill: #F8D7DA !important; stroke: #DC3545 !important; }

Real-time updates

Phase 1 — Polling (simple)

// TanStack Query con polling
const { data } = useQuery({
  queryKey: ['process-instance', pid],
  queryFn: () => fetch(`/v2/process-instances/${pid}`).then(r => r.json()),
  refetchInterval: 5000,  // 5s polling
  refetchOnWindowFocus: true
});

Funciona bien hasta ~100 usuarios concurrentes mirando Operate.

Phase 2 — Server-Sent Events (SSE)

Si polling se vuelve problema, switch a SSE:

// Frontend
const eventSource = new EventSource(`/v2/process-instances/${pid}/stream`);
eventSource.onmessage = (event) => {
  const update = JSON.parse(event.data);
  queryClient.setQueryData(['process-instance', pid], update);
};

// Backend (Express/NestJS)
app.get('/v2/process-instances/:key/stream', async (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');

  const channel = `pi-${req.params.key}`;
  const subscriber = pgPool.connect();
  await subscriber.query(`LISTEN ${channel}`);

  subscriber.on('notification', (msg) => {
    res.write(`data: ${msg.payload}\n\n`);
  });

  req.on('close', () => {
    subscriber.query(`UNLISTEN ${channel}`);
    subscriber.release();
  });
});

// Engine emite NOTIFY al actualizar state
NOTIFY pi_123, '{"intent": "ELEMENT_COMPLETED", "element_id": "task1"}';

PostgreSQL LISTEN/NOTIFY es gratis y escala razonablemente. No requiere Redis ni broker adicional.

TASKLIST — User tasks

MVP scope (3 sprints)

Sprint 1 — Mis tasks + claim

Features: - Lista "Mis tasks" (asignadas a mí) - Lista "Disponibles" (puedo claim) - Claim task - Filtros básicos (priority, due date)

Endpoints:

GET  /v2/user-tasks/search
POST /v2/user-tasks/{key}/assignment
DELETE /v2/user-tasks/{key}/assignee

Sprint 2 — Complete con forms

Features: - Detalle de task con variables - Render form via JSON Schema - Complete task - Validation client + server side

Endpoints:

GET  /v2/user-tasks/{key}
GET  /v2/user-tasks/{key}/form
POST /v2/user-tasks/{key}/completion
PATCH /v2/user-tasks/{key}  (update variables)

Sprint 3 — Search + sort + delegation

Features: - Búsqueda full-text en variables - Sort por priority, dueDate, creationDate - Reassign task a otro usuario (admin only) - Audit log de cambios

Schema PostgreSQL

CREATE TABLE user_tasks (
    user_task_key BIGINT PRIMARY KEY,
    process_instance_key BIGINT NOT NULL,
    process_definition_key BIGINT NOT NULL,
    element_instance_key BIGINT NOT NULL,
    element_id TEXT NOT NULL,
    tenant_id TEXT NOT NULL,

    state TEXT NOT NULL,  -- CREATED | ASSIGNED | COMPLETED | CANCELED | FAILED
    assignee TEXT,
    candidate_users TEXT[],
    candidate_groups TEXT[],

    form_key TEXT,        -- formId reference
    form_id TEXT,         -- deployed form
    form_version INT,

    name TEXT,            -- display name (from BPMN)
    priority INT DEFAULT 50,
    due_date TIMESTAMPTZ,
    follow_up_date TIMESTAMPTZ,

    created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
    assigned_at TIMESTAMPTZ,
    completed_at TIMESTAMPTZ
);

-- Critical indexes
CREATE INDEX idx_task_assignee_state ON user_tasks(assignee, state) 
    WHERE state IN ('CREATED', 'ASSIGNED');
CREATE INDEX idx_task_candidate_users ON user_tasks USING GIN(candidate_users) 
    WHERE state = 'CREATED';
CREATE INDEX idx_task_candidate_groups ON user_tasks USING GIN(candidate_groups) 
    WHERE state = 'CREATED';
CREATE INDEX idx_task_tenant_state_priority ON user_tasks(tenant_id, state, priority DESC, due_date ASC NULLS LAST) 
    WHERE state IN ('CREATED', 'ASSIGNED');
CREATE INDEX idx_task_pi ON user_tasks(process_instance_key);

-- Forms storage
CREATE TABLE forms (
    form_key BIGINT PRIMARY KEY,
    form_id TEXT NOT NULL,
    version INT NOT NULL,
    tenant_id TEXT NOT NULL,
    schema JSONB NOT NULL,        -- JSON Schema definition
    ui_schema JSONB,              -- UI hints for rendering
    deployment_key BIGINT NOT NULL,
    deployed_at TIMESTAMPTZ NOT NULL,
    UNIQUE (form_id, version, tenant_id)
);

-- Audit log de task changes
CREATE TABLE user_task_audit_log (
    audit_log_id BIGSERIAL PRIMARY KEY,
    user_task_key BIGINT NOT NULL,
    action TEXT NOT NULL,         -- ASSIGNED | UNASSIGNED | COMPLETED | UPDATED
    actor TEXT NOT NULL,
    before_state JSONB,
    after_state JSONB,
    timestamp TIMESTAMPTZ DEFAULT NOW() NOT NULL
);

CREATE INDEX idx_audit_task ON user_task_audit_log(user_task_key, timestamp DESC);

Queries clave

"Mis tasks" optimizada

-- Tasks asignados a mí + claimable por mí (yo en candidates o mi group en candidates)
SELECT 
    ut.user_task_key,
    ut.element_id,
    ut.name,
    ut.process_instance_key,
    ut.priority,
    ut.due_date,
    ut.created_at,
    ut.assignee,
    pi.bpmn_process_id,
    pi.business_id
FROM user_tasks ut
JOIN process_instances pi ON pi.process_instance_key = ut.process_instance_key
WHERE ut.tenant_id = $1
  AND ut.state IN ('CREATED', 'ASSIGNED')
  AND (
    ut.assignee = $2                              -- asignadas a mí
    OR (
      ut.state = 'CREATED'                        -- sin asignar
      AND ut.assignee IS NULL
      AND (
        $2 = ANY(ut.candidate_users)              -- yo en candidates
        OR ut.candidate_groups && $3::text[]      -- mis groups overlap
      )
    )
  )
ORDER BY 
    CASE WHEN ut.assignee = $2 THEN 0 ELSE 1 END,  -- mis tasks primero
    ut.priority DESC,
    ut.due_date ASC NULLS LAST,
    ut.created_at ASC
LIMIT 50;

GIN indexes hacen ANY() y && operations sub-100ms.

Claim atomic (evitar race conditions)

-- Atomic claim: solo si nadie más lo claimeó
UPDATE user_tasks
SET 
    assignee = $1,
    state = 'ASSIGNED',
    assigned_at = NOW()
WHERE user_task_key = $2
  AND state = 'CREATED'
  AND assignee IS NULL
  AND (
    $1 = ANY(candidate_users)
    OR candidate_groups && $3::text[]
  )
RETURNING user_task_key, element_id, process_instance_key;

Si retorna 0 rows → alguien más lo claimeó o no tienes permiso. Frontend muestra error friendly.

Search en variables (full-text)

-- Buscar tasks cuyas variables contengan "customer123"
SELECT DISTINCT ut.user_task_key, ut.name, ut.priority, ut.due_date
FROM user_tasks ut
JOIN variables v ON v.process_instance_key = ut.process_instance_key
WHERE ut.tenant_id = $1
  AND ut.state IN ('CREATED', 'ASSIGNED')
  AND v.value::text ILIKE '%' || $2 || '%'
ORDER BY ut.priority DESC
LIMIT 50;

Para search más sofisticada, considerar Postgres FTS:

CREATE INDEX idx_var_fts ON variables USING GIN(to_tsvector('english', value::text));

-- Query
WHERE to_tsvector('english', v.value::text) @@ plainto_tsquery('english', $2);

Forms via JSON Schema

Schema storage

{
  "form_id": "loan-application-v1",
  "version": 1,
  "schema": {
    "type": "object",
    "properties": {
      "amount": {
        "type": "number",
        "minimum": 1000,
        "maximum": 1000000,
        "title": "Loan Amount (USD)"
      },
      "purpose": {
        "type": "string",
        "enum": ["personal", "business", "education"],
        "title": "Purpose"
      },
      "term_months": {
        "type": "integer",
        "enum": [12, 24, 36, 48, 60],
        "title": "Term (months)"
      },
      "notes": {
        "type": "string",
        "title": "Additional notes",
        "format": "textarea"
      }
    },
    "required": ["amount", "purpose", "term_months"]
  },
  "ui_schema": {
    "notes": { "ui:widget": "textarea", "ui:options": { "rows": 4 } },
    "amount": { "ui:widget": "updown" }
  }
}

Frontend rendering

import Form from '@rjsf/core';
import validator from '@rjsf/validator-ajv8';

function TaskCompletionForm({ task, form, onSubmit }) {
  const [variables, setVariables] = useState(task.variables);

  const handleSubmit = ({ formData }) => {
    onSubmit({
      variables: formData
    });
  };

  return (
    <Form
      schema={form.schema}
      uiSchema={form.ui_schema}
      formData={variables}
      validator={validator}
      onSubmit={handleSubmit}
      onChange={({ formData }) => setVariables(formData)}
    >
      <button type="submit">Complete Task</button>
    </Form>
  );
}

react-jsonschema-form maneja: - Field rendering (text, number, date, select, checkbox, etc.) - Validation con AJV - Error display - Custom widgets si necesario

Server-side validation

import Ajv from 'ajv';
const ajv = new Ajv();

async function completeTask(taskKey: number, variables: any) {
  const task = await db.getUserTask(taskKey);
  const form = await db.getForm(task.form_id, task.form_version);

  const validate = ajv.compile(form.schema);
  const valid = validate(variables);

  if (!valid) {
    throw new ValidationError(validate.errors);
  }

  await db.completeTask(taskKey, variables);
}

Doble validación (client + server) — defensive programming.

Frontend architecture

Tasklist/
├── MyTasks/
│   ├── TaskList.tsx          (mis tasks + claimable)
│   ├── TaskFilters.tsx       (priority, due date)
│   └── TaskCard.tsx          (preview)
├── TaskDetail/
│   ├── Header.tsx            (name, assignee, dates)
│   ├── VariablesSummary.tsx  (read-only context)
│   ├── FormRenderer.tsx      (JSON Schema → form)
│   └── Actions.tsx           (claim, complete, unclaim)
├── shared/
│   ├── api/
│   └── components/

Real-time updates

Notificaciones de nuevos tasks

Cuando un task se asigna o aparece en candidates:

-- Trigger en user_tasks
CREATE OR REPLACE FUNCTION notify_task_change() RETURNS trigger AS $$
BEGIN
  IF NEW.assignee IS NOT NULL AND (OLD.assignee IS NULL OR OLD.assignee != NEW.assignee) THEN
    PERFORM pg_notify('user_tasks_' || NEW.assignee, json_build_object(
      'type', 'NEW_TASK',
      'user_task_key', NEW.user_task_key,
      'name', NEW.name
    )::text);
  END IF;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER user_task_notify
AFTER INSERT OR UPDATE ON user_tasks
FOR EACH ROW EXECUTE FUNCTION notify_task_change();

Frontend escucha SSE → muestra toast "New task: Review Loan Application".

Cross-cutting concerns

Authentication & Authorization

Ambas apps: - Authenticate vía session token del Identity layer - Authorization checks en el backend: - Operate: rol operator o admin puede ver/operar - Tasklist: cualquier usuario authenticated puede ver sus tasks; rol admin puede ver todas

// Middleware ejemplo (Express/NestJS)
app.use('/v2/process-instances', requireRole(['operator', 'admin']));
app.use('/v2/user-tasks', requireAuth());  // any user
app.use('/v2/user-tasks/*/admin-actions', requireRole(['admin']));

Tenant isolation

Todas las queries deben incluir tenant_id:

// Middleware: inject tenant_id en el query context
app.use((req, res, next) => {
  req.tenantId = resolveTenantFromToken(req.user);
  next();
});

// Repository wrapper
async function findInstances(tenantId: string, filters: Filters) {
  return db.query(`
    SELECT * FROM process_instances
    WHERE tenant_id = $1 AND ...
  `, [tenantId, ...]);
}

NUNCA permitir queries sin tenant_id clause. Considerar Row-Level Security (RLS) de Postgres como defensa adicional:

ALTER TABLE process_instances ENABLE ROW LEVEL SECURITY;

CREATE POLICY pi_tenant_isolation ON process_instances
  FOR ALL
  USING (tenant_id = current_setting('app.current_tenant'));

-- Engine sets the variable per session
SET LOCAL app.current_tenant = 'tenant-abc';

Caching strategy

Don't over-cache. Postgres es rápido si queries están bien indexadas.

Cache candidates: - process_definitions (rarely change) — cache LRU en memoria - forms — cache LRU - User roles → tenant mapping — cache 5 min

NO cachear: - Process instances (cambian constantemente) - User tasks (claim conflicts) - Incidents

Pagination strategy

Pattern Pros Cons Cuándo usar
OFFSET/LIMIT Simple, jump-to-page Slow para large datasets Datasets pequeños fijos
Cursor-based Fast, consistent No jump-to-page Listas grandes (default)
Keyset Like cursor pero con sort key Complejo si multiple sort Listas con orden fijo

Recomendación: cursor-based default. OFFSET solo para tablas pequeñas (forms, process_definitions).

// Cursor response
{
  "items": [...],
  "next_cursor": "eyJrZXkiOiAxMjM0NX0=",  // base64 encoded
  "has_more": true
}

Bulk endpoints

Para batch operations, devolver operation key + status async:

POST /v2/operations/batch/cancel-instances
Body: { "process_instance_keys": [1, 2, 3, ...] }
Response: { "batch_operation_key": 9876, "status": "PENDING", "total": 100 }

GET /v2/operations/{key}
Response: { 
  "status": "RUNNING", 
  "total": 100, 
  "completed": 45, 
  "failed": 2 
}

Cliente polling el status hasta COMPLETED. Mejor UX que blocking 100 single calls.

Performance targets

Operación Target Notas
Lista de 50 instances < 100ms Con índices apropiados
Detalle instance + tree < 200ms Recursive CTE
Variables completas < 100ms JOIN simple
Search "Mis tasks" < 50ms GIN indexes
Claim task < 30ms Single atomic UPDATE
Complete task < 100ms Insert command + validate
Polling cycle (5s) < 200ms total 1 query simple

Si alguno se degrada → check pg_stat_statements y EXPLAIN ANALYZE.

Resumen ejecutivo

Operate MVP — 3 sprints, ~10K LOC

Sprint Features
1 Lista filtrable + detalle + tree + variables + history
2 Incidents (list, detail, resolve)
3 Batch ops + BPMN viewer + auto-refresh

Tasklist MVP — 3 sprints, ~8K LOC

Sprint Features
1 Mis tasks + claim + filtros básicos
2 Detalle + JSON Schema forms + complete
3 Search en variables + sort + delegation + audit

Stack común

  • Frontend: React + Tailwind + TanStack Query + bpmn-js
  • Backend: Node.js/Express o Python/FastAPI o Go, pluggable según preferencia
  • DB: PostgreSQL único (mismo del engine)
  • Real-time: SSE con LISTEN/NOTIFY (Phase 2)
  • Forms: JSON Schema + react-jsonschema-form

Total inversión

  • ~18 sprints (~4.5 meses con 1 frontend + 1 backend)
  • ~18K LOC total (vs ~250K de Camunda Operate + Tasklist)
  • 0 infraestructura adicional (mismo Postgres)

Diferenciación vs Camunda

Aspecto Camunda MVP
Data freshness Eventually consistent (~seconds) Real-time
Forms Embedded BPMN, deployed forms JSON Schema únicamente
Visual modeling Modeler integrated Reuse Camunda Modeler
Search Native ES queries Postgres FTS + GIN
Scaling Doble store (ES) Single store (Postgres)

Operate + Tasklist son el face del MVP al usuario. Hacerlos simple, rápidos, y real-time es la diferenciación principal vs Camunda 8.

Referencias