
Los desarrolladores de FileMaker que construyen integraciones se hacen siempre la misma pregunta: ¿puedo bloquear un registro a través de la Data API u OData y hacer que ese bloqueo sea visible para todos los usuarios de la base de datos? La respuesta corta es no. Los bloqueos nativos pertenecen a los clientes interactivos, y las APIs son sin estado. Pero la necesidad es real, especialmente a medida que interfaces más ricas se trasladan a los WebViewers y la UI tiene que coordinarse con la base de datos mediante guiones.
Este artículo describe un bloqueo suave basado en concesiones (leases) que funciona en FileMaker Pro, Go, WebDirect, WebViewers y clientes API. Refleja mi comprensión actual, y las correcciones y enfoques alternativos son bienvenidos.
1. Por qué los bloqueos nativos no se trasladan #
El bloqueo nativo de registros pertenece a un cliente que tiene un registro en modo de edición, y dura solo lo que dura la edición. La Data API y OData son sin estado: cada petición es independiente, un guion ejecutado a través de una API termina cuando devuelve su respuesta, y ninguna de las dos APIs tiene un endpoint de bloqueo. FileMaker Server tampoco tiene forma de enviar un bloqueo a los clientes conectados.
Por tanto, un guion del lado del servidor no puede mantener un bloqueo nativo entre peticiones, ni puede poner un campo en modo de edición en nombre de un usuario de API. Lo que un guion sí puede hacer es mantener un bloqueo nativo durante su propia ejecución. Eso es inútil como bloqueo en sí, pero es exactamente lo que necesitamos como mutex corto que hace atómicas las transiciones de bloqueo (sección 4).
2. Principios de diseño #
- Una tabla separada. El estado de bloqueo vive en su propia tabla
Locks, nunca como campos del registro de datos. Los heartbeats escritos en el registro de datos incrementarían sumodId(rompiendo la concurrencia optimista), colisionarían con los editores (error 301) y contaminarían las marcas de modificación, los registros de auditoría y los triggers. - Concesiones, no bloqueos. Un bloqueo es una concesión con expiración que el titular renueva continuamente. Un cliente que se cae simplemente deja de renovar.
- Un solo reloj. Toda la temporización usa los milisegundos UTC del servidor. Los relojes de los clientes nunca se comparan.
- Un token de fencing. La adquisición devuelve un token secreto. Renovar, liberar y cada escritura deben presentarlo, de modo que un cliente que se durmió más allá de su concesión no pueda actuar sobre un bloqueo que ya no le pertenece.
- Transiciones atómicas. Cada cambio de estado ocurre mientras la fila de bloqueo está abierta (bloqueada nativamente) dentro de un mismo guion, lo que cierra la carrera que el diseño anterior solo estrechaba.
- Imposición por privilegios. Las escrituras directas a las tablas protegidas se deniegan. Las escrituras pasan por guiones que verifican el bloqueo.
- Dos capas. El bloqueo suave evita la edición concurrente.
modIdatrapa lo que aún se cuele.
Una consecuencia conviene enunciarla pronto: una concesión que ha expirado pero no ha sido tomada por otro sigue siendo válida, porque el token demuestra que nadie más adquirió el bloqueo entretanto. La expiración solo decide cuándo otros pueden tomar el control.
3. La tabla Locks #
Una fila por registro bloqueable, creada perezosamente en el primer uso.
| Campo | Tipo | Notas |
|---|---|---|
id | Texto | Clave primaria, auto-introducir Get ( UUID ) |
lock_key | Texto | target_table & "|" & target_uuid — clave única por registro |
target_table | Texto | Nombre lógico de la tabla bloqueada |
target_uuid | Texto | UUID estable del registro bloqueado (nunca el ID interno del registro) |
owner_id | Texto | Cuenta o identidad del usuario. Vacío significa libre |
owner_name | Texto | Nombre a mostrar en los avisos |
owner_client | Texto | pro, go, webviewer, api |
lock_token | Texto | Get ( UUID ) por adquisición. Un secreto, nunca devuelto por Status |
generation | Número | +1 en cada adquisición. Monótono, útil en logs y para fencing posterior |
acquired_ms, renewed_ms, expires_ms | Número | Valores de Get ( CurrentTimeUTCMilliseconds ). El UTC numérico evita la ambigüedad del horario de verano |
renew_count | Número | Heartbeats en la concesión actual |
El lock_key único convierte una carrera de creación en el error 504 (valor no único), que los guiones tratan como “otro lo creó primero, búscalo de nuevo”.
Añade una pequeña tabla LockLog (ts_ms, lock_key, event, owner_id, generation, detail). Eventos: ACQUIRE, REACQUIRE, TAKEOVER, DENIED, RELEASE, EXPIRE, FORCE_RELEASE, WRITE_OK, WRITE_REJECTED. Cuesta casi nada y ahorra horas cuando alguien pregunta por qué un registro estaba bloqueado.
4. Los guiones #
Todos los guiones toman un parámetro JSON, devuelven JSON mediante Exit Script, y tienen activado Ejecutar guion con privilegios de acceso total. Una función personalizada LockResult ( ok ; code ; message ; data ) construye el resultado:
{ "ok": 1, "code": "GRANTED", "message": "", "data": { ... } }
| Código | Significado |
|---|---|
| GRANTED, RENEWED, RELEASED, STATUS | Éxito |
| DENIED | Lo tiene otra persona. data contiene owner, since, expires |
| LOST | El token ya no es válido (liberado o tomado por otro) |
| BUSY | Contención en la fila de bloqueo. Reintentar en breve |
| IN_USE | El registro de datos está bloqueado nativamente por un editor (error 301) |
| CONFLICT | modId o los valores esperados ya no coinciden |
| INVALID, NOT_FOUND, ERROR | Entrada incorrecta, registro inexistente o un error inesperado de FileMaker (en data) |
4.1 Lock_Open_Row (subguion) #
Busca la fila de bloqueo, la crea si se permite, y la deja abierta en la sesión actual. Todo lo demás se construye sobre esto.
# Lock_Open_Row param: { key, table, id, create }
Set Error Capture [ On ]
Set Variable [ $p ; Get ( ScriptParameter ) ]
Set Variable [ $key ; JSONGetElement ( $p ; "key" ) ]
Set Variable [ $create ; GetAsNumber ( JSONGetElement ( $p ; "create" ) ) ]
Go to Layout [ "Locks" ]
Set Variable [ $try ; 0 ]
Loop
Set Variable [ $try ; $try + 1 ]
Exit Loop If [ $try > 6 ]
Enter Find Mode [ Pause: Off ]
Set Field [ Locks::lock_key ; "==" & $key ]
Perform Find [ ]
If [ Get ( LastError ) = 401 ] # no row yet
If [ not $create ]
Exit Script [ "MISSING" ]
End If
New Record/Request
Set Field [ Locks::lock_key ; $key ]
Set Field [ Locks::target_table ; JSONGetElement ( $p ; "table" ) ]
Set Field [ Locks::target_uuid ; JSONGetElement ( $p ; "id" ) ]
Commit Records/Requests [ No dialog ]
If [ Get ( LastError ) ≠ 0 ] # 504: another script created it first
Revert Record/Request [ No dialog ]
End If
Else
Open Record/Request
If [ Get ( LastError ) = 0 ]
Exit Script [ "OK" ]
End If
# 301: another script holds the row right now (milliseconds)
Pause/Resume Script [ Duration (seconds): 0.15 ]
End If
End Loop
Exit Script [ "BUSY" ]
4.2 Lock_Acquire #
# Lock_Acquire param: { table, id, user, name, client, ttl }
Set Error Capture [ On ]
Set Variable [ $in ; Get ( ScriptParameter ) ]
Set Variable [ $table ; JSONGetElement ( $in ; "table" ) ]
Set Variable [ $id ; JSONGetElement ( $in ; "id" ) ]
Set Variable [ $user ; Get ( AccountName ) ] # see section 5 for shared service accounts
Set Variable [ $ttl ; Case ( IsEmpty ( JSONGetElement ( $in ; "ttl" ) ) ; 90 ;
Min ( Max ( GetAsNumber ( JSONGetElement ( $in ; "ttl" ) ) ; 30 ) ; 600 ) ) ]
If [ IsEmpty ( $table ) or IsEmpty ( $id ) ]
Exit Script [ LockResult ( 0 ; "INVALID" ; "table and id are required" ; "" ) ]
End If
Set Variable [ $key ; $table & "|" & $id ]
Perform Script [ "Lock_Open_Row" ; Parameter: JSON ( key, table, id, create = 1 ) ]
If [ Get ( ScriptResult ) ≠ "OK" ]
Exit Script [ LockResult ( 0 ; "BUSY" ; "Lock table busy, retry" ; "" ) ]
End If
# The row is open, so these values are current and nobody else can change them
Set Variable [ $now ; Get ( CurrentTimeUTCMilliseconds ) ]
Set Variable [ $held ; not IsEmpty ( Locks::owner_id ) and Locks::expires_ms > $now ]
If [ $held and Locks::owner_id ≠ $user ]
Set Variable [ $out ; LockResult ( 0 ; "DENIED" ; "Locked by another user" ;
JSON ( owner = Locks::owner_name, since = Locks::acquired_ms, expires = Locks::expires_ms ) ) ]
Revert Record/Request [ No dialog ]
Perform Script [ "Lock_Log" ; "DENIED" ]
Exit Script [ $out ]
End If
Set Variable [ $event ; Case ( IsEmpty ( Locks::owner_id ) ; "ACQUIRE" ; $held ; "REACQUIRE" ; "TAKEOVER" ) ]
Set Field [ Locks::owner_id ; $user ]
Set Field [ Locks::owner_name ; JSONGetElement ( $in ; "name" ) ]
Set Field [ Locks::owner_client ; JSONGetElement ( $in ; "client" ) ]
Set Field [ Locks::lock_token ; Get ( UUID ) ] # a new token invalidates any previous holder
Set Field [ Locks::generation ; Locks::generation + 1 ]
Set Field [ Locks::acquired_ms ; $now ]
Set Field [ Locks::renewed_ms ; $now ]
Set Field [ Locks::expires_ms ; $now + $ttl * 1000 ]
Set Field [ Locks::renew_count ; 0 ]
Commit Records/Requests [ No dialog ]
If [ Get ( LastError ) ≠ 0 ]
Exit Script [ LockResult ( 0 ; "ERROR" ; "Commit failed" ; JSON ( error = Get ( LastError ) ) ) ]
End If
Perform Script [ "Lock_Log" ; $event ]
Exit Script [ LockResult ( 1 ; "GRANTED" ; "" ;
JSON ( token = Locks::lock_token, generation = Locks::generation, expires = Locks::expires_ms, ttl = $ttl ) ) ]
Una readquisición del mismo usuario desde otra sesión se concede y rota el token, de modo que la sesión anterior verá LOST en su próximo heartbeat. Si prefieres que sea estricto un usuario por sesión, almacena y compara también un id de sesión.
4.3 Lock_Renew #
# Lock_Renew param: { table, id, token, ttl }
Open the row (Lock_Open_Row, create = 0)
If [ result = "MISSING" or IsEmpty ( $token ) or Locks::lock_token ≠ $token ]
Revert Record/Request [ No dialog ]
Exit Script [ LockResult ( 0 ; "LOST" ; "Lock was released or taken over" ; "" ) ]
End If
# A matching token proves nobody took over, even if the lease has lapsed
Set Field [ Locks::renewed_ms ; $now ]
Set Field [ Locks::expires_ms ; $now + $ttl * 1000 ]
Set Field [ Locks::renew_count ; Locks::renew_count + 1 ]
Commit Records/Requests [ No dialog ]
Exit Script [ LockResult ( 1 ; "RENEWED" ; "" ; JSON ( expires = Locks::expires_ms ) ) ]
4.4 Lock_Release #
# Lock_Release param: { table, id, token }
Open the row (create = 0)
If [ result = "MISSING" ]
Exit Script [ LockResult ( 1 ; "RELEASED" ; "Nothing to release" ; "" ) ] # idempotent
End If
If [ Locks::lock_token ≠ $token ]
Revert Record/Request [ No dialog ] # never touch someone else's lock
Exit Script [ LockResult ( 0 ; "LOST" ; "Not the current holder" ; "" ) ]
End If
Set Field [ owner_id, owner_name, owner_client, lock_token, acquired_ms, renewed_ms, expires_ms ; "" ]
Commit Records/Requests [ No dialog ] # generation is kept
Perform Script [ "Lock_Log" ; "RELEASE" ]
Exit Script [ LockResult ( 1 ; "RELEASED" ; "" ; "" ) ]
4.5 Lock_Status #
De solo lectura, usado para avisos y vistas de lista. Acepta una lista de registros de modo que una pantalla de lista cuesta una sola llamada, y nunca devuelve el token.
# Lock_Status param: { items: [ { table, id }, ... ] }
Set Variable [ $now ; Get ( CurrentTimeUTCMilliseconds ) ]
For each item:
Find Locks::lock_key == table & "|" & id # no Open Record needed
If found and owner_id is not empty and expires_ms > $now
append { id, locked = 1, owner = owner_name, client = owner_client, since = acquired_ms, expires = expires_ms }
Else
append { id, locked = 0 }
Exit Script [ LockResult ( 1 ; "STATUS" ; "" ; $array ) ]
4.6 Record_Update_Guarded #
Esta es la única vía por la que los clientes API escriben en una tabla protegida. Verifica el token, el modId y, opcionalmente, los valores originales de los campos que se modifican.
# Record_Update_Guarded param: { table, id, token, modId, fields: {…}, expected: {…} }
Set Variable [ $cfg ; Lock_Config ( $table ) ] # layout, uuid field, allow-list of writable fields
Open the lock row (create = 0) in this window
If [ missing or Locks::lock_token ≠ $token ]
Revert Record/Request ; Log WRITE_REJECTED
Exit Script [ LockResult ( 0 ; "LOST" ; "Lock is not held by this token" ; "" ) ]
End If
# The lock row stays open for the whole write: nobody can take over, renew or release meanwhile.
# Use a second window for the data record. Changing layout in this window would commit the lock row.
New Window [ Name: "data" ]
Go to Layout [ $cfg.layout ]
Find where $cfg.uuidField == $id # 401 -> NOT_FOUND
Open Record/Request
If [ Get ( LastError ) = 301 ]
Close Window ; Revert the lock row
Exit Script [ LockResult ( 0 ; "IN_USE" ; "Someone is editing this record" ; "" ) ]
End If
If [ not IsEmpty ( $modId ) and Get ( RecordModificationCount ) ≠ $modId ]
→ CONFLICT, with the current modId in data
End If
For each key in JSONListKeys ( $expected ):
If GetField ( key ) ≠ expected value → CONFLICT, listing the field names
For each key in JSONListKeys ( $fields ):
If key is not in $cfg.allowed → INVALID
Set Field By Name ( $table & "::" & key ; JSONGetElement ( $fields ; key ) )
Commit Records/Requests [ No dialog ]
If [ Get ( LastError ) ≠ 0 ] → Revert, ERROR with the error code (validation errors included)
Set Variable [ $newMod ; Get ( RecordModificationCount ) ]
Close Window ; Revert the lock row (nothing changed in it) ; Log WRITE_OK
Exit Script [ LockResult ( 1 ; "OK" ; "" ; JSON ( modId = $newMod ) ) ]
En cualquier ruta de fallo, cierra la ventana y revierte ambos registros abiertos antes de salir. La lista de permitidos importa: sin ella este guion es un endpoint de escritura genérico.
4.7 Lock_Cleanup y Lock_Force_Release #
Lock_Cleanup se ejecuta como guion programado del servidor cada pocos minutos. La toma de control ya gestiona la expiración, así que la limpieza existe para mantener limpios los datos de estado y los logs:
Find Locks where owner_id is not empty and expires_ms < Get ( CurrentTimeUTCMilliseconds ) - 10000
Loop: Open Record/Request
re-check that it is still expired (a renewal may have arrived) → clear the owner fields, Commit, Log EXPIRE
Delete LockLog rows older than 30 days
Lock_Force_Release es para administradores: comprueba Get ( PrivilegeSetName ), limpia la fila y registra FORCE_RELEASE con el nombre del administrador. Toda llamada de soporte por un bloqueo atascado termina aquí, así que constrúyelo desde el primer día.
5. Imposición #
Un bloqueo suave es consultivo a menos que el servidor rechace las escrituras que lo saltan. Para las cuentas de API:
- Registros: solo visualización en las tablas protegidas, sin edición, creación ni eliminación. Las escrituras pasan por
Record_Update_Guarded. - Locks y LockLog: sin acceso directo. Esto también mantiene
lock_tokenlejos de otros clientes, ya que un token legible es un secreto de portador. - Guiones: acceso de ejecución solo a los guiones
Lock_*y a los escritores protegidos. - Identidad: usa
Get ( AccountName )como titular cuando cada persona tiene una cuenta de FileMaker. Si un backend de confianza llama con una única cuenta de servicio compartida, pasa el usuario en el parámetro y el bloqueo es exactamente tan fiable como ese backend.
Cualquier cosa con acceso total, como herramientas de administración y guiones de servidor, aún puede saltarse el bloqueo. Trata el bloqueo suave como una herramienta de coordinación para clientes que cooperan, no como una frontera de seguridad.
6. Clientes FileMaker Pro y Go #
El bloqueo nativo sigue protegiendo a los usuarios de Pro que editan al mismo tiempo, pero los clientes API no pueden ver un bloqueo nativo hasta que su escritura choca con el error 301. Para que los usuarios de Pro sean participantes de primera clase, haz que ellos también tomen un bloqueo suave:
| Trigger | Acción |
|---|---|
| OnRecordLoad | Lock_Status para el registro. Establece $$LOCKED_BY_OTHER y el texto del aviso |
| Botón de edición (o primera entrada en un campo) | Lock_Acquire mediante Perform Script on Server, guarda $$TOKEN. En DENIED, permanece en solo lectura y muestra quién lo tiene |
| OnTimer, cada 30 s | Titular: Lock_Renew. En LOST, revierte las ediciones locales y avisa. No titular: Lock_Status para refrescar el aviso |
| Guardar, OnRecordRevert, OnLayoutExit, OnWindowClose | Valida si es necesario, luego Lock_Release |
| OnRecordCommit | Una última comprobación de renovación. Si LOST, devuelve False para cancelar la validación |
Una ventana solo tiene un temporizador, así que este único guion hace tanto el heartbeat como el sondeo, y puede actuar de despachador si la presentación ya usa un temporizador. Oculta los controles de edición o bloquea la entrada en campos (OnObjectEnter devolviendo False cuando $$LOCKED_BY_OTHER = 1) y muestra un aviso como “Bloqueado por María desde las 10:42”. El sondeo sigue añadiendo hasta un intervalo de temporizador de latencia.
7. Clientes API y WebViewer #
Una llamada a guion independiente a través de la Data API tiene este aspecto (el parámetro va codificado en URL en la práctica, y scriptResult vuelve como una cadena que debes parsear una segunda vez):
GET /fmi/data/vLatest/databases/CRM/layouts/Locks_API/script/Lock_Acquire
?script.param={"table":"Invoices","id":"3F2A…","name":"Maria","client":"webviewer","ttl":90}
Los mismos guiones funcionan a través de OData donde tu versión de servidor pueda ejecutarlos. Reglas para una UI de navegador o WebViewer:
- Heartbeat a TTL/3 (30 s para un TTL de 90 s), y renueva también inmediatamente en
visibilitychange, porque los navegadores estrangulan los temporizadores en pestañas en segundo plano. - Mantén el token en memoria por pestaña. Dos pestañas del mismo usuario son dos sesiones, y la segunda toma el control de la primera.
- Al descargar la página, envía un
Lock_Releasede mejor esfuerzo confetchykeepalive: true.navigator.sendBeaconno puede establecer la cabecera Authorization que necesita la Data API, así que úsalo solo contra tu propio backend. El TTL es la red de seguridad en cualquier caso. - En LOST, congela el formulario, mantén visibles los cambios del usuario y ofrece recargar el registro. Nunca reintentes una escritura en silencio.
- En BUSY, reintenta con un backoff corto aleatorio.
8. Concurrencia optimista #
Como el tráfico de bloqueos vive en su propia tabla, el modId del registro de datos ahora solo cambia cuando cambian datos reales, así que es una versión fiable. La Data API lo devuelve con cada registro, y un PATCH que incluye un modId obsoleto falla con el error 306. Úsalo de dos maneras:
- PATCH simple en tablas sin requisito de bloqueo: envía el
modIdque leíste, y en caso de 306 recarga y reintenta. - Escrituras protegidas: envía el
modIdy el token juntos.
modId es grueso: cualquier cambio en el registro, incluso de un campo no relacionado, provoca un conflicto. El objeto expected en Record_Update_Guarded es la opción más fina. El cliente envía los valores originales de los campos que está cambiando, y el guion rechaza la escritura solo si esos campos concretos cambiaron, de modo que las ediciones que no se solapan se fusionan.
Dos límites de alcance a documentar en tu solución: los portales y los registros relacionados tienen su propio modId, y un bloqueo sobre un padre no cubre a sus hijos por sí solo. Para bloquear un documento completo, haz que la clave de bloqueo sea el registro raíz, y deja que los escritores protegidos de las tablas hijas acepten el token de la raíz. No he verificado el comportamiento de ETag o If-Match en el endpoint OData de FileMaker. Si no está disponible, llama al guion protegido en su lugar.
9. Modos de fallo #
| Situación | Resultado |
|---|---|
| El cliente se duerme más allá de su TTL, nadie más tocó el registro | Renovar y escribir siguen funcionando (el token coincide). modId protege contra datos obsoletos |
| Alguien tomó el control entretanto | Renovar y las escrituras protegidas devuelven LOST |
| El cliente se cae | La concesión expira tras el TTL y la siguiente adquisición toma el control |
| El guion de servidor muere a mitad de transición | La sesión se cierra, la fila abierta se libera y los cambios no validados se descartan |
| FileMaker Server se reinicia | Las filas persisten y las concesiones expiran con normalidad |
| Usuario de Pro editando nativamente | Las escrituras API reciben IN_USE hasta que la edición termina |
| Bloqueo atascado | Lock_Force_Release |
Si tu versión de FileMaker proporciona los pasos Open Transaction y Commit Transaction (introducidos en FileMaker 2024, que yo sepa), encajan bien dentro de Record_Update_Guarded para escrituras multirregistro. Aún así no pueden abarcar varias peticiones API, por lo que no reemplazan la concesión.
10. Cuando un bloqueo suave no es suficiente #
FileMaker no envía cambios, así que todos los enfoques anteriores tienen latencia de sondeo, y la tabla de bloqueos es un punto caliente bajo mucha contención. Si necesitas notificación instantánea a los usuarios de WebViewer o un alto volumen de bloqueos, traslada la coordinación de bloqueos a un servicio externo, por ejemplo Redis con SET key value NX PX ttl y pub/sub, o un pequeño servicio delante de él. Los guiones de FileMaker lo llaman entonces mediante Insert from URL. Las contrapartidas son una dependencia extra y una segunda fuente de verdad que debe ser alcanzable desde cada escritor. Para la mayoría de soluciones, el diseño basado en tabla descrito aquí es suficiente.
Conclusión #
Sigues sin poder activar un bloqueo nativo de FileMaker desde la Data API u OData, pero puedes conseguir edición coordinada entre todos los clientes con unos pocos ingredientes: una tabla Locks dedicada, concesiones renovadas contra el reloj del servidor, un token de fencing verificado en cada escritura, transiciones de bloqueo hechas atómicas por un bloqueo nativo mantenido dentro del guion, privilegios que obligan a escribir a través de esos guiones, y modId como segunda línea de defensa. Constrúyelo como coordinación, hazlo cumplir en el servidor y regístralo todo.