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:
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:
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¶
- analysis/webapps-architecture-mvp — Estrategia overall por componente
- analysis/scaling-strategy-postgres — Cómo escalan estos webapps en cada fase
- entities/operate — Operate de Camunda como referencia
- entities/tasklist — Tasklist de Camunda como referencia
- concepts/postgres-monitoring — Queries para identificar bottlenecks
- analysis/intuit-production-benchmarks — Performance targets validados
- bpmn-js: https://github.com/bpmn-io/bpmn-js
- react-jsonschema-form: https://github.com/rjsf-team/react-jsonschema-form
- TanStack Query: https://tanstack.com/query/latest